Skip to content

Commit 12f911e

Browse files
feat: MCP ChatGPT App (#241)
* feat: add ChatGPT Apps SDK integration with widget UI for MCP server Add `ui://widget/cran.html` resource serving interactive search widget for ChatGPT Apps. Update all search tools (`search_packages`, `search_authors`, `search_universal`) to return `structuredContent` responses consumed by widget via `window.openai.toolOutput`. Add OpenAI-specific metadata annotations including `outputTemplate`, `toolInvocation` labels, `readOnlyHint`, and `openWorldHint`. Introduce `makeToolResponse()` helper * refactor: improve search service type safety and structure Add explicit TypeScript types for search hits (`PackageCombinedHit`, `AuthorCombinedHit`, `CombinedSearchHit`) with discriminated union on `type` field. Update `SearchService.searchUniversal()` to return structured response with `searchType` and `query` fields alongside typed `combined` results. Refactor search result mapping to construct typed objects with URLs upfront before sorting, eliminating redundant transformations. Add `Package * feat: enhance ChatGPT widget with package metadata and improved UI Add `title` and `last_released_at` fields to package search results throughout the stack. Update `PackageService` to fetch these fields from database queries and include them in search hits. Propagate fields through `SearchService` to MCP server responses for consumption by ChatGPT widget. Redesign widget UI with Radix Colors dark mode palette, replacing previous light theme. Implement card-based layout with Iris/Jade color variants for * refactor: remove ChatGPT Apps SDK widget integration from MCP server Remove `ui://widget/cran.html` resource and all `openai/outputTemplate` metadata from MCP server tools. Delete widget HTML file serving logic and associated file system dependencies. Update documentation to reflect MCP-only functionality without ChatGPT Apps SDK integration. Rename server from "Crane App" to "CRAN/E" and adjust tool invocation labels accordingly. Simplify CSP requirements by removing widget-specific directives. * refactor: update package resource description in MCP server Change resource description from "CRAN/E package" to "CRAN R-package" to clarify that resources represent R packages from CRAN rather than the CRAN/E platform itself. * ``` feat: add MCP navigation link and homepage promotion Add "MCP" item to main navigation menu. Add promotional text on homepage linking to MCP page with icon, encouraging use of remote MCP-server for agents. Apply fade-in animation to new homepage element. ``` * feat: centralize MCP version management and display on MCP page Extract MCP server version into shared `config.server.ts` constant. Update MCP server initialization and MCP page to use centralized version. Add loader to MCP page to fetch and display version dynamically. Add `mcpVersion` field to `package.json` for version tracking. Fix accessibility issues by converting copy button from div to button element and correcting apostrophe escaping.
1 parent df290b7 commit 12f911e

16 files changed

+952
-1110
lines changed

docs/mcp.md

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,40 @@
11
# MCP Server (Crane App)
22

3-
Minimal notes to integrate the deployed MCP server.
4-
53
## Endpoint
64
- Path: `/api/mcp`
7-
- Transport: Streamable HTTP (supports SSE for streaming). Works with standard MCP clients.
8-
- Hosted inside React Router (`loader` + `action`) so it ships with every deployment.
5+
- Transport: Streamable HTTP (supports SSE for streaming); see `web/app/routes/api.mcp.ts`.
6+
- Hosted inside Remix route (`loader` + `action`) so it ships with every deployment.
97

108
## Server Info
119
- Name: `Crane App MCP Server`
1210
- Version: `1.0.0`
1311

14-
## Tools
12+
## Tools (all read-only, open-world)
1513
- `search_packages`
1614
- Input: `{ query: string; limit?: number }`
1715
- Action: Searches CRAN packages via `PackageService.searchPackages`.
18-
- Response: includes `url` pointing to the package page (`/package/<name>`).
16+
- Response (structuredContent): `{ searchType: "packages", query, combined, packages: { hits }, authors: { hits: [] } }`
17+
- Metadata: toolInvocation labels, `openai/toolDefinition.readOnlyHint = true`, `openWorldHint = true`.
1918
- `search_authors`
2019
- Input: `{ query: string; limit?: number }`
2120
- Action: Searches authors via `AuthorService.searchAuthors`.
22-
- Response: includes `url` pointing to the author page (`/author/<name>`).
21+
- Response (structuredContent): `{ searchType: "authors", query, combined, packages: { hits: { combined: [] } }, authors: { hits } }`
22+
- Metadata: same annotations as above.
2323
- `search_universal`
2424
- Input: `{ query: string }`
2525
- Action: Combined packages + authors via `SearchService.searchUniversal`.
26-
- Response: each item has `url` to the package/author page.
26+
- Response (structuredContent): `{ searchType: "universal", query, combined, packages: { hits }, authors: { hits } }`
27+
- Metadata: same annotations as above.
28+
29+
All tool responses also include `content` with a JSON string for debugging.
2730

2831
## Resources
2932
- `cran://package/{name}`
3033
- Description: Full details for a CRAN package.
31-
- Data: Includes metadata, authors, maintainer, dependency relations, and download statistics (last month/year).
34+
- Data: Includes metadata, authors, maintainer, dependency relations, download stats (month/year), and links back to site.
3235
- `cran://author/{name}`
3336
- Description: Full details for a package author.
34-
- Data: Includes author metadata and a list of all authored packages with roles.
35-
36-
Responses are returned as MCP tool results with `content: [{ type: "text", text: JSON.stringify(result, null, 2) }]`.
37+
- Data: Includes author metadata and list of authored packages with roles and links.
3738

3839
## Client Connect (example)
3940
```ts
@@ -43,9 +44,22 @@ import { WebSocket } from "ws"; // or fetch-based transport depending on client
4344
const client = new Client({ serverUrl: "https://<host>/api/mcp" });
4445
await client.connect();
4546
```
46-
4747
For raw HTTP/SSE clients, POST MCP JSON-RPC messages to `/api/mcp` and GET the same path to open the stream.
4848

49+
## Deployment & CSP (for ChatGPT app submission)
50+
- MCP must be publicly reachable (no localhost/test endpoints).
51+
- Define CSP to allow fetches to Supabase/CRAN. Example:
52+
- `connect-src`: your MCP host, Supabase API domains, CRAN logs.
53+
- Ensure CORS is permissive for `/api/mcp` (Streamable HTTP).
54+
55+
## Submission checklist (OpenAI Apps Directory)
56+
- Verified organization; submit via https://platform.openai.com/apps-manage (Owner role).
57+
- Metadata: toolInvocation labels, `openai/toolDefinition.readOnlyHint = true`, `openWorldHint = true`.
58+
- Privacy policy URL + support contact.
59+
- Accurate app name/description/screenshots.
60+
- Tools labeled with readOnlyHint/openWorldHint; no destructive actions.
61+
- Global data residency (EU projects currently not accepted for submission).
62+
4963
## Dependencies
5064
- `@modelcontextprotocol/sdk`
5165
- `zod`

web/app/data/package.service.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { packageIdSchema, packageNameSchema } from "./package.shape";
22
import { SitemapItem } from "./types";
3-
import { Tables } from "./supabase.types.generated";
3+
import { Database, Tables } from "./supabase.types.generated";
44
import { supabase } from "./supabase.server";
55
import { slog } from "../modules/observability.server";
66
import { authorIdSchema } from "./author.shape";
@@ -27,11 +27,29 @@ type CacheKey = "sitemap-items" | "count-packages";
2727

2828
type CacheValue = SitemapItem[] | number;
2929

30+
type PackageSearchHit =
31+
| {
32+
name: string;
33+
synopsis: string | null;
34+
title?: string | null;
35+
last_released_at?: string | null;
36+
}
37+
| {
38+
name: string;
39+
synopsis: string | null;
40+
title?: string | null;
41+
last_released_at?: string | null;
42+
sources: Array<
43+
[
44+
/* source name */ string,
45+
/* source data */
46+
Database["public"]["Functions"]["match_package_embeddings"]["Returns"],
47+
]
48+
>;
49+
};
50+
3051
type SearchResult = {
31-
combined: Array<{
32-
name: string;
33-
synopsis: string;
34-
} | null>;
52+
combined: Array<PackageSearchHit | null>;
3553
isSemanticPreferred: boolean;
3654
};
3755

@@ -380,7 +398,7 @@ export class PackageService {
380398
// ! ilike is expensive, but we want to make sure we get the exact match w/o case sensitivity.
381399
supabase
382400
.from("cran_packages")
383-
.select("id,name,synopsis")
401+
.select("id,name,synopsis,title,last_released_at")
384402
.ilike("name", query)
385403
.maybeSingle(),
386404
isSimilaritySearchEnabled
@@ -455,6 +473,10 @@ export class PackageService {
455473
.map((item) => ({
456474
name: item.name,
457475
synopsis: item.synopsis,
476+
// @ts-expect-error - RPC might return title/last_released_at if updated, otherwise undefined
477+
title: item.title,
478+
// @ts-expect-error - RPC might return title/last_released_at if updated, otherwise undefined
479+
last_released_at: item.last_released_at,
458480
}));
459481

460482
// Group sources by package id and source name, so that multiple hits per source & package
@@ -474,7 +496,7 @@ export class PackageService {
474496
groupedSourcesByPackageIds.map(async (item) => {
475497
const { data, error } = await supabase
476498
.from("cran_packages")
477-
.select("name,synopsis")
499+
.select("name,synopsis,title,last_released_at")
478500
.eq("id", item.packageId)
479501
.maybeSingle();
480502

@@ -486,6 +508,8 @@ export class PackageService {
486508
return {
487509
name: data.name,
488510
synopsis: data.synopsis,
511+
title: data.title,
512+
last_released_at: data.last_released_at,
489513
sources: Object.entries(item.sources),
490514
};
491515
}),

web/app/data/search.service.ts

Lines changed: 93 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@ import { AuthorService } from "./author.service";
22
import { PackageService } from "./package.service";
33
import { BASE_URL } from "../modules/app";
44
import { slog } from "../modules/observability.server";
5+
import type { Database } from "./supabase.types.generated";
6+
7+
export type PackageCombinedHit = {
8+
type: "package";
9+
name: string;
10+
synopsis: string | null;
11+
url: string;
12+
sources?: Array<
13+
[
14+
string,
15+
Database["public"]["Functions"]["match_package_embeddings"]["Returns"],
16+
]
17+
>;
18+
};
19+
20+
export type AuthorCombinedHit = {
21+
type: "author";
22+
name: string;
23+
id?: number;
24+
levenshtein_distance?: number;
25+
url: string;
26+
};
27+
28+
export type CombinedSearchHit = PackageCombinedHit | AuthorCombinedHit;
529

630
export class SearchService {
731
static async searchUniversal(query: string) {
@@ -30,56 +54,83 @@ export class SearchService {
3054

3155
// Re-sort the hits by 'includes' of name and synopsis
3256
const resortQuery = normalizedQuery.toLowerCase();
33-
const combined = [...packageHits.combined, ...authorHits].sort((a, b) => {
34-
const aIncludes =
35-
a &&
36-
(a.name.toLowerCase().includes(resortQuery) ||
37-
("synopsis" in a && a.synopsis?.toLowerCase().includes(resortQuery)));
38-
const bIncludes =
39-
b &&
40-
(b.name.toLowerCase().includes(resortQuery) ||
41-
("synopsis" in b && b.synopsis?.toLowerCase().includes(resortQuery)));
42-
43-
if (aIncludes && !bIncludes) return -1;
44-
if (!aIncludes && bIncludes) return 1;
45-
return 0;
46-
});
47-
4857
const withPackageUrl = (name: string) =>
4958
`${BASE_URL}/package/${encodeURIComponent(name)}`;
5059
const withAuthorUrl = (name: string) =>
5160
`${BASE_URL}/author/${encodeURIComponent(name)}`;
5261

5362
const nonNull = <T>(item: T): item is NonNullable<T> => item != null;
5463

55-
return {
56-
combined: combined.filter(nonNull).map((item) => {
57-
if ("synopsis" in item) {
58-
return {
59-
...item,
60-
url: withPackageUrl(item.name),
61-
};
64+
const packageCombined: PackageCombinedHit[] = packageHits.combined
65+
.filter(nonNull)
66+
.map((item) => ({
67+
type: "package" as const,
68+
name: item.name,
69+
synopsis: item.synopsis ?? null,
70+
title: item.title,
71+
last_released_at: item.last_released_at,
72+
url: withPackageUrl(item.name),
73+
sources: "sources" in item ? item.sources : undefined,
74+
}));
75+
76+
const authorCombined: AuthorCombinedHit[] = authorHits.map((item) => ({
77+
type: "author" as const,
78+
name: item.name,
79+
id: item.id,
80+
levenshtein_distance: item.levenshtein_distance,
81+
url: withAuthorUrl(item.name),
82+
}));
83+
84+
const getRelevanceRank = (hit: CombinedSearchHit) => {
85+
const nameLower = hit.name.toLowerCase();
86+
const synopsisLower =
87+
"synopsis" in hit && typeof hit.synopsis === "string"
88+
? hit.synopsis.toLowerCase()
89+
: "";
90+
91+
const exactMatch = nameLower === resortQuery;
92+
const startsWith = nameLower.startsWith(resortQuery);
93+
const includes =
94+
nameLower.includes(resortQuery) ||
95+
(synopsisLower ? synopsisLower.includes(resortQuery) : false);
96+
97+
const levenshtein =
98+
"levenshtein_distance" in hit &&
99+
typeof hit.levenshtein_distance === "number"
100+
? hit.levenshtein_distance
101+
: Number.POSITIVE_INFINITY;
102+
103+
return [
104+
exactMatch ? 0 : 1,
105+
startsWith ? 0 : 1,
106+
includes ? 0 : 1,
107+
levenshtein,
108+
] as const;
109+
};
110+
111+
const combinedWithType: CombinedSearchHit[] = [
112+
...packageCombined,
113+
...authorCombined,
114+
]
115+
.map((hit, idx) => ({
116+
hit,
117+
idx,
118+
rank: getRelevanceRank(hit),
119+
}))
120+
.sort((a, b) => {
121+
for (let i = 0; i < a.rank.length; i++) {
122+
if (a.rank[i] !== b.rank[i]) {
123+
return a.rank[i] - b.rank[i];
124+
}
62125
}
63-
return {
64-
...item,
65-
url: withAuthorUrl(item.name),
66-
};
67-
}),
68-
packages: {
69-
hits: {
70-
...packageHits,
71-
combined: packageHits.combined.filter(nonNull).map((item) => ({
72-
...item,
73-
url: withPackageUrl(item.name),
74-
})),
75-
},
76-
},
77-
authors: {
78-
hits: authorHits.filter(nonNull).map((item) => ({
79-
...item,
80-
url: withAuthorUrl(item.name),
81-
})),
82-
},
126+
return a.idx - b.idx;
127+
})
128+
.map(({ hit }) => hit);
129+
130+
return {
131+
searchType: "universal" as const,
132+
query: normalizedQuery,
133+
combined: combinedWithType,
83134
};
84135
}
85136
}

0 commit comments

Comments
 (0)