@foundation0/api 1.1.10 → 1.1.12

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/manual.md CHANGED
@@ -44,23 +44,27 @@ Tool call (prefixed or unprefixed names both work here):
44
44
  { "name": "mcp.describeTool", "arguments": { "args": ["projects.listProjects"] } }
45
45
  ```
46
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
- "repoName": "<repo-name>",
59
- "ignoreCase": true,
60
- "maxCount": 50
61
- }
62
- }
63
- ```
47
+ ### C) `mcp.search` for "search docs/spec" requests
48
+
49
+ Tool call:
50
+
51
+ ```json
52
+ {
53
+ "name": "mcp.search",
54
+ "arguments": {
55
+ "section": "spec",
56
+ "pattern": "authentication",
57
+ "paths": ["."],
58
+ "repoName": "<repo-name>",
59
+ "ignoreCase": true,
60
+ "maxCount": 50
61
+ }
62
+ }
63
+ ```
64
+
65
+ Notes:
66
+ - If only one project exists in the workspace, `projectName` is optional and will be inferred.
67
+ - If multiple projects exist, pass `projectName` explicitly.
64
68
 
65
69
  ### D) `mcp.workspace` to debug repoName/workspaceRoot/cwd issues
66
70
 
@@ -88,14 +92,28 @@ Guideline: if a tool has a natural named parameter (`projectName`, `agentName`,
88
92
 
89
93
  ## 4) Common calls (examples)
90
94
 
91
- ### A) List projects
95
+ ### A) List projects
92
96
 
93
97
  ```json
94
- {
95
- "name": "projects.listProjects",
96
- "arguments": { "repoName": "<repo-name>" }
97
- }
98
- ```
98
+ {
99
+ "name": "projects.listProjects",
100
+ "arguments": { "repoName": "<repo-name>" }
101
+ }
102
+ ```
103
+
104
+ ### E) Search specs without `projectName` (single-project workspaces)
105
+
106
+ ```json
107
+ {
108
+ "name": "projects.searchSpecs",
109
+ "arguments": {
110
+ "pattern": "TASK-003",
111
+ "paths": ["07_roadmap/phases.md"],
112
+ "repoName": "<repo-name>",
113
+ "source": "auto"
114
+ }
115
+ }
116
+ ```
99
117
 
100
118
  ### B) List agents
101
119
 
@@ -272,6 +272,143 @@ describe("createExampleMcpServer request handling", () => {
272
272
  }
273
273
  });
274
274
 
275
+ it("runs projects.searchSpecs without projectName when only one project exists", async () => {
276
+ const originalCwd = process.cwd();
277
+ const tempDir = await fs.mkdtemp(
278
+ path.join(os.tmpdir(), "f0-mcp-server-searchspecs-no-project-"),
279
+ );
280
+ try {
281
+ await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
282
+ await fs.writeFile(
283
+ path.join(tempDir, ".git", "config"),
284
+ [
285
+ '[remote "origin"]',
286
+ "\turl = https://example.com/F0/adl.git",
287
+ "",
288
+ ].join("\n"),
289
+ "utf8",
290
+ );
291
+ await fs.mkdir(path.join(tempDir, "spec", "07_roadmap"), {
292
+ recursive: true,
293
+ });
294
+ await fs.writeFile(
295
+ path.join(tempDir, "spec", "07_roadmap", "phases.md"),
296
+ "# Phases\n\n- TASK-003 deliver search fallback\n",
297
+ "utf8",
298
+ );
299
+
300
+ process.chdir(tempDir);
301
+ const instance = createExampleMcpServer();
302
+ const handler = getToolHandler(instance);
303
+
304
+ const result = await handler(
305
+ {
306
+ method: "tools/call",
307
+ params: {
308
+ name: "projects.searchSpecs",
309
+ arguments: {
310
+ pattern: "TASK-003",
311
+ paths: ["07_roadmap/phases.md"],
312
+ source: "auto",
313
+ },
314
+ },
315
+ },
316
+ {},
317
+ );
318
+
319
+ expect(result.isError).toBe(false);
320
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
321
+ expect(payload.ok).toBe(true);
322
+ expect(payload.result.section).toBe("spec");
323
+ expect(payload.result.source).toBe("local");
324
+ expect(payload.result.filesMatched).toBe(1);
325
+ expect(payload.result.output).toContain("TASK-003");
326
+ } finally {
327
+ process.chdir(originalCwd);
328
+ await fs.rm(tempDir, { recursive: true, force: true });
329
+ }
330
+ });
331
+
332
+ it('accepts owner/repo projectName selectors when /projects exists', async () => {
333
+ const originalCwd = process.cwd();
334
+ const tempDir = await fs.mkdtemp(
335
+ path.join(os.tmpdir(), "f0-mcp-server-projectname-leaf-"),
336
+ );
337
+ try {
338
+ await fs.mkdir(path.join(tempDir, "api"), { recursive: true });
339
+ await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
340
+ recursive: true,
341
+ });
342
+ await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
343
+ await fs.writeFile(path.join(tempDir, ".git", "config"), "", "utf8");
344
+
345
+ process.chdir(tempDir);
346
+ const instance = createExampleMcpServer();
347
+ const handler = getToolHandler(instance);
348
+
349
+ const result = await handler(
350
+ {
351
+ method: "tools/call",
352
+ params: {
353
+ name: "projects.resolveProjectRoot",
354
+ arguments: {
355
+ projectName: "F0/adl",
356
+ },
357
+ },
358
+ },
359
+ {},
360
+ );
361
+
362
+ expect(result.isError).toBe(false);
363
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
364
+ expect(payload.ok).toBe(true);
365
+ expect(payload.result).toBe(path.join(path.resolve(tempDir), "projects", "adl"));
366
+ } finally {
367
+ process.chdir(originalCwd);
368
+ await fs.rm(tempDir, { recursive: true, force: true });
369
+ }
370
+ });
371
+
372
+ it("parses owner/repo repoName into repo segment even without full-name metadata", async () => {
373
+ const originalCwd = process.cwd();
374
+ const tempDir = await fs.mkdtemp(
375
+ path.join(os.tmpdir(), "f0-mcp-server-reponame-segment-fallback-"),
376
+ );
377
+ try {
378
+ await fs.mkdir(path.join(tempDir, "api"), { recursive: true });
379
+ await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
380
+ recursive: true,
381
+ });
382
+ await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
383
+ await fs.writeFile(path.join(tempDir, ".git", "config"), "", "utf8");
384
+
385
+ process.chdir(tempDir);
386
+ const instance = createExampleMcpServer();
387
+ const handler = getToolHandler(instance);
388
+
389
+ const result = await handler(
390
+ {
391
+ method: "tools/call",
392
+ params: {
393
+ name: "projects.listProjects",
394
+ arguments: {
395
+ repoName: "F0/adl",
396
+ },
397
+ },
398
+ },
399
+ {},
400
+ );
401
+
402
+ expect(result.isError).toBe(false);
403
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
404
+ expect(payload.ok).toBe(true);
405
+ expect(payload.result).toContain("adl");
406
+ } finally {
407
+ process.chdir(originalCwd);
408
+ await fs.rm(tempDir, { recursive: true, force: true });
409
+ }
410
+ });
411
+
275
412
  it("auto-detects Git workspace from process.cwd() when repoName is omitted", async () => {
276
413
  const tempDir = await fs.mkdtemp(
277
414
  path.join(os.tmpdir(), "f0-mcp-server-autoworkspace-"),
package/mcp/server.ts CHANGED
@@ -393,6 +393,11 @@ const resolveRepoSelectorOptions = (
393
393
  next.repoName = matched;
394
394
  return next;
395
395
  }
396
+ if (repoSegment !== "." && repoSegment !== "..") {
397
+ next.processRoot = ctx.defaultProcessRoot;
398
+ next.repoName = repoSegment;
399
+ return next;
400
+ }
396
401
  }
397
402
  }
398
403
 
@@ -597,9 +602,23 @@ const coercePayloadForTool = (
597
602
  )
598
603
  return;
599
604
  const name = popStringOption(options, "projectName", "project");
600
- if (name) {
601
- if (args.length === 0) args.push(name);
602
- else args[0] = name;
605
+ const fallback = (() => {
606
+ if (name) return null;
607
+ const repoName =
608
+ typeof options.repoName === "string" ? options.repoName.trim() : "";
609
+ if (!repoName) return null;
610
+ const leaf = repoName
611
+ .split(/[\\/]/)
612
+ .map((part) => part.trim())
613
+ .filter((part) => part.length > 0)
614
+ .at(-1);
615
+ if (!leaf || leaf === "." || leaf === "..") return null;
616
+ return leaf;
617
+ })();
618
+ const resolvedName = name ?? fallback;
619
+ if (resolvedName) {
620
+ if (args.length === 0) args.push(resolvedName);
621
+ else args[0] = resolvedName;
603
622
  }
604
623
  };
605
624
 
@@ -659,7 +678,12 @@ const coercePayloadForTool = (
659
678
  rgArgs.push(pattern, ...(paths.length > 0 ? paths : ["."]));
660
679
 
661
680
  if (args.length === 0) {
662
- // Can't build without projectName; leave for underlying error.
681
+ // If projectName is omitted, preserve the rg args via options.args so
682
+ // projects.searchDocs/searchSpecs can run in seed-options mode.
683
+ // (projectName will be inferred when only one project exists).
684
+ if (!Array.isArray(options.args) || options.args.length === 0) {
685
+ options.args = rgArgs;
686
+ }
663
687
  return;
664
688
  }
665
689
 
@@ -668,13 +692,17 @@ const coercePayloadForTool = (
668
692
  }
669
693
  };
670
694
 
671
- switch (toolName) {
672
- case "projects.listProjects": {
673
- // No positional args. processRoot is injected from the selected repoName.
695
+ switch (toolName) {
696
+ case "projects.listProjects": {
697
+ // No positional args. processRoot is injected from the selected repoName.
698
+ break;
699
+ }
700
+ case "projects.resolveProjectRoot":
701
+ case "projects.listProjectDocs": {
702
+ coerceProjectName();
703
+ ensureArg0(null);
674
704
  break;
675
705
  }
676
- case "projects.resolveProjectRoot":
677
- case "projects.listProjectDocs":
678
706
  case "projects.fetchGitTasks":
679
707
  case "projects.createGitIssue": {
680
708
  coerceProjectName();
@@ -690,7 +718,11 @@ const coercePayloadForTool = (
690
718
  "docPath",
691
719
  );
692
720
  if (requestPath) {
693
- ensureArgs(args[0] ?? undefined, requestPath);
721
+ if (args.length === 0) {
722
+ args.push(null, requestPath);
723
+ } else if (args.length === 1) {
724
+ args.push(requestPath);
725
+ }
694
726
  }
695
727
  break;
696
728
  }
@@ -1095,7 +1127,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1095
1127
  properties: {
1096
1128
  projectName: {
1097
1129
  type: "string",
1098
- description: 'Project name under /projects (e.g. "adl").',
1130
+ description:
1131
+ 'Project name. In a monorepo: folder under /projects (e.g. "adl"). Optional when only one project exists.',
1099
1132
  },
1100
1133
  repoName: {
1101
1134
  type: "string",
@@ -1113,7 +1146,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1113
1146
  additionalProperties: true,
1114
1147
  },
1115
1148
  },
1116
- required: ["projectName"],
1149
+ required: [],
1117
1150
  },
1118
1151
  "projects.listProjectDocs": {
1119
1152
  type: "object",
@@ -1121,7 +1154,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1121
1154
  properties: {
1122
1155
  projectName: {
1123
1156
  type: "string",
1124
- description: 'Project name under /projects (e.g. "adl").',
1157
+ description:
1158
+ 'Project name. In a monorepo: folder under /projects (e.g. "adl"). Optional when only one project exists.',
1125
1159
  },
1126
1160
  repoName: {
1127
1161
  type: "string",
@@ -1139,7 +1173,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1139
1173
  additionalProperties: true,
1140
1174
  },
1141
1175
  },
1142
- required: ["projectName"],
1176
+ required: [],
1143
1177
  },
1144
1178
  "projects.readProjectDoc": {
1145
1179
  type: "object",
@@ -1147,7 +1181,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1147
1181
  properties: {
1148
1182
  projectName: {
1149
1183
  type: "string",
1150
- description: 'Project name under /projects (e.g. "adl").',
1184
+ description:
1185
+ 'Project name. In a monorepo: folder under /projects (e.g. "adl"). Optional when only one project exists.',
1151
1186
  },
1152
1187
  requestPath: {
1153
1188
  type: "string",
@@ -1170,7 +1205,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1170
1205
  additionalProperties: true,
1171
1206
  },
1172
1207
  },
1173
- required: ["projectName", "requestPath"],
1208
+ required: ["requestPath"],
1174
1209
  },
1175
1210
  "projects.searchDocs": {
1176
1211
  type: "object",
@@ -1178,7 +1213,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1178
1213
  properties: {
1179
1214
  projectName: {
1180
1215
  type: "string",
1181
- description: 'Project name under /projects (e.g. "adl").',
1216
+ description:
1217
+ 'Project name. In a monorepo: folder under /projects (e.g. "adl"). Optional when only one project exists.',
1182
1218
  },
1183
1219
  pattern: {
1184
1220
  type: "string",
@@ -1239,7 +1275,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1239
1275
  },
1240
1276
  args: {
1241
1277
  type: "array",
1242
- description: "Legacy rg-like args (projectName, PATTERN, [PATH...]).",
1278
+ description:
1279
+ "Legacy rg-like args ([projectName], PATTERN, [PATH...]). projectName may be omitted when only one project exists.",
1243
1280
  items: {},
1244
1281
  },
1245
1282
  options: {
@@ -1248,7 +1285,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1248
1285
  additionalProperties: true,
1249
1286
  },
1250
1287
  },
1251
- required: ["projectName"],
1288
+ required: [],
1252
1289
  },
1253
1290
  "projects.searchSpecs": {
1254
1291
  type: "object",
@@ -1256,7 +1293,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1256
1293
  properties: {
1257
1294
  projectName: {
1258
1295
  type: "string",
1259
- description: 'Project name under /projects (e.g. "adl").',
1296
+ description:
1297
+ 'Project name. In a monorepo: folder under /projects (e.g. "adl"). Optional when only one project exists.',
1260
1298
  },
1261
1299
  pattern: {
1262
1300
  type: "string",
@@ -1317,7 +1355,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1317
1355
  },
1318
1356
  args: {
1319
1357
  type: "array",
1320
- description: "Legacy rg-like args (projectName, PATTERN, [PATH...]).",
1358
+ description:
1359
+ "Legacy rg-like args ([projectName], PATTERN, [PATH...]). projectName may be omitted when only one project exists.",
1321
1360
  items: {},
1322
1361
  },
1323
1362
  options: {
@@ -1326,7 +1365,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1326
1365
  additionalProperties: true,
1327
1366
  },
1328
1367
  },
1329
- required: ["projectName"],
1368
+ required: [],
1330
1369
  },
1331
1370
  "projects.parseProjectTargetSpec": {
1332
1371
  type: "object",
@@ -1601,7 +1640,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1601
1640
  properties: {
1602
1641
  projectName: {
1603
1642
  type: "string",
1604
- description: 'Project name under /projects (e.g. "adl").',
1643
+ description:
1644
+ 'Project name. In a monorepo: folder under /projects (e.g. "adl"). Optional when only one project exists.',
1605
1645
  },
1606
1646
  section: {
1607
1647
  type: "string",
@@ -2360,9 +2400,9 @@ export const createExampleMcpServer = (
2360
2400
  const hint = (() => {
2361
2401
  if (!hasProjectsDir) {
2362
2402
  return [
2363
- "Git workspace does not contain /projects.",
2364
- "Pass repoName as \"adl\" or \"F0/adl\".",
2365
- "For single-repo layouts, use source:\"auto\" or source:\"gitea\" on search tools.",
2403
+ "Git workspace does not contain /projects (single-repo layout).",
2404
+ "Search tools can omit projectName; it will be inferred when only one project exists.",
2405
+ 'Use repoName like "adl" or "F0/adl" when needed.',
2366
2406
  ].join(" ");
2367
2407
  }
2368
2408
  if (hasProjectsDir && projects.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundation0/api",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "Foundation 0 API",
5
5
  "type": "module",
6
6
  "bin": {
package/projects.ts CHANGED
@@ -304,31 +304,46 @@ export function usage(): string {
304
304
  `The active file created is [file].active.<ext>.\n`
305
305
  }
306
306
 
307
- export function resolveProjectRoot(projectName: string, processRoot: string = process.cwd()): string {
308
- if (!projectName || projectName.trim().length === 0) {
307
+ export function resolveProjectRoot(projectNameInput: string, processRoot: string = process.cwd()): string {
308
+ const resolvedProcessRoot = path.resolve(processRoot)
309
+
310
+ const rawProjectName = typeof projectNameInput === 'string' ? projectNameInput.trim() : ''
311
+ const inferredProjectName = rawProjectName.length > 0 ? rawProjectName : null
312
+ const projectName = inferredProjectName ?? (() => {
313
+ const projects = listProjects(resolvedProcessRoot)
314
+ if (projects.length === 1) {
315
+ return projects[0]
316
+ }
317
+ if (projects.length > 1) {
318
+ throw new Error(`project-name is required. Available projects: ${projects.join(', ')}`)
319
+ }
309
320
  throw new Error('project-name is required.')
310
- }
321
+ })()
311
322
 
312
- const normalized = projectName.trim().replace(/[\\/]+/g, path.sep)
323
+ const normalized = projectName.replace(/[\\/]+/g, path.sep)
313
324
  if (normalized === '.' || normalized === '..' || normalized.includes(path.sep + '..') || normalized.includes('..')) {
314
325
  throw new Error('project-name may not include path traversal.')
315
326
  }
316
327
 
317
- const resolvedProcessRoot = path.resolve(processRoot)
318
- const projectRoot = path.join(resolveProjectsRoot(resolvedProcessRoot), normalized)
328
+ const projectsRoot = resolveProjectsRoot(resolvedProcessRoot)
329
+ const projectRoot = path.join(projectsRoot, normalized)
319
330
  if (!existsDir(projectRoot)) {
320
- const identity = resolveProjectRepoIdentity(resolvedProcessRoot)
321
- const normalizedKey = normalized.replace(/\\/g, '/').toLowerCase()
322
- const normalizedLeaf = normalized.split(path.sep).filter(Boolean).at(-1)?.toLowerCase() ?? null
323
- const aliases = [
324
- identity?.repo?.toLowerCase() ?? null,
325
- identity?.owner && identity?.repo ? `${identity.owner}/${identity.repo}`.toLowerCase() : null,
326
- path.basename(resolvedProcessRoot).toLowerCase(),
327
- ].filter((value): value is string => Boolean(value))
328
- const hasAliasMatch = aliases.some((alias) => alias === normalizedKey || alias === normalizedLeaf)
331
+ if (normalized.includes(path.sep)) {
332
+ const leaf = normalized.split(path.sep).filter(Boolean).at(-1)
333
+ if (leaf && leaf !== normalized) {
334
+ const leafRoot = path.join(projectsRoot, leaf)
335
+ if (existsDir(leafRoot)) {
336
+ return leafRoot
337
+ }
338
+ }
339
+ }
329
340
 
330
- if (hasAliasMatch) {
331
- return resolvedProcessRoot
341
+ if (!existsDir(projectsRoot)) {
342
+ const hasSingleRepoDocs = existsDir(path.join(resolvedProcessRoot, 'docs'))
343
+ const hasSingleRepoSpec = existsDir(path.join(resolvedProcessRoot, 'spec'))
344
+ if (hasSingleRepoDocs || hasSingleRepoSpec) {
345
+ return resolvedProcessRoot
346
+ }
332
347
  }
333
348
 
334
349
  throw new Error(`Project folder not found: ${projectRoot}`)
@@ -411,11 +426,18 @@ async function searchProjectSection(
411
426
  const optionProjectName = parseStringOption(options.projectName) ?? ''
412
427
  const sourcePreference = parseSearchSourceOption(options.source, options.remote)
413
428
 
414
- if (!positionalProjectName && !optionProjectName) {
415
- throw new Error('Missing project name. Pass it as first arg or options.projectName.')
416
- }
417
-
418
429
  let projectName = positionalProjectName || optionProjectName
430
+
431
+ if (!projectName) {
432
+ const candidates = listProjects(processRoot)
433
+ if (candidates.length === 1) {
434
+ projectName = candidates[0]
435
+ } else if (candidates.length > 1) {
436
+ throw new Error(`Missing project name. Available projects: ${candidates.join(', ')}`)
437
+ } else {
438
+ throw new Error('Missing project name. Pass it as first arg or options.projectName.')
439
+ }
440
+ }
419
441
  let projectRoot: string | null = null
420
442
 
421
443
  if (positionalProjectName && optionProjectName && positionalProjectName !== optionProjectName) {