mcp-rustdoc 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -1
- package/dist/index.js +483 -55
- package/package.json +1 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# mcp-rustdoc
|
|
2
2
|
|
|
3
|
-
An MCP server that gives AI assistants deep access to the Rust ecosystem. It scrapes docs.rs (and `doc.rust-lang.org` for `std`/`core`/`alloc`) with surgical DOM extraction (cheerio) and queries the crates.io API, exposing
|
|
3
|
+
An MCP server that gives AI assistants deep access to the Rust ecosystem. It scrapes docs.rs (and `doc.rust-lang.org` for `std`/`core`/`alloc`) with surgical DOM extraction (cheerio) and queries the crates.io API, exposing nine tools that cover everything from high-level crate overviews to individual method signatures, feature gates, trait impls, and code examples. Responses are cached in memory (5-minute TTL) to avoid redundant fetches.
|
|
4
4
|
|
|
5
5
|
## Tools
|
|
6
6
|
|
|
@@ -13,6 +13,8 @@ An MCP server that gives AI assistants deep access to the Rust ecosystem. It scr
|
|
|
13
13
|
| `lookup_crate_item` | Item detail: signature, docs, methods, variants, optionally trait impls + examples |
|
|
14
14
|
| `search_crate` | Ranked symbol search (exact > prefix > substring) with canonical paths |
|
|
15
15
|
| `search_crates` | Search crates.io by keyword — returns name, description, downloads, version |
|
|
16
|
+
| `get_crate_versions` | All published versions with dates and yanked status (crates.io API) |
|
|
17
|
+
| `get_source_code` | Raw source code of a file from docs.rs or doc.rust-lang.org |
|
|
16
18
|
|
|
17
19
|
Every tool accepts an optional `version` parameter to pin a specific crate version instead of `latest`.
|
|
18
20
|
|
|
@@ -371,6 +373,52 @@ Search for Rust crates on crates.io by keyword.
|
|
|
371
373
|
|
|
372
374
|
---
|
|
373
375
|
|
|
376
|
+
### `get_crate_versions`
|
|
377
|
+
|
|
378
|
+
List all published versions of a crate from crates.io.
|
|
379
|
+
|
|
380
|
+
| Parameter | Type | Required | Description |
|
|
381
|
+
|---|---|---|---|
|
|
382
|
+
| `crateName` | string | yes | Crate name |
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
> get_crate_versions({ crateName: "serde" })
|
|
386
|
+
|
|
387
|
+
# serde — 312 versions
|
|
388
|
+
|
|
389
|
+
1.0.219 2025-02-01
|
|
390
|
+
1.0.218 2025-01-12
|
|
391
|
+
1.0.217 2024-12-23
|
|
392
|
+
...
|
|
393
|
+
0.1.0 2014-12-09
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
### `get_source_code`
|
|
399
|
+
|
|
400
|
+
Fetch the raw source code of a file from docs.rs (or doc.rust-lang.org for std crates).
|
|
401
|
+
|
|
402
|
+
| Parameter | Type | Required | Description |
|
|
403
|
+
|---|---|---|---|
|
|
404
|
+
| `crateName` | string | yes | Crate name |
|
|
405
|
+
| `path` | string | yes | Source path relative to crate root (e.g. `"src/lib.rs"`, `"src/sync/mutex.rs"`) |
|
|
406
|
+
| `version` | string | no | Pinned version |
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
> get_source_code({ crateName: "tokio", path: "src/sync/mutex.rs" })
|
|
410
|
+
|
|
411
|
+
# Source: tokio/src/sync/mutex.rs
|
|
412
|
+
https://docs.rs/tokio/latest/src/tokio/sync/mutex.rs
|
|
413
|
+
|
|
414
|
+
\`\`\`rust
|
|
415
|
+
use crate::sync::batch_semaphore as semaphore;
|
|
416
|
+
...
|
|
417
|
+
\`\`\`
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
374
422
|
## Recommended workflows
|
|
375
423
|
|
|
376
424
|
### Exploring a new crate
|
|
@@ -408,6 +456,8 @@ src/
|
|
|
408
456
|
lookup-item.ts lookup_crate_item
|
|
409
457
|
search.ts search_crate
|
|
410
458
|
search-crates.ts search_crates
|
|
459
|
+
crate-versions.ts get_crate_versions
|
|
460
|
+
source-code.ts get_source_code
|
|
411
461
|
crate-metadata.ts get_crate_metadata
|
|
412
462
|
crate-brief.ts get_crate_brief
|
|
413
463
|
```
|
package/dist/index.js
CHANGED
|
@@ -2,26 +2,51 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import axios from "axios";
|
|
6
5
|
import { load } from "cheerio";
|
|
7
|
-
import { convert } from "html-to-text";
|
|
8
6
|
const DEFAULT_TTL = 5 * 60 * 1e3;
|
|
7
|
+
const STALE_GRACE = 15 * 60 * 1e3;
|
|
8
|
+
const MAX_ENTRIES = 500;
|
|
9
9
|
const store = /* @__PURE__ */ new Map();
|
|
10
10
|
function cacheGet(key) {
|
|
11
11
|
const entry = store.get(key);
|
|
12
12
|
if (!entry) return null;
|
|
13
|
-
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
if (now > entry.staleExpiry) {
|
|
14
15
|
store.delete(key);
|
|
15
16
|
return null;
|
|
16
17
|
}
|
|
18
|
+
entry.lastAccess = now;
|
|
17
19
|
return entry.data;
|
|
18
20
|
}
|
|
21
|
+
function cacheIsStale(key) {
|
|
22
|
+
const entry = store.get(key);
|
|
23
|
+
if (!entry) return true;
|
|
24
|
+
return Date.now() > entry.expiry;
|
|
25
|
+
}
|
|
19
26
|
function cacheSet(key, data, ttl = DEFAULT_TTL) {
|
|
20
|
-
store.
|
|
27
|
+
while (store.size >= MAX_ENTRIES) evictLru();
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
store.set(key, {
|
|
30
|
+
data,
|
|
31
|
+
expiry: now + ttl,
|
|
32
|
+
staleExpiry: now + STALE_GRACE,
|
|
33
|
+
lastAccess: now
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function evictLru() {
|
|
37
|
+
let oldestKey = null;
|
|
38
|
+
let oldestAccess = Infinity;
|
|
39
|
+
for (const [key, entry] of store) {
|
|
40
|
+
if (entry.lastAccess < oldestAccess) {
|
|
41
|
+
oldestAccess = entry.lastAccess;
|
|
42
|
+
oldestKey = key;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (oldestKey) store.delete(oldestKey);
|
|
21
46
|
}
|
|
22
47
|
const DOCS_BASE = "https://docs.rs";
|
|
23
48
|
const CRATES_IO = "https://crates.io/api/v1";
|
|
24
|
-
const USER_AGENT = "mcp-rust-docs/
|
|
49
|
+
const USER_AGENT = "mcp-rust-docs/4.0.0";
|
|
25
50
|
const MAX_DOC_LENGTH = 6e3;
|
|
26
51
|
const MAX_SEARCH_RESULTS = 100;
|
|
27
52
|
const SECTION_TO_TYPE = {
|
|
@@ -72,24 +97,78 @@ function modToUrlPrefix(modulePath) {
|
|
|
72
97
|
function modToRustPrefix(modulePath) {
|
|
73
98
|
return modulePath ? modulePath.replace(/\./g, "::") + "::" : "";
|
|
74
99
|
}
|
|
100
|
+
class HttpError extends Error {
|
|
101
|
+
constructor(status, message) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.status = status;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function fetchWithRetry(fn, retries = 2, baseDelay = 500) {
|
|
107
|
+
for (let i = 0; i <= retries; i++) {
|
|
108
|
+
try {
|
|
109
|
+
return await fn();
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (i === retries) throw e;
|
|
112
|
+
const isRetryable = e instanceof HttpError && e.status >= 500;
|
|
113
|
+
if (!isRetryable) throw e;
|
|
114
|
+
console.log(`[retry ${i + 1}/${retries}] ${e.message}`);
|
|
115
|
+
await new Promise((r) => setTimeout(r, baseDelay * (i + 1)));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
throw new Error("unreachable");
|
|
119
|
+
}
|
|
120
|
+
async function fetchText(url, timeout = 15e3) {
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(url, {
|
|
125
|
+
headers: { "User-Agent": USER_AGENT },
|
|
126
|
+
signal: controller.signal
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok) throw new HttpError(res.status, `HTTP ${res.status} for ${url}`);
|
|
129
|
+
return await res.text();
|
|
130
|
+
} finally {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function fetchJson(url, timeout = 1e4) {
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(url, {
|
|
139
|
+
headers: { "User-Agent": USER_AGENT },
|
|
140
|
+
signal: controller.signal
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) throw new HttpError(res.status, `HTTP ${res.status} for ${url}`);
|
|
143
|
+
return await res.json();
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
75
148
|
async function fetchDom(url) {
|
|
76
|
-
const
|
|
149
|
+
const cacheKey = `dom:${url}`;
|
|
150
|
+
const cached = cacheGet(cacheKey);
|
|
77
151
|
if (cached) {
|
|
78
152
|
console.log(`[cache hit] ${url}`);
|
|
153
|
+
if (cacheIsStale(cacheKey)) {
|
|
154
|
+
console.log(`[stale refresh] ${url}`);
|
|
155
|
+
fetchWithRetry(() => fetchText(url)).then((html2) => cacheSet(cacheKey, html2)).catch(() => {
|
|
156
|
+
});
|
|
157
|
+
}
|
|
79
158
|
return load(cached);
|
|
80
159
|
}
|
|
81
|
-
const
|
|
82
|
-
cacheSet(
|
|
83
|
-
return load(
|
|
160
|
+
const html = await fetchWithRetry(() => fetchText(url));
|
|
161
|
+
cacheSet(cacheKey, html);
|
|
162
|
+
return load(html);
|
|
84
163
|
}
|
|
85
164
|
function cleanHtml(html) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}).trim();
|
|
165
|
+
const $ = load(`<body>${html}</body>`);
|
|
166
|
+
$("img").remove();
|
|
167
|
+
$("summary.hideme").remove();
|
|
168
|
+
$("p, div, br, h1, h2, h3, h4, h5, h6, li, tr").each((_, el) => {
|
|
169
|
+
$(el).before("\n");
|
|
170
|
+
});
|
|
171
|
+
return $("body").text().replace(/\n{3,}/g, "\n\n").trim();
|
|
93
172
|
}
|
|
94
173
|
function truncate(text, max) {
|
|
95
174
|
return text.length > max ? text.slice(0, max) + "\n\n[…truncated]" : text;
|
|
@@ -100,7 +179,6 @@ function textResult(text) {
|
|
|
100
179
|
function errorResult(msg) {
|
|
101
180
|
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
|
|
102
181
|
}
|
|
103
|
-
const cratesIoHeaders = { "User-Agent": USER_AGENT };
|
|
104
182
|
async function fetchCrateInfo(name) {
|
|
105
183
|
const cacheKey = `crate-info:${name}`;
|
|
106
184
|
const cached = cacheGet(cacheKey);
|
|
@@ -108,17 +186,16 @@ async function fetchCrateInfo(name) {
|
|
|
108
186
|
console.log(`[cache hit] crate-info ${name}`);
|
|
109
187
|
return cached;
|
|
110
188
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
});
|
|
189
|
+
const data = await fetchWithRetry(
|
|
190
|
+
() => fetchJson(`${CRATES_IO}/crates/${name}`)
|
|
191
|
+
);
|
|
115
192
|
const c = data.crate;
|
|
116
193
|
const info = {
|
|
117
194
|
name: c.name,
|
|
118
195
|
version: c.max_stable_version || c.max_version,
|
|
119
196
|
description: c.description,
|
|
120
|
-
documentation: c.documentation,
|
|
121
|
-
repository: c.repository,
|
|
197
|
+
documentation: c.documentation ?? null,
|
|
198
|
+
repository: c.repository ?? null,
|
|
122
199
|
downloads: c.downloads
|
|
123
200
|
};
|
|
124
201
|
cacheSet(cacheKey, info);
|
|
@@ -131,10 +208,9 @@ async function fetchCrateVersionInfo(name, version) {
|
|
|
131
208
|
console.log(`[cache hit] crate-version ${name}@${version}`);
|
|
132
209
|
return cached;
|
|
133
210
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
211
|
+
const data = await fetchWithRetry(
|
|
212
|
+
() => fetchJson(`${CRATES_IO}/crates/${name}/${version}`)
|
|
213
|
+
);
|
|
138
214
|
const v = data.version;
|
|
139
215
|
const features = v.features ?? {};
|
|
140
216
|
const info = {
|
|
@@ -142,7 +218,8 @@ async function fetchCrateVersionInfo(name, version) {
|
|
|
142
218
|
features,
|
|
143
219
|
defaultFeatures: features["default"] ?? [],
|
|
144
220
|
yanked: v.yanked,
|
|
145
|
-
license: v.license
|
|
221
|
+
license: v.license,
|
|
222
|
+
msrv: v.rust_version ?? null
|
|
146
223
|
};
|
|
147
224
|
cacheSet(cacheKey, info);
|
|
148
225
|
return info;
|
|
@@ -154,10 +231,9 @@ async function fetchCrateDeps(name, version) {
|
|
|
154
231
|
console.log(`[cache hit] crate-deps ${name}@${version}`);
|
|
155
232
|
return cached;
|
|
156
233
|
}
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
});
|
|
234
|
+
const data = await fetchWithRetry(
|
|
235
|
+
() => fetchJson(`${CRATES_IO}/crates/${name}/${version}/dependencies`)
|
|
236
|
+
);
|
|
161
237
|
const deps = (data.dependencies ?? []).map((d) => ({
|
|
162
238
|
name: d.crate_id,
|
|
163
239
|
req: d.req,
|
|
@@ -168,6 +244,35 @@ async function fetchCrateDeps(name, version) {
|
|
|
168
244
|
cacheSet(cacheKey, deps);
|
|
169
245
|
return deps;
|
|
170
246
|
}
|
|
247
|
+
async function searchAllItems(crateName, query, version = "latest") {
|
|
248
|
+
const url = docsUrl(crateName, "all.html", version);
|
|
249
|
+
const $ = await fetchDom(url);
|
|
250
|
+
const q = query.toLowerCase();
|
|
251
|
+
const results = [];
|
|
252
|
+
$("h3").each((_, h3) => {
|
|
253
|
+
const rawId = $(h3).attr("id") ?? "";
|
|
254
|
+
const type = SECTION_TO_TYPE[rawId] ?? rawId;
|
|
255
|
+
$(h3).next("ul.all-items").find("li a").each((_2, a) => {
|
|
256
|
+
const name = $(a).text().trim();
|
|
257
|
+
const lower = name.toLowerCase();
|
|
258
|
+
const bareName = name.includes("::") ? name.split("::").pop() : name;
|
|
259
|
+
const bareNameLower = bareName.toLowerCase();
|
|
260
|
+
let score = 0;
|
|
261
|
+
if (bareNameLower === q) score = 100;
|
|
262
|
+
else if (lower === q) score = 95;
|
|
263
|
+
else if (bareNameLower.startsWith(q)) score = 60;
|
|
264
|
+
else if (lower.startsWith(q)) score = 55;
|
|
265
|
+
else if (lower.includes(q)) score = 20;
|
|
266
|
+
if (score > 0) {
|
|
267
|
+
const parts = name.split("::");
|
|
268
|
+
const modulePath = parts.length > 1 ? parts.slice(0, -1).join(".") : "";
|
|
269
|
+
results.push({ type, name, path: name, modulePath, bareName, score });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
274
|
+
return results;
|
|
275
|
+
}
|
|
171
276
|
function extractItemFeatureGate($) {
|
|
172
277
|
const gate = $(".item-info .stab.portability").first().text().trim();
|
|
173
278
|
return gate || null;
|
|
@@ -209,7 +314,7 @@ const itemTypeEnum = z.enum([
|
|
|
209
314
|
"derive"
|
|
210
315
|
]);
|
|
211
316
|
const versionParam = z.string().optional().describe('Crate version (e.g. "1.49.0"). Defaults to latest.');
|
|
212
|
-
function register$
|
|
317
|
+
function register$b(server2) {
|
|
213
318
|
server2.tool(
|
|
214
319
|
"lookup_crate_docs",
|
|
215
320
|
"Fetch the main documentation for a Rust crate. Returns overview, version, sections, and re-exports.",
|
|
@@ -246,17 +351,18 @@ function register$6(server2) {
|
|
|
246
351
|
}
|
|
247
352
|
);
|
|
248
353
|
}
|
|
249
|
-
function register$
|
|
354
|
+
function register$a(server2) {
|
|
250
355
|
server2.tool(
|
|
251
356
|
"get_crate_items",
|
|
252
|
-
"List public items in a crate root or module. Returns names, types, feature gates, and short descriptions.",
|
|
357
|
+
"List public items in a crate root or module. Returns names, types, feature gates, and short descriptions. Supports filtering by item type and feature gate.",
|
|
253
358
|
{
|
|
254
359
|
crateName: z.string().describe("Crate name"),
|
|
255
360
|
modulePath: z.string().optional().describe('Dot-separated module path (e.g. "sync", "io.util"). Omit for crate root.'),
|
|
256
361
|
itemType: itemTypeEnum.optional().describe("Filter results to a single item type"),
|
|
362
|
+
feature: z.string().optional().describe('Filter to items gated behind this feature (e.g. "sync", "fs")'),
|
|
257
363
|
version: versionParam
|
|
258
364
|
},
|
|
259
|
-
async ({ crateName, modulePath, itemType, version }) => {
|
|
365
|
+
async ({ crateName, modulePath, itemType, feature, version }) => {
|
|
260
366
|
try {
|
|
261
367
|
const ver = version ?? "latest";
|
|
262
368
|
const url = docsUrl(crateName, `${modToUrlPrefix(modulePath)}index.html`, ver);
|
|
@@ -272,18 +378,23 @@ function register$5(server2) {
|
|
|
272
378
|
const desc = $dt.next("dd").text().trim();
|
|
273
379
|
const gate = $dt.find(".stab.portability code").first().text().trim();
|
|
274
380
|
if (!name) return;
|
|
381
|
+
if (feature) {
|
|
382
|
+
if (!gate || !gate.toLowerCase().includes(feature.toLowerCase())) return;
|
|
383
|
+
}
|
|
275
384
|
const tag = gate ? ` [feature: ${gate}]` : "";
|
|
276
385
|
lines.push(`[${type}] ${name}${tag} — ${desc}`);
|
|
277
386
|
});
|
|
278
387
|
});
|
|
279
388
|
const label = modulePath ? `${crateName}::${modulePath.replace(/\./g, "::")}` : crateName;
|
|
389
|
+
const filters = [];
|
|
390
|
+
if (itemType) filters.push(`type: ${itemType}`);
|
|
391
|
+
if (feature) filters.push(`feature: ${feature}`);
|
|
392
|
+
const filterLabel = filters.length ? ` (${filters.join(", ")})` : "";
|
|
280
393
|
if (!lines.length) {
|
|
281
|
-
return textResult(
|
|
282
|
-
`No items found in ${label}${itemType ? ` (type: ${itemType})` : ""}.`
|
|
283
|
-
);
|
|
394
|
+
return textResult(`No items found in ${label}${filterLabel}.`);
|
|
284
395
|
}
|
|
285
396
|
return textResult(
|
|
286
|
-
[`# Items in ${label}${
|
|
397
|
+
[`# Items in ${label}${filterLabel}`, url, "", ...lines].join("\n")
|
|
287
398
|
);
|
|
288
399
|
} catch (e) {
|
|
289
400
|
return errorResult(`Could not list items. ${e.message}`);
|
|
@@ -291,15 +402,15 @@ function register$5(server2) {
|
|
|
291
402
|
}
|
|
292
403
|
);
|
|
293
404
|
}
|
|
294
|
-
function register$
|
|
405
|
+
function register$9(server2) {
|
|
295
406
|
server2.tool(
|
|
296
407
|
"lookup_crate_item",
|
|
297
|
-
"Get detailed documentation for a specific item. Returns signature, docs, feature gate, methods, trait impls, and optionally examples.",
|
|
408
|
+
"Get detailed documentation for a specific item. Returns signature, docs, feature gate, methods, trait impls, and optionally examples. Auto-discovers modulePath if omitted.",
|
|
298
409
|
{
|
|
299
410
|
crateName: z.string().describe("Crate name"),
|
|
300
411
|
itemType: itemTypeEnum.describe("Item type"),
|
|
301
412
|
itemName: z.string().describe('Item name (e.g. "Mutex", "spawn", "Serialize")'),
|
|
302
|
-
modulePath: z.string().optional().describe('Dot-separated module path (e.g. "sync").
|
|
413
|
+
modulePath: z.string().optional().describe('Dot-separated module path (e.g. "sync"). Auto-discovered if omitted.'),
|
|
303
414
|
version: versionParam,
|
|
304
415
|
includeExamples: z.boolean().optional().describe("Include code examples from the docs. Default: false."),
|
|
305
416
|
includeImpls: z.boolean().optional().describe("Include trait implementation list. Default: false.")
|
|
@@ -307,11 +418,46 @@ function register$4(server2) {
|
|
|
307
418
|
async ({ crateName, itemType, itemName, modulePath, version, includeExamples, includeImpls }) => {
|
|
308
419
|
try {
|
|
309
420
|
const ver = version ?? "latest";
|
|
310
|
-
|
|
421
|
+
let resolvedModulePath = modulePath;
|
|
422
|
+
if (resolvedModulePath === void 0) {
|
|
423
|
+
const hits = await searchAllItems(crateName, itemName, ver);
|
|
424
|
+
const match = hits.find(
|
|
425
|
+
(h) => h.bareName.toLowerCase() === itemName.toLowerCase() && h.type === itemType
|
|
426
|
+
) ?? hits.find(
|
|
427
|
+
(h) => h.bareName.toLowerCase() === itemName.toLowerCase()
|
|
428
|
+
);
|
|
429
|
+
if (match) {
|
|
430
|
+
resolvedModulePath = match.modulePath || void 0;
|
|
431
|
+
console.log(`[auto-discovery] ${itemName} → ${match.name} (${match.type})`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const prefix = modToUrlPrefix(resolvedModulePath);
|
|
311
435
|
const page = itemType === "mod" ? `${prefix}${itemName}/index.html` : `${prefix}${TYPE_FILE_PREFIX[itemType] ?? `${itemType}.`}${itemName}.html`;
|
|
312
436
|
const url = docsUrl(crateName, page, ver);
|
|
313
|
-
|
|
314
|
-
|
|
437
|
+
let $;
|
|
438
|
+
try {
|
|
439
|
+
$ = await fetchDom(url);
|
|
440
|
+
} catch (fetchErr) {
|
|
441
|
+
const hits = await searchAllItems(crateName, itemName, ver);
|
|
442
|
+
const fuzzy = hits.filter(
|
|
443
|
+
(h) => h.bareName.toLowerCase().includes(itemName.toLowerCase()) || itemName.toLowerCase().includes(h.bareName.toLowerCase())
|
|
444
|
+
);
|
|
445
|
+
if (fuzzy.length) {
|
|
446
|
+
const suggestions = fuzzy.slice(0, 10).map(
|
|
447
|
+
(h) => ` [${h.type}] ${crateName}::${h.name}`
|
|
448
|
+
);
|
|
449
|
+
return textResult([
|
|
450
|
+
`Could not find ${itemType} "${itemName}" at the expected path.`,
|
|
451
|
+
"",
|
|
452
|
+
"Did you mean one of these?",
|
|
453
|
+
...suggestions,
|
|
454
|
+
"",
|
|
455
|
+
"Tip: use search_crate to find the exact name and module path."
|
|
456
|
+
].join("\n"));
|
|
457
|
+
}
|
|
458
|
+
throw fetchErr;
|
|
459
|
+
}
|
|
460
|
+
const fullName = `${crateName}::${modToRustPrefix(resolvedModulePath)}${itemName}`;
|
|
315
461
|
const decl = $("pre.rust.item-decl").text().trim();
|
|
316
462
|
const featureGate = extractItemFeatureGate($);
|
|
317
463
|
const doc = truncate(
|
|
@@ -389,7 +535,7 @@ function scoreMatch(name, query) {
|
|
|
389
535
|
if (lower.includes(q)) return 20;
|
|
390
536
|
return 0;
|
|
391
537
|
}
|
|
392
|
-
function register$
|
|
538
|
+
function register$8(server2) {
|
|
393
539
|
server2.tool(
|
|
394
540
|
"search_crate",
|
|
395
541
|
"Search for items by name within a Rust crate. Returns ranked results with canonical paths and item types.",
|
|
@@ -433,7 +579,7 @@ function register$3(server2) {
|
|
|
433
579
|
}
|
|
434
580
|
);
|
|
435
581
|
}
|
|
436
|
-
function register$
|
|
582
|
+
function register$7(server2) {
|
|
437
583
|
server2.tool(
|
|
438
584
|
"get_crate_metadata",
|
|
439
585
|
"Get crate metadata from crates.io: version, features, default features, optional dependencies, and links.",
|
|
@@ -467,6 +613,7 @@ Use lookup_crate_docs, get_crate_items, lookup_crate_item, or search_crate to br
|
|
|
467
613
|
parts.push(` crates.io: https://crates.io/crates/${info.name}`);
|
|
468
614
|
parts.push(` license: ${versionInfo.license}`);
|
|
469
615
|
parts.push(` downloads: ${info.downloads.toLocaleString()}`);
|
|
616
|
+
if (versionInfo.msrv) parts.push(` msrv: ${versionInfo.msrv}`);
|
|
470
617
|
const { features, defaultFeatures } = versionInfo;
|
|
471
618
|
parts.push("", "## Features");
|
|
472
619
|
parts.push(` default = [${defaultFeatures.join(", ")}]`);
|
|
@@ -500,7 +647,7 @@ Use lookup_crate_docs, get_crate_items, lookup_crate_item, or search_crate to br
|
|
|
500
647
|
}
|
|
501
648
|
);
|
|
502
649
|
}
|
|
503
|
-
function register$
|
|
650
|
+
function register$6(server2) {
|
|
504
651
|
server2.tool(
|
|
505
652
|
"get_crate_brief",
|
|
506
653
|
"Bundle call: fetches crate metadata, overview docs, module list, re-exports, and optionally items from focused modules — all in one shot.",
|
|
@@ -610,7 +757,7 @@ function register$1(server2) {
|
|
|
610
757
|
}
|
|
611
758
|
);
|
|
612
759
|
}
|
|
613
|
-
function register(server2) {
|
|
760
|
+
function register$5(server2) {
|
|
614
761
|
server2.tool(
|
|
615
762
|
"search_crates",
|
|
616
763
|
"Search for Rust crates on crates.io by keyword. Returns name, description, downloads, and version.",
|
|
@@ -629,11 +776,12 @@ function register(server2) {
|
|
|
629
776
|
console.log(`[cache hit] search-crates "${query}" page=${page}`);
|
|
630
777
|
return textResult(cached);
|
|
631
778
|
}
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
779
|
+
const params = new URLSearchParams({
|
|
780
|
+
q: query,
|
|
781
|
+
per_page: String(perPage),
|
|
782
|
+
page: String(page)
|
|
636
783
|
});
|
|
784
|
+
const data = await fetchJson(`${CRATES_IO}/crates?${params}`);
|
|
637
785
|
const crates = data.crates ?? [];
|
|
638
786
|
if (!crates.length) {
|
|
639
787
|
return textResult(`No crates found for "${query}".`);
|
|
@@ -658,8 +806,288 @@ function register(server2) {
|
|
|
658
806
|
}
|
|
659
807
|
);
|
|
660
808
|
}
|
|
809
|
+
function register$4(server2) {
|
|
810
|
+
server2.tool(
|
|
811
|
+
"get_crate_versions",
|
|
812
|
+
"List all published versions of a crate from crates.io, with yanked status and release dates.",
|
|
813
|
+
{
|
|
814
|
+
crateName: z.string().describe("Crate name")
|
|
815
|
+
},
|
|
816
|
+
async ({ crateName }) => {
|
|
817
|
+
try {
|
|
818
|
+
if (isStdCrate(crateName)) {
|
|
819
|
+
return textResult(
|
|
820
|
+
`"${crateName}" is part of the Rust standard library and is not published on crates.io.
|
|
821
|
+
Its version matches the Rust toolchain version.`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
const cacheKey = `crate-versions:${crateName}`;
|
|
825
|
+
const cached = cacheGet(cacheKey);
|
|
826
|
+
if (cached) {
|
|
827
|
+
console.log(`[cache hit] crate-versions ${crateName}`);
|
|
828
|
+
return textResult(cached);
|
|
829
|
+
}
|
|
830
|
+
const data = await fetchJson(`${CRATES_IO}/crates/${crateName}/versions`);
|
|
831
|
+
const versions = (data.versions ?? []).map((v) => ({
|
|
832
|
+
num: v.num,
|
|
833
|
+
yanked: v.yanked,
|
|
834
|
+
created_at: v.created_at,
|
|
835
|
+
license: v.license ?? ""
|
|
836
|
+
}));
|
|
837
|
+
if (!versions.length) {
|
|
838
|
+
return textResult(`No versions found for "${crateName}".`);
|
|
839
|
+
}
|
|
840
|
+
const lines = versions.map((v) => {
|
|
841
|
+
const date = v.created_at.slice(0, 10);
|
|
842
|
+
const yanked = v.yanked ? " [YANKED]" : "";
|
|
843
|
+
return ` ${v.num} ${date}${yanked}`;
|
|
844
|
+
});
|
|
845
|
+
const result = [
|
|
846
|
+
`# ${crateName} — ${versions.length} versions`,
|
|
847
|
+
"",
|
|
848
|
+
...lines
|
|
849
|
+
].join("\n");
|
|
850
|
+
cacheSet(cacheKey, result);
|
|
851
|
+
return textResult(result);
|
|
852
|
+
} catch (e) {
|
|
853
|
+
return errorResult(`Could not fetch versions for "${crateName}". ${e.message}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
function register$3(server2) {
|
|
859
|
+
server2.tool(
|
|
860
|
+
"get_source_code",
|
|
861
|
+
"Fetch the source code of a Rust item from docs.rs. Returns the raw source implementation.",
|
|
862
|
+
{
|
|
863
|
+
crateName: z.string().describe("Crate name"),
|
|
864
|
+
path: z.string().describe('Source path relative to crate root (e.g. "src/lib.rs", "src/sync/mutex.rs")'),
|
|
865
|
+
version: versionParam
|
|
866
|
+
},
|
|
867
|
+
async ({ crateName, path, version }) => {
|
|
868
|
+
try {
|
|
869
|
+
if (isStdCrate(crateName)) {
|
|
870
|
+
const url2 = `https://doc.rust-lang.org/stable/src/${crateName}/${path}`;
|
|
871
|
+
const $2 = await fetchDom(url2);
|
|
872
|
+
const code2 = $2("#source-code").text().trim() || $2("pre.rust").text().trim();
|
|
873
|
+
if (!code2) return errorResult(`No source code found at ${url2}`);
|
|
874
|
+
return textResult([
|
|
875
|
+
`# Source: ${crateName}/${path}`,
|
|
876
|
+
url2,
|
|
877
|
+
"",
|
|
878
|
+
"```rust",
|
|
879
|
+
truncate(code2, 12e3),
|
|
880
|
+
"```"
|
|
881
|
+
].join("\n"));
|
|
882
|
+
}
|
|
883
|
+
const ver = version ?? "latest";
|
|
884
|
+
const url = `${DOCS_BASE}/${crateName}/${ver}/src/${crateSlug(crateName)}/${path}`;
|
|
885
|
+
const $ = await fetchDom(url);
|
|
886
|
+
const code = $("#source-code").text().trim() || $("pre.rust").text().trim() || $(".src-line-numbers + code").text().trim();
|
|
887
|
+
if (!code) {
|
|
888
|
+
return errorResult(`No source code found at ${url}. Check that the path is correct.`);
|
|
889
|
+
}
|
|
890
|
+
return textResult([
|
|
891
|
+
`# Source: ${crateName}/${path}`,
|
|
892
|
+
url,
|
|
893
|
+
"",
|
|
894
|
+
"```rust",
|
|
895
|
+
truncate(code, 12e3),
|
|
896
|
+
"```"
|
|
897
|
+
].join("\n"));
|
|
898
|
+
} catch (e) {
|
|
899
|
+
return errorResult(`Could not fetch source for "${crateName}/${path}". ${e.message}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
const querySchema = z.object({
|
|
905
|
+
itemType: itemTypeEnum.describe("Item type"),
|
|
906
|
+
itemName: z.string().describe("Item name"),
|
|
907
|
+
modulePath: z.string().optional().describe("Dot-separated module path")
|
|
908
|
+
});
|
|
909
|
+
function register$2(server2) {
|
|
910
|
+
server2.tool(
|
|
911
|
+
"batch_lookup",
|
|
912
|
+
"Look up multiple items in a single call. Returns a compact summary (signature + short doc) for each item. Saves round-trips when you need several items.",
|
|
913
|
+
{
|
|
914
|
+
crateName: z.string().describe("Crate name"),
|
|
915
|
+
items: z.array(querySchema).min(1).max(20).describe("Items to look up (max 20)"),
|
|
916
|
+
version: versionParam
|
|
917
|
+
},
|
|
918
|
+
async ({ crateName, items, version }) => {
|
|
919
|
+
const ver = version ?? "latest";
|
|
920
|
+
const parts = [`# Batch lookup: ${crateName} (${items.length} items)`, ""];
|
|
921
|
+
const results = await Promise.allSettled(
|
|
922
|
+
items.map(async ({ itemType, itemName, modulePath }) => {
|
|
923
|
+
let resolvedModulePath = modulePath;
|
|
924
|
+
if (resolvedModulePath === void 0) {
|
|
925
|
+
const hits = await searchAllItems(crateName, itemName, ver);
|
|
926
|
+
const match = hits.find(
|
|
927
|
+
(h) => h.bareName.toLowerCase() === itemName.toLowerCase() && h.type === itemType
|
|
928
|
+
) ?? hits.find(
|
|
929
|
+
(h) => h.bareName.toLowerCase() === itemName.toLowerCase()
|
|
930
|
+
);
|
|
931
|
+
if (match) resolvedModulePath = match.modulePath || void 0;
|
|
932
|
+
}
|
|
933
|
+
const prefix = modToUrlPrefix(resolvedModulePath);
|
|
934
|
+
const page = itemType === "mod" ? `${prefix}${itemName}/index.html` : `${prefix}${TYPE_FILE_PREFIX[itemType] ?? `${itemType}.`}${itemName}.html`;
|
|
935
|
+
const url = docsUrl(crateName, page, ver);
|
|
936
|
+
const $ = await fetchDom(url);
|
|
937
|
+
const fullName = `${crateName}::${modToRustPrefix(resolvedModulePath)}${itemName}`;
|
|
938
|
+
const decl = $("pre.rust.item-decl").text().trim();
|
|
939
|
+
const featureGate = extractItemFeatureGate($);
|
|
940
|
+
const doc = truncate(
|
|
941
|
+
cleanHtml($("details.toggle.top-doc").html() ?? ""),
|
|
942
|
+
500
|
|
943
|
+
);
|
|
944
|
+
return { itemType, fullName, decl, featureGate, doc, url };
|
|
945
|
+
})
|
|
946
|
+
);
|
|
947
|
+
for (let i = 0; i < results.length; i++) {
|
|
948
|
+
const r = results[i];
|
|
949
|
+
const { itemType, itemName } = items[i];
|
|
950
|
+
if (r.status === "fulfilled") {
|
|
951
|
+
const { fullName, decl, featureGate, doc, url } = r.value;
|
|
952
|
+
parts.push(`## ${itemType} ${fullName}`, url);
|
|
953
|
+
if (featureGate) parts.push(`> ${featureGate}`);
|
|
954
|
+
if (decl) parts.push("```rust", decl, "```");
|
|
955
|
+
if (doc) parts.push(doc);
|
|
956
|
+
parts.push("");
|
|
957
|
+
} else {
|
|
958
|
+
parts.push(`## ${itemType} ${itemName}`, ` Error: ${r.reason?.message ?? "unknown error"}`, "");
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return textResult(parts.join("\n"));
|
|
962
|
+
}
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
function extractGhOwnerRepo(url) {
|
|
966
|
+
const m = url.match(/github\.com\/([^/]+\/[^/]+)/);
|
|
967
|
+
if (!m) return null;
|
|
968
|
+
return m[1].replace(/\.git$/, "");
|
|
969
|
+
}
|
|
970
|
+
function register$1(server2) {
|
|
971
|
+
server2.tool(
|
|
972
|
+
"get_crate_changelog",
|
|
973
|
+
"Fetch recent GitHub releases for a crate. Requires the crate to have a GitHub repository link on crates.io.",
|
|
974
|
+
{
|
|
975
|
+
crateName: z.string().describe("Crate name"),
|
|
976
|
+
count: z.number().min(1).max(20).optional().describe("Number of releases to fetch (default 5, max 20)")
|
|
977
|
+
},
|
|
978
|
+
async ({ crateName, count: rawCount }) => {
|
|
979
|
+
const count = rawCount ?? 5;
|
|
980
|
+
try {
|
|
981
|
+
if (isStdCrate(crateName)) {
|
|
982
|
+
return textResult(
|
|
983
|
+
`"${crateName}" is part of the Rust standard library.
|
|
984
|
+
See https://github.com/rust-lang/rust/blob/master/RELEASES.md for changelogs.`
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
const cacheKey = `changelog:${crateName}:${count}`;
|
|
988
|
+
const cached = cacheGet(cacheKey);
|
|
989
|
+
if (cached) {
|
|
990
|
+
console.log(`[cache hit] changelog ${crateName}`);
|
|
991
|
+
return textResult(cached);
|
|
992
|
+
}
|
|
993
|
+
const info = await fetchCrateInfo(crateName);
|
|
994
|
+
if (!info.repository) {
|
|
995
|
+
return errorResult(`No repository link found for "${crateName}" on crates.io.`);
|
|
996
|
+
}
|
|
997
|
+
const ownerRepo = extractGhOwnerRepo(info.repository);
|
|
998
|
+
if (!ownerRepo) {
|
|
999
|
+
return errorResult(`Repository "${info.repository}" is not a GitHub URL.`);
|
|
1000
|
+
}
|
|
1001
|
+
const releases = await fetchJson(
|
|
1002
|
+
`https://api.github.com/repos/${ownerRepo}/releases?per_page=${count}`
|
|
1003
|
+
);
|
|
1004
|
+
if (!releases.length) {
|
|
1005
|
+
return textResult(`No GitHub releases found for ${ownerRepo}.`);
|
|
1006
|
+
}
|
|
1007
|
+
const parts = [
|
|
1008
|
+
`# ${crateName} — recent releases (${ownerRepo})`,
|
|
1009
|
+
""
|
|
1010
|
+
];
|
|
1011
|
+
for (const r of releases) {
|
|
1012
|
+
const date = r.published_at?.slice(0, 10) ?? "unknown";
|
|
1013
|
+
const title = r.name || r.tag_name;
|
|
1014
|
+
const body = r.body ? truncate(r.body.trim(), 1e3) : "(no release notes)";
|
|
1015
|
+
parts.push(`## ${title} (${date})`, body, "");
|
|
1016
|
+
}
|
|
1017
|
+
const result = parts.join("\n");
|
|
1018
|
+
cacheSet(cacheKey, result);
|
|
1019
|
+
return textResult(result);
|
|
1020
|
+
} catch (e) {
|
|
1021
|
+
return errorResult(`Could not fetch changelog for "${crateName}". ${e.message}`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
function register(server2) {
|
|
1027
|
+
server2.tool(
|
|
1028
|
+
"resolve_type",
|
|
1029
|
+
'Resolve a Rust type path (e.g. "tokio::sync::Mutex" or "std::collections::HashMap") to its documentation. Parses the path to determine the crate, module, and item name automatically.',
|
|
1030
|
+
{
|
|
1031
|
+
typePath: z.string().describe('Full Rust type path (e.g. "tokio::sync::Mutex", "std::collections::HashMap")'),
|
|
1032
|
+
version: versionParam
|
|
1033
|
+
},
|
|
1034
|
+
async ({ typePath, version }) => {
|
|
1035
|
+
try {
|
|
1036
|
+
const segments = typePath.split("::").filter(Boolean);
|
|
1037
|
+
if (segments.length < 2) {
|
|
1038
|
+
return errorResult(
|
|
1039
|
+
`Type path "${typePath}" must have at least a crate and item name (e.g. "serde::Serialize").`
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
const crateName = segments[0];
|
|
1043
|
+
const itemName = segments[segments.length - 1];
|
|
1044
|
+
const ver = version ?? "latest";
|
|
1045
|
+
const hits = await searchAllItems(crateName, itemName, ver);
|
|
1046
|
+
const exactMatch = hits.find((h) => {
|
|
1047
|
+
const fullPath = h.name.replace(/::/g, "::");
|
|
1048
|
+
const expectedPath = segments.slice(1).join("::");
|
|
1049
|
+
return fullPath === expectedPath && h.bareName === itemName;
|
|
1050
|
+
}) ?? hits.find((h) => h.bareName === itemName);
|
|
1051
|
+
if (!exactMatch) {
|
|
1052
|
+
const suggestions = hits.slice(0, 10).map(
|
|
1053
|
+
(h) => ` [${h.type}] ${crateName}::${h.name}`
|
|
1054
|
+
);
|
|
1055
|
+
const parts2 = [`Could not resolve "${typePath}".`];
|
|
1056
|
+
if (suggestions.length) {
|
|
1057
|
+
parts2.push("", "Similar items found:", ...suggestions);
|
|
1058
|
+
}
|
|
1059
|
+
return textResult(parts2.join("\n"));
|
|
1060
|
+
}
|
|
1061
|
+
const modulePath = exactMatch.modulePath ? exactMatch.modulePath.replace(/\./g, "/") + "/" : "";
|
|
1062
|
+
const prefix = TYPE_FILE_PREFIX[exactMatch.type] ?? `${exactMatch.type}.`;
|
|
1063
|
+
const page = exactMatch.type === "mod" ? `${modulePath}${itemName}/index.html` : `${modulePath}${prefix}${itemName}.html`;
|
|
1064
|
+
const url = docsUrl(crateName, page, ver);
|
|
1065
|
+
const $ = await fetchDom(url);
|
|
1066
|
+
const decl = $("pre.rust.item-decl").text().trim();
|
|
1067
|
+
const featureGate = extractItemFeatureGate($);
|
|
1068
|
+
const doc = truncate(
|
|
1069
|
+
cleanHtml($("details.toggle.top-doc").html() ?? ""),
|
|
1070
|
+
2e3
|
|
1071
|
+
);
|
|
1072
|
+
const fullName = `${crateName}::${exactMatch.name}`;
|
|
1073
|
+
const parts = [`# ${exactMatch.type} ${fullName}`, url, ""];
|
|
1074
|
+
if (featureGate) parts.push(`> ${featureGate}`, "");
|
|
1075
|
+
if (decl) parts.push("```rust", decl, "```", "");
|
|
1076
|
+
if (doc) parts.push(doc);
|
|
1077
|
+
return textResult(parts.join("\n"));
|
|
1078
|
+
} catch (e) {
|
|
1079
|
+
return errorResult(`Could not resolve "${typePath}". ${e.message}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
661
1084
|
console.log = (...args) => console.error(...args);
|
|
662
|
-
const server = new McpServer({ name: "rust-docs", version: "
|
|
1085
|
+
const server = new McpServer({ name: "rust-docs", version: "4.0.0" });
|
|
1086
|
+
register$b(server);
|
|
1087
|
+
register$a(server);
|
|
1088
|
+
register$9(server);
|
|
1089
|
+
register$8(server);
|
|
1090
|
+
register$7(server);
|
|
663
1091
|
register$6(server);
|
|
664
1092
|
register$5(server);
|
|
665
1093
|
register$4(server);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-rustdoc",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server for fetching and browsing Rust crate documentation from docs.rs and crates.io",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -43,9 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@modelcontextprotocol/sdk": "1.26.0",
|
|
46
|
-
"axios": "1.13.4",
|
|
47
46
|
"cheerio": "1.2.0",
|
|
48
|
-
"html-to-text": "9.0.5",
|
|
49
47
|
"zod": "4.3.6"
|
|
50
48
|
},
|
|
51
49
|
"devDependencies": {
|