@foundation0/api 1.1.4 → 1.1.6
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.ts +6 -0
- package/mcp/manual.md +161 -0
- package/mcp/server.test.ts +251 -3
- package/mcp/server.ts +274 -95
- package/package.json +2 -1
package/mcp/cli.ts
CHANGED
|
@@ -67,6 +67,8 @@ const parseListArg = (value: string | undefined): string[] => {
|
|
|
67
67
|
const serverName = getArgValue('--server-name', 'f0-mcp')
|
|
68
68
|
const serverVersion = getArgValue('--server-version', '1.0.0')
|
|
69
69
|
const toolsPrefix = getArgValue('--tools-prefix') ?? process.env.MCP_TOOLS_PREFIX
|
|
70
|
+
const repoRoot =
|
|
71
|
+
getArgValue('--repo-root') ?? process.env.MCP_REPO_ROOT ?? process.env.F0_REPO_ROOT
|
|
70
72
|
const allowedRootEndpoints = parseListArg(
|
|
71
73
|
getArgValue('--allowed-root-endpoints') ?? process.env.MCP_ALLOWED_ROOT_ENDPOINTS,
|
|
72
74
|
)
|
|
@@ -80,6 +82,9 @@ if (hasFlag('--help') || hasFlag('-h')) {
|
|
|
80
82
|
console.log(' --server-name <name>')
|
|
81
83
|
console.log(' --server-version <version>')
|
|
82
84
|
console.log(' --tools-prefix <prefix>')
|
|
85
|
+
console.log(' --repo-root <path>')
|
|
86
|
+
console.log(' Default repo root on the server filesystem (contains /api and /projects).')
|
|
87
|
+
console.log(' Env: MCP_REPO_ROOT or F0_REPO_ROOT.')
|
|
83
88
|
console.log(' --allowed-root-endpoints <csv>')
|
|
84
89
|
console.log(' Example: --allowed-root-endpoints projects')
|
|
85
90
|
console.log(' Example: --allowed-root-endpoints agents,projects')
|
|
@@ -96,6 +101,7 @@ void runExampleMcpServer({
|
|
|
96
101
|
serverName: serverName ?? undefined,
|
|
97
102
|
serverVersion: serverVersion ?? undefined,
|
|
98
103
|
toolsPrefix,
|
|
104
|
+
repoRoot: repoRoot ?? undefined,
|
|
99
105
|
allowedRootEndpoints,
|
|
100
106
|
disableWrite,
|
|
101
107
|
enableIssues,
|
package/mcp/manual.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Foundation0 MCP Tool-Calling Manual (for LLMs)
|
|
2
|
+
|
|
3
|
+
This is a **tool-calling guide** for the Foundation0 MCP server in `api/mcp/*`.
|
|
4
|
+
Assume the server is already configured in your MCP host and you can call its tools.
|
|
5
|
+
|
|
6
|
+
## 0) Golden rules
|
|
7
|
+
|
|
8
|
+
1. If you are unsure about a tool name, call `mcp.listTools` first and use the exact name it returns (prefixes vary).
|
|
9
|
+
2. Use `mcp.describeTool` before your first call to a tool to get the input schema + an example payload.
|
|
10
|
+
3. Prefer named keys when possible (e.g. `projectName`, `repoRoot`) rather than relying on positional `args`.
|
|
11
|
+
4. When a tool needs `repoRoot`, it must be the **repo root** containing both `api/` and `projects/` (not a single project folder). (Legacy alias: `processRoot` is still accepted.) If omitted, the server will try to auto-detect a repo root from its current working directory (best effort).
|
|
12
|
+
5. Tool results are returned as a **JSON text envelope**: parse the text as JSON and check `ok`.
|
|
13
|
+
|
|
14
|
+
Note: `repoRoot` is a path on the **server's filesystem** (not the client/LLM). If you're unsure what the server can see, call `mcp.workspace`.
|
|
15
|
+
|
|
16
|
+
## 1) Tool naming (prefixes + aliases)
|
|
17
|
+
|
|
18
|
+
Depending on how the server was started, tool names may be:
|
|
19
|
+
|
|
20
|
+
- unprefixed: `projects.listProjects`
|
|
21
|
+
- prefixed: `api.projects.listProjects`
|
|
22
|
+
|
|
23
|
+
The server usually exposes both when a prefix is set, but do not assume: **discover at runtime** via `mcp.listTools`.
|
|
24
|
+
|
|
25
|
+
Some tools also have OpenAI-safe underscore aliases (no dots). Example:
|
|
26
|
+
|
|
27
|
+
- `net.curl` may also be available as `net_curl`
|
|
28
|
+
|
|
29
|
+
## 2) The 4 discovery calls
|
|
30
|
+
|
|
31
|
+
### A) List all tools
|
|
32
|
+
|
|
33
|
+
Tool call:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "name": "mcp.listTools", "arguments": {} }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### B) Describe one tool (schema + example)
|
|
40
|
+
|
|
41
|
+
Tool call (prefixed or unprefixed names both work here):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "name": "mcp.describeTool", "arguments": { "args": ["projects.listProjects"] } }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### C) `mcp.search` for "search docs/spec" requests
|
|
48
|
+
|
|
49
|
+
Tool call:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"name": "mcp.search",
|
|
54
|
+
"arguments": {
|
|
55
|
+
"projectName": "<project-name>",
|
|
56
|
+
"section": "spec",
|
|
57
|
+
"pattern": "authentication",
|
|
58
|
+
"repoRoot": "<repo-root>",
|
|
59
|
+
"ignoreCase": true,
|
|
60
|
+
"maxCount": 50
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### D) `mcp.workspace` to debug repoRoot/cwd issues
|
|
66
|
+
|
|
67
|
+
Tool call:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{ "name": "mcp.workspace", "arguments": {} }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 3) Payload shapes (important)
|
|
74
|
+
|
|
75
|
+
Most tools accept either:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{ "args": ["<project-name>"], "options": { "repoRoot": "<repo-root>" } }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
or named keys (recommended). The server merges top-level keys into `options`:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{ "projectName": "<project-name>", "repoRoot": "<repo-root>" }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Guideline: if a tool has a natural named parameter (`projectName`, `agentName`, `target`, `taskRef`), pass it explicitly.
|
|
88
|
+
|
|
89
|
+
## 4) Common calls (examples)
|
|
90
|
+
|
|
91
|
+
### A) List projects
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"name": "projects.listProjects",
|
|
96
|
+
"arguments": { "repoRoot": "<repo-root>" }
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### B) List agents
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"name": "agents.listAgents",
|
|
105
|
+
"arguments": { "repoRoot": "<repo-root>" }
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### C) Set an active file (projects)
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"name": "projects.setActive",
|
|
114
|
+
"arguments": {
|
|
115
|
+
"args": ["<project-name>", "/implementation-plan.v0.0.1"],
|
|
116
|
+
"options": { "repoRoot": "<repo-root>", "latest": true }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### D) Batch multiple calls
|
|
122
|
+
|
|
123
|
+
Call `batch` (or `<prefix>.batch`) to run multiple tool calls:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"name": "batch",
|
|
128
|
+
"arguments": {
|
|
129
|
+
"calls": [
|
|
130
|
+
{ "tool": "projects.usage" },
|
|
131
|
+
{ "tool": "projects.listProjects", "options": { "repoRoot": "<repo-root>" } }
|
|
132
|
+
],
|
|
133
|
+
"continueOnError": true,
|
|
134
|
+
"maxConcurrency": 4
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 5) Reading responses (envelopes + errors)
|
|
140
|
+
|
|
141
|
+
Tool results are returned as text containing JSON like:
|
|
142
|
+
|
|
143
|
+
- success: `{ "ok": true, "result": ... }`
|
|
144
|
+
- error: `{ "ok": false, "error": { "message": "...", "details": { ... } } }`
|
|
145
|
+
|
|
146
|
+
If you get:
|
|
147
|
+
|
|
148
|
+
- **Unknown tool**: use the `suggestions` from the error (when present), or call `mcp.listTools` again and retry.
|
|
149
|
+
- **Missing project name**: pass `projectName` (or set `args[0]`).
|
|
150
|
+
- **Project folder not found: ...projects/.../projects/...**: you likely passed the wrong `repoRoot` (it must be the repo root; legacy alias `processRoot`).
|
|
151
|
+
- **`projects.listProjects()` returns `[]` unexpectedly**: call `mcp.workspace` to confirm the server’s `cwd`/`repoRoot` and whether `/projects` exists.
|
|
152
|
+
|
|
153
|
+
## 6) Tool availability (read/write/admin)
|
|
154
|
+
|
|
155
|
+
The server can be started in modes that hide tools:
|
|
156
|
+
|
|
157
|
+
- read-only mode removes write-capable tools
|
|
158
|
+
- admin-only tools are hidden unless the server is started in admin mode
|
|
159
|
+
- root namespaces can be whitelisted (so entire namespaces may be missing)
|
|
160
|
+
|
|
161
|
+
If a tool is not listed by `mcp.listTools`, you cannot call it in the current server configuration.
|
package/mcp/server.test.ts
CHANGED
|
@@ -32,9 +32,18 @@ describe("createExampleMcpServer endpoint whitelist", () => {
|
|
|
32
32
|
const names = instance.tools.map((tool) => tool.name);
|
|
33
33
|
|
|
34
34
|
expect(names.length).toBeGreaterThan(0);
|
|
35
|
-
expect(
|
|
35
|
+
expect(
|
|
36
|
+
names.every(
|
|
37
|
+
(name) => name.startsWith("projects.") || name.startsWith("mcp."),
|
|
38
|
+
),
|
|
39
|
+
).toBe(true);
|
|
36
40
|
expect(names.some((name) => name.startsWith("agents."))).toBe(false);
|
|
37
41
|
expect(names.some((name) => name.startsWith("net."))).toBe(false);
|
|
42
|
+
expect(names).toContain("mcp.usage");
|
|
43
|
+
expect(names).toContain("mcp.listTools");
|
|
44
|
+
expect(names).toContain("mcp.describeTool");
|
|
45
|
+
expect(names).toContain("mcp.workspace");
|
|
46
|
+
expect(names).not.toContain("mcp.search");
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
it("throws on unknown root endpoints", () => {
|
|
@@ -77,12 +86,21 @@ describe("createExampleMcpServer endpoint whitelist", () => {
|
|
|
77
86
|
const names = instance.tools.map((tool) => tool.name);
|
|
78
87
|
|
|
79
88
|
expect(names.length).toBeGreaterThan(0);
|
|
80
|
-
expect(
|
|
89
|
+
expect(
|
|
90
|
+
names.every(
|
|
91
|
+
(name) => name.startsWith("projects.") || name.startsWith("mcp."),
|
|
92
|
+
),
|
|
93
|
+
).toBe(true);
|
|
81
94
|
expect(names).toContain("projects.readGitTask");
|
|
82
95
|
expect(names).toContain("projects.fetchGitTasks");
|
|
83
96
|
expect(names).not.toContain("projects.createGitIssue");
|
|
84
97
|
expect(names).not.toContain("projects.writeGitTask");
|
|
85
98
|
expect(names).not.toContain("projects.syncTasks");
|
|
99
|
+
expect(names).toContain("mcp.usage");
|
|
100
|
+
expect(names).toContain("mcp.listTools");
|
|
101
|
+
expect(names).toContain("mcp.describeTool");
|
|
102
|
+
expect(names).toContain("mcp.workspace");
|
|
103
|
+
expect(names).not.toContain("mcp.search");
|
|
86
104
|
});
|
|
87
105
|
|
|
88
106
|
it("re-enables issue write endpoints when enableIssues=true with disableWrite", () => {
|
|
@@ -186,6 +204,233 @@ describe("createExampleMcpServer request handling", () => {
|
|
|
186
204
|
}
|
|
187
205
|
});
|
|
188
206
|
|
|
207
|
+
it("accepts repoRoot for projects tools (preferred over legacy processRoot)", async () => {
|
|
208
|
+
const tempDir = await fs.mkdtemp(
|
|
209
|
+
path.join(os.tmpdir(), "f0-mcp-server-reporoot-"),
|
|
210
|
+
);
|
|
211
|
+
try {
|
|
212
|
+
await fs.mkdir(path.join(tempDir, "api"), { recursive: true });
|
|
213
|
+
await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
|
|
214
|
+
recursive: true,
|
|
215
|
+
});
|
|
216
|
+
await fs.mkdir(path.join(tempDir, "projects", "beta", "docs"), {
|
|
217
|
+
recursive: true,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const instance = createExampleMcpServer();
|
|
221
|
+
const handler = getToolHandler(instance);
|
|
222
|
+
|
|
223
|
+
const result = await handler(
|
|
224
|
+
{
|
|
225
|
+
method: "tools/call",
|
|
226
|
+
params: {
|
|
227
|
+
name: "projects.listProjects",
|
|
228
|
+
arguments: {
|
|
229
|
+
repoRoot: tempDir,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(result.isError).toBe(false);
|
|
237
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
238
|
+
expect(payload.ok).toBe(true);
|
|
239
|
+
expect(payload.result).toContain("adl");
|
|
240
|
+
expect(payload.result).toContain("beta");
|
|
241
|
+
} finally {
|
|
242
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("auto-detects repoRoot from process.cwd() when repoRoot is omitted", async () => {
|
|
247
|
+
const tempDir = await fs.mkdtemp(
|
|
248
|
+
path.join(os.tmpdir(), "f0-mcp-server-autoreporoot-"),
|
|
249
|
+
);
|
|
250
|
+
const originalCwd = process.cwd();
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await fs.mkdir(path.join(tempDir, "api"), { recursive: true });
|
|
254
|
+
await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
|
|
255
|
+
recursive: true,
|
|
256
|
+
});
|
|
257
|
+
await fs.mkdir(path.join(tempDir, "projects", "beta", "docs"), {
|
|
258
|
+
recursive: true,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
process.chdir(tempDir);
|
|
262
|
+
|
|
263
|
+
const instance = createExampleMcpServer();
|
|
264
|
+
const handler = getToolHandler(instance);
|
|
265
|
+
|
|
266
|
+
const result = await handler(
|
|
267
|
+
{
|
|
268
|
+
method: "tools/call",
|
|
269
|
+
params: {
|
|
270
|
+
name: "projects.listProjects",
|
|
271
|
+
arguments: {},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{},
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(result.isError).toBe(false);
|
|
278
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
279
|
+
expect(payload.ok).toBe(true);
|
|
280
|
+
expect(payload.result).toContain("adl");
|
|
281
|
+
expect(payload.result).toContain("beta");
|
|
282
|
+
} finally {
|
|
283
|
+
process.chdir(originalCwd);
|
|
284
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("still accepts legacy processRoot for backwards compatibility", async () => {
|
|
289
|
+
const tempDir = await fs.mkdtemp(
|
|
290
|
+
path.join(os.tmpdir(), "f0-mcp-server-processroot-"),
|
|
291
|
+
);
|
|
292
|
+
try {
|
|
293
|
+
await fs.mkdir(path.join(tempDir, "api"), { recursive: true });
|
|
294
|
+
await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
|
|
295
|
+
recursive: true,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const instance = createExampleMcpServer();
|
|
299
|
+
const handler = getToolHandler(instance);
|
|
300
|
+
|
|
301
|
+
const result = await handler(
|
|
302
|
+
{
|
|
303
|
+
method: "tools/call",
|
|
304
|
+
params: {
|
|
305
|
+
name: "projects.listProjects",
|
|
306
|
+
arguments: {
|
|
307
|
+
processRoot: tempDir,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
{},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
expect(result.isError).toBe(false);
|
|
315
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
316
|
+
expect(payload.ok).toBe(true);
|
|
317
|
+
expect(payload.result).toContain("adl");
|
|
318
|
+
} finally {
|
|
319
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("normalizes repoRoot when caller accidentally passes a project root", async () => {
|
|
324
|
+
const tempDir = await fs.mkdtemp(
|
|
325
|
+
path.join(os.tmpdir(), "f0-mcp-server-reporoot-normalize-"),
|
|
326
|
+
);
|
|
327
|
+
try {
|
|
328
|
+
await fs.mkdir(path.join(tempDir, "api"), { recursive: true });
|
|
329
|
+
await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
|
|
330
|
+
recursive: true,
|
|
331
|
+
});
|
|
332
|
+
await fs.mkdir(path.join(tempDir, "projects", "beta", "docs"), {
|
|
333
|
+
recursive: true,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const instance = createExampleMcpServer();
|
|
337
|
+
const handler = getToolHandler(instance);
|
|
338
|
+
|
|
339
|
+
const result = await handler(
|
|
340
|
+
{
|
|
341
|
+
method: "tools/call",
|
|
342
|
+
params: {
|
|
343
|
+
name: "projects.listProjects",
|
|
344
|
+
arguments: {
|
|
345
|
+
repoRoot: path.join(tempDir, "projects", "adl"),
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
{},
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
expect(result.isError).toBe(false);
|
|
353
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
354
|
+
expect(payload.ok).toBe(true);
|
|
355
|
+
expect(payload.result).toContain("adl");
|
|
356
|
+
expect(payload.result).toContain("beta");
|
|
357
|
+
} finally {
|
|
358
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("uses server default repoRoot when repoRoot is omitted", async () => {
|
|
363
|
+
const tempDir = await fs.mkdtemp(
|
|
364
|
+
path.join(os.tmpdir(), "f0-mcp-server-default-reporoot-"),
|
|
365
|
+
);
|
|
366
|
+
try {
|
|
367
|
+
await fs.mkdir(path.join(tempDir, "api"), { recursive: true });
|
|
368
|
+
await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
|
|
369
|
+
recursive: true,
|
|
370
|
+
});
|
|
371
|
+
await fs.mkdir(path.join(tempDir, "projects", "beta", "docs"), {
|
|
372
|
+
recursive: true,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const instance = createExampleMcpServer({ repoRoot: tempDir });
|
|
376
|
+
const handler = getToolHandler(instance);
|
|
377
|
+
|
|
378
|
+
const result = await handler(
|
|
379
|
+
{
|
|
380
|
+
method: "tools/call",
|
|
381
|
+
params: {
|
|
382
|
+
name: "projects.listProjects",
|
|
383
|
+
arguments: {},
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
{},
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
expect(result.isError).toBe(false);
|
|
390
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
391
|
+
expect(payload.ok).toBe(true);
|
|
392
|
+
expect(payload.result).toContain("adl");
|
|
393
|
+
expect(payload.result).toContain("beta");
|
|
394
|
+
} finally {
|
|
395
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("exposes mcp.workspace to explain server filesystem context", async () => {
|
|
400
|
+
const tempDir = await fs.mkdtemp(
|
|
401
|
+
path.join(os.tmpdir(), "f0-mcp-server-workspace-"),
|
|
402
|
+
);
|
|
403
|
+
try {
|
|
404
|
+
await fs.mkdir(path.join(tempDir, "api"), { recursive: true });
|
|
405
|
+
await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
|
|
406
|
+
recursive: true,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const instance = createExampleMcpServer({ repoRoot: tempDir });
|
|
410
|
+
const handler = getToolHandler(instance);
|
|
411
|
+
|
|
412
|
+
const result = await handler(
|
|
413
|
+
{
|
|
414
|
+
method: "tools/call",
|
|
415
|
+
params: {
|
|
416
|
+
name: "mcp.workspace",
|
|
417
|
+
arguments: {},
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
{},
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
expect(result.isError).toBe(false);
|
|
424
|
+
const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
425
|
+
expect(payload.ok).toBe(true);
|
|
426
|
+
expect(payload.result.repoRoot).toBe(path.resolve(tempDir));
|
|
427
|
+
expect(payload.result.hasProjectsDir).toBe(true);
|
|
428
|
+
expect(Array.isArray(payload.result.projects)).toBe(true);
|
|
429
|
+
} finally {
|
|
430
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
189
434
|
it('parses continueOnError from string "false" (fails fast)', async () => {
|
|
190
435
|
const instance = createExampleMcpServer();
|
|
191
436
|
const handler = getToolHandler(instance);
|
|
@@ -256,7 +501,10 @@ describe("createExampleMcpServer request handling", () => {
|
|
|
256
501
|
const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
257
502
|
expect(payload.ok).toBe(true);
|
|
258
503
|
expect(typeof payload.result).toBe("string");
|
|
259
|
-
expect(payload.result).toContain("
|
|
504
|
+
expect(payload.result).toContain("MCP");
|
|
505
|
+
expect(payload.result).toContain("projects.listProjects");
|
|
506
|
+
expect(payload.result).not.toContain("f0 projects");
|
|
507
|
+
expect(payload.result).not.toContain("--generate-spec");
|
|
260
508
|
});
|
|
261
509
|
|
|
262
510
|
it("exposes mcp.describeTool for tool discovery", async () => {
|
package/mcp/server.ts
CHANGED
|
@@ -48,6 +48,51 @@ type ApiEndpoint = {
|
|
|
48
48
|
mcp: ToolNamespace;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
const MCP_HELPER_TOOL_KEYS = ["usage", "listTools", "describeTool", "workspace"] as const;
|
|
52
|
+
type McpHelperToolKey = (typeof MCP_HELPER_TOOL_KEYS)[number];
|
|
53
|
+
const MCP_HELPER_TOOL_NAMES = new Set<string>(
|
|
54
|
+
MCP_HELPER_TOOL_KEYS.map((key) => `mcp.${key}`),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const formatToolNameForUsage = (
|
|
58
|
+
toolName: string,
|
|
59
|
+
toolsPrefix: string | undefined,
|
|
60
|
+
): string => (toolsPrefix ? `${toolsPrefix}.${toolName}` : toolName);
|
|
61
|
+
|
|
62
|
+
const buildProjectsMcpUsage = (toolsPrefix: string | undefined): string => {
|
|
63
|
+
const tool = (name: string) => formatToolNameForUsage(name, toolsPrefix);
|
|
64
|
+
return [
|
|
65
|
+
"MCP Projects usage (tools/call):",
|
|
66
|
+
`- ${tool("projects.listProjects")}: { options: { repoRoot: \"<repo-root>\" } }`,
|
|
67
|
+
`- ${tool("projects.generateSpec")}: { args: [\"<projectName>\"], options: { repoRoot: \"<repo-root>\" } }`,
|
|
68
|
+
`- ${tool("projects.setActive")}: { args: [\"<projectName>\", \"/implementation-plan.v0.0.1\"], options: { repoRoot: \"<repo-root>\", latest: true } }`,
|
|
69
|
+
"",
|
|
70
|
+
"Discovery:",
|
|
71
|
+
`- ${tool("mcp.listTools")}: list available tools (and their schemas)`,
|
|
72
|
+
`- ${tool("mcp.describeTool")}: { args: [\"projects.generateSpec\"] } for schema + example`,
|
|
73
|
+
"",
|
|
74
|
+
"Payload shape:",
|
|
75
|
+
"- Prefer { args: [...], options: {...} }. Top-level keys are also treated as options.",
|
|
76
|
+
].join("\n");
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const buildAgentsMcpUsage = (toolsPrefix: string | undefined): string => {
|
|
80
|
+
const tool = (name: string) => formatToolNameForUsage(name, toolsPrefix);
|
|
81
|
+
return [
|
|
82
|
+
"MCP Agents usage (tools/call):",
|
|
83
|
+
`- ${tool("agents.listAgents")}: { options: { repoRoot: \"<repo-root>\" } }`,
|
|
84
|
+
`- ${tool("agents.loadAgent")}: { args: [\"<agentName>\"], options: { repoRoot: \"<repo-root>\" } }`,
|
|
85
|
+
`- ${tool("agents.setActive")}: { args: [\"<agentName>\", \"/system/boot.v0.0.1\"], options: { repoRoot: \"<repo-root>\", latest: true } }`,
|
|
86
|
+
"",
|
|
87
|
+
"Discovery:",
|
|
88
|
+
`- ${tool("mcp.listTools")}: list available tools (and their schemas)`,
|
|
89
|
+
`- ${tool("mcp.describeTool")}: { args: [\"agents.setActive\"] } for schema + example`,
|
|
90
|
+
"",
|
|
91
|
+
"Payload shape:",
|
|
92
|
+
"- Prefer { args: [...], options: {...} }. Top-level keys are also treated as options.",
|
|
93
|
+
].join("\n");
|
|
94
|
+
};
|
|
95
|
+
|
|
51
96
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
52
97
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
98
|
|
|
@@ -131,11 +176,11 @@ const isDir = (candidate: string): boolean => {
|
|
|
131
176
|
const looksLikeRepoRoot = (candidate: string): boolean =>
|
|
132
177
|
isDir(path.join(candidate, "projects")) && isDir(path.join(candidate, "api"));
|
|
133
178
|
|
|
134
|
-
const
|
|
179
|
+
const normalizeRepoRoot = (raw: string): string => {
|
|
135
180
|
const resolved = path.resolve(raw);
|
|
136
181
|
if (looksLikeRepoRoot(resolved)) return resolved;
|
|
137
182
|
|
|
138
|
-
// Common mistake: passing a project root like ".../projects/adl" as
|
|
183
|
+
// Common mistake: passing a project root like ".../projects/adl" as repoRoot.
|
|
139
184
|
// Try to find the containing repo root by walking up a few levels.
|
|
140
185
|
let current = resolved;
|
|
141
186
|
for (let depth = 0; depth < 8; depth += 1) {
|
|
@@ -155,14 +200,30 @@ const normalizeProcessRoot = (raw: string): string => {
|
|
|
155
200
|
return resolved;
|
|
156
201
|
};
|
|
157
202
|
|
|
158
|
-
const
|
|
203
|
+
const normalizeRepoRootOption = (
|
|
159
204
|
options: Record<string, unknown>,
|
|
160
205
|
): Record<string, unknown> => {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
206
|
+
const rawRepoRoot = typeof options.repoRoot === "string" ? options.repoRoot : null;
|
|
207
|
+
const rawProcessRoot =
|
|
208
|
+
typeof options.processRoot === "string" ? options.processRoot : null;
|
|
209
|
+
const raw = rawRepoRoot ?? rawProcessRoot;
|
|
210
|
+
|
|
211
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
212
|
+
const next = { ...options };
|
|
213
|
+
delete next.repoRoot;
|
|
214
|
+
delete next.processRoot;
|
|
215
|
+
return next;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const trimmed = raw.trim();
|
|
219
|
+
const normalized = normalizeRepoRoot(trimmed);
|
|
220
|
+
|
|
221
|
+
const next: Record<string, unknown> = { ...options, repoRoot: normalized };
|
|
222
|
+
delete next.processRoot;
|
|
223
|
+
|
|
224
|
+
const alreadyCanonical =
|
|
225
|
+
rawRepoRoot !== null && rawRepoRoot === normalized && !("processRoot" in options);
|
|
226
|
+
return alreadyCanonical ? options : next;
|
|
166
227
|
};
|
|
167
228
|
|
|
168
229
|
type NormalizedToolPayload = {
|
|
@@ -283,7 +344,7 @@ const normalizePayload = (payload: unknown): NormalizedToolPayload => {
|
|
|
283
344
|
|
|
284
345
|
return {
|
|
285
346
|
args,
|
|
286
|
-
options:
|
|
347
|
+
options: normalizeRepoRootOption(options),
|
|
287
348
|
};
|
|
288
349
|
};
|
|
289
350
|
|
|
@@ -390,7 +451,7 @@ const coercePayloadForTool = (
|
|
|
390
451
|
|
|
391
452
|
switch (toolName) {
|
|
392
453
|
case "projects.listProjects": {
|
|
393
|
-
// No positional args.
|
|
454
|
+
// No positional args. repoRoot is handled via options.repoRoot + buildRepoRootOnly.
|
|
394
455
|
break;
|
|
395
456
|
}
|
|
396
457
|
case "projects.resolveProjectRoot":
|
|
@@ -480,7 +541,7 @@ const coercePayloadForTool = (
|
|
|
480
541
|
break;
|
|
481
542
|
}
|
|
482
543
|
|
|
483
|
-
return { args, options:
|
|
544
|
+
return { args, options: normalizeRepoRootOption(options) };
|
|
484
545
|
};
|
|
485
546
|
|
|
486
547
|
const normalizeBatchToolCall = (
|
|
@@ -791,7 +852,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
791
852
|
description: "Unused.",
|
|
792
853
|
additionalProperties: true,
|
|
793
854
|
},
|
|
794
|
-
|
|
855
|
+
repoRoot: {
|
|
795
856
|
type: "string",
|
|
796
857
|
description: "Unused.",
|
|
797
858
|
},
|
|
@@ -801,7 +862,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
801
862
|
type: "object",
|
|
802
863
|
additionalProperties: true,
|
|
803
864
|
properties: {
|
|
804
|
-
|
|
865
|
+
repoRoot: {
|
|
805
866
|
type: "string",
|
|
806
867
|
description:
|
|
807
868
|
'Repo root containing /projects and /agents. If you have a project root like ".../projects/adl", omit this or pass its parent.',
|
|
@@ -817,10 +878,10 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
817
878
|
type: "string",
|
|
818
879
|
description: 'Project name under /projects (e.g. "adl").',
|
|
819
880
|
},
|
|
820
|
-
|
|
881
|
+
repoRoot: {
|
|
821
882
|
type: "string",
|
|
822
883
|
description:
|
|
823
|
-
"Repo root
|
|
884
|
+
"Repo root on the server filesystem (contains /projects and /agents). If omitted, uses the server default (auto-detected from cwd).",
|
|
824
885
|
},
|
|
825
886
|
args: {
|
|
826
887
|
type: "array",
|
|
@@ -843,7 +904,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
843
904
|
type: "string",
|
|
844
905
|
description: 'Project name under /projects (e.g. "adl").',
|
|
845
906
|
},
|
|
846
|
-
|
|
907
|
+
repoRoot: {
|
|
847
908
|
type: "string",
|
|
848
909
|
description: "Repo root containing /projects and /agents.",
|
|
849
910
|
},
|
|
@@ -873,7 +934,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
873
934
|
description:
|
|
874
935
|
'Doc path under docs/, starting with "docs/..." (or a bare filename in catalog).',
|
|
875
936
|
},
|
|
876
|
-
|
|
937
|
+
repoRoot: {
|
|
877
938
|
type: "string",
|
|
878
939
|
description: "Repo root containing /projects and /agents.",
|
|
879
940
|
},
|
|
@@ -950,7 +1011,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
950
1011
|
type: "string",
|
|
951
1012
|
description: "Remote repo override for gitea mode.",
|
|
952
1013
|
},
|
|
953
|
-
|
|
1014
|
+
repoRoot: {
|
|
954
1015
|
type: "string",
|
|
955
1016
|
description: "Repo root containing /projects and /agents.",
|
|
956
1017
|
},
|
|
@@ -1027,7 +1088,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
1027
1088
|
type: "string",
|
|
1028
1089
|
description: "Remote repo override for gitea mode.",
|
|
1029
1090
|
},
|
|
1030
|
-
|
|
1091
|
+
repoRoot: {
|
|
1031
1092
|
type: "string",
|
|
1032
1093
|
description: "Repo root containing /projects and /agents.",
|
|
1033
1094
|
},
|
|
@@ -1143,7 +1204,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
1143
1204
|
type: "boolean",
|
|
1144
1205
|
description: "If true, only return issues with TASK-* IDs.",
|
|
1145
1206
|
},
|
|
1146
|
-
|
|
1207
|
+
repoRoot: {
|
|
1147
1208
|
type: "string",
|
|
1148
1209
|
description: "Repo root containing /projects.",
|
|
1149
1210
|
},
|
|
@@ -1184,7 +1245,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
1184
1245
|
type: "boolean",
|
|
1185
1246
|
description: "If true, restrict to issues with TASK-* payloads.",
|
|
1186
1247
|
},
|
|
1187
|
-
|
|
1248
|
+
repoRoot: {
|
|
1188
1249
|
type: "string",
|
|
1189
1250
|
description: "Repo root containing /projects.",
|
|
1190
1251
|
},
|
|
@@ -1241,7 +1302,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
1241
1302
|
type: "string",
|
|
1242
1303
|
description: "Optional task signature.",
|
|
1243
1304
|
},
|
|
1244
|
-
|
|
1305
|
+
repoRoot: {
|
|
1245
1306
|
type: "string",
|
|
1246
1307
|
description: "Repo root containing /projects.",
|
|
1247
1308
|
},
|
|
@@ -1275,7 +1336,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
1275
1336
|
items: { type: "string" },
|
|
1276
1337
|
description: "Labels to set.",
|
|
1277
1338
|
},
|
|
1278
|
-
|
|
1339
|
+
repoRoot: {
|
|
1279
1340
|
type: "string",
|
|
1280
1341
|
description: "Repo root containing /projects.",
|
|
1281
1342
|
},
|
|
@@ -1292,6 +1353,21 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
1292
1353
|
},
|
|
1293
1354
|
required: ["projectName", "title"],
|
|
1294
1355
|
},
|
|
1356
|
+
"mcp.workspace": {
|
|
1357
|
+
type: "object",
|
|
1358
|
+
additionalProperties: true,
|
|
1359
|
+
properties: {
|
|
1360
|
+
repoRoot: {
|
|
1361
|
+
type: "string",
|
|
1362
|
+
description:
|
|
1363
|
+
"Optional repo root on the server filesystem. If omitted, uses the server default (auto-detected by walking up from cwd). Legacy alias: processRoot.",
|
|
1364
|
+
},
|
|
1365
|
+
},
|
|
1366
|
+
required: [],
|
|
1367
|
+
$comment: safeJsonStringify({
|
|
1368
|
+
example: {},
|
|
1369
|
+
}),
|
|
1370
|
+
},
|
|
1295
1371
|
"mcp.search": {
|
|
1296
1372
|
type: "object",
|
|
1297
1373
|
additionalProperties: true,
|
|
@@ -1349,7 +1425,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
|
|
|
1349
1425
|
type: "string",
|
|
1350
1426
|
description: "Optional override cache directory.",
|
|
1351
1427
|
},
|
|
1352
|
-
|
|
1428
|
+
repoRoot: {
|
|
1353
1429
|
type: "string",
|
|
1354
1430
|
description:
|
|
1355
1431
|
"Repo root containing /projects. If you pass a project root, it will be normalized.",
|
|
@@ -1384,7 +1460,7 @@ const buildArgsSchemaFromPlaceholders = (
|
|
|
1384
1460
|
const TOOL_ARGS_SCHEMA_OVERRIDES: Record<string, Record<string, unknown>> = {
|
|
1385
1461
|
"projects.listProjects": {
|
|
1386
1462
|
type: "array",
|
|
1387
|
-
description: "No positional arguments. Use options.
|
|
1463
|
+
description: "No positional arguments. Use options.repoRoot if needed.",
|
|
1388
1464
|
minItems: 0,
|
|
1389
1465
|
maxItems: 0,
|
|
1390
1466
|
items: {},
|
|
@@ -1499,9 +1575,9 @@ const getInvocationPlanName = (toolName: string): string => {
|
|
|
1499
1575
|
const plan = toolInvocationPlans[toolName];
|
|
1500
1576
|
if (!plan) return "default";
|
|
1501
1577
|
if (plan === buildOptionsOnly) return "optionsOnly";
|
|
1502
|
-
if (plan ===
|
|
1503
|
-
if (plan ===
|
|
1504
|
-
if (plan ===
|
|
1578
|
+
if (plan === buildOptionsThenRepoRoot) return "optionsThenRepoRoot";
|
|
1579
|
+
if (plan === buildRepoRootThenOptions) return "repoRootThenOptions";
|
|
1580
|
+
if (plan === buildRepoRootOnly) return "repoRootOnly";
|
|
1505
1581
|
return "custom";
|
|
1506
1582
|
};
|
|
1507
1583
|
|
|
@@ -1513,17 +1589,17 @@ const buildInvocationExample = (toolName: string): Record<string, unknown> => {
|
|
|
1513
1589
|
const example: Record<string, unknown> = {};
|
|
1514
1590
|
if (requiredArgs && requiredArgs.length > 0) {
|
|
1515
1591
|
example.args = [...requiredArgs];
|
|
1516
|
-
} else if (plan !== "
|
|
1592
|
+
} else if (plan !== "repoRootOnly") {
|
|
1517
1593
|
example.args = ["<arg0>"];
|
|
1518
1594
|
}
|
|
1519
1595
|
|
|
1520
|
-
if (plan === "
|
|
1521
|
-
example.options = {
|
|
1596
|
+
if (plan === "repoRootOnly") {
|
|
1597
|
+
example.options = { repoRoot: "<repo-root>", ...defaultOptions };
|
|
1522
1598
|
return example;
|
|
1523
1599
|
}
|
|
1524
1600
|
|
|
1525
|
-
if (plan === "
|
|
1526
|
-
example.options = {
|
|
1601
|
+
if (plan === "optionsThenRepoRoot" || plan === "repoRootThenOptions") {
|
|
1602
|
+
example.options = { repoRoot: "<repo-root>", ...defaultOptions };
|
|
1527
1603
|
return example;
|
|
1528
1604
|
}
|
|
1529
1605
|
|
|
@@ -1554,10 +1630,10 @@ const defaultToolInputSchema = (toolName: string) => ({
|
|
|
1554
1630
|
additionalProperties: true,
|
|
1555
1631
|
description: "Named options",
|
|
1556
1632
|
},
|
|
1557
|
-
|
|
1633
|
+
repoRoot: {
|
|
1558
1634
|
type: "string",
|
|
1559
1635
|
description:
|
|
1560
|
-
'Repo root
|
|
1636
|
+
'Repo root on the server filesystem (contains /projects and /agents). If omitted, uses the server default (auto-detected from cwd). If you have a project root like ".../projects/adl", omit this or pass its parent.',
|
|
1561
1637
|
},
|
|
1562
1638
|
},
|
|
1563
1639
|
$comment: safeJsonStringify({
|
|
@@ -1673,11 +1749,13 @@ const buildToolList = (
|
|
|
1673
1749
|
type ToolInvoker = (
|
|
1674
1750
|
args: unknown[],
|
|
1675
1751
|
options: Record<string, unknown>,
|
|
1752
|
+
defaultRepoRoot: string,
|
|
1676
1753
|
) => unknown[];
|
|
1677
1754
|
|
|
1678
1755
|
const buildOptionsOnly = (
|
|
1679
1756
|
args: unknown[],
|
|
1680
1757
|
options: Record<string, unknown>,
|
|
1758
|
+
_defaultRepoRoot: string,
|
|
1681
1759
|
): unknown[] => {
|
|
1682
1760
|
const invocationArgs: unknown[] = [...args];
|
|
1683
1761
|
if (Object.keys(options).length > 0) {
|
|
@@ -1686,44 +1764,44 @@ const buildOptionsOnly = (
|
|
|
1686
1764
|
return invocationArgs;
|
|
1687
1765
|
};
|
|
1688
1766
|
|
|
1689
|
-
const
|
|
1767
|
+
const buildOptionsThenRepoRoot = (
|
|
1690
1768
|
args: unknown[],
|
|
1691
1769
|
options: Record<string, unknown>,
|
|
1770
|
+
defaultRepoRoot: string,
|
|
1692
1771
|
): unknown[] => {
|
|
1693
1772
|
const invocationArgs: unknown[] = [...args];
|
|
1694
1773
|
const remaining = { ...options };
|
|
1695
|
-
const
|
|
1696
|
-
if (typeof
|
|
1697
|
-
delete remaining.
|
|
1774
|
+
const repoRoot = remaining.repoRoot;
|
|
1775
|
+
if (typeof repoRoot === "string") {
|
|
1776
|
+
delete remaining.repoRoot;
|
|
1698
1777
|
}
|
|
1778
|
+
const resolvedRepoRoot =
|
|
1779
|
+
typeof repoRoot === "string" ? repoRoot : defaultRepoRoot;
|
|
1699
1780
|
|
|
1700
1781
|
if (Object.keys(remaining).length > 0) {
|
|
1701
1782
|
invocationArgs.push(remaining);
|
|
1702
|
-
} else if (
|
|
1703
|
-
// Preserve positional slot for signatures like fn(projectName, options?,
|
|
1783
|
+
} else if (resolvedRepoRoot) {
|
|
1784
|
+
// Preserve positional slot for signatures like fn(projectName, options?, repoRoot?).
|
|
1704
1785
|
invocationArgs.push({});
|
|
1705
1786
|
}
|
|
1706
|
-
|
|
1707
|
-
invocationArgs.push(processRoot);
|
|
1708
|
-
}
|
|
1787
|
+
invocationArgs.push(resolvedRepoRoot);
|
|
1709
1788
|
|
|
1710
1789
|
return invocationArgs;
|
|
1711
1790
|
};
|
|
1712
1791
|
|
|
1713
|
-
const
|
|
1792
|
+
const buildRepoRootThenOptions = (
|
|
1714
1793
|
args: unknown[],
|
|
1715
1794
|
options: Record<string, unknown>,
|
|
1795
|
+
defaultRepoRoot: string,
|
|
1716
1796
|
): unknown[] => {
|
|
1717
1797
|
const invocationArgs: unknown[] = [...args];
|
|
1718
1798
|
const remaining = { ...options };
|
|
1719
|
-
const
|
|
1720
|
-
if (typeof
|
|
1721
|
-
delete remaining.
|
|
1799
|
+
const repoRoot = remaining.repoRoot;
|
|
1800
|
+
if (typeof repoRoot === "string") {
|
|
1801
|
+
delete remaining.repoRoot;
|
|
1722
1802
|
}
|
|
1723
1803
|
|
|
1724
|
-
|
|
1725
|
-
invocationArgs.push(processRoot);
|
|
1726
|
-
}
|
|
1804
|
+
invocationArgs.push(typeof repoRoot === "string" ? repoRoot : defaultRepoRoot);
|
|
1727
1805
|
if (Object.keys(remaining).length > 0) {
|
|
1728
1806
|
invocationArgs.push(remaining);
|
|
1729
1807
|
}
|
|
@@ -1731,39 +1809,38 @@ const buildProcessRootThenOptions = (
|
|
|
1731
1809
|
return invocationArgs;
|
|
1732
1810
|
};
|
|
1733
1811
|
|
|
1734
|
-
const
|
|
1812
|
+
const buildRepoRootOnly = (
|
|
1735
1813
|
args: unknown[],
|
|
1736
1814
|
options: Record<string, unknown>,
|
|
1815
|
+
defaultRepoRoot: string,
|
|
1737
1816
|
): unknown[] => {
|
|
1738
1817
|
const invocationArgs: unknown[] = [...args];
|
|
1739
|
-
const
|
|
1740
|
-
|
|
1741
|
-
invocationArgs.push(processRoot);
|
|
1742
|
-
}
|
|
1818
|
+
const repoRoot = options.repoRoot;
|
|
1819
|
+
invocationArgs.push(typeof repoRoot === "string" ? repoRoot : defaultRepoRoot);
|
|
1743
1820
|
return invocationArgs;
|
|
1744
1821
|
};
|
|
1745
1822
|
|
|
1746
1823
|
const toolInvocationPlans: Record<string, ToolInvoker> = {
|
|
1747
|
-
"agents.setActive":
|
|
1748
|
-
"agents.resolveAgentsRootFrom":
|
|
1749
|
-
"projects.setActive":
|
|
1750
|
-
"projects.generateSpec":
|
|
1751
|
-
"projects.syncTasks":
|
|
1752
|
-
"projects.clearIssues":
|
|
1753
|
-
"projects.fetchGitTasks":
|
|
1754
|
-
"projects.createGitIssue":
|
|
1755
|
-
"projects.readGitTask":
|
|
1756
|
-
"projects.writeGitTask":
|
|
1824
|
+
"agents.setActive": buildRepoRootThenOptions,
|
|
1825
|
+
"agents.resolveAgentsRootFrom": buildRepoRootOnly,
|
|
1826
|
+
"projects.setActive": buildRepoRootThenOptions,
|
|
1827
|
+
"projects.generateSpec": buildOptionsThenRepoRoot,
|
|
1828
|
+
"projects.syncTasks": buildOptionsThenRepoRoot,
|
|
1829
|
+
"projects.clearIssues": buildOptionsThenRepoRoot,
|
|
1830
|
+
"projects.fetchGitTasks": buildOptionsThenRepoRoot,
|
|
1831
|
+
"projects.createGitIssue": buildOptionsThenRepoRoot,
|
|
1832
|
+
"projects.readGitTask": buildOptionsThenRepoRoot,
|
|
1833
|
+
"projects.writeGitTask": buildOptionsThenRepoRoot,
|
|
1757
1834
|
"agents.resolveTargetFile": buildOptionsOnly,
|
|
1758
1835
|
"projects.resolveProjectTargetFile": buildOptionsOnly,
|
|
1759
|
-
"agents.loadAgent":
|
|
1760
|
-
"agents.loadAgentPrompt":
|
|
1761
|
-
"projects.resolveImplementationPlan": (args, options) => {
|
|
1836
|
+
"agents.loadAgent": buildRepoRootOnly,
|
|
1837
|
+
"agents.loadAgentPrompt": buildRepoRootOnly,
|
|
1838
|
+
"projects.resolveImplementationPlan": (args, options, _defaultRepoRoot) => {
|
|
1762
1839
|
const invocationArgs: unknown[] = [...args];
|
|
1763
1840
|
const remaining = { ...options };
|
|
1764
|
-
const
|
|
1765
|
-
if (typeof
|
|
1766
|
-
delete remaining.
|
|
1841
|
+
const repoRoot = remaining.repoRoot;
|
|
1842
|
+
if (typeof repoRoot === "string") {
|
|
1843
|
+
delete remaining.repoRoot;
|
|
1767
1844
|
}
|
|
1768
1845
|
|
|
1769
1846
|
// This tool is a low-level helper: projects.resolveImplementationPlan(projectRoot, inputFile?, options?)
|
|
@@ -1775,35 +1852,36 @@ const toolInvocationPlans: Record<string, ToolInvoker> = {
|
|
|
1775
1852
|
invocationArgs.push(remaining);
|
|
1776
1853
|
}
|
|
1777
1854
|
|
|
1778
|
-
// Intentionally do NOT append
|
|
1855
|
+
// Intentionally do NOT append repoRoot: projectRoot is the first positional argument.
|
|
1779
1856
|
return invocationArgs;
|
|
1780
1857
|
},
|
|
1781
|
-
"agents.main":
|
|
1782
|
-
"agents.resolveAgentsRoot":
|
|
1783
|
-
"agents.listAgents":
|
|
1784
|
-
"projects.resolveProjectRoot":
|
|
1785
|
-
"projects.listProjects":
|
|
1786
|
-
"projects.listProjectDocs":
|
|
1787
|
-
"projects.readProjectDoc":
|
|
1788
|
-
"projects.main":
|
|
1858
|
+
"agents.main": buildRepoRootOnly,
|
|
1859
|
+
"agents.resolveAgentsRoot": buildRepoRootOnly,
|
|
1860
|
+
"agents.listAgents": buildRepoRootOnly,
|
|
1861
|
+
"projects.resolveProjectRoot": buildRepoRootOnly,
|
|
1862
|
+
"projects.listProjects": buildRepoRootOnly,
|
|
1863
|
+
"projects.listProjectDocs": buildRepoRootOnly,
|
|
1864
|
+
"projects.readProjectDoc": buildRepoRootOnly,
|
|
1865
|
+
"projects.main": buildRepoRootOnly,
|
|
1789
1866
|
};
|
|
1790
1867
|
|
|
1791
1868
|
const invokeTool = async (
|
|
1792
1869
|
tool: ToolDefinition,
|
|
1793
1870
|
payload: unknown,
|
|
1871
|
+
defaultRepoRoot: string,
|
|
1794
1872
|
): Promise<unknown> => {
|
|
1795
1873
|
const normalized = normalizePayload(payload);
|
|
1796
1874
|
const { args, options } = coercePayloadForTool(tool.name, normalized);
|
|
1797
1875
|
const invoke =
|
|
1798
1876
|
toolInvocationPlans[tool.name] ??
|
|
1799
|
-
((rawArgs, rawOptions) => {
|
|
1877
|
+
((rawArgs, rawOptions, _repoRoot) => {
|
|
1800
1878
|
const invocationArgs = [...rawArgs];
|
|
1801
1879
|
if (Object.keys(rawOptions).length > 0) {
|
|
1802
1880
|
invocationArgs.push(rawOptions);
|
|
1803
1881
|
}
|
|
1804
1882
|
return invocationArgs;
|
|
1805
1883
|
});
|
|
1806
|
-
const invocationArgs = invoke(args, options);
|
|
1884
|
+
const invocationArgs = invoke(args, options, defaultRepoRoot);
|
|
1807
1885
|
|
|
1808
1886
|
return Promise.resolve(tool.method(...invocationArgs));
|
|
1809
1887
|
};
|
|
@@ -1812,6 +1890,11 @@ export interface ExampleMcpServerOptions {
|
|
|
1812
1890
|
serverName?: string;
|
|
1813
1891
|
serverVersion?: string;
|
|
1814
1892
|
toolsPrefix?: string;
|
|
1893
|
+
/**
|
|
1894
|
+
* Optional default repo root on the server filesystem.
|
|
1895
|
+
* If omitted, the server attempts to auto-detect by walking up from cwd.
|
|
1896
|
+
*/
|
|
1897
|
+
repoRoot?: string;
|
|
1815
1898
|
allowedRootEndpoints?: string[];
|
|
1816
1899
|
disableWrite?: boolean;
|
|
1817
1900
|
enableIssues?: boolean;
|
|
@@ -1852,6 +1935,7 @@ const READ_ONLY_TOOL_NAMES = new Set<string>([
|
|
|
1852
1935
|
"mcp.usage",
|
|
1853
1936
|
"mcp.listTools",
|
|
1854
1937
|
"mcp.describeTool",
|
|
1938
|
+
"mcp.workspace",
|
|
1855
1939
|
"mcp.search",
|
|
1856
1940
|
]);
|
|
1857
1941
|
|
|
@@ -1942,6 +2026,12 @@ export const createExampleMcpServer = (
|
|
|
1942
2026
|
options: ExampleMcpServerOptions = {},
|
|
1943
2027
|
): ExampleMcpServerInstance => {
|
|
1944
2028
|
let toolCatalog: unknown[] = [];
|
|
2029
|
+
const defaultRepoRoot = normalizeRepoRoot(
|
|
2030
|
+
options.repoRoot ??
|
|
2031
|
+
process.env.MCP_REPO_ROOT ??
|
|
2032
|
+
process.env.F0_REPO_ROOT ??
|
|
2033
|
+
process.cwd(),
|
|
2034
|
+
);
|
|
1945
2035
|
|
|
1946
2036
|
const parseString = (value: unknown): string | null => {
|
|
1947
2037
|
if (typeof value !== "string") return null;
|
|
@@ -1981,11 +2071,63 @@ export const createExampleMcpServer = (
|
|
|
1981
2071
|
"F0 MCP helper tools:",
|
|
1982
2072
|
"- mcp.listTools: returns tool catalog with access + invocation hints",
|
|
1983
2073
|
"- mcp.describeTool: describe one tool by name (prefixed or unprefixed)",
|
|
2074
|
+
"- mcp.workspace: explain server filesystem context (cwd, repoRoot, projects)",
|
|
1984
2075
|
"- mcp.search: LLM-friendly search over project docs/spec (local-first)",
|
|
1985
2076
|
"",
|
|
1986
2077
|
'Tip: Prefer mcp.search for "search spec/docs" requests.',
|
|
1987
2078
|
].join("\n"),
|
|
1988
2079
|
listTools: () => ({ tools: toolCatalog }),
|
|
2080
|
+
workspace: (input?: unknown) => {
|
|
2081
|
+
const payload = isRecord(input) ? input : {};
|
|
2082
|
+
const received = isRecord(input)
|
|
2083
|
+
? {
|
|
2084
|
+
keys: Object.keys(input),
|
|
2085
|
+
repoRoot: (input as any).repoRoot ?? null,
|
|
2086
|
+
processRoot: (input as any).processRoot ?? null,
|
|
2087
|
+
}
|
|
2088
|
+
: { keys: [], repoRoot: null, processRoot: null };
|
|
2089
|
+
const raw =
|
|
2090
|
+
parseString(payload.repoRoot) ?? parseString(payload.processRoot);
|
|
2091
|
+
const repoRoot = raw ? normalizeRepoRoot(raw) : defaultRepoRoot;
|
|
2092
|
+
|
|
2093
|
+
const projectsDir = path.join(repoRoot, "projects");
|
|
2094
|
+
const apiDir = path.join(repoRoot, "api");
|
|
2095
|
+
const agentsDir = path.join(repoRoot, "agents");
|
|
2096
|
+
const hasProjectsDir = isDir(projectsDir);
|
|
2097
|
+
const hasApiDir = isDir(apiDir);
|
|
2098
|
+
const hasAgentsDir = isDir(agentsDir);
|
|
2099
|
+
|
|
2100
|
+
const projects = hasProjectsDir ? projectsApi.listProjects(repoRoot) : [];
|
|
2101
|
+
const hint = (() => {
|
|
2102
|
+
if (!hasProjectsDir) {
|
|
2103
|
+
return [
|
|
2104
|
+
"Repo does not contain /projects on the server filesystem.",
|
|
2105
|
+
"Start the MCP server from the monorepo root (the folder that contains both /api and /projects), or pass repoRoot explicitly.",
|
|
2106
|
+
"repoRoot is a server-side path, not a client/LLM path.",
|
|
2107
|
+
].join(" ");
|
|
2108
|
+
}
|
|
2109
|
+
if (hasProjectsDir && projects.length === 0) {
|
|
2110
|
+
return [
|
|
2111
|
+
"Found /projects, but no projects with docs/ were detected.",
|
|
2112
|
+
"Each project folder must contain a docs/ directory to be listed.",
|
|
2113
|
+
].join(" ");
|
|
2114
|
+
}
|
|
2115
|
+
return null;
|
|
2116
|
+
})();
|
|
2117
|
+
|
|
2118
|
+
return {
|
|
2119
|
+
received,
|
|
2120
|
+
cwd: process.cwd(),
|
|
2121
|
+
configuredRepoRoot: options.repoRoot ?? null,
|
|
2122
|
+
defaultRepoRoot,
|
|
2123
|
+
repoRoot,
|
|
2124
|
+
hasProjectsDir,
|
|
2125
|
+
hasApiDir,
|
|
2126
|
+
hasAgentsDir,
|
|
2127
|
+
projects,
|
|
2128
|
+
hint,
|
|
2129
|
+
};
|
|
2130
|
+
},
|
|
1989
2131
|
describeTool: (toolName: string) => {
|
|
1990
2132
|
const normalized = typeof toolName === "string" ? toolName.trim() : "";
|
|
1991
2133
|
if (!normalized) {
|
|
@@ -2026,9 +2168,10 @@ export const createExampleMcpServer = (
|
|
|
2026
2168
|
},
|
|
2027
2169
|
search: async (input: unknown) => {
|
|
2028
2170
|
const payload = isRecord(input) ? input : {};
|
|
2029
|
-
const
|
|
2030
|
-
const raw =
|
|
2031
|
-
|
|
2171
|
+
const repoRoot = (() => {
|
|
2172
|
+
const raw =
|
|
2173
|
+
parseString(payload.repoRoot) ?? parseString(payload.processRoot);
|
|
2174
|
+
return raw ? normalizeRepoRoot(raw) : defaultRepoRoot;
|
|
2032
2175
|
})();
|
|
2033
2176
|
|
|
2034
2177
|
const sectionRaw = parseString(payload.section)?.toLowerCase();
|
|
@@ -2076,11 +2219,11 @@ export const createExampleMcpServer = (
|
|
|
2076
2219
|
|
|
2077
2220
|
const projectName = ensureProjectName(
|
|
2078
2221
|
parseString(payload.projectName),
|
|
2079
|
-
|
|
2222
|
+
repoRoot,
|
|
2080
2223
|
);
|
|
2081
2224
|
|
|
2082
2225
|
const searchOptions: Record<string, unknown> = {
|
|
2083
|
-
processRoot,
|
|
2226
|
+
processRoot: repoRoot,
|
|
2084
2227
|
};
|
|
2085
2228
|
|
|
2086
2229
|
const source = parseString(payload.source)?.toLowerCase();
|
|
@@ -2106,9 +2249,15 @@ export const createExampleMcpServer = (
|
|
|
2106
2249
|
} satisfies ToolNamespace;
|
|
2107
2250
|
|
|
2108
2251
|
const api: ApiEndpoint = {
|
|
2109
|
-
agents:
|
|
2252
|
+
agents: {
|
|
2253
|
+
...agentsApi,
|
|
2254
|
+
usage: () => buildAgentsMcpUsage(options.toolsPrefix),
|
|
2255
|
+
},
|
|
2110
2256
|
net: netApi,
|
|
2111
|
-
projects:
|
|
2257
|
+
projects: {
|
|
2258
|
+
...projectsApi,
|
|
2259
|
+
usage: () => buildProjectsMcpUsage(options.toolsPrefix),
|
|
2260
|
+
},
|
|
2112
2261
|
mcp: mcpApi,
|
|
2113
2262
|
};
|
|
2114
2263
|
|
|
@@ -2140,7 +2289,18 @@ export const createExampleMcpServer = (
|
|
|
2140
2289
|
const selectedTools = selectedRoots.flatMap((root) =>
|
|
2141
2290
|
collectTools(api[root], [root]),
|
|
2142
2291
|
);
|
|
2143
|
-
const
|
|
2292
|
+
const selectedToolsWithMcpHelpers = (() => {
|
|
2293
|
+
if (selectedRoots.includes("mcp")) return selectedTools;
|
|
2294
|
+
const helperApi: Partial<Record<McpHelperToolKey, unknown>> = {};
|
|
2295
|
+
for (const key of MCP_HELPER_TOOL_KEYS) {
|
|
2296
|
+
helperApi[key] = (mcpApi as any)[key];
|
|
2297
|
+
}
|
|
2298
|
+
const helperTools = collectTools(helperApi as ToolNamespace, ["mcp"]).filter(
|
|
2299
|
+
(tool) => MCP_HELPER_TOOL_NAMES.has(tool.name),
|
|
2300
|
+
);
|
|
2301
|
+
return [...helperTools, ...selectedTools];
|
|
2302
|
+
})();
|
|
2303
|
+
const selectedToolsWithAliases = applyToolAliases(selectedToolsWithMcpHelpers);
|
|
2144
2304
|
buildToolMeta(selectedToolsWithAliases);
|
|
2145
2305
|
const adminFilteredTools = Boolean(options.admin)
|
|
2146
2306
|
? selectedToolsWithAliases
|
|
@@ -2207,7 +2367,7 @@ export const createExampleMcpServer = (
|
|
|
2207
2367
|
tool: prefix
|
|
2208
2368
|
? `${prefix}.projects.listProjects`
|
|
2209
2369
|
: "projects.listProjects",
|
|
2210
|
-
options: {
|
|
2370
|
+
options: { repoRoot: "<repo-root>" },
|
|
2211
2371
|
},
|
|
2212
2372
|
],
|
|
2213
2373
|
continueOnError: true,
|
|
@@ -2386,7 +2546,22 @@ export const createExampleMcpServer = (
|
|
|
2386
2546
|
/projects[\\/].+projects[\\/]/i.test(message)
|
|
2387
2547
|
) {
|
|
2388
2548
|
details.hint =
|
|
2389
|
-
"You likely passed a project root as
|
|
2549
|
+
"You likely passed a project root as repoRoot. repoRoot should be the repo root containing /projects. (Legacy alias: processRoot.)";
|
|
2550
|
+
details.suggestion = "Call mcp.workspace to see cwd/repoRoot on the server.";
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
if (
|
|
2554
|
+
/Project folder not found:/i.test(message) &&
|
|
2555
|
+
!details.hint &&
|
|
2556
|
+
/projects[\\/]/i.test(message)
|
|
2557
|
+
) {
|
|
2558
|
+
details.hint =
|
|
2559
|
+
"repoRoot might be wrong, or the server filesystem does not contain /projects for this repoRoot.";
|
|
2560
|
+
details.suggestion = "Call mcp.workspace to see cwd/repoRoot on the server.";
|
|
2561
|
+
details.example = {
|
|
2562
|
+
tool: prefix ? `${prefix}.mcp.workspace` : "mcp.workspace",
|
|
2563
|
+
arguments: {},
|
|
2564
|
+
};
|
|
2390
2565
|
}
|
|
2391
2566
|
|
|
2392
2567
|
if (/Missing search pattern\./i.test(message)) {
|
|
@@ -2450,7 +2625,11 @@ export const createExampleMcpServer = (
|
|
|
2450
2625
|
}
|
|
2451
2626
|
|
|
2452
2627
|
try {
|
|
2453
|
-
const data = await invokeTool(
|
|
2628
|
+
const data = await invokeTool(
|
|
2629
|
+
toolDefinition,
|
|
2630
|
+
{ args, options },
|
|
2631
|
+
defaultRepoRoot,
|
|
2632
|
+
);
|
|
2454
2633
|
return { index, tool, isError: false, data };
|
|
2455
2634
|
} catch (error) {
|
|
2456
2635
|
const message =
|
|
@@ -2502,7 +2681,7 @@ export const createExampleMcpServer = (
|
|
|
2502
2681
|
}
|
|
2503
2682
|
|
|
2504
2683
|
try {
|
|
2505
|
-
const data = await invokeTool(tool, request.params.arguments);
|
|
2684
|
+
const data = await invokeTool(tool, request.params.arguments, defaultRepoRoot);
|
|
2506
2685
|
return toolOk(data);
|
|
2507
2686
|
} catch (error) {
|
|
2508
2687
|
const message = error instanceof Error ? error.message : String(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foundation0/api",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "Foundation 0 API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"scripts": {
|
|
36
36
|
"mcp": "bun run mcp/cli.ts",
|
|
37
37
|
"test": "bun test",
|
|
38
|
+
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov",
|
|
38
39
|
"deploy": "pnpm publish --access public",
|
|
39
40
|
"version:patch": "pnpm version patch && git commit -am \"Bump version to %s\"",
|
|
40
41
|
"version:minor": "pnpm version minor && git commit -am \"Bump version to %s\"",
|