@webjsdev/mcp 0.1.14 → 0.1.16

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/src/mcp-source.js CHANGED
@@ -13,7 +13,8 @@
13
13
  * `src/` entry-point files.
14
14
  * - `query`: grep the framework `src/` trees, returning bounded `file:line`
15
15
  * hits (with a disclosed cap, no silent truncation).
16
- * - `path`: read one source file (e.g. `server/src/ssr.js`), traversal-guarded
16
+ * - `path`: read one source file (e.g. `server/src/ssr/head.js`; the bare
17
+ * `server/src/ssr.js` is a barrel over the sibling directory), traversal-guarded
17
18
  * to stay inside a resolved framework package root.
18
19
  *
19
20
  * READ-ONLY and side-effect-free: it only reads files, loads no module, and
@@ -209,7 +210,7 @@ export async function readSource(deps, path) {
209
210
  const pkg = segs[0];
210
211
  const entry = deps.roots.find((r) => r.pkg === pkg);
211
212
  if (!entry) {
212
- return `Unknown or unresolvable package "${pkg || path}". Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}. Pass a path like server/src/ssr.js.`;
213
+ return `Unknown or unresolvable package "${pkg || path}". Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}. Pass a path like server/src/ssr/head.js.`;
213
214
  }
214
215
  const abs = resolve(entry.root, segs.slice(1).join('/'));
215
216
  const srcLabel = entry.src.split(sep).pop();
@@ -237,17 +238,158 @@ export async function readSource(deps, path) {
237
238
  }
238
239
  }
239
240
 
241
+ /** Bound one export's rendered declaration (a long interface body is cut here). */
242
+ const MAX_DECL_LINES = 40;
243
+ /** Bound the hits one lookup returns (overloads plus a JS fallback stay well under this). */
244
+ const MAX_EXPORT_HITS = 8;
245
+
246
+ /**
247
+ * Collect the JSDoc block (or the `//` run) that ends on the line right above
248
+ * `line`, as the lines themselves, or `[]` when the declaration has no comment.
249
+ * @param {string[]} lines
250
+ * @param {number} line index of the declaration line
251
+ * @returns {string[]}
252
+ */
253
+ function docAbove(lines, line) {
254
+ let i = line - 1;
255
+ if (i < 0) return [];
256
+ if (lines[i].trim().endsWith('*/')) {
257
+ const end = i;
258
+ while (i >= 0 && !lines[i].trim().startsWith('/**')) {
259
+ if (lines[i].trim().startsWith('/*') && !lines[i].trim().startsWith('/**')) return [];
260
+ i--;
261
+ }
262
+ return i < 0 ? [] : lines.slice(i, end + 1);
263
+ }
264
+ const end = i;
265
+ while (i >= 0 && lines[i].trim().startsWith('//')) i--;
266
+ return i === end ? [] : lines.slice(i + 1, end + 1);
267
+ }
268
+
269
+ /**
270
+ * The declaration that starts on `line`: a `function` / `const` / `type`
271
+ * statement runs to its terminating `;` (a `.js` function stops at its opening
272
+ * brace, so the body is never printed); an `interface` / `class` runs to the
273
+ * brace that closes it. Capped at {@link MAX_DECL_LINES}.
274
+ * @param {string[]} lines
275
+ * @param {number} line
276
+ * @param {boolean} isTypes true for a `.d.ts` file
277
+ * @returns {string[]}
278
+ */
279
+ function declarationFrom(lines, line, isTypes) {
280
+ const first = lines[line];
281
+ const braced = /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:interface|class|enum)\b/.test(first)
282
+ || (/^export\s+(?:declare\s+)?(?:const|let|var|type)\b/.test(first) && /[{(]\s*$/.test(first) && !/;\s*$/.test(first));
283
+ const out = [];
284
+ let depth = 0;
285
+ // An authored `export const x = (...) => {` is its first line: the value is
286
+ // the implementation, and the doc above it is the contract.
287
+ if (!isTypes && /^export\s+(?:const|let|var)\b/.test(first)) return [first];
288
+ for (let i = line; i < lines.length && out.length < MAX_DECL_LINES; i++) {
289
+ const l = lines[i];
290
+ if (!isTypes && /^export\s+(?:async\s+)?function\b/.test(first)) {
291
+ // A JS function: the signature only, cut at the body's opening brace.
292
+ const cut = l.indexOf('{');
293
+ if (cut >= 0 && (i > line || !/^\s*\/\//.test(l))) { out.push(l.slice(0, cut).trimEnd()); return out; }
294
+ out.push(l);
295
+ continue;
296
+ }
297
+ out.push(l);
298
+ if (braced) {
299
+ for (const ch of l) { if (ch === '{') depth++; else if (ch === '}') depth--; }
300
+ if (depth <= 0 && /}/.test(l) && i > line) return out;
301
+ if (depth <= 0 && i === line && /}\s*;?\s*$/.test(l)) return out;
302
+ } else {
303
+ // Balanced parens and braces matter for a multi-line signature; angle
304
+ // brackets are not counted (an arrow's `=>` would unbalance them).
305
+ for (const ch of l) { if (ch === '(' || ch === '{') depth++; else if (ch === ')' || ch === '}') depth--; }
306
+ if (depth <= 0 && /;\s*$/.test(l)) return out;
307
+ if (depth <= 0 && !isTypes && /=\s*\(.*\)\s*=>/.test(first) && i === line) return out;
308
+ }
309
+ }
310
+ if (out.length >= MAX_DECL_LINES) out.push(` ... (cut at ${MAX_DECL_LINES} lines; read the file with \`path\` for the rest)`);
311
+ return out;
312
+ }
313
+
314
+ /**
315
+ * `export` mode: ONE export's signature plus its doc comment, so an agent
316
+ * checking a contract pays for the declaration instead of the whole file. The
317
+ * typed declarations win (`index.d.ts` and `src/**\/*.d.ts` under each package
318
+ * root, where `@webjsdev/server` keeps every export with its doc and
319
+ * `@webjsdev/core` keeps them beside the source); the authored `.js` is the
320
+ * fallback when no typed hit exists or none of them carries a doc, since the
321
+ * JSDoc there is the behaviour contract. Overloads are consecutive hits and
322
+ * all print. A miss discloses the searched packages (the #837 rule): nothing
323
+ * resolvable means NOTHING was searched, never "not found".
324
+ *
325
+ * @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readFile: Function, readdir: Function }} deps
326
+ * @param {string} name an export name, e.g. `createAuth`
327
+ * @param {string} [pkgFilter] one of the framework packages, e.g. `server`
328
+ * @returns {Promise<string>}
329
+ */
330
+ export async function lookupExport(deps, name, pkgFilter) {
331
+ const n = String(name || '').trim();
332
+ if (!/^[A-Za-z_$][\w$]*$/.test(n)) return 'Pass an export name (letters, digits, _ or $), e.g. `createAuth`.';
333
+ const roots = pkgFilter ? deps.roots.filter((r) => r.pkg === pkgFilter) : deps.roots;
334
+ if (!deps.roots.length) {
335
+ return 'No @webjsdev/* source is resolvable here, so NOTHING was searched (run inside a webjs app or the monorepo). This is not the same as "not found".';
336
+ }
337
+ if (!roots.length) {
338
+ return `@webjsdev/${pkgFilter} is not installed/resolvable here. Resolvable: ${deps.roots.map((r) => r.pkg).join(', ')}.`;
339
+ }
340
+ const typesRe = new RegExp(`^export\\s+(?:declare\\s+)?(?:abstract\\s+)?(?:async\\s+)?(?:function\\*?|const|let|var|class|interface|type|enum)\\s+${n.replace(/\$/g, '\\$')}\\b`);
341
+ const jsRe = new RegExp(`^export\\s+(?:async\\s+)?(?:function\\*?|const|let|var|class)\\s+${n.replace(/\$/g, '\\$')}\\b`);
342
+ /** @type {Array<{ pkg: string, rel: string, line: number, doc: string[], decl: string[], types: boolean }>} */
343
+ const hits = [];
344
+ for (const r of roots) {
345
+ const typeFiles = walkSource(r.root, deps).filter((f) => /\.d\.(?:ts|mts|cts)$/.test(f));
346
+ const jsFiles = walkSource(r.src, deps).filter((f) => /\.(?:js|mjs)$/.test(f));
347
+ for (const [files, isTypes] of [[typeFiles, true], [jsFiles, false]]) {
348
+ for (const file of files) {
349
+ let text = '';
350
+ try { text = await deps.readFile(file, 'utf8'); } catch { continue; }
351
+ if (!text.includes(n)) continue;
352
+ const lines = text.split('\n');
353
+ const rel = relative(r.root, file).split(sep).join('/');
354
+ for (let i = 0; i < lines.length; i++) {
355
+ if (!(isTypes ? typesRe : jsRe).test(lines[i])) continue;
356
+ hits.push({ pkg: r.pkg, rel, line: i + 1, doc: docAbove(lines, i), decl: declarationFrom(lines, i, isTypes), types: isTypes });
357
+ }
358
+ }
359
+ }
360
+ }
361
+ const searched = deps.roots.map((r) => r.pkg).join(', ');
362
+ if (!hits.length) {
363
+ return `No export named "${n}" in the searched @webjsdev/* packages (${roots.map((r) => r.pkg).join(', ')}${pkgFilter ? `; resolvable: ${searched}` : ''}). Try \`query\` for a substring search, or \`path\` to read a file.`;
364
+ }
365
+ // The typed declarations are the answer; the JS doc is added only when the
366
+ // typed hits say nothing about behaviour (no doc on any of them), or there
367
+ // is no typed hit at all.
368
+ const typed = hits.filter((h) => h.types);
369
+ const typedHasDoc = typed.some((h) => h.doc.length);
370
+ const shown = typed.length && typedHasDoc ? typed : typed.length ? [...typed, ...hits.filter((h) => !h.types && h.doc.length)] : hits;
371
+ const out = [];
372
+ for (const h of shown.slice(0, MAX_EXPORT_HITS)) {
373
+ out.push(`@webjsdev/${h.pkg} ${h.rel}:${h.line}`);
374
+ out.push(...h.doc, ...h.decl, '');
375
+ }
376
+ if (shown.length > MAX_EXPORT_HITS) out.push(`... (${shown.length - MAX_EXPORT_HITS} more declarations; narrow with \`package\`)`);
377
+ return out.join('\n').trimEnd();
378
+ }
379
+
240
380
  /**
241
- * The `source` tool entry point. Dispatches on the args: `path` reads a file,
242
- * `query` greps, otherwise (or with `package`) lists the packages. PURE given
243
- * `deps` (`{ roots, readFile, readdir }`).
381
+ * The `source` tool entry point. Dispatches on the args: `export` looks up one
382
+ * export's signature and doc, `path` reads a file, `query` greps, otherwise
383
+ * (or with `package`) lists the packages. PURE given `deps`
384
+ * (`{ roots, readFile, readdir }`).
244
385
  *
245
386
  * @param {object} deps
246
- * @param {{ query?: string, path?: string, package?: string }} [args]
387
+ * @param {{ export?: string, query?: string, path?: string, package?: string }} [args]
247
388
  * @returns {Promise<string>}
248
389
  */
249
390
  export async function runSourceTool(deps, args) {
250
391
  const a = args || {};
392
+ if (a.export) return lookupExport(deps, a.export, a.package);
251
393
  if (a.path) return readSource(deps, a.path);
252
394
  if (a.query) return grepSources(deps, a.query);
253
395
  return listSources(deps, a.package);
package/src/mcp.js CHANGED
@@ -33,6 +33,9 @@ import {
33
33
  getPrompt,
34
34
  } from './mcp-docs.js';
35
35
  import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
36
+ // The export lookup behind the `source` tool's `export` arg, re-exported for
37
+ // `webjs source <Export>` in the CLI (#1623), which shells the same text.
38
+ export { lookupExport, resolveFrameworkRoots } from './mcp-source.js';
36
39
  import { projectRoutes } from './routes-report.js';
37
40
 
38
41
  const PROTOCOL_VERSION = '2024-11-05';
@@ -118,9 +121,13 @@ const UI_SCHEMA = {
118
121
  const SOURCE_SCHEMA = {
119
122
  type: 'object',
120
123
  properties: {
124
+ export: {
125
+ type: 'string',
126
+ description: 'One export name, e.g. createAuth or optimistic: returns its signature plus the doc comment above it (from the typed declarations, with the authored JSDoc as the fallback), not the whole file. The cheapest way to check a contract.',
127
+ },
121
128
  path: {
122
129
  type: 'string',
123
- description: 'A framework source file to read, e.g. server/src/ssr.js or @webjsdev/core/src/render-client.js.',
130
+ description: 'A framework source file to read, e.g. server/src/ssr/head.js or @webjsdev/core/src/render-client/parts.js. The top-level ssr.js / render-client.js paths are barrels; the code is in the sibling directory.',
124
131
  },
125
132
  query: {
126
133
  type: 'string',
@@ -157,7 +164,7 @@ const TOOL_DEFS = [
157
164
  {
158
165
  name: 'source',
159
166
  description:
160
- 'Read the FRAMEWORK authored source (webjs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `path` to read a file (e.g. server/src/ssr.js), `query` to grep the @webjsdev/* src trees, or no args to list the packages + entry points. Use when the docs do not answer something. Read-only.',
167
+ 'Read the FRAMEWORK authored source (webjs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `export` for the signature plus doc comment of ONE export (the cheap way to check a contract: `export: "createAuth"`), `path` to read a file (e.g. server/src/ssr/head.js; the bare server/src/ssr.js is a barrel that re-exports from the sibling directory), `query` to grep the @webjsdev/* src trees, or no args to list the packages + entry points. Use when the docs do not answer something. Read-only.',
161
168
  inputSchema: SOURCE_SCHEMA,
162
169
  },
163
170
  {