Skip to content

Commit df290b7

Browse files
feact: mcp (#240)
* feat: add mcp server * chore: exclude docs directory from TypeScript compilation * feat: add URL fields to MCP server search responses Add `url` field to all search results in MCP server tools (search_packages, search_authors, search_universal) pointing to the respective package/author pages. Update SearchService to include URLs in universal search results. * feat: add package and author resources to MCP server Add `cran://package/{name}` and `cran://author/{name}` resources to MCP server. Package resource includes full metadata, authors, maintainer, dependency relations, and download statistics (last month/year). Author resource includes author metadata and list of authored packages with roles. Update documentation and VS Code spell check settings. * refactor: consolidate package data fetching logic into shared enrichment method Extract common package data fetching and processing logic from MCP server and package page loader into `PackageService.getEnrichedPackageByName()` method. This eliminates code duplication by centralizing the logic for fetching package metadata, relations, authors, maintainer, downloads, and trending status. Both the MCP resource handler and package route loader now use this shared method, following DRY principle. * feat: add MCP section to about page with server URL copy functionality Add dedicated MCP (Model Context Protocol) section to about page explaining CRAN/E's MCP server capabilities. Include description of three main resources (Package Resource, Author Resource, Search Tools) and their use cases. Add copy-to-clipboard functionality for MCP server URL with visual feedback. Update footer link label to "About & MCP" and add "MCP" to page anchors navigation. * chore: format licenses.json with prettier formatting
1 parent 777a6bc commit df290b7

22 files changed

+2155
-174
lines changed

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,7 @@
2020
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
2121
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"],
2222
["tw`([^`]*)`", "tw`([^`]*)`"]
23-
]
23+
],
24+
"spellright.language": ["en"],
25+
"spellright.documentTypes": ["markdown", "latex", "plaintext"]
2426
}

.vscode/spellright.dict

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
cran

docs/mcp.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# MCP Server (Crane App)
2+
3+
Minimal notes to integrate the deployed MCP server.
4+
5+
## Endpoint
6+
- 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.
9+
10+
## Server Info
11+
- Name: `Crane App MCP Server`
12+
- Version: `1.0.0`
13+
14+
## Tools
15+
- `search_packages`
16+
- Input: `{ query: string; limit?: number }`
17+
- Action: Searches CRAN packages via `PackageService.searchPackages`.
18+
- Response: includes `url` pointing to the package page (`/package/<name>`).
19+
- `search_authors`
20+
- Input: `{ query: string; limit?: number }`
21+
- Action: Searches authors via `AuthorService.searchAuthors`.
22+
- Response: includes `url` pointing to the author page (`/author/<name>`).
23+
- `search_universal`
24+
- Input: `{ query: string }`
25+
- Action: Combined packages + authors via `SearchService.searchUniversal`.
26+
- Response: each item has `url` to the package/author page.
27+
28+
## Resources
29+
- `cran://package/{name}`
30+
- Description: Full details for a CRAN package.
31+
- Data: Includes metadata, authors, maintainer, dependency relations, and download statistics (last month/year).
32+
- `cran://author/{name}`
33+
- 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+
38+
## Client Connect (example)
39+
```ts
40+
import { Client } from "@modelcontextprotocol/sdk/client";
41+
import { WebSocket } from "ws"; // or fetch-based transport depending on client
42+
43+
const client = new Client({ serverUrl: "https://<host>/api/mcp" });
44+
await client.connect();
45+
```
46+
47+
For raw HTTP/SSE clients, POST MCP JSON-RPC messages to `/api/mcp` and GET the same path to open the stream.
48+
49+
## Dependencies
50+
- `@modelcontextprotocol/sdk`
51+
- `zod`
52+
53+
## Notes
54+
- Stateless by default; adjust `WebStandardStreamableHTTPServerTransport` options in `app/routes/api.mcp.ts` if you need session IDs or retry tuning.

web/app/data/package.service.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@ import { slog } from "../modules/observability.server";
66
import { authorIdSchema } from "./author.shape";
77
import { groupBy, omit, uniqBy } from "es-toolkit";
88
import TTLCache from "@isaacs/ttlcache";
9-
import { format, hoursToMilliseconds, minutesToMilliseconds } from "date-fns";
9+
import {
10+
format,
11+
formatRelative,
12+
hoursToMilliseconds,
13+
minutesToMilliseconds,
14+
subDays,
15+
} from "date-fns";
1016
import { google } from "@ai-sdk/google";
1117
import { embed } from "ai";
18+
import { AuthorService } from "./author.service";
19+
import { PackageInsightService } from "./package-insight.service.server";
1220

1321
// import { embed } from "ai";
1422
// import { google } from "@ai-sdk/google";
@@ -27,6 +35,30 @@ type SearchResult = {
2735
isSemanticPreferred: boolean;
2836
};
2937

38+
type EnrichedPackage = {
39+
pkg: Package;
40+
relations: Awaited<
41+
ReturnType<typeof PackageService.getPackageRelationsByPackageId>
42+
>;
43+
groupedRelations: ReturnType<typeof groupBy<any, string>>;
44+
authorsData: Awaited<ReturnType<typeof AuthorService.getAuthorsByPackageId>>;
45+
authorsList: Array<Tables<"authors"> & { roles: string[] }>;
46+
maintainer: (Tables<"authors"> & { roles: string[] }) | undefined;
47+
dailyDownloads: Awaited<
48+
ReturnType<typeof PackageInsightService.getDailyDownloadsForPackage>
49+
>;
50+
yearlyDailyDownloads: Awaited<
51+
ReturnType<typeof PackageInsightService.getDailyDownloadsForPackage>
52+
>;
53+
trendingPackages: Awaited<
54+
ReturnType<typeof PackageInsightService.getTrendingPackages>
55+
>;
56+
totalMonthDownloads: number;
57+
totalYearDownloads: number;
58+
isTrending: boolean;
59+
lastRelease: string;
60+
};
61+
3062
export class PackageService {
3163
/** Cache for singular domains, e.g. the sitemap. */
3264
private static cache = new TTLCache<CacheKey, CacheValue>({
@@ -63,6 +95,98 @@ export class PackageService {
6395
return data;
6496
}
6597

98+
/**
99+
* Get enriched package data with relations, authors, maintainer, downloads, and trending status.
100+
* This method consolidates the data fetching and processing logic used by both
101+
* the MCP resource and the package page loader to follow DRY principle.
102+
*/
103+
static async getEnrichedPackageByName(
104+
packageName: string,
105+
): Promise<EnrichedPackage | null> {
106+
packageNameSchema.parse(packageName);
107+
108+
const pkg = await this.getPackageByName(packageName);
109+
if (!pkg) {
110+
return null;
111+
}
112+
113+
const now = new Date();
114+
115+
const [
116+
relations,
117+
authorsData,
118+
dailyDownloads,
119+
yearlyDailyDownloads,
120+
trendingPackages,
121+
] = await Promise.all([
122+
this.getPackageRelationsByPackageId(pkg.id),
123+
AuthorService.getAuthorsByPackageId(pkg.id),
124+
PackageInsightService.getDailyDownloadsForPackage(
125+
packageName,
126+
"last-month",
127+
),
128+
PackageInsightService.getDailyDownloadsForPackage(
129+
packageName,
130+
`${format(subDays(now, 365), "yyyy-MM-dd")}:${format(now, "yyyy-MM-dd")}`,
131+
),
132+
PackageInsightService.getTrendingPackages(),
133+
]);
134+
135+
// Process Relations
136+
const groupedRelations = groupBy(
137+
relations || [],
138+
(item) => item.relationship_type,
139+
);
140+
141+
// Process Authors & Maintainers
142+
const authorsList = (authorsData || [])
143+
.map(({ author, roles }) => ({
144+
...((Array.isArray(author) ? author[0] : author) as Tables<"authors">),
145+
roles: roles || [],
146+
}))
147+
.filter((a) => !a.roles.includes("mnt"));
148+
149+
const maintainer = (authorsData || [])
150+
.map(({ author, roles }) => ({
151+
...((Array.isArray(author) ? author[0] : author) as Tables<"authors">),
152+
roles: roles || [],
153+
}))
154+
.filter((a) => a.roles.includes("mnt"))
155+
.at(0);
156+
157+
// Process Downloads
158+
const totalMonthDownloads =
159+
dailyDownloads
160+
.at(0)
161+
?.downloads?.reduce((acc, curr) => acc + curr.downloads, 0) || 0;
162+
163+
const totalYearDownloads =
164+
yearlyDailyDownloads
165+
.at(0)
166+
?.downloads?.reduce((acc, curr) => acc + curr.downloads, 0) || 0;
167+
168+
const isTrending =
169+
trendingPackages.findIndex((item) => item.package === packageName) !== -1;
170+
171+
const lastRelease = formatRelative(new Date(pkg.last_released_at), now);
172+
173+
return {
174+
pkg,
175+
relations,
176+
groupedRelations,
177+
authorsData,
178+
authorsList,
179+
maintainer,
180+
dailyDownloads,
181+
yearlyDailyDownloads,
182+
trendingPackages,
183+
totalMonthDownloads,
184+
totalYearDownloads,
185+
isTrending,
186+
lastRelease,
187+
};
188+
}
189+
66190
static async getPackageIdByName(packageName: string): Promise<number | null> {
67191
packageNameSchema.parse(packageName);
68192

web/app/data/search.service.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { AuthorService } from "./author.service";
2+
import { PackageService } from "./package.service";
3+
import { BASE_URL } from "../modules/app";
4+
import { slog } from "../modules/observability.server";
5+
6+
export class SearchService {
7+
static async searchUniversal(query: string) {
8+
const normalizedQuery = query.trim().slice(0, 512);
9+
10+
const [packages, authors] = await Promise.allSettled([
11+
PackageService.searchPackages(normalizedQuery),
12+
AuthorService.searchAuthors(normalizedQuery),
13+
]);
14+
15+
if (packages.status === "rejected") {
16+
slog.error("Failed to search packages", { error: packages.reason });
17+
}
18+
if (authors.status === "rejected") {
19+
slog.error("Failed to search authors", { error: authors.reason });
20+
}
21+
22+
const packageHits =
23+
packages.status === "fulfilled"
24+
? packages.value
25+
: {
26+
combined: [],
27+
isSemanticPreferred: false,
28+
};
29+
const authorHits = authors.status === "fulfilled" ? authors.value : [];
30+
31+
// Re-sort the hits by 'includes' of name and synopsis
32+
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+
48+
const withPackageUrl = (name: string) =>
49+
`${BASE_URL}/package/${encodeURIComponent(name)}`;
50+
const withAuthorUrl = (name: string) =>
51+
`${BASE_URL}/author/${encodeURIComponent(name)}`;
52+
53+
const nonNull = <T>(item: T): item is NonNullable<T> => item != null;
54+
55+
return {
56+
combined: combined.filter(nonNull).map((item) => {
57+
if ("synopsis" in item) {
58+
return {
59+
...item,
60+
url: withPackageUrl(item.name),
61+
};
62+
}
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+
},
83+
};
84+
}
85+
}

web/app/licenses.json

Lines changed: 977 additions & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)