@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/cli.mjs +1 -1
- package/mcp/cli.ts +3 -3
- package/mcp/client.test.ts +13 -0
- package/mcp/client.ts +12 -4
- package/mcp/server.test.ts +133 -0
- package/mcp/server.ts +1344 -107
- package/package.json +1 -1
- package/projects.ts +1189 -0
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
|
|
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
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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:
|
|
1084
|
+
type ToolInvoker = (args: unknown[], options: Record<string, unknown>) => unknown[]
|
|
219
1085
|
|
|
220
|
-
const buildOptionsOnly = (args:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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 =
|
|
1728
|
+
const tool = resolveTool(requestedName)
|
|
480
1729
|
|
|
481
1730
|
if (!tool) {
|
|
482
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|