@foundation0/api 1.1.1 → 1.1.2

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/mcp/server.ts CHANGED
@@ -3,6 +3,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
4
4
  import * as agentsApi from '../agents.ts'
5
5
  import * as projectsApi from '../projects.ts'
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
6
8
 
7
9
  type ApiMethod = (...args: unknown[]) => unknown
8
10
  type ToolInvocationPayload = {
@@ -19,6 +21,7 @@ type BatchToolCall = {
19
21
  type BatchToolCallPayload = {
20
22
  calls: BatchToolCall[]
21
23
  continueOnError: boolean
24
+ maxConcurrency: number
22
25
  }
23
26
  type BatchResult = {
24
27
  index: number
@@ -37,12 +40,168 @@ type ToolNamespace = Record<string, unknown>
37
40
  type ApiEndpoint = {
38
41
  agents: ToolNamespace
39
42
  projects: ToolNamespace
43
+ mcp: ToolNamespace
40
44
  }
41
45
 
42
46
  const isRecord = (value: unknown): value is Record<string, unknown> =>
43
47
  typeof value === 'object' && value !== null && !Array.isArray(value)
44
48
 
45
- const normalizePayload = (payload: unknown): { args: string[]; options: Record<string, unknown> } => {
49
+ const parseBooleanish = (value: unknown): boolean | null => {
50
+ if (value === true || value === false) return value
51
+ if (value === 1 || value === 0) return Boolean(value)
52
+ if (typeof value !== 'string') return null
53
+ const normalized = value.trim().toLowerCase()
54
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
55
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
56
+ return null
57
+ }
58
+
59
+ const parsePositiveInteger = (value: unknown): number | null => {
60
+ const numeric = typeof value === 'string' && value.trim() !== ''
61
+ ? Number(value)
62
+ : (typeof value === 'number' ? value : NaN)
63
+
64
+ if (!Number.isInteger(numeric) || numeric <= 0) return null
65
+ return numeric
66
+ }
67
+
68
+ const safeJsonStringify = (value: unknown): string => {
69
+ try {
70
+ return JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? v.toString() : v), 2)
71
+ } catch (error) {
72
+ const message = error instanceof Error ? error.message : String(error)
73
+ return JSON.stringify({ ok: false, error: { message, note: 'Failed to JSON stringify tool result.' } }, null, 2)
74
+ }
75
+ }
76
+
77
+ type ToolEnvelope =
78
+ | { ok: true; result: unknown }
79
+ | { ok: false; error: { message: string; details?: unknown } }
80
+
81
+ const toolOk = (result: unknown) => ({
82
+ isError: false,
83
+ content: [{ type: 'text', text: safeJsonStringify({ ok: true, result } satisfies ToolEnvelope) }],
84
+ })
85
+
86
+ const toolErr = (message: string, details?: unknown) => ({
87
+ isError: true,
88
+ content: [{ type: 'text', text: safeJsonStringify({ ok: false, error: { message, details } } satisfies ToolEnvelope) }],
89
+ })
90
+
91
+ const isDir = (candidate: string): boolean => {
92
+ try {
93
+ return fs.statSync(candidate).isDirectory()
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ const looksLikeRepoRoot = (candidate: string): boolean =>
100
+ isDir(path.join(candidate, 'projects')) && isDir(path.join(candidate, 'api'))
101
+
102
+ const normalizeProcessRoot = (raw: string): string => {
103
+ const resolved = path.resolve(raw)
104
+ if (looksLikeRepoRoot(resolved)) return resolved
105
+
106
+ // Common mistake: passing a project root like ".../projects/adl" as processRoot.
107
+ // Try to find the containing repo root by walking up a few levels.
108
+ let current = resolved
109
+ for (let depth = 0; depth < 8; depth += 1) {
110
+ const parent = path.dirname(current)
111
+ if (parent === current) break
112
+ if (looksLikeRepoRoot(parent)) return parent
113
+ current = parent
114
+ }
115
+
116
+ const parts = resolved.split(path.sep).filter((part) => part.length > 0)
117
+ const projectsIndex = parts.lastIndexOf('projects')
118
+ if (projectsIndex >= 0) {
119
+ const candidate = parts.slice(0, projectsIndex).join(path.sep)
120
+ if (candidate && looksLikeRepoRoot(candidate)) return candidate
121
+ }
122
+
123
+ return resolved
124
+ }
125
+
126
+ const normalizeProcessRootOption = (options: Record<string, unknown>): Record<string, unknown> => {
127
+ const raw = options.processRoot
128
+ if (typeof raw !== 'string' || raw.trim().length === 0) return options
129
+ const normalized = normalizeProcessRoot(raw.trim())
130
+ if (normalized === raw) return options
131
+ return { ...options, processRoot: normalized }
132
+ }
133
+
134
+ type NormalizedToolPayload = { args: unknown[]; options: Record<string, unknown> }
135
+
136
+ const parseStringish = (value: unknown): string | null => {
137
+ if (typeof value !== 'string') return null
138
+ const trimmed = value.trim()
139
+ return trimmed.length > 0 ? trimmed : null
140
+ }
141
+
142
+ const parseStringArrayish = (value: unknown): string[] => {
143
+ if (Array.isArray(value)) {
144
+ return value.map((entry) => String(entry)).map((entry) => entry.trim()).filter((entry) => entry.length > 0)
145
+ }
146
+ const asString = parseStringish(value)
147
+ return asString ? [asString] : []
148
+ }
149
+
150
+ const popOption = (options: Record<string, unknown>, key: string): unknown => {
151
+ const value = options[key]
152
+ delete options[key]
153
+ return value
154
+ }
155
+
156
+ const popStringOption = (options: Record<string, unknown>, ...keys: string[]): string | null => {
157
+ for (const key of keys) {
158
+ const value = options[key]
159
+ const parsed = parseStringish(value)
160
+ if (parsed) {
161
+ delete options[key]
162
+ return parsed
163
+ }
164
+ }
165
+ return null
166
+ }
167
+
168
+ const popStringArrayOption = (options: Record<string, unknown>, ...keys: string[]): string[] => {
169
+ for (const key of keys) {
170
+ const value = options[key]
171
+ const parsed = parseStringArrayish(value)
172
+ if (parsed.length > 0) {
173
+ delete options[key]
174
+ return parsed
175
+ }
176
+ }
177
+ return []
178
+ }
179
+
180
+ const popBooleanOption = (options: Record<string, unknown>, ...keys: string[]): boolean | null => {
181
+ for (const key of keys) {
182
+ const value = options[key]
183
+ const parsed = parseBooleanish(value)
184
+ if (parsed !== null) {
185
+ delete options[key]
186
+ return parsed
187
+ }
188
+ }
189
+ return null
190
+ }
191
+
192
+ const popIntegerOption = (options: Record<string, unknown>, ...keys: string[]): number | null => {
193
+ for (const key of keys) {
194
+ const value = options[key]
195
+ const parsed = parsePositiveInteger(value)
196
+ if (parsed !== null) {
197
+ delete options[key]
198
+ return parsed
199
+ }
200
+ }
201
+ return null
202
+ }
203
+
204
+ const normalizePayload = (payload: unknown): NormalizedToolPayload => {
46
205
  if (!isRecord(payload)) {
47
206
  return {
48
207
  args: [],
@@ -53,7 +212,7 @@ const normalizePayload = (payload: unknown): { args: string[]; options: Record<s
53
212
  const explicitArgs = Array.isArray(payload.args) ? payload.args : undefined
54
213
  const explicitOptions = isRecord(payload.options) ? payload.options : undefined
55
214
 
56
- const args = explicitArgs ? explicitArgs.map((entry) => String(entry)) : []
215
+ const args = explicitArgs ? [...explicitArgs] : []
57
216
  const options: Record<string, unknown> = explicitOptions ? { ...explicitOptions } : {}
58
217
 
59
218
  for (const [key, value] of Object.entries(payload)) {
@@ -68,10 +227,186 @@ const normalizePayload = (payload: unknown): { args: string[]; options: Record<s
68
227
 
69
228
  return {
70
229
  args,
71
- options,
230
+ options: normalizeProcessRootOption(options),
72
231
  }
73
232
  }
74
233
 
234
+ const coercePayloadForTool = (toolName: string, input: NormalizedToolPayload): NormalizedToolPayload => {
235
+ const args = [...input.args]
236
+ const options: Record<string, unknown> = { ...input.options }
237
+
238
+ const ensureArg0 = (value: unknown) => {
239
+ if (args.length > 0) return
240
+ if (value !== undefined) {
241
+ args.push(value)
242
+ }
243
+ }
244
+
245
+ const ensureArgs = (...values: Array<unknown>) => {
246
+ while (args.length < values.length) {
247
+ const next = values[args.length]
248
+ if (next === undefined || next === null) break
249
+ args.push(next)
250
+ }
251
+ }
252
+
253
+ const coerceProjectName = (): void => {
254
+ if (args.length > 0 && typeof args[0] === 'string' && args[0].trim().length > 0) return
255
+ const name = popStringOption(options, 'projectName', 'project')
256
+ if (name) {
257
+ if (args.length === 0) args.push(name)
258
+ else args[0] = name
259
+ }
260
+ }
261
+
262
+ const coerceProjectSearch = (): void => {
263
+ coerceProjectName()
264
+
265
+ // If caller already provided [projectName, pattern, ...], keep it.
266
+ if (args.length >= 2) return
267
+
268
+ const pattern =
269
+ popStringOption(options, 'pattern', 'query', 'q') ?? null
270
+ const paths = popStringArrayOption(options, 'paths')
271
+ const globs = popStringArrayOption(options, 'globs')
272
+ const ref = popStringOption(options, 'ref')
273
+ const source = popStringOption(options, 'source')
274
+
275
+ if (source) options.source = source
276
+ if (ref) options.ref = ref
277
+
278
+ const refresh = popBooleanOption(options, 'refresh')
279
+ if (refresh !== null) options.refresh = refresh
280
+ const cacheDir = popStringOption(options, 'cacheDir')
281
+ if (cacheDir) options.cacheDir = cacheDir
282
+
283
+ const owner = popStringOption(options, 'owner')
284
+ if (owner) options.owner = owner
285
+ const repo = popStringOption(options, 'repo')
286
+ if (repo) options.repo = repo
287
+
288
+ if (!pattern) return
289
+
290
+ const rgArgs: string[] = []
291
+ const addFlag = (key: string, flag: string) => {
292
+ const raw = popBooleanOption(options, key)
293
+ if (raw === true) rgArgs.push(flag)
294
+ }
295
+
296
+ addFlag('ignoreCase', '--ignore-case')
297
+ addFlag('caseSensitive', '--case-sensitive')
298
+ addFlag('smartCase', '--smart-case')
299
+ addFlag('fixedStrings', '--fixed-strings')
300
+ addFlag('wordRegexp', '--word-regexp')
301
+ addFlag('includeHidden', '--hidden')
302
+ addFlag('filesWithMatches', '--files-with-matches')
303
+ addFlag('filesWithoutMatch', '--files-without-match')
304
+ addFlag('countOnly', '--count')
305
+ addFlag('onlyMatching', '--only-matching')
306
+
307
+ const maxCount = popIntegerOption(options, 'maxCount')
308
+ if (maxCount !== null) {
309
+ rgArgs.push('--max-count', String(maxCount))
310
+ }
311
+
312
+ for (const glob of globs) {
313
+ rgArgs.push('--glob', glob)
314
+ }
315
+
316
+ rgArgs.push(pattern, ...(paths.length > 0 ? paths : ['.']))
317
+
318
+ if (args.length === 0) {
319
+ // Can't build without projectName; leave for underlying error.
320
+ return
321
+ }
322
+
323
+ if (args.length === 1) {
324
+ args.push(...rgArgs)
325
+ }
326
+ }
327
+
328
+ switch (toolName) {
329
+ case 'projects.listProjects': {
330
+ // No positional args. processRoot is handled via options.processRoot + buildProcessRootOnly.
331
+ break
332
+ }
333
+ case 'projects.resolveProjectRoot':
334
+ case 'projects.listProjectDocs':
335
+ case 'projects.fetchGitTasks': {
336
+ coerceProjectName()
337
+ break
338
+ }
339
+ case 'projects.readProjectDoc': {
340
+ coerceProjectName()
341
+ if (args.length >= 2) break
342
+ const requestPath = popStringOption(options, 'requestPath', 'path', 'docPath')
343
+ if (requestPath) {
344
+ ensureArgs(args[0] ?? undefined, requestPath)
345
+ }
346
+ break
347
+ }
348
+ case 'projects.searchDocs':
349
+ case 'projects.searchSpecs': {
350
+ coerceProjectSearch()
351
+ break
352
+ }
353
+ case 'projects.parseProjectTargetSpec': {
354
+ if (args.length >= 1) break
355
+ const spec = popStringOption(options, 'spec', 'target')
356
+ if (spec) {
357
+ args.push(spec)
358
+ }
359
+ break
360
+ }
361
+ case 'projects.resolveProjectTargetFile': {
362
+ if (args.length >= 2) break
363
+ const targetDir = popStringOption(options, 'targetDir', 'dir')
364
+ const spec = options.spec
365
+ if (targetDir) {
366
+ if (spec !== undefined) {
367
+ delete options.spec
368
+ }
369
+ if (args.length === 0) args.push(targetDir)
370
+ if (args.length === 1 && spec !== undefined) args.push(spec)
371
+ }
372
+
373
+ const latest = popBooleanOption(options, 'latest')
374
+ if (latest !== null) {
375
+ options.latest = latest
376
+ }
377
+ break
378
+ }
379
+ case 'projects.resolveImplementationPlan': {
380
+ if (args.length >= 1) break
381
+ const projectRoot = popStringOption(options, 'projectRoot')
382
+ const inputFile = popStringOption(options, 'inputFile')
383
+ const requireActive = popBooleanOption(options, 'requireActive')
384
+ if (projectRoot) {
385
+ args.push(projectRoot)
386
+ if (inputFile) args.push(inputFile)
387
+ }
388
+ if (requireActive !== null) {
389
+ options.requireActive = requireActive
390
+ }
391
+ break
392
+ }
393
+ case 'projects.readGitTask':
394
+ case 'projects.writeGitTask': {
395
+ coerceProjectName()
396
+ if (args.length >= 2) break
397
+ const taskRef = popStringOption(options, 'taskRef', 'ref', 'issue', 'issueNumber', 'taskId')
398
+ if (taskRef && args.length >= 1) {
399
+ ensureArgs(args[0], taskRef)
400
+ }
401
+ break
402
+ }
403
+ default:
404
+ break
405
+ }
406
+
407
+ return { args, options: normalizeProcessRootOption(options) }
408
+ }
409
+
75
410
  const normalizeBatchToolCall = (
76
411
  call: unknown,
77
412
  index: number,
@@ -93,6 +428,9 @@ const normalizeBatchToolCall = (
93
428
  }
94
429
 
95
430
  for (const [key, value] of Object.entries(extras)) {
431
+ if (key === 'tool' || key === 'args' || key === 'options') {
432
+ continue
433
+ }
96
434
  if (value !== undefined) {
97
435
  normalized.options[key] = value
98
436
  }
@@ -114,13 +452,30 @@ const normalizeBatchPayload = (payload: unknown): BatchToolCallPayload => {
114
452
  }
115
453
 
116
454
  const calls = payload.calls.map((call, index) => normalizeBatchToolCall(call, index))
455
+ const continueOnError = (() => {
456
+ if (payload.continueOnError === undefined) return false
457
+ const parsed = parseBooleanish(payload.continueOnError)
458
+ if (parsed === null) {
459
+ throw new Error('"continueOnError" must be a boolean or boolean-like string (true/false, 1/0).')
460
+ }
461
+ return parsed
462
+ })()
463
+ const maxConcurrency = (() => {
464
+ if (payload.maxConcurrency === undefined) return 8
465
+ const parsed = parsePositiveInteger(payload.maxConcurrency)
466
+ if (!parsed) {
467
+ throw new Error('"maxConcurrency" must be a positive integer.')
468
+ }
469
+ return parsed
470
+ })()
117
471
 
118
472
  return {
119
473
  calls: calls.map(({ tool, payload }) => ({
120
474
  tool,
121
475
  ...payload,
122
476
  })),
123
- continueOnError: Boolean(payload.continueOnError),
477
+ continueOnError,
478
+ maxConcurrency,
124
479
  }
125
480
  }
126
481
 
@@ -146,32 +501,526 @@ const collectTools = (api: ToolNamespace, namespace: string[], path: string[] =
146
501
  const buildToolName = (tool: ToolDefinition, prefix?: string): string =>
147
502
  prefix ? `${prefix}.${tool.name}` : tool.name
148
503
 
149
- const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?: string) => {
150
- const toolEntries = tools.map((tool) => ({
151
- name: buildToolName(tool, prefix),
152
- description: `Call API method ${tool.path.join('.')}`,
153
- inputSchema: {
504
+ type ToolAccess = 'read' | 'write' | 'admin'
505
+ type ToolMeta = {
506
+ access: ToolAccess
507
+ category?: string
508
+ notes?: string
509
+ }
510
+
511
+ const TOOL_META: Record<string, ToolMeta> = {}
512
+
513
+ const TOOL_REQUIRED_ARGS: Record<string, string[]> = {
514
+ 'agents.createAgent': ['<agent-name>'],
515
+ 'agents.setActive': ['<agent-name>', '</file-path>'],
516
+ 'agents.loadAgent': ['<agent-name>'],
517
+ 'agents.loadAgentPrompt': ['<agent-name>'],
518
+ 'agents.runAgent': ['<agent-name>'],
519
+ 'projects.resolveProjectRoot': ['<project-name>'],
520
+ 'projects.listProjectDocs': ['<project-name>'],
521
+ 'projects.readProjectDoc': ['<project-name>', '</doc-path>'],
522
+ 'projects.searchDocs': ['<project-name>', '<pattern>', '[path...]'],
523
+ 'projects.searchSpecs': ['<project-name>', '<pattern>', '[path...]'],
524
+ 'projects.generateSpec': ['<project-name>'],
525
+ 'projects.setActive': ['<project-name>', '</file-path>'],
526
+ 'projects.fetchGitTasks': ['<project-name>'],
527
+ 'projects.readGitTask': ['<project-name>', '<issue-number|task-id>'],
528
+ 'projects.writeGitTask': ['<project-name>', '<issue-number|task-id>'],
529
+ }
530
+
531
+ const TOOL_DEFAULT_OPTIONS: Record<string, Record<string, unknown>> = {
532
+ 'projects.searchDocs': { source: 'auto' },
533
+ 'projects.searchSpecs': { source: 'auto' },
534
+ }
535
+
536
+ const TOOL_USAGE_HINTS: Record<string, string> = {
537
+ 'projects.searchDocs': ' Usage: args=[projectName, pattern, ...paths]. Options include source/local cache controls.',
538
+ 'projects.searchSpecs': ' Usage: args=[projectName, pattern, ...paths]. Options include source/local cache controls.',
539
+ 'projects.setActive': ' Usage: args=[projectName, /file-path]. Use options.latest=true to auto-pick latest version.',
540
+ 'agents.setActive': ' Usage: args=[agentName, /file-path]. Use options.latest=true to auto-pick latest version.',
541
+ }
542
+
543
+ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
544
+ 'projects.usage': {
545
+ type: 'object',
546
+ additionalProperties: true,
547
+ properties: {
548
+ args: {
549
+ type: 'array',
550
+ description: 'Unused. This tool takes no arguments.',
551
+ minItems: 0,
552
+ maxItems: 0,
553
+ items: {},
554
+ },
555
+ options: {
556
+ type: 'object',
557
+ description: 'Unused.',
558
+ additionalProperties: true,
559
+ },
560
+ processRoot: {
561
+ type: 'string',
562
+ description: 'Unused.',
563
+ },
564
+ },
565
+ },
566
+ 'projects.listProjects': {
567
+ type: 'object',
568
+ additionalProperties: true,
569
+ properties: {
570
+ processRoot: {
571
+ type: 'string',
572
+ description: 'Repo root containing /projects and /agents. If you have a project root like ".../projects/adl", omit this or pass its parent.',
573
+ },
574
+ },
575
+ required: [],
576
+ },
577
+ 'projects.resolveProjectRoot': {
578
+ type: 'object',
579
+ additionalProperties: true,
580
+ properties: {
581
+ projectName: { type: 'string', description: 'Project name under /projects (e.g. "adl").' },
582
+ processRoot: {
583
+ type: 'string',
584
+ description: 'Repo root containing /projects and /agents. If omitted, uses server cwd.',
585
+ },
586
+ args: { type: 'array', description: 'Legacy positional args (projectName).', items: {} },
587
+ options: { type: 'object', description: 'Legacy named options.', additionalProperties: true },
588
+ },
589
+ required: ['projectName'],
590
+ },
591
+ 'projects.listProjectDocs': {
592
+ type: 'object',
593
+ additionalProperties: true,
594
+ properties: {
595
+ projectName: { type: 'string', description: 'Project name under /projects (e.g. "adl").' },
596
+ processRoot: { type: 'string', description: 'Repo root containing /projects and /agents.' },
597
+ args: { type: 'array', description: 'Legacy positional args (projectName).', items: {} },
598
+ options: { type: 'object', description: 'Legacy named options.', additionalProperties: true },
599
+ },
600
+ required: ['projectName'],
601
+ },
602
+ 'projects.readProjectDoc': {
603
+ type: 'object',
604
+ additionalProperties: true,
605
+ properties: {
606
+ projectName: { type: 'string', description: 'Project name under /projects (e.g. "adl").' },
607
+ requestPath: { type: 'string', description: 'Doc path under docs/, starting with "docs/..." (or a bare filename in catalog).', },
608
+ processRoot: { type: 'string', description: 'Repo root containing /projects and /agents.' },
609
+ args: { type: 'array', description: 'Legacy positional args (projectName, requestPath).', items: {} },
610
+ options: { type: 'object', description: 'Legacy named options.', additionalProperties: true },
611
+ },
612
+ required: ['projectName', 'requestPath'],
613
+ },
614
+ 'projects.searchDocs': {
615
+ type: 'object',
616
+ additionalProperties: true,
617
+ properties: {
618
+ projectName: { type: 'string', description: 'Project name under /projects (e.g. "adl").' },
619
+ pattern: { type: 'string', description: 'Search pattern (like rg PATTERN).', },
620
+ query: { type: 'string', description: 'Alias of pattern.' },
621
+ q: { type: 'string', description: 'Alias of pattern.' },
622
+ paths: { type: 'array', items: { type: 'string' }, description: 'Paths inside docs/ to search. Defaults to [\".\"].' },
623
+ globs: { type: 'array', items: { type: 'string' }, description: 'Glob filters (like rg --glob).' },
624
+ ignoreCase: { type: 'boolean' },
625
+ caseSensitive: { type: 'boolean' },
626
+ smartCase: { type: 'boolean' },
627
+ fixedStrings: { type: 'boolean' },
628
+ wordRegexp: { type: 'boolean' },
629
+ maxCount: { type: 'integer', minimum: 1 },
630
+ includeHidden: { type: 'boolean' },
631
+ filesWithMatches: { type: 'boolean' },
632
+ filesWithoutMatch: { type: 'boolean' },
633
+ countOnly: { type: 'boolean' },
634
+ onlyMatching: { type: 'boolean' },
635
+ ref: { type: 'string', description: 'Optional git ref for remote search.' },
636
+ source: { type: 'string', enum: ['local', 'gitea', 'auto'], description: 'local=filesystem, gitea=remote, auto=local-first.' },
637
+ refresh: { type: 'boolean', description: 'Refresh cached search corpus.' },
638
+ cacheDir: { type: 'string', description: 'Optional override cache directory.' },
639
+ owner: { type: 'string', description: 'Remote owner override for gitea mode.' },
640
+ repo: { type: 'string', description: 'Remote repo override for gitea mode.' },
641
+ processRoot: { type: 'string', description: 'Repo root containing /projects and /agents.' },
642
+ args: { type: 'array', description: 'Legacy rg-like args (projectName, PATTERN, [PATH...]).', items: {} },
643
+ options: { type: 'object', description: 'Legacy named options.', additionalProperties: true },
644
+ },
645
+ required: ['projectName'],
646
+ },
647
+ 'projects.searchSpecs': {
648
+ type: 'object',
649
+ additionalProperties: true,
650
+ properties: {
651
+ projectName: { type: 'string', description: 'Project name under /projects (e.g. "adl").' },
652
+ pattern: { type: 'string', description: 'Search pattern (like rg PATTERN).', },
653
+ query: { type: 'string', description: 'Alias of pattern.' },
654
+ q: { type: 'string', description: 'Alias of pattern.' },
655
+ paths: { type: 'array', items: { type: 'string' }, description: 'Paths inside spec/ to search. Defaults to [\".\"].' },
656
+ globs: { type: 'array', items: { type: 'string' }, description: 'Glob filters (like rg --glob).' },
657
+ ignoreCase: { type: 'boolean' },
658
+ caseSensitive: { type: 'boolean' },
659
+ smartCase: { type: 'boolean' },
660
+ fixedStrings: { type: 'boolean' },
661
+ wordRegexp: { type: 'boolean' },
662
+ maxCount: { type: 'integer', minimum: 1 },
663
+ includeHidden: { type: 'boolean' },
664
+ filesWithMatches: { type: 'boolean' },
665
+ filesWithoutMatch: { type: 'boolean' },
666
+ countOnly: { type: 'boolean' },
667
+ onlyMatching: { type: 'boolean' },
668
+ ref: { type: 'string', description: 'Optional git ref for remote search.' },
669
+ source: { type: 'string', enum: ['local', 'gitea', 'auto'], description: 'local=filesystem, gitea=remote, auto=local-first.' },
670
+ refresh: { type: 'boolean', description: 'Refresh cached search corpus.' },
671
+ cacheDir: { type: 'string', description: 'Optional override cache directory.' },
672
+ owner: { type: 'string', description: 'Remote owner override for gitea mode.' },
673
+ repo: { type: 'string', description: 'Remote repo override for gitea mode.' },
674
+ processRoot: { type: 'string', description: 'Repo root containing /projects and /agents.' },
675
+ args: { type: 'array', description: 'Legacy rg-like args (projectName, PATTERN, [PATH...]).', items: {} },
676
+ options: { type: 'object', description: 'Legacy named options.', additionalProperties: true },
677
+ },
678
+ required: ['projectName'],
679
+ },
680
+ 'projects.parseProjectTargetSpec': {
681
+ type: 'object',
682
+ additionalProperties: true,
683
+ properties: {
684
+ spec: { type: 'string', description: 'Target spec string like \"/implementation-plan.v0.0.1\" or \"spec/README.md\".' },
685
+ target: { type: 'string', description: 'Alias of spec.' },
686
+ args: { type: 'array', description: 'Legacy positional args (spec).', items: {} },
687
+ options: { type: 'object', description: 'Legacy named options.', additionalProperties: true },
688
+ },
689
+ anyOf: [{ required: ['spec'] }, { required: ['target'] }],
690
+ },
691
+ 'projects.resolveProjectTargetFile': {
692
+ type: 'object',
693
+ additionalProperties: true,
694
+ properties: {
695
+ targetDir: { type: 'string', description: 'Directory to scan.' },
696
+ spec: { type: 'object', description: 'Output of projects.parseProjectTargetSpec().', additionalProperties: true },
697
+ latest: { type: 'boolean', description: 'If true and no version given, select latest versioned file.' },
698
+ args: { type: 'array', description: 'Legacy positional args (targetDir, spec).', items: {} },
699
+ options: { type: 'object', description: 'Legacy named options (e.g. {latest:true}).', additionalProperties: true },
700
+ },
701
+ required: ['targetDir', 'spec'],
702
+ },
703
+ 'projects.resolveImplementationPlan': {
704
+ type: 'object',
705
+ additionalProperties: true,
706
+ properties: {
707
+ projectRoot: { type: 'string', description: 'Absolute project directory (e.g. C:/.../projects/adl).' },
708
+ inputFile: { type: 'string', description: 'Optional file within docs/ (or absolute) to select plan.' },
709
+ requireActive: { type: 'boolean', description: 'If true, require implementation-plan.active.* to exist.' },
710
+ args: { type: 'array', description: 'Legacy positional args (projectRoot, inputFile?).', items: {} },
711
+ options: { type: 'object', description: 'Legacy named options (e.g. {requireActive:true}).', additionalProperties: true },
712
+ },
713
+ required: ['projectRoot'],
714
+ },
715
+ 'projects.fetchGitTasks': {
716
+ type: 'object',
717
+ additionalProperties: true,
718
+ properties: {
719
+ projectName: { type: 'string', description: 'Project name under /projects.' },
720
+ owner: { type: 'string', description: 'Remote owner override.' },
721
+ repo: { type: 'string', description: 'Remote repo override.' },
722
+ state: { type: 'string', enum: ['open', 'closed', 'all'], description: 'Issue state filter.' },
723
+ taskOnly: { type: 'boolean', description: 'If true, only return issues with TASK-* IDs.' },
724
+ processRoot: { type: 'string', description: 'Repo root containing /projects.' },
725
+ args: { type: 'array', description: 'Legacy positional args (projectName).', items: {} },
726
+ options: { type: 'object', description: 'Legacy options (owner/repo/state/taskOnly).', additionalProperties: true },
727
+ },
728
+ required: ['projectName'],
729
+ },
730
+ 'projects.readGitTask': {
731
+ type: 'object',
732
+ additionalProperties: true,
733
+ properties: {
734
+ projectName: { type: 'string', description: 'Project name under /projects.' },
735
+ taskRef: { type: 'string', description: 'Issue number (e.g. \"123\") or task ID (e.g. \"TASK-001\").' },
736
+ owner: { type: 'string', description: 'Remote owner override.' },
737
+ repo: { type: 'string', description: 'Remote repo override.' },
738
+ state: { type: 'string', enum: ['open', 'closed', 'all'], description: 'Search scope.' },
739
+ taskOnly: { type: 'boolean', description: 'If true, restrict to issues with TASK-* payloads.' },
740
+ processRoot: { type: 'string', description: 'Repo root containing /projects.' },
741
+ args: { type: 'array', description: 'Legacy positional args (projectName, taskRef).', items: {} },
742
+ options: { type: 'object', description: 'Legacy options.', additionalProperties: true },
743
+ },
744
+ required: ['projectName', 'taskRef'],
745
+ },
746
+ 'projects.writeGitTask': {
747
+ type: 'object',
748
+ additionalProperties: true,
749
+ properties: {
750
+ projectName: { type: 'string', description: 'Project name under /projects.' },
751
+ taskRef: { type: 'string', description: 'Issue number (e.g. \"123\") or task ID (e.g. \"TASK-001\").' },
752
+ owner: { type: 'string', description: 'Remote owner override.' },
753
+ repo: { type: 'string', description: 'Remote repo override.' },
754
+ createIfMissing: { type: 'boolean', description: 'Create issue if taskRef is TASK-* and not found.' },
755
+ title: { type: 'string', description: 'Issue title override.' },
756
+ body: { type: 'string', description: 'Issue body override.' },
757
+ labels: { type: 'array', items: { type: 'string' }, description: 'Labels to set.' },
758
+ state: { type: 'string', enum: ['open', 'closed'], description: 'Issue state.' },
759
+ taskDependencies: { type: 'array', items: { type: 'string' }, description: 'TASK-* dependencies.' },
760
+ taskSignature: { type: 'string', description: 'Optional task signature.' },
761
+ processRoot: { type: 'string', description: 'Repo root containing /projects.' },
762
+ args: { type: 'array', description: 'Legacy positional args (projectName, taskRef).', items: {} },
763
+ options: { type: 'object', description: 'Legacy options.', additionalProperties: true },
764
+ },
765
+ required: ['projectName', 'taskRef'],
766
+ },
767
+ 'mcp.search': {
768
+ type: 'object',
769
+ additionalProperties: true,
770
+ properties: {
771
+ projectName: { type: 'string', description: 'Project name under /projects (e.g. "adl").' },
772
+ section: { type: 'string', enum: ['spec', 'docs'], description: 'Search target section.' },
773
+ pattern: { type: 'string', description: 'Search pattern (like rg PATTERN).' },
774
+ query: { type: 'string', description: 'Alias of pattern.' },
775
+ q: { type: 'string', description: 'Alias of pattern.' },
776
+ paths: { type: 'array', items: { type: 'string' }, description: 'Paths to search within the section.' },
777
+ globs: { type: 'array', items: { type: 'string' }, description: 'Glob filters (like rg --glob).' },
778
+ ignoreCase: { type: 'boolean' },
779
+ caseSensitive: { type: 'boolean' },
780
+ smartCase: { type: 'boolean' },
781
+ fixedStrings: { type: 'boolean' },
782
+ wordRegexp: { type: 'boolean' },
783
+ maxCount: { type: 'integer', minimum: 1 },
784
+ includeHidden: { type: 'boolean' },
785
+ filesWithMatches: { type: 'boolean' },
786
+ filesWithoutMatch: { type: 'boolean' },
787
+ countOnly: { type: 'boolean' },
788
+ onlyMatching: { type: 'boolean' },
789
+ ref: { type: 'string', description: 'Optional git ref for remote search.' },
790
+ source: { type: 'string', enum: ['local', 'gitea', 'auto'], description: 'local=filesystem, gitea=remote, auto=local-first.' },
791
+ refresh: { type: 'boolean', description: 'Refresh cached search corpus.' },
792
+ cacheDir: { type: 'string', description: 'Optional override cache directory.' },
793
+ processRoot: {
794
+ type: 'string',
795
+ description: 'Repo root containing /projects. If you pass a project root, it will be normalized.',
796
+ },
797
+ },
798
+ $comment: safeJsonStringify({
799
+ example: {
800
+ projectName: 'adl',
801
+ section: 'spec',
802
+ pattern: 'REQ-',
803
+ paths: ['.'],
804
+ source: 'auto',
805
+ },
806
+ }),
807
+ },
808
+ }
809
+
810
+ const buildArgsSchemaFromPlaceholders = (placeholders: string[]): Record<string, unknown> => ({
811
+ type: 'array',
812
+ description: 'Positional arguments',
813
+ minItems: placeholders.length,
814
+ prefixItems: placeholders.map((placeholder) => ({
815
+ type: 'string',
816
+ title: placeholder,
817
+ description: placeholder,
818
+ })),
819
+ items: {},
820
+ })
821
+
822
+ const TOOL_ARGS_SCHEMA_OVERRIDES: Record<string, Record<string, unknown>> = {
823
+ 'projects.listProjects': {
824
+ type: 'array',
825
+ description: 'No positional arguments. Use options.processRoot if needed.',
826
+ minItems: 0,
827
+ maxItems: 0,
828
+ items: {},
829
+ },
830
+ 'projects.usage': {
831
+ type: 'array',
832
+ description: 'No positional arguments.',
833
+ minItems: 0,
834
+ maxItems: 0,
835
+ items: {},
836
+ },
837
+ 'agents.usage': {
838
+ type: 'array',
839
+ description: 'No positional arguments.',
840
+ minItems: 0,
841
+ maxItems: 0,
842
+ items: {},
843
+ },
844
+ 'projects.searchDocs': {
845
+ type: 'array',
846
+ description: 'rg-like: projectName, pattern, then optional paths.',
847
+ minItems: 2,
848
+ prefixItems: [
849
+ { type: 'string', title: 'projectName' },
850
+ { type: 'string', title: 'pattern' },
851
+ ],
852
+ items: { type: 'string', title: 'path' },
853
+ },
854
+ 'projects.searchSpecs': {
855
+ type: 'array',
856
+ description: 'rg-like: projectName, pattern, then optional paths.',
857
+ minItems: 2,
858
+ prefixItems: [
859
+ { type: 'string', title: 'projectName' },
860
+ { type: 'string', title: 'pattern' },
861
+ ],
862
+ items: { type: 'string', title: 'path' },
863
+ },
864
+ 'projects.resolveProjectTargetFile': {
865
+ type: 'array',
866
+ description: 'Low-level helper: resolve versioned file in a directory.',
867
+ minItems: 2,
868
+ prefixItems: [
869
+ { type: 'string', title: 'targetDir', description: 'Directory to scan (absolute or relative to cwd).' },
870
+ {
871
+ type: 'object',
872
+ title: 'spec',
873
+ description: 'Output of projects.parseProjectTargetSpec().',
874
+ additionalProperties: true,
875
+ },
876
+ ],
877
+ items: {},
878
+ },
879
+ 'agents.resolveTargetFile': {
880
+ type: 'array',
881
+ description: 'Low-level helper: resolve versioned file in a directory.',
882
+ minItems: 2,
883
+ prefixItems: [
884
+ { type: 'string', title: 'targetDir' },
885
+ {
886
+ type: 'object',
887
+ title: 'spec',
888
+ description: 'Output of agents.parseTargetSpec().',
889
+ additionalProperties: true,
890
+ },
891
+ ],
892
+ items: {},
893
+ },
894
+ 'projects.resolveImplementationPlan': {
895
+ type: 'array',
896
+ description: 'Low-level helper: resolve docs implementation plan path for a given projectRoot.',
897
+ minItems: 1,
898
+ prefixItems: [
899
+ { type: 'string', title: 'projectRoot', description: 'Absolute path to a project folder (e.g. .../projects/adl).' },
900
+ { type: 'string', title: 'inputFile', description: 'Optional path within docs/ (or absolute) to pick a plan.' },
901
+ ],
902
+ items: {},
903
+ },
904
+ }
905
+
906
+ const toolArgsSchema = (toolName: string): Record<string, unknown> => {
907
+ const override = TOOL_ARGS_SCHEMA_OVERRIDES[toolName]
908
+ if (override) return override
909
+
910
+ const requiredArgs = TOOL_REQUIRED_ARGS[toolName]
911
+ if (requiredArgs && requiredArgs.length > 0) {
912
+ return buildArgsSchemaFromPlaceholders(requiredArgs)
913
+ }
914
+
915
+ return {
916
+ type: 'array',
917
+ items: {},
918
+ description: 'Positional arguments',
919
+ }
920
+ }
921
+
922
+ const getInvocationPlanName = (toolName: string): string => {
923
+ const plan = toolInvocationPlans[toolName]
924
+ if (!plan) return 'default'
925
+ if (plan === buildOptionsOnly) return 'optionsOnly'
926
+ if (plan === buildOptionsThenProcessRoot) return 'optionsThenProcessRoot'
927
+ if (plan === buildProcessRootThenOptions) return 'processRootThenOptions'
928
+ if (plan === buildProcessRootOnly) return 'processRootOnly'
929
+ return 'custom'
930
+ }
931
+
932
+ const buildInvocationExample = (toolName: string): Record<string, unknown> => {
933
+ const requiredArgs = TOOL_REQUIRED_ARGS[toolName]
934
+ const plan = getInvocationPlanName(toolName)
935
+ const defaultOptions = TOOL_DEFAULT_OPTIONS[toolName] ?? {}
936
+
937
+ const example: Record<string, unknown> = {}
938
+ if (requiredArgs && requiredArgs.length > 0) {
939
+ example.args = [...requiredArgs]
940
+ } else if (plan !== 'processRootOnly') {
941
+ example.args = ['<arg0>']
942
+ }
943
+
944
+ if (plan === 'processRootOnly') {
945
+ example.options = { processRoot: '<repo-root>', ...defaultOptions }
946
+ return example
947
+ }
948
+
949
+ if (plan === 'optionsThenProcessRoot' || plan === 'processRootThenOptions') {
950
+ example.options = { processRoot: '<repo-root>', ...defaultOptions }
951
+ return example
952
+ }
953
+
954
+ if (plan === 'optionsOnly') {
955
+ example.options = Object.keys(defaultOptions).length > 0 ? { ...defaultOptions } : { example: true }
956
+ return example
957
+ }
958
+
959
+ example.options = Object.keys(defaultOptions).length > 0 ? { ...defaultOptions } : { example: true }
960
+ return example
961
+ }
962
+
963
+ const defaultToolInputSchema = (toolName: string) => ({
964
+ type: 'object',
965
+ additionalProperties: true,
966
+ properties: {
967
+ args: {
968
+ ...toolArgsSchema(toolName),
969
+ },
970
+ options: {
154
971
  type: 'object',
155
972
  additionalProperties: true,
156
- properties: {
157
- args: {
158
- type: 'array',
159
- items: { type: 'string' },
160
- description: 'Positional arguments',
161
- },
162
- options: {
163
- type: 'object',
164
- additionalProperties: true,
165
- description: 'Named options',
166
- },
167
- },
973
+ description: 'Named options',
168
974
  },
169
- }))
975
+ processRoot: {
976
+ type: 'string',
977
+ description:
978
+ 'Repo root containing /projects and /agents. If you have a project root like ".../projects/adl", omit this or pass its parent.',
979
+ },
980
+ },
981
+ $comment: safeJsonStringify({
982
+ note: 'Preferred: pass args as JSON-native values. Avoid stringifying objects.',
983
+ invocationPlan: getInvocationPlanName(toolName),
984
+ example: buildInvocationExample(toolName),
985
+ }),
986
+ })
987
+
988
+ const buildToolList = (
989
+ tools: ToolDefinition[],
990
+ batchToolName: string,
991
+ prefix: string | undefined,
992
+ exposeUnprefixedAliases: boolean,
993
+ ) => {
994
+ const toolEntries = tools.flatMap((tool) => {
995
+ const canonicalName = buildToolName(tool, prefix)
996
+ const schema = TOOL_INPUT_SCHEMA_OVERRIDES[tool.name] ?? defaultToolInputSchema(tool.name)
997
+ const base = {
998
+ // Some UIs only show tool descriptions. Make the fastest path be "copy schema, fill values".
999
+ description: safeJsonStringify(schema),
1000
+ inputSchema: schema,
1001
+ }
1002
+
1003
+ const entries: Array<{ name: string; description: string; inputSchema: unknown }> = [
1004
+ {
1005
+ name: canonicalName,
1006
+ ...base,
1007
+ },
1008
+ ]
1009
+
1010
+ if (prefix && exposeUnprefixedAliases) {
1011
+ entries.push({
1012
+ name: tool.name,
1013
+ description: safeJsonStringify(schema),
1014
+ inputSchema: base.inputSchema,
1015
+ })
1016
+ }
1017
+
1018
+ return entries
1019
+ })
170
1020
 
171
1021
  const batchTool = {
172
1022
  name: batchToolName,
173
- description:
174
- 'Preferred mode for client calls: execute multiple MCP tool calls in one request with parallel execution.',
1023
+ description: '',
175
1024
  inputSchema: {
176
1025
  type: 'object',
177
1026
  additionalProperties: true,
@@ -189,7 +1038,7 @@ const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?:
189
1038
  },
190
1039
  args: {
191
1040
  type: 'array',
192
- items: { type: 'string' },
1041
+ items: {},
193
1042
  description: 'Positional args for the tool',
194
1043
  },
195
1044
  options: {
@@ -207,17 +1056,34 @@ const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?:
207
1056
  description: 'Whether to continue when a call in the batch fails',
208
1057
  default: false,
209
1058
  },
1059
+ maxConcurrency: {
1060
+ type: 'integer',
1061
+ minimum: 1,
1062
+ description: 'Max number of calls to execute concurrently when continueOnError=true.',
1063
+ default: 8,
1064
+ },
210
1065
  },
211
1066
  required: ['calls'],
212
1067
  },
213
1068
  }
214
1069
 
215
- return [...toolEntries, batchTool]
1070
+ ;(batchTool as any).description = safeJsonStringify(batchTool.inputSchema)
1071
+
1072
+ const out = [...toolEntries, batchTool]
1073
+ if (prefix && exposeUnprefixedAliases) {
1074
+ out.push({
1075
+ ...batchTool,
1076
+ name: 'batch',
1077
+ description: safeJsonStringify(batchTool.inputSchema),
1078
+ })
1079
+ }
1080
+
1081
+ return out
216
1082
  }
217
1083
 
218
- type ToolInvoker = (args: string[], options: Record<string, unknown>) => unknown[]
1084
+ type ToolInvoker = (args: unknown[], options: Record<string, unknown>) => unknown[]
219
1085
 
220
- const buildOptionsOnly = (args: string[], options: Record<string, unknown>): unknown[] => {
1086
+ const buildOptionsOnly = (args: unknown[], options: Record<string, unknown>): unknown[] => {
221
1087
  const invocationArgs: unknown[] = [...args]
222
1088
  if (Object.keys(options).length > 0) {
223
1089
  invocationArgs.push(options)
@@ -225,7 +1091,7 @@ const buildOptionsOnly = (args: string[], options: Record<string, unknown>): unk
225
1091
  return invocationArgs
226
1092
  }
227
1093
 
228
- const buildOptionsThenProcessRoot = (args: string[], options: Record<string, unknown>): unknown[] => {
1094
+ const buildOptionsThenProcessRoot = (args: unknown[], options: Record<string, unknown>): unknown[] => {
229
1095
  const invocationArgs: unknown[] = [...args]
230
1096
  const remaining = { ...options }
231
1097
  const processRoot = remaining.processRoot
@@ -243,7 +1109,7 @@ const buildOptionsThenProcessRoot = (args: string[], options: Record<string, unk
243
1109
  return invocationArgs
244
1110
  }
245
1111
 
246
- const buildProcessRootThenOptions = (args: string[], options: Record<string, unknown>): unknown[] => {
1112
+ const buildProcessRootThenOptions = (args: unknown[], options: Record<string, unknown>): unknown[] => {
247
1113
  const invocationArgs: unknown[] = [...args]
248
1114
  const remaining = { ...options }
249
1115
  const processRoot = remaining.processRoot
@@ -261,7 +1127,7 @@ const buildProcessRootThenOptions = (args: string[], options: Record<string, unk
261
1127
  return invocationArgs
262
1128
  }
263
1129
 
264
- const buildProcessRootOnly = (args: string[], options: Record<string, unknown>): unknown[] => {
1130
+ const buildProcessRootOnly = (args: unknown[], options: Record<string, unknown>): unknown[] => {
265
1131
  const invocationArgs: unknown[] = [...args]
266
1132
  const processRoot = options.processRoot
267
1133
  if (typeof processRoot === 'string') {
@@ -283,9 +1149,29 @@ const toolInvocationPlans: Record<string, ToolInvoker> = {
283
1149
  'agents.resolveTargetFile': buildOptionsOnly,
284
1150
  'projects.resolveProjectTargetFile': buildOptionsOnly,
285
1151
  'agents.loadAgent': buildProcessRootOnly,
1152
+ 'agents.loadAgentPrompt': buildProcessRootOnly,
1153
+ 'projects.resolveImplementationPlan': (args, options) => {
1154
+ const invocationArgs: unknown[] = [...args]
1155
+ const remaining = { ...options }
1156
+ const processRoot = remaining.processRoot
1157
+ if (typeof processRoot === 'string') {
1158
+ delete remaining.processRoot
1159
+ }
1160
+
1161
+ // This tool is a low-level helper: projects.resolveImplementationPlan(projectRoot, inputFile?, options?)
1162
+ // If the caller provides options but no inputFile, preserve the positional slot.
1163
+ if (Object.keys(remaining).length > 0) {
1164
+ if (invocationArgs.length === 1) {
1165
+ invocationArgs.push(undefined)
1166
+ }
1167
+ invocationArgs.push(remaining)
1168
+ }
1169
+
1170
+ // Intentionally do NOT append processRoot: projectRoot is the first positional argument.
1171
+ return invocationArgs
1172
+ },
286
1173
  'agents.main': buildProcessRootOnly,
287
1174
  'agents.resolveAgentsRoot': buildProcessRootOnly,
288
- 'agents.resolveAgentsRootFrom': buildProcessRootOnly,
289
1175
  'agents.listAgents': buildProcessRootOnly,
290
1176
  'projects.resolveProjectRoot': buildProcessRootOnly,
291
1177
  'projects.listProjects': buildProcessRootOnly,
@@ -295,7 +1181,8 @@ const toolInvocationPlans: Record<string, ToolInvoker> = {
295
1181
  }
296
1182
 
297
1183
  const invokeTool = async (tool: ToolDefinition, payload: unknown): Promise<unknown> => {
298
- const { args, options } = normalizePayload(payload)
1184
+ const normalized = normalizePayload(payload)
1185
+ const { args, options } = coercePayloadForTool(tool.name, normalized)
299
1186
  const invoke = toolInvocationPlans[tool.name] ?? ((rawArgs, rawOptions) => {
300
1187
  const invocationArgs = [...rawArgs]
301
1188
  if (Object.keys(rawOptions).length > 0) {
@@ -316,6 +1203,7 @@ export interface ExampleMcpServerOptions {
316
1203
  disableWrite?: boolean
317
1204
  enableIssues?: boolean
318
1205
  admin?: boolean
1206
+ exposeUnprefixedToolAliases?: boolean
319
1207
  }
320
1208
 
321
1209
  export type ExampleMcpServerInstance = {
@@ -325,7 +1213,7 @@ export type ExampleMcpServerInstance = {
325
1213
  run: () => Promise<Server>
326
1214
  }
327
1215
 
328
- const READ_ONLY_TOOL_NAMES = new Set([
1216
+ const READ_ONLY_TOOL_NAMES = new Set<string>([
329
1217
  'agents.usage',
330
1218
  'agents.resolveAgentsRoot',
331
1219
  'agents.resolveAgentsRootFrom',
@@ -339,15 +1227,21 @@ const READ_ONLY_TOOL_NAMES = new Set([
339
1227
  'projects.listProjects',
340
1228
  'projects.listProjectDocs',
341
1229
  'projects.readProjectDoc',
1230
+ 'projects.searchDocs',
1231
+ 'projects.searchSpecs',
342
1232
  'projects.resolveImplementationPlan',
343
1233
  'projects.fetchGitTasks',
344
1234
  'projects.readGitTask',
345
1235
  'projects.parseProjectTargetSpec',
346
1236
  'projects.resolveProjectTargetFile',
1237
+ 'mcp.usage',
1238
+ 'mcp.listTools',
1239
+ 'mcp.describeTool',
1240
+ 'mcp.search',
347
1241
  ])
348
1242
 
349
1243
  const isWriteCapableTool = (toolName: string): boolean => !READ_ONLY_TOOL_NAMES.has(toolName)
350
- const ISSUE_TOOL_NAMES = new Set([
1244
+ const ISSUE_TOOL_NAMES = new Set<string>([
351
1245
  'projects.fetchGitTasks',
352
1246
  'projects.readGitTask',
353
1247
  'projects.writeGitTask',
@@ -355,16 +1249,193 @@ const ISSUE_TOOL_NAMES = new Set([
355
1249
  'projects.clearIssues',
356
1250
  ])
357
1251
  const isIssueTool = (toolName: string): boolean => ISSUE_TOOL_NAMES.has(toolName)
358
- const ADMIN_TOOL_NAMES = new Set([
1252
+ const ADMIN_TOOL_NAMES = new Set<string>([
359
1253
  'projects.syncTasks',
360
1254
  'projects.clearIssues',
1255
+ 'agents.runAgent',
1256
+ 'agents.main',
1257
+ 'projects.main',
361
1258
  ])
362
1259
  const isAdminTool = (toolName: string): boolean => ADMIN_TOOL_NAMES.has(toolName)
363
1260
 
1261
+ const getToolAccess = (toolName: string): ToolAccess => {
1262
+ if (isAdminTool(toolName)) return 'admin'
1263
+ return isWriteCapableTool(toolName) ? 'write' : 'read'
1264
+ }
1265
+
1266
+ const buildToolMeta = (tools: ToolDefinition[]): void => {
1267
+ for (const tool of tools) {
1268
+ TOOL_META[tool.name] = {
1269
+ access: getToolAccess(tool.name),
1270
+ category: tool.path[0],
1271
+ notes: isAdminTool(tool.name) ? ' Admin-only.' : undefined,
1272
+ }
1273
+ }
1274
+ }
1275
+
1276
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1277
+
1278
+ const runWithConcurrency = async <T>(
1279
+ tasks: Array<() => Promise<T>>,
1280
+ maxConcurrency: number,
1281
+ ): Promise<T[]> => {
1282
+ const results: T[] = new Array(tasks.length)
1283
+ let nextIndex = 0
1284
+
1285
+ const worker = async (): Promise<void> => {
1286
+ while (true) {
1287
+ const index = nextIndex
1288
+ nextIndex += 1
1289
+ if (index >= tasks.length) return
1290
+ results[index] = await tasks[index]()
1291
+ }
1292
+ }
1293
+
1294
+ const concurrency = Math.max(1, Math.min(maxConcurrency, tasks.length))
1295
+ await Promise.all(Array.from({ length: concurrency }, () => worker()))
1296
+ return results
1297
+ }
1298
+
364
1299
  export const createExampleMcpServer = (options: ExampleMcpServerOptions = {}): ExampleMcpServerInstance => {
1300
+ let toolCatalog: unknown[] = []
1301
+
1302
+ const parseString = (value: unknown): string | null => {
1303
+ if (typeof value !== 'string') return null
1304
+ const trimmed = value.trim()
1305
+ return trimmed.length > 0 ? trimmed : null
1306
+ }
1307
+
1308
+ const parseBoolean = (value: unknown): boolean => value === true || value === 1 || value === '1' || value === 'true'
1309
+
1310
+ const parseStringArray = (value: unknown): string[] => {
1311
+ if (Array.isArray(value)) {
1312
+ return value.map((entry) => String(entry)).map((entry) => entry.trim()).filter((entry) => entry.length > 0)
1313
+ }
1314
+ const asString = parseString(value)
1315
+ return asString ? [asString] : []
1316
+ }
1317
+
1318
+ const ensureProjectName = (projectName: string | null, processRoot: string): string => {
1319
+ if (projectName) return projectName
1320
+ const projects = projectsApi.listProjects(processRoot)
1321
+ if (projects.length === 1) return projects[0]
1322
+ throw new Error(`projectName is required. Available projects: ${projects.join(', ')}`)
1323
+ }
1324
+
1325
+ const mcpApi = {
1326
+ usage: () =>
1327
+ [
1328
+ 'F0 MCP helper tools:',
1329
+ '- mcp.listTools: returns tool catalog with access + invocation hints',
1330
+ '- mcp.describeTool: describe one tool by name (prefixed or unprefixed)',
1331
+ '- mcp.search: LLM-friendly search over project docs/spec (local-first)',
1332
+ '',
1333
+ 'Tip: Prefer mcp.search for "search spec/docs" requests.',
1334
+ ].join('\n'),
1335
+ listTools: () => ({ tools: toolCatalog }),
1336
+ describeTool: (toolName: string) => {
1337
+ const normalized = typeof toolName === 'string' ? toolName.trim() : ''
1338
+ if (!normalized) {
1339
+ throw new Error('toolName is required.')
1340
+ }
1341
+
1342
+ const matches = toolCatalog.filter((entry) =>
1343
+ isRecord(entry) && (entry.name === normalized || (entry as any).unprefixedName === normalized),
1344
+ ) as Array<Record<string, unknown>>
1345
+
1346
+ if (matches.length === 0) {
1347
+ const needle = normalized.toLowerCase()
1348
+ const candidates = toolCatalog
1349
+ .filter(isRecord)
1350
+ .map((entry) => String((entry as any).name ?? ''))
1351
+ .filter((name) => name.length > 0)
1352
+ const suggestions = candidates
1353
+ .filter((name) => name.toLowerCase().includes(needle) || needle.includes(name.toLowerCase()))
1354
+ .slice(0, 8)
1355
+ const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : ''
1356
+ throw new Error(`Unknown tool: ${normalized}.${hint}`)
1357
+ }
1358
+
1359
+ const canonical = matches.find((entry) => (entry as any).kind === 'canonical')
1360
+ return canonical ?? matches[0]
1361
+ },
1362
+ search: async (input: unknown) => {
1363
+ const payload = isRecord(input) ? input : {}
1364
+ const processRoot = (() => {
1365
+ const raw = parseString(payload.processRoot)
1366
+ return raw ? normalizeProcessRoot(raw) : process.cwd()
1367
+ })()
1368
+
1369
+ const sectionRaw = parseString(payload.section)?.toLowerCase()
1370
+ const section = sectionRaw === 'docs' ? 'docs' : 'spec'
1371
+
1372
+ const pattern =
1373
+ parseString(payload.pattern) ??
1374
+ parseString(payload.query) ??
1375
+ parseString(payload.q)
1376
+ if (!pattern) {
1377
+ throw new Error('pattern is required (or query/q).')
1378
+ }
1379
+
1380
+ const rgArgs: string[] = []
1381
+ if (parseBoolean(payload.ignoreCase)) rgArgs.push('--ignore-case')
1382
+ if (parseBoolean(payload.caseSensitive)) rgArgs.push('--case-sensitive')
1383
+ if (parseBoolean(payload.smartCase)) rgArgs.push('--smart-case')
1384
+ if (parseBoolean(payload.fixedStrings)) rgArgs.push('--fixed-strings')
1385
+ if (parseBoolean(payload.wordRegexp)) rgArgs.push('--word-regexp')
1386
+ if (parseBoolean(payload.includeHidden)) rgArgs.push('--hidden')
1387
+ if (parseBoolean(payload.filesWithMatches)) rgArgs.push('--files-with-matches')
1388
+ if (parseBoolean(payload.filesWithoutMatch)) rgArgs.push('--files-without-match')
1389
+ if (parseBoolean(payload.countOnly)) rgArgs.push('--count')
1390
+ if (parseBoolean(payload.onlyMatching)) rgArgs.push('--only-matching')
1391
+
1392
+ const maxCount = typeof payload.maxCount === 'number' ? payload.maxCount : Number(payload.maxCount)
1393
+ if (Number.isFinite(maxCount) && maxCount > 0) {
1394
+ rgArgs.push('--max-count', String(Math.floor(maxCount)))
1395
+ }
1396
+
1397
+ for (const glob of parseStringArray(payload.globs)) {
1398
+ rgArgs.push('--glob', glob)
1399
+ }
1400
+
1401
+ rgArgs.push(pattern)
1402
+ const paths = parseStringArray(payload.paths)
1403
+ if (paths.length > 0) {
1404
+ rgArgs.push(...paths)
1405
+ }
1406
+
1407
+ const projectName = ensureProjectName(parseString(payload.projectName), processRoot)
1408
+
1409
+ const searchOptions: Record<string, unknown> = {
1410
+ processRoot,
1411
+ }
1412
+
1413
+ const source = parseString(payload.source)?.toLowerCase()
1414
+ if (source) {
1415
+ searchOptions.source = source
1416
+ }
1417
+ const ref = parseString(payload.ref)
1418
+ if (ref) {
1419
+ searchOptions.ref = ref
1420
+ }
1421
+ if (parseBoolean(payload.refresh)) {
1422
+ searchOptions.refresh = true
1423
+ }
1424
+ const cacheDir = parseString(payload.cacheDir)
1425
+ if (cacheDir) {
1426
+ searchOptions.cacheDir = cacheDir
1427
+ }
1428
+
1429
+ return section === 'docs'
1430
+ ? await projectsApi.searchDocs(projectName, ...rgArgs, searchOptions)
1431
+ : await projectsApi.searchSpecs(projectName, ...rgArgs, searchOptions)
1432
+ },
1433
+ } satisfies ToolNamespace
1434
+
365
1435
  const api: ApiEndpoint = {
366
1436
  agents: agentsApi,
367
1437
  projects: projectsApi,
1438
+ mcp: mcpApi,
368
1439
  }
369
1440
 
370
1441
  const allRoots = Object.keys(api) as Array<keyof ApiEndpoint>
@@ -386,6 +1457,7 @@ export const createExampleMcpServer = (options: ExampleMcpServerOptions = {}): E
386
1457
  })()
387
1458
 
388
1459
  const selectedTools = selectedRoots.flatMap((root) => collectTools(api[root], [root]))
1460
+ buildToolMeta(selectedTools)
389
1461
  const adminFilteredTools = Boolean(options.admin)
390
1462
  ? selectedTools
391
1463
  : selectedTools.filter((tool) => !isAdminTool(tool.name))
@@ -393,8 +1465,116 @@ export const createExampleMcpServer = (options: ExampleMcpServerOptions = {}): E
393
1465
  ? adminFilteredTools.filter((tool) => !isWriteCapableTool(tool.name) || (Boolean(options.enableIssues) && isIssueTool(tool.name)))
394
1466
  : adminFilteredTools
395
1467
  const prefix = options.toolsPrefix
1468
+ const exposeUnprefixedAliases = options.exposeUnprefixedToolAliases ?? true
396
1469
  const batchToolName = prefix ? `${prefix}.batch` : 'batch'
397
1470
 
1471
+ toolCatalog = tools.flatMap((tool) => {
1472
+ const canonicalName = buildToolName(tool, prefix)
1473
+ const schema = TOOL_INPUT_SCHEMA_OVERRIDES[tool.name] ?? defaultToolInputSchema(tool.name)
1474
+ const base = {
1475
+ canonicalName,
1476
+ unprefixedName: tool.name,
1477
+ access: getToolAccess(tool.name),
1478
+ category: TOOL_META[tool.name]?.category ?? tool.path[0],
1479
+ invocationPlan: getInvocationPlanName(tool.name),
1480
+ example: buildInvocationExample(tool.name),
1481
+ description: safeJsonStringify(schema),
1482
+ inputSchema: schema,
1483
+ }
1484
+
1485
+ const out = [
1486
+ {
1487
+ name: canonicalName,
1488
+ kind: 'canonical' as const,
1489
+ ...base,
1490
+ },
1491
+ ]
1492
+
1493
+ if (prefix && exposeUnprefixedAliases) {
1494
+ out.push({
1495
+ name: tool.name,
1496
+ kind: 'alias' as const,
1497
+ aliasOf: canonicalName,
1498
+ ...base,
1499
+ })
1500
+ }
1501
+
1502
+ return out
1503
+ })
1504
+
1505
+ toolCatalog.push({
1506
+ name: batchToolName,
1507
+ kind: 'canonical' as const,
1508
+ canonicalName: batchToolName,
1509
+ unprefixedName: 'batch',
1510
+ access: 'write',
1511
+ category: 'mcp',
1512
+ invocationPlan: 'custom',
1513
+ example: {
1514
+ calls: [
1515
+ { tool: prefix ? `${prefix}.projects.usage` : 'projects.usage' },
1516
+ { tool: prefix ? `${prefix}.projects.listProjects` : 'projects.listProjects', options: { processRoot: '<repo-root>' } },
1517
+ ],
1518
+ continueOnError: true,
1519
+ maxConcurrency: 4,
1520
+ },
1521
+ description: safeJsonStringify({
1522
+ type: 'object',
1523
+ additionalProperties: true,
1524
+ properties: {
1525
+ calls: { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: true } },
1526
+ continueOnError: { type: 'boolean', default: false },
1527
+ maxConcurrency: { type: 'integer', minimum: 1, default: 8 },
1528
+ },
1529
+ required: ['calls'],
1530
+ }),
1531
+ inputSchema: {
1532
+ type: 'object',
1533
+ additionalProperties: true,
1534
+ properties: {
1535
+ calls: { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: true } },
1536
+ continueOnError: { type: 'boolean', default: false },
1537
+ maxConcurrency: { type: 'integer', minimum: 1, default: 8 },
1538
+ },
1539
+ required: ['calls'],
1540
+ },
1541
+ })
1542
+ if (prefix && exposeUnprefixedAliases) {
1543
+ toolCatalog.push({
1544
+ name: 'batch',
1545
+ kind: 'alias' as const,
1546
+ aliasOf: batchToolName,
1547
+ canonicalName: batchToolName,
1548
+ unprefixedName: 'batch',
1549
+ access: 'write',
1550
+ category: 'mcp',
1551
+ invocationPlan: 'custom',
1552
+ example: {
1553
+ calls: [{ tool: batchToolName }],
1554
+ },
1555
+ description: safeJsonStringify({
1556
+ type: 'object',
1557
+ additionalProperties: true,
1558
+ properties: {
1559
+ calls: { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: true } },
1560
+ continueOnError: { type: 'boolean', default: false },
1561
+ maxConcurrency: { type: 'integer', minimum: 1, default: 8 },
1562
+ },
1563
+ required: ['calls'],
1564
+ }),
1565
+ inputSchema: {
1566
+ type: 'object',
1567
+ additionalProperties: true,
1568
+ properties: {
1569
+ calls: { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: true } },
1570
+ continueOnError: { type: 'boolean', default: false },
1571
+ maxConcurrency: { type: 'integer', minimum: 1, default: 8 },
1572
+ },
1573
+ required: ['calls'],
1574
+ },
1575
+ })
1576
+ }
1577
+
398
1578
  const server = new Server(
399
1579
  {
400
1580
  name: options.serverName ?? 'example-api',
@@ -408,100 +1588,157 @@ export const createExampleMcpServer = (options: ExampleMcpServerOptions = {}): E
408
1588
  )
409
1589
 
410
1590
  const toolByName = new Map<string, ToolDefinition>(tools.map((tool) => [buildToolName(tool, prefix), tool]))
1591
+ const toolByUnprefixedName = new Map<string, ToolDefinition>(
1592
+ prefix ? tools.map((tool) => [tool.name, tool]) : [],
1593
+ )
1594
+
1595
+ const knownToolNames = (() => {
1596
+ const names = new Set<string>()
1597
+ for (const name of toolByName.keys()) names.add(name)
1598
+ if (prefix && exposeUnprefixedAliases) {
1599
+ for (const tool of tools) names.add(tool.name)
1600
+ names.add('batch')
1601
+ }
1602
+ names.add(batchToolName)
1603
+ return names
1604
+ })()
1605
+
1606
+ const suggestToolNames = (requested: string): string[] => {
1607
+ const needle = requested.trim().toLowerCase()
1608
+ if (!needle) return []
1609
+
1610
+ const score = (candidate: string): number => {
1611
+ const cand = candidate.toLowerCase()
1612
+ if (cand === needle) return 0
1613
+ if (cand.includes(needle)) return 1
1614
+ if (needle.includes(cand)) return 2
1615
+ const needleLast = needle.split('.').pop() ?? needle
1616
+ const candLast = cand.split('.').pop() ?? cand
1617
+ if (needleLast && candLast === needleLast) return 3
1618
+ if (candLast.includes(needleLast)) return 4
1619
+ return 100
1620
+ }
1621
+
1622
+ return Array.from(knownToolNames)
1623
+ .map((name) => ({ name, s: score(name) }))
1624
+ .filter((entry) => entry.s < 100)
1625
+ .sort((a, b) => a.s - b.s || a.name.localeCompare(b.name))
1626
+ .slice(0, 8)
1627
+ .map((entry) => entry.name)
1628
+ }
1629
+
1630
+ const resolveTool = (name: string): ToolDefinition | null => {
1631
+ const direct = toolByName.get(name)
1632
+ if (direct) return direct
1633
+ if (prefix) {
1634
+ const unprefixed = name.replace(new RegExp(`^${escapeRegExp(prefix)}\\.`), '')
1635
+ return toolByUnprefixedName.get(unprefixed) ?? toolByName.get(`${prefix}.${unprefixed}`) ?? null
1636
+ }
1637
+ return null
1638
+ }
1639
+
1640
+ const enrichToolError = (toolName: string, message: string): { message: string; details?: unknown } => {
1641
+ const details: Record<string, unknown> = {}
1642
+ const unprefixedToolName = prefix
1643
+ ? toolName.replace(new RegExp(`^${escapeRegExp(prefix)}\\.`), '')
1644
+ : toolName
1645
+
1646
+ if (/Missing project name\./i.test(message)) {
1647
+ details.hint = 'Provide projectName (recommended) or pass it as the first positional arg (args[0]).'
1648
+ details.suggestion = 'Prefer mcp.search with { projectName, section, pattern }.'
1649
+ details.example = { tool: prefix ? `${prefix}.mcp.search` : 'mcp.search', arguments: { projectName: '<project-name>', section: 'spec', pattern: '<pattern>' } }
1650
+ }
1651
+
1652
+ if (/Project folder not found:/i.test(message) && /projects[\\/].+projects[\\/]/i.test(message)) {
1653
+ details.hint =
1654
+ 'You likely passed a project root as processRoot. processRoot should be the repo root containing /projects.'
1655
+ }
1656
+
1657
+ if (/Missing search pattern\./i.test(message)) {
1658
+ details.hint = 'Provide pattern/query (recommended) or a PATTERN as the first rg positional.'
1659
+ }
1660
+
1661
+ if (Object.keys(details).length === 0) {
1662
+ return { message }
1663
+ }
1664
+
1665
+ details.tool = toolName
1666
+ details.inputSchema = TOOL_INPUT_SCHEMA_OVERRIDES[unprefixedToolName] ?? defaultToolInputSchema(unprefixedToolName)
1667
+ details.invocationExample = buildInvocationExample(unprefixedToolName)
1668
+ return { message, details }
1669
+ }
411
1670
 
412
1671
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
413
- tools: buildToolList(tools, batchToolName, prefix),
1672
+ tools: buildToolList(tools, batchToolName, prefix, exposeUnprefixedAliases),
414
1673
  }))
415
1674
 
416
1675
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
417
1676
  const requestedName = request.params.name
418
1677
 
419
- if (requestedName === batchToolName) {
1678
+ const isBatchName = requestedName === batchToolName || (prefix && requestedName === 'batch')
1679
+ if (isBatchName) {
420
1680
  try {
421
- const { calls, continueOnError } = normalizeBatchPayload(request.params.arguments)
1681
+ const { calls, continueOnError, maxConcurrency } = normalizeBatchPayload(request.params.arguments)
422
1682
  const executions = calls.map(({ tool, args = [], options = {} }, index) => ({ tool, args, options, index }))
423
- const results = await Promise.all(
424
- executions.map(async ({ tool, args, options, index }) => {
425
- const toolDefinition = toolByName.get(tool)
426
- if (!toolDefinition) {
427
- return {
428
- index,
429
- tool,
430
- isError: true,
431
- data: `Unknown tool: ${tool}`,
432
- } as BatchResult
1683
+
1684
+ const runCall = async ({ tool, args, options, index }: typeof executions[number]): Promise<BatchResult> => {
1685
+ const toolDefinition = resolveTool(tool)
1686
+ if (!toolDefinition) {
1687
+ const message = `Unknown tool: ${tool}`
1688
+ if (!continueOnError) {
1689
+ throw new Error(message)
433
1690
  }
1691
+ const suggestions = suggestToolNames(tool)
1692
+ return { index, tool, isError: true, data: { message, suggestions } }
1693
+ }
434
1694
 
435
- try {
436
- const data = await invokeTool(toolDefinition, { args, options })
437
- return {
438
- index,
439
- tool,
440
- isError: false,
441
- data,
442
- } as BatchResult
443
- } catch (error) {
444
- if (continueOnError) {
445
- return {
446
- index,
447
- tool,
448
- isError: true,
449
- data: error instanceof Error ? error.message : String(error),
450
- } as BatchResult
451
- }
452
- throw error
1695
+ try {
1696
+ const data = await invokeTool(toolDefinition, { args, options })
1697
+ return { index, tool, isError: false, data }
1698
+ } catch (error) {
1699
+ const message = error instanceof Error ? error.message : String(error)
1700
+ if (!continueOnError) {
1701
+ throw new Error(message)
453
1702
  }
454
- }),
455
- )
456
-
457
- return {
458
- isError: results.some((result) => result.isError),
459
- content: [
460
- {
461
- type: 'text',
462
- text: JSON.stringify(results, null, 2),
463
- },
464
- ],
1703
+ const enriched = enrichToolError(tool, message)
1704
+ return { index, tool, isError: true, data: { message: enriched.message, details: enriched.details } }
1705
+ }
465
1706
  }
1707
+
1708
+ const results = continueOnError
1709
+ ? await runWithConcurrency(executions.map((execution) => () => runCall(execution)), maxConcurrency)
1710
+ : await (async () => {
1711
+ const out: BatchResult[] = []
1712
+ for (const execution of executions) {
1713
+ out.push(await runCall(execution))
1714
+ }
1715
+ return out
1716
+ })()
1717
+
1718
+ const ordered = [...results].sort((a, b) => a.index - b.index)
1719
+ const hasErrors = ordered.some((result) => result.isError)
1720
+ return hasErrors
1721
+ ? toolErr('One or more batch calls failed.', { results: ordered })
1722
+ : toolOk({ results: ordered })
466
1723
  } catch (error) {
467
- return {
468
- isError: true,
469
- content: [
470
- {
471
- type: 'text',
472
- text: error instanceof Error ? error.message : String(error),
473
- },
474
- ],
475
- }
1724
+ return toolErr(error instanceof Error ? error.message : String(error))
476
1725
  }
477
1726
  }
478
1727
 
479
- const tool = toolByName.get(requestedName)
1728
+ const tool = resolveTool(requestedName)
480
1729
 
481
1730
  if (!tool) {
482
- throw new Error(`Unknown tool: ${requestedName}`)
1731
+ const suggestions = suggestToolNames(requestedName)
1732
+ return toolErr(`Unknown tool: ${requestedName}`, suggestions.length > 0 ? { suggestions } : undefined)
483
1733
  }
484
1734
 
485
1735
  try {
486
1736
  const data = await invokeTool(tool, request.params.arguments)
487
- return {
488
- content: [
489
- {
490
- type: 'text',
491
- text: JSON.stringify(data, null, 2),
492
- },
493
- ],
494
- }
1737
+ return toolOk(data)
495
1738
  } catch (error) {
496
- return {
497
- isError: true,
498
- content: [
499
- {
500
- type: 'text',
501
- text: error instanceof Error ? error.message : String(error),
502
- },
503
- ],
504
- }
1739
+ const message = error instanceof Error ? error.message : String(error)
1740
+ const enriched = enrichToolError(requestedName, message)
1741
+ return toolErr(enriched.message, enriched.details)
505
1742
  }
506
1743
  })
507
1744
 
@@ -519,4 +1756,4 @@ export const runExampleMcpServer = async (options: ExampleMcpServerOptions = {})
519
1756
  }
520
1757
 
521
1758
  export const normalizeToolCallNameForServer = (prefix: string | undefined, toolName: string): string =>
522
- prefix ? toolName.replace(new RegExp(`^${prefix}\\.`), '') : toolName
1759
+ prefix ? toolName.replace(new RegExp(`^${escapeRegExp(prefix)}\\.`), '') : toolName