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.
Files changed (3) hide show
  1. package/README.md +51 -1
  2. package/dist/index.js +483 -55
  3. 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 seven 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.
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
- if (Date.now() > entry.expiry) {
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.set(key, { data, expiry: Date.now() + ttl });
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/3.0.0";
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 cached = cacheGet(`dom:${url}`);
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 { data } = await axios.get(url, { timeout: 15e3 });
82
- cacheSet(`dom:${url}`, data);
83
- return load(data);
160
+ const html = await fetchWithRetry(() => fetchText(url));
161
+ cacheSet(cacheKey, html);
162
+ return load(html);
84
163
  }
85
164
  function cleanHtml(html) {
86
- return convert(html, {
87
- wordwrap: 120,
88
- selectors: [
89
- { selector: "a", options: { ignoreHref: true } },
90
- { selector: "img", format: "skip" }
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 { data } = await axios.get(`${CRATES_IO}/crates/${name}`, {
112
- headers: cratesIoHeaders,
113
- timeout: 1e4
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 { data } = await axios.get(`${CRATES_IO}/crates/${name}/${version}`, {
135
- headers: cratesIoHeaders,
136
- timeout: 1e4
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 { data } = await axios.get(`${CRATES_IO}/crates/${name}/${version}/dependencies`, {
158
- headers: cratesIoHeaders,
159
- timeout: 1e4
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$6(server2) {
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$5(server2) {
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}${itemType ? ` [${itemType}]` : ""}`, url, "", ...lines].join("\n")
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$4(server2) {
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"). Omit if at crate root.'),
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
- const prefix = modToUrlPrefix(modulePath);
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
- const $ = await fetchDom(url);
314
- const fullName = `${crateName}::${modToRustPrefix(modulePath)}${itemName}`;
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$3(server2) {
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$2(server2) {
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$1(server2) {
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 { data } = await axios.get(`${CRATES_IO}/crates`, {
633
- params: { q: query, per_page: perPage, page },
634
- headers: { "User-Agent": USER_AGENT },
635
- timeout: 1e4
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: "3.0.0" });
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.0.0",
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": {