@foundation0/api 1.1.11 → 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,103 @@ 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
+
275
372
  it("parses owner/repo repoName into repo segment even without full-name metadata", async () => {
276
373
  const originalCwd = process.cwd();
277
374
  const tempDir = await fs.mkdtemp(
package/mcp/server.ts CHANGED
@@ -602,9 +602,23 @@ const coercePayloadForTool = (
602
602
  )
603
603
  return;
604
604
  const name = popStringOption(options, "projectName", "project");
605
- if (name) {
606
- if (args.length === 0) args.push(name);
607
- 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;
608
622
  }
609
623
  };
610
624
 
@@ -664,7 +678,12 @@ const coercePayloadForTool = (
664
678
  rgArgs.push(pattern, ...(paths.length > 0 ? paths : ["."]));
665
679
 
666
680
  if (args.length === 0) {
667
- // 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
+ }
668
687
  return;
669
688
  }
670
689
 
@@ -673,13 +692,17 @@ const coercePayloadForTool = (
673
692
  }
674
693
  };
675
694
 
676
- switch (toolName) {
677
- case "projects.listProjects": {
678
- // 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);
679
704
  break;
680
705
  }
681
- case "projects.resolveProjectRoot":
682
- case "projects.listProjectDocs":
683
706
  case "projects.fetchGitTasks":
684
707
  case "projects.createGitIssue": {
685
708
  coerceProjectName();
@@ -695,7 +718,11 @@ const coercePayloadForTool = (
695
718
  "docPath",
696
719
  );
697
720
  if (requestPath) {
698
- 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
+ }
699
726
  }
700
727
  break;
701
728
  }
@@ -1100,7 +1127,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1100
1127
  properties: {
1101
1128
  projectName: {
1102
1129
  type: "string",
1103
- 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.',
1104
1132
  },
1105
1133
  repoName: {
1106
1134
  type: "string",
@@ -1118,7 +1146,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1118
1146
  additionalProperties: true,
1119
1147
  },
1120
1148
  },
1121
- required: ["projectName"],
1149
+ required: [],
1122
1150
  },
1123
1151
  "projects.listProjectDocs": {
1124
1152
  type: "object",
@@ -1126,7 +1154,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1126
1154
  properties: {
1127
1155
  projectName: {
1128
1156
  type: "string",
1129
- 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.',
1130
1159
  },
1131
1160
  repoName: {
1132
1161
  type: "string",
@@ -1144,7 +1173,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1144
1173
  additionalProperties: true,
1145
1174
  },
1146
1175
  },
1147
- required: ["projectName"],
1176
+ required: [],
1148
1177
  },
1149
1178
  "projects.readProjectDoc": {
1150
1179
  type: "object",
@@ -1152,7 +1181,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1152
1181
  properties: {
1153
1182
  projectName: {
1154
1183
  type: "string",
1155
- 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.',
1156
1186
  },
1157
1187
  requestPath: {
1158
1188
  type: "string",
@@ -1175,7 +1205,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1175
1205
  additionalProperties: true,
1176
1206
  },
1177
1207
  },
1178
- required: ["projectName", "requestPath"],
1208
+ required: ["requestPath"],
1179
1209
  },
1180
1210
  "projects.searchDocs": {
1181
1211
  type: "object",
@@ -1183,7 +1213,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1183
1213
  properties: {
1184
1214
  projectName: {
1185
1215
  type: "string",
1186
- 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.',
1187
1218
  },
1188
1219
  pattern: {
1189
1220
  type: "string",
@@ -1244,7 +1275,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1244
1275
  },
1245
1276
  args: {
1246
1277
  type: "array",
1247
- 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.",
1248
1280
  items: {},
1249
1281
  },
1250
1282
  options: {
@@ -1253,7 +1285,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1253
1285
  additionalProperties: true,
1254
1286
  },
1255
1287
  },
1256
- required: ["projectName"],
1288
+ required: [],
1257
1289
  },
1258
1290
  "projects.searchSpecs": {
1259
1291
  type: "object",
@@ -1261,7 +1293,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1261
1293
  properties: {
1262
1294
  projectName: {
1263
1295
  type: "string",
1264
- 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.',
1265
1298
  },
1266
1299
  pattern: {
1267
1300
  type: "string",
@@ -1322,7 +1355,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1322
1355
  },
1323
1356
  args: {
1324
1357
  type: "array",
1325
- 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.",
1326
1360
  items: {},
1327
1361
  },
1328
1362
  options: {
@@ -1331,7 +1365,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1331
1365
  additionalProperties: true,
1332
1366
  },
1333
1367
  },
1334
- required: ["projectName"],
1368
+ required: [],
1335
1369
  },
1336
1370
  "projects.parseProjectTargetSpec": {
1337
1371
  type: "object",
@@ -1606,7 +1640,8 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1606
1640
  properties: {
1607
1641
  projectName: {
1608
1642
  type: "string",
1609
- 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.',
1610
1645
  },
1611
1646
  section: {
1612
1647
  type: "string",
@@ -2365,9 +2400,9 @@ export const createExampleMcpServer = (
2365
2400
  const hint = (() => {
2366
2401
  if (!hasProjectsDir) {
2367
2402
  return [
2368
- "Git workspace does not contain /projects.",
2369
- "Pass repoName as \"adl\" or \"F0/adl\".",
2370
- "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.',
2371
2406
  ].join(" ");
2372
2407
  }
2373
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.11",
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) {