@foundation0/api 1.1.7 → 1.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/server.ts CHANGED
@@ -9,6 +9,7 @@ import * as netApi from "../net.ts";
9
9
  import * as projectsApi from "../projects.ts";
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
 
13
14
  type ApiMethod = (...args: unknown[]) => unknown;
14
15
  type ToolInvocationPayload = {
@@ -173,109 +174,176 @@ const isDir = (candidate: string): boolean => {
173
174
  }
174
175
  };
175
176
 
176
- const looksLikeRepoRoot = (candidate: string): boolean =>
177
- isDir(path.join(candidate, "projects")) && isDir(path.join(candidate, "api"));
178
-
179
- const normalizeRepoRoot = (raw: string): string => {
180
- const resolved = path.resolve(raw);
181
- if (looksLikeRepoRoot(resolved)) return resolved;
177
+ const fileExists = (candidate: string): boolean => {
178
+ try {
179
+ fs.statSync(candidate);
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ };
182
185
 
183
- // Common mistake: passing a project root like ".../projects/adl" as repoRoot.
184
- // Try to find the containing repo root by walking up a few levels.
185
- let current = resolved;
186
- for (let depth = 0; depth < 8; depth += 1) {
186
+ const findGitRepoRoot = (startDir: string): string | null => {
187
+ let current = path.resolve(startDir);
188
+ for (let depth = 0; depth < 32; depth += 1) {
189
+ const dotGit = path.join(current, ".git");
190
+ if (isDir(dotGit) || fileExists(dotGit)) return current;
187
191
  const parent = path.dirname(current);
188
- if (parent === current) break;
189
- if (looksLikeRepoRoot(parent)) return parent;
192
+ if (parent === current) return null;
190
193
  current = parent;
191
194
  }
195
+ return null;
196
+ };
197
+
198
+ const resolveGitDir = (workspaceRoot: string): string | null => {
199
+ const dotGit = path.join(workspaceRoot, ".git");
200
+ if (isDir(dotGit)) return dotGit;
201
+ if (!fileExists(dotGit)) return null;
192
202
 
193
- const parts = resolved.split(path.sep).filter((part) => part.length > 0);
194
- const projectsIndex = parts.lastIndexOf("projects");
195
- if (projectsIndex >= 0) {
196
- const candidate = parts.slice(0, projectsIndex).join(path.sep);
197
- if (candidate && looksLikeRepoRoot(candidate)) return candidate;
203
+ try {
204
+ const content = fs.readFileSync(dotGit, "utf8");
205
+ const match = content.match(/^\s*gitdir:\s*(.+)\s*$/im);
206
+ if (!match) return null;
207
+ const raw = match[1].trim();
208
+ if (!raw) return null;
209
+ const resolved = path.resolve(workspaceRoot, raw);
210
+ return isDir(resolved) ? resolved : null;
211
+ } catch {
212
+ return null;
198
213
  }
214
+ };
199
215
 
200
- return resolved;
216
+ type GitRemoteIdentity = {
217
+ owner: string | null;
218
+ repo: string;
219
+ fullName: string | null;
201
220
  };
202
221
 
203
- const normalizeRepoRootOption = (
204
- options: Record<string, unknown>,
205
- ): Record<string, unknown> => {
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;
222
+ const parseGitRemoteIdentityFromUrl = (
223
+ remoteUrl: string,
224
+ ): GitRemoteIdentity | null => {
225
+ const trimmed = remoteUrl.trim();
226
+ if (!trimmed) return null;
227
+
228
+ const withoutHash = trimmed.split("#")[0] ?? trimmed;
229
+ const withoutQuery = withoutHash.split("?")[0] ?? withoutHash;
230
+ const withoutGit = withoutQuery.endsWith(".git")
231
+ ? withoutQuery.slice(0, -4)
232
+ : withoutQuery;
233
+ const normalized = withoutGit.replace(/\\/g, "/");
234
+ const scpPathMatch = normalized.match(/^[^@]+@[^:]+:(.+)$/);
235
+ let pathPart = scpPathMatch?.[1] ?? normalized;
236
+
237
+ if (!scpPathMatch) {
238
+ try {
239
+ const url = new URL(normalized);
240
+ pathPart = url.pathname;
241
+ } catch {
242
+ // Keep normalized value as-is.
243
+ }
216
244
  }
217
245
 
218
- const trimmed = raw.trim();
219
- const normalized = normalizeRepoRoot(trimmed);
246
+ const cleanPath = pathPart.replace(/^\/+|\/+$/g, "");
247
+ if (!cleanPath) return null;
248
+ const segments = cleanPath
249
+ .split("/")
250
+ .map((segment) => segment.trim())
251
+ .filter((segment) => segment.length > 0);
252
+ if (segments.length === 0) return null;
253
+ const repo = segments[segments.length - 1];
254
+ if (!repo) return null;
255
+ const owner = segments.length > 1 ? segments[segments.length - 2] : null;
256
+ return {
257
+ owner,
258
+ repo,
259
+ fullName: owner ? `${owner}/${repo}` : null,
260
+ };
261
+ };
262
+
263
+ const readGitRemoteUrl = (gitDir: string): string | null => {
264
+ const configPath = path.join(gitDir, "config");
265
+ if (!fileExists(configPath)) return null;
266
+ try {
267
+ const config = fs.readFileSync(configPath, "utf8");
268
+ let currentRemote: string | null = null;
269
+ const remoteUrls = new Map<string, string>();
270
+
271
+ for (const rawLine of config.split(/\r?\n/)) {
272
+ const line = rawLine.trim();
273
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
274
+
275
+ const sectionMatch = line.match(/^\[\s*([^\s\]]+)(?:\s+"([^"]+)")?\s*\]\s*$/);
276
+ if (sectionMatch) {
277
+ const section = sectionMatch[1].toLowerCase();
278
+ const name = sectionMatch[2] ?? null;
279
+ currentRemote = section === "remote" ? name : null;
280
+ continue;
281
+ }
220
282
 
221
- const next: Record<string, unknown> = { ...options, repoRoot: normalized };
222
- delete next.processRoot;
283
+ if (!currentRemote) continue;
284
+ const kvMatch = line.match(/^([A-Za-z0-9][-A-Za-z0-9]*)\s*=\s*(.*)$/);
285
+ if (!kvMatch) continue;
286
+ const key = kvMatch[1].toLowerCase();
287
+ if (key !== "url") continue;
288
+ const value = kvMatch[2].trim().replace(/^"(.*)"$/, "$1").trim();
289
+ if (value) remoteUrls.set(currentRemote, value);
290
+ }
223
291
 
224
- const alreadyCanonical =
225
- rawRepoRoot !== null && rawRepoRoot === normalized && !("processRoot" in options);
226
- return alreadyCanonical ? options : next;
292
+ return remoteUrls.get("origin") ?? remoteUrls.values().next().value ?? null;
293
+ } catch {
294
+ return null;
295
+ }
227
296
  };
228
297
 
229
- const looksLikePathish = (value: string): boolean => {
230
- const trimmed = value.trim();
231
- if (!trimmed) return false;
232
- if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return true;
233
- return trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith(".");
298
+ const detectRepoIdentityFromGitConfig = (
299
+ workspaceRoot: string,
300
+ ): GitRemoteIdentity | null => {
301
+ const gitDir = resolveGitDir(workspaceRoot);
302
+ if (!gitDir) return null;
303
+ const remoteUrl = readGitRemoteUrl(gitDir);
304
+ if (!remoteUrl) return null;
305
+ return parseGitRemoteIdentityFromUrl(remoteUrl);
234
306
  };
235
307
 
236
- const parseRepoMap = (raw: string | undefined): Record<string, string> => {
237
- const input = typeof raw === "string" ? raw.trim() : "";
238
- if (!input) return {};
308
+ type RepoResolutionContext = {
309
+ defaultProcessRoot: string;
310
+ defaultRepoName: string;
311
+ defaultRepoFullName: string | null;
312
+ repoNames: string[];
313
+ repoNameByKey: Record<string, string>;
314
+ };
239
315
 
240
- if (input.startsWith("{")) {
241
- try {
242
- const parsed = JSON.parse(input);
243
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
244
- return {};
245
- }
246
- const out: Record<string, string> = {};
247
- for (const [name, root] of Object.entries(parsed)) {
248
- if (!name || typeof root !== "string" || !root.trim()) continue;
249
- out[name.trim()] = root.trim();
250
- }
251
- return out;
252
- } catch {
253
- return {};
254
- }
255
- }
316
+ const repoNameKey = (value: string): string => value.trim().toLowerCase();
256
317
 
257
- const entries = input
258
- .split(/[;,]/g)
259
- .map((entry) => entry.trim())
260
- .filter((entry) => entry.length > 0);
261
-
262
- const out: Record<string, string> = {};
263
- for (const entry of entries) {
264
- const [name, root] = entry.split("=", 2);
265
- if (!name || !root) continue;
266
- const key = name.trim();
267
- const value = root.trim();
268
- if (!key || !value) continue;
269
- out[key] = value;
318
+ const uniqueStrings = (values: Array<string | null | undefined>): string[] => {
319
+ const seen = new Set<string>();
320
+ const out: string[] = [];
321
+ for (const value of values) {
322
+ if (!value) continue;
323
+ const trimmed = value.trim();
324
+ if (!trimmed) continue;
325
+ const key = repoNameKey(trimmed);
326
+ if (seen.has(key)) continue;
327
+ seen.add(key);
328
+ out.push(trimmed);
270
329
  }
271
330
  return out;
272
331
  };
273
332
 
274
- type RepoResolutionContext = {
275
- defaultRepoRoot: string;
276
- defaultRepoName: string;
277
- repoMapByKey: Record<string, string>;
278
- repoNames: string[];
333
+ const buildRepoNameContext = (
334
+ defaultRepoName: string,
335
+ defaultRepoFullName: string | null,
336
+ ): { repoNames: string[]; repoNameByKey: Record<string, string> } => {
337
+ const repoNames = uniqueStrings([
338
+ defaultRepoName,
339
+ defaultRepoFullName,
340
+ defaultRepoFullName?.split("/").at(-1) ?? null,
341
+ ]);
342
+ const repoNameByKey: Record<string, string> = {};
343
+ for (const name of repoNames) {
344
+ repoNameByKey[repoNameKey(name)] = name;
345
+ }
346
+ return { repoNames, repoNameByKey };
279
347
  };
280
348
 
281
349
  const resolveRepoSelectorOptions = (
@@ -283,68 +351,100 @@ const resolveRepoSelectorOptions = (
283
351
  ctx: RepoResolutionContext,
284
352
  ): Record<string, unknown> => {
285
353
  const next: Record<string, unknown> = { ...options };
286
-
287
- const explicitRoot =
288
- (typeof next.repoRoot === "string" && next.repoRoot.trim().length > 0
289
- ? next.repoRoot.trim()
290
- : null) ??
291
- (typeof next.processRoot === "string" && next.processRoot.trim().length > 0
292
- ? next.processRoot.trim()
293
- : null);
294
-
295
- if (explicitRoot) {
296
- next.repoRoot = normalizeRepoRoot(explicitRoot);
297
- delete next.processRoot;
298
- delete next.repoName;
299
- return next;
300
- }
354
+ delete next.repoRoot;
355
+ delete next.processRoot;
301
356
 
302
357
  const repoName =
303
358
  typeof next.repoName === "string" && next.repoName.trim().length > 0
304
359
  ? next.repoName.trim()
305
360
  : null;
306
361
  if (!repoName) {
307
- delete next.processRoot;
362
+ next.processRoot = ctx.defaultProcessRoot;
308
363
  return next;
309
364
  }
310
365
 
311
- if (looksLikePathish(repoName)) {
312
- next.repoRoot = normalizeRepoRoot(repoName);
313
- delete next.processRoot;
314
- delete next.repoName;
366
+ const needle = repoNameKey(repoName);
367
+ const canonical = ctx.repoNameByKey[needle];
368
+ if (canonical) {
369
+ next.processRoot = ctx.defaultProcessRoot;
370
+ next.repoName = canonical;
315
371
  return next;
316
372
  }
317
373
 
318
- const needle = repoName.toLowerCase();
319
- if (needle === ctx.defaultRepoName.toLowerCase()) {
320
- next.repoRoot = ctx.defaultRepoRoot;
321
- delete next.processRoot;
322
- delete next.repoName;
374
+ if (ctx.defaultRepoFullName && needle === repoNameKey(ctx.defaultRepoFullName)) {
375
+ next.processRoot = ctx.defaultProcessRoot;
376
+ next.repoName = ctx.defaultRepoFullName;
323
377
  return next;
324
378
  }
325
379
 
326
- const mapped = ctx.repoMapByKey[needle];
327
- if (mapped) {
328
- next.repoRoot = normalizeRepoRoot(mapped);
329
- delete next.processRoot;
330
- delete next.repoName;
380
+ if (needle === repoNameKey(ctx.defaultRepoName)) {
381
+ next.processRoot = ctx.defaultProcessRoot;
382
+ next.repoName = ctx.defaultRepoName;
331
383
  return next;
332
384
  }
333
385
 
386
+ const slashParts = repoName.split("/").filter((part) => part.trim().length > 0);
387
+ if (slashParts.length >= 2) {
388
+ const repoSegment = slashParts[slashParts.length - 1]?.trim();
389
+ if (repoSegment) {
390
+ const matched = ctx.repoNameByKey[repoNameKey(repoSegment)];
391
+ if (matched) {
392
+ next.processRoot = ctx.defaultProcessRoot;
393
+ next.repoName = matched;
394
+ return next;
395
+ }
396
+ }
397
+ }
398
+
334
399
  const suggestions = ctx.repoNames
335
- .filter((name) => name.toLowerCase().includes(needle))
400
+ .filter((name) => repoNameKey(name).includes(needle))
336
401
  .slice(0, 8);
337
402
  const hint =
338
403
  suggestions.length > 0
339
404
  ? ` Did you mean: ${suggestions.join(", ")}?`
340
- : ctx.repoNames.length > 0
341
- ? ` Available repos: ${ctx.repoNames.join(", ")}.`
342
- : "";
405
+ : ` Available repoName values: ${ctx.repoNames.join(", ")}.`;
343
406
  throw new Error(
344
- `Unknown repoName: ${repoName}.${hint} Tip: call mcp.workspace to see the server repo context.`,
407
+ `Unknown repoName: ${repoName}.${hint} Tip: use "adl" or "F0/adl" style selectors.`,
345
408
  );
346
409
  };
347
410
 
411
+ const normalizeRepoNameForDisplay = (
412
+ requestedRepoName: string | null,
413
+ ctx: RepoResolutionContext,
414
+ ): string => {
415
+ if (!requestedRepoName) return ctx.defaultRepoName;
416
+ const canonical = ctx.repoNameByKey[repoNameKey(requestedRepoName)];
417
+ return canonical ?? requestedRepoName;
418
+ };
419
+
420
+ const resolveProcessRootFromGit = (fallbackDir: string): string => {
421
+ const cwdRoot = findGitRepoRoot(process.cwd());
422
+ if (cwdRoot) return cwdRoot;
423
+ const fallbackRoot = findGitRepoRoot(fallbackDir);
424
+ if (fallbackRoot) return fallbackRoot;
425
+ return path.resolve(process.cwd());
426
+ };
427
+
428
+ const resolveRepoIdentityFromGit = (
429
+ processRoot: string,
430
+ ): {
431
+ defaultRepoName: string;
432
+ defaultRepoFullName: string | null;
433
+ } => {
434
+ const gitIdentity = detectRepoIdentityFromGitConfig(processRoot);
435
+ if (gitIdentity) {
436
+ return {
437
+ defaultRepoName: gitIdentity.repo,
438
+ defaultRepoFullName: gitIdentity.fullName,
439
+ };
440
+ }
441
+
442
+ return {
443
+ defaultRepoName: path.basename(processRoot),
444
+ defaultRepoFullName: null,
445
+ };
446
+ };
447
+
348
448
  type NormalizedToolPayload = {
349
449
  args: unknown[];
350
450
  options: Record<string, unknown>;
@@ -463,7 +563,7 @@ const normalizePayload = (payload: unknown): NormalizedToolPayload => {
463
563
 
464
564
  return {
465
565
  args,
466
- options: normalizeRepoRootOption(options),
566
+ options,
467
567
  };
468
568
  };
469
569
 
@@ -570,7 +670,7 @@ const coercePayloadForTool = (
570
670
 
571
671
  switch (toolName) {
572
672
  case "projects.listProjects": {
573
- // No positional args. repoRoot is resolved from repoName/repoRoot/processRoot and passed via buildRepoRootOnly.
673
+ // No positional args. processRoot is injected from the selected repoName.
574
674
  break;
575
675
  }
576
676
  case "projects.resolveProjectRoot":
@@ -660,7 +760,7 @@ const coercePayloadForTool = (
660
760
  break;
661
761
  }
662
762
 
663
- return { args, options: normalizeRepoRootOption(options) };
763
+ return { args, options };
664
764
  };
665
765
 
666
766
  const normalizeBatchToolCall = (
@@ -1000,7 +1100,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1000
1100
  repoName: {
1001
1101
  type: "string",
1002
1102
  description:
1003
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1103
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1004
1104
  },
1005
1105
  args: {
1006
1106
  type: "array",
@@ -1026,7 +1126,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1026
1126
  repoName: {
1027
1127
  type: "string",
1028
1128
  description:
1029
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1129
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1030
1130
  },
1031
1131
  args: {
1032
1132
  type: "array",
@@ -1057,7 +1157,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1057
1157
  repoName: {
1058
1158
  type: "string",
1059
1159
  description:
1060
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1160
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1061
1161
  },
1062
1162
  args: {
1063
1163
  type: "array",
@@ -1135,7 +1235,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1135
1235
  repoName: {
1136
1236
  type: "string",
1137
1237
  description:
1138
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1238
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1139
1239
  },
1140
1240
  args: {
1141
1241
  type: "array",
@@ -1213,7 +1313,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1213
1313
  repoName: {
1214
1314
  type: "string",
1215
1315
  description:
1216
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1316
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1217
1317
  },
1218
1318
  args: {
1219
1319
  type: "array",
@@ -1330,7 +1430,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1330
1430
  repoName: {
1331
1431
  type: "string",
1332
1432
  description:
1333
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1433
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1334
1434
  },
1335
1435
  args: {
1336
1436
  type: "array",
@@ -1372,7 +1472,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1372
1472
  repoName: {
1373
1473
  type: "string",
1374
1474
  description:
1375
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1475
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1376
1476
  },
1377
1477
  args: {
1378
1478
  type: "array",
@@ -1430,7 +1530,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1430
1530
  repoName: {
1431
1531
  type: "string",
1432
1532
  description:
1433
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1533
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1434
1534
  },
1435
1535
  args: {
1436
1536
  type: "array",
@@ -1465,7 +1565,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1465
1565
  repoName: {
1466
1566
  type: "string",
1467
1567
  description:
1468
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1568
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1469
1569
  },
1470
1570
  args: {
1471
1571
  type: "array",
@@ -1487,7 +1587,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1487
1587
  repoName: {
1488
1588
  type: "string",
1489
1589
  description:
1490
- "Optional repo selector (LLM-friendly). Omit to use server default. You can also pass a server filesystem path via legacy repoRoot/processRoot.",
1590
+ 'Optional repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1491
1591
  },
1492
1592
  },
1493
1593
  required: [],
@@ -1555,7 +1655,7 @@ const TOOL_INPUT_SCHEMA_OVERRIDES: Record<string, unknown> = {
1555
1655
  repoName: {
1556
1656
  type: "string",
1557
1657
  description:
1558
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1658
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1559
1659
  },
1560
1660
  },
1561
1661
  $comment: safeJsonStringify({
@@ -1702,9 +1802,9 @@ const getInvocationPlanName = (toolName: string): string => {
1702
1802
  const plan = toolInvocationPlans[toolName];
1703
1803
  if (!plan) return "default";
1704
1804
  if (plan === buildOptionsOnly) return "optionsOnly";
1705
- if (plan === buildOptionsThenRepoRoot) return "optionsThenRepoRoot";
1706
- if (plan === buildRepoRootThenOptions) return "repoRootThenOptions";
1707
- if (plan === buildRepoRootOnly) return "repoRootOnly";
1805
+ if (plan === buildOptionsThenProcessRoot) return "optionsThenProcessRoot";
1806
+ if (plan === buildProcessRootThenOptions) return "processRootThenOptions";
1807
+ if (plan === buildProcessRootOnly) return "processRootOnly";
1708
1808
  return "custom";
1709
1809
  };
1710
1810
 
@@ -1716,16 +1816,16 @@ const buildInvocationExample = (toolName: string): Record<string, unknown> => {
1716
1816
  const example: Record<string, unknown> = {};
1717
1817
  if (requiredArgs && requiredArgs.length > 0) {
1718
1818
  example.args = [...requiredArgs];
1719
- } else if (plan !== "repoRootOnly") {
1819
+ } else if (plan !== "processRootOnly") {
1720
1820
  example.args = ["<arg0>"];
1721
1821
  }
1722
1822
 
1723
- if (plan === "repoRootOnly") {
1823
+ if (plan === "processRootOnly") {
1724
1824
  example.options = { repoName: "<repo-name>", ...defaultOptions };
1725
1825
  return example;
1726
1826
  }
1727
1827
 
1728
- if (plan === "optionsThenRepoRoot" || plan === "repoRootThenOptions") {
1828
+ if (plan === "optionsThenProcessRoot" || plan === "processRootThenOptions") {
1729
1829
  example.options = { repoName: "<repo-name>", ...defaultOptions };
1730
1830
  return example;
1731
1831
  }
@@ -1760,7 +1860,7 @@ const defaultToolInputSchema = (toolName: string) => ({
1760
1860
  repoName: {
1761
1861
  type: "string",
1762
1862
  description:
1763
- "Repo selector (LLM-friendly). Omit to use server default. Legacy path aliases: repoRoot/processRoot.",
1863
+ 'Repo selector. Accepts "<repo>" or "<owner>/<repo>". Omit to use the current Git workspace.',
1764
1864
  },
1765
1865
  },
1766
1866
  $comment: safeJsonStringify({
@@ -1876,13 +1976,13 @@ const buildToolList = (
1876
1976
  type ToolInvoker = (
1877
1977
  args: unknown[],
1878
1978
  options: Record<string, unknown>,
1879
- defaultRepoRoot: string,
1979
+ defaultProcessRoot: string,
1880
1980
  ) => unknown[];
1881
1981
 
1882
1982
  const buildOptionsOnly = (
1883
1983
  args: unknown[],
1884
1984
  options: Record<string, unknown>,
1885
- _defaultRepoRoot: string,
1985
+ _defaultProcessRoot: string,
1886
1986
  ): unknown[] => {
1887
1987
  const invocationArgs: unknown[] = [...args];
1888
1988
  if (Object.keys(options).length > 0) {
@@ -1891,44 +1991,48 @@ const buildOptionsOnly = (
1891
1991
  return invocationArgs;
1892
1992
  };
1893
1993
 
1894
- const buildOptionsThenRepoRoot = (
1994
+ const buildOptionsThenProcessRoot = (
1895
1995
  args: unknown[],
1896
1996
  options: Record<string, unknown>,
1897
- defaultRepoRoot: string,
1997
+ defaultProcessRoot: string,
1898
1998
  ): unknown[] => {
1899
1999
  const invocationArgs: unknown[] = [...args];
1900
2000
  const remaining = { ...options };
1901
- const repoRoot = remaining.repoRoot;
1902
- if (typeof repoRoot === "string") {
1903
- delete remaining.repoRoot;
2001
+ const processRoot = remaining.processRoot;
2002
+ if (typeof processRoot === "string") {
2003
+ delete remaining.processRoot;
1904
2004
  }
1905
- const resolvedRepoRoot =
1906
- typeof repoRoot === "string" ? repoRoot : defaultRepoRoot;
2005
+ delete remaining.repoRoot;
2006
+ const resolvedProcessRoot =
2007
+ typeof processRoot === "string" ? processRoot : defaultProcessRoot;
1907
2008
 
1908
2009
  if (Object.keys(remaining).length > 0) {
1909
2010
  invocationArgs.push(remaining);
1910
- } else if (resolvedRepoRoot) {
1911
- // Preserve positional slot for signatures like fn(projectName, options?, repoRoot?).
2011
+ } else if (resolvedProcessRoot) {
2012
+ // Preserve positional slot for signatures like fn(projectName, options?, processRoot?).
1912
2013
  invocationArgs.push({});
1913
2014
  }
1914
- invocationArgs.push(resolvedRepoRoot);
2015
+ invocationArgs.push(resolvedProcessRoot);
1915
2016
 
1916
2017
  return invocationArgs;
1917
2018
  };
1918
2019
 
1919
- const buildRepoRootThenOptions = (
2020
+ const buildProcessRootThenOptions = (
1920
2021
  args: unknown[],
1921
2022
  options: Record<string, unknown>,
1922
- defaultRepoRoot: string,
2023
+ defaultProcessRoot: string,
1923
2024
  ): unknown[] => {
1924
2025
  const invocationArgs: unknown[] = [...args];
1925
2026
  const remaining = { ...options };
1926
- const repoRoot = remaining.repoRoot;
1927
- if (typeof repoRoot === "string") {
1928
- delete remaining.repoRoot;
2027
+ const processRoot = remaining.processRoot;
2028
+ if (typeof processRoot === "string") {
2029
+ delete remaining.processRoot;
1929
2030
  }
2031
+ delete remaining.repoRoot;
1930
2032
 
1931
- invocationArgs.push(typeof repoRoot === "string" ? repoRoot : defaultRepoRoot);
2033
+ invocationArgs.push(
2034
+ typeof processRoot === "string" ? processRoot : defaultProcessRoot,
2035
+ );
1932
2036
  if (Object.keys(remaining).length > 0) {
1933
2037
  invocationArgs.push(remaining);
1934
2038
  }
@@ -1936,39 +2040,42 @@ const buildRepoRootThenOptions = (
1936
2040
  return invocationArgs;
1937
2041
  };
1938
2042
 
1939
- const buildRepoRootOnly = (
2043
+ const buildProcessRootOnly = (
1940
2044
  args: unknown[],
1941
2045
  options: Record<string, unknown>,
1942
- defaultRepoRoot: string,
2046
+ defaultProcessRoot: string,
1943
2047
  ): unknown[] => {
1944
2048
  const invocationArgs: unknown[] = [...args];
1945
- const repoRoot = options.repoRoot;
1946
- invocationArgs.push(typeof repoRoot === "string" ? repoRoot : defaultRepoRoot);
2049
+ const processRoot = options.processRoot;
2050
+ invocationArgs.push(
2051
+ typeof processRoot === "string" ? processRoot : defaultProcessRoot,
2052
+ );
1947
2053
  return invocationArgs;
1948
2054
  };
1949
2055
 
1950
2056
  const toolInvocationPlans: Record<string, ToolInvoker> = {
1951
- "agents.setActive": buildRepoRootThenOptions,
1952
- "agents.resolveAgentsRootFrom": buildRepoRootOnly,
1953
- "projects.setActive": buildRepoRootThenOptions,
1954
- "projects.generateSpec": buildOptionsThenRepoRoot,
1955
- "projects.syncTasks": buildOptionsThenRepoRoot,
1956
- "projects.clearIssues": buildOptionsThenRepoRoot,
1957
- "projects.fetchGitTasks": buildOptionsThenRepoRoot,
1958
- "projects.createGitIssue": buildOptionsThenRepoRoot,
1959
- "projects.readGitTask": buildOptionsThenRepoRoot,
1960
- "projects.writeGitTask": buildOptionsThenRepoRoot,
2057
+ "agents.setActive": buildProcessRootThenOptions,
2058
+ "agents.resolveAgentsRootFrom": buildProcessRootOnly,
2059
+ "projects.setActive": buildProcessRootThenOptions,
2060
+ "projects.generateSpec": buildOptionsThenProcessRoot,
2061
+ "projects.syncTasks": buildOptionsThenProcessRoot,
2062
+ "projects.clearIssues": buildOptionsThenProcessRoot,
2063
+ "projects.fetchGitTasks": buildOptionsThenProcessRoot,
2064
+ "projects.createGitIssue": buildOptionsThenProcessRoot,
2065
+ "projects.readGitTask": buildOptionsThenProcessRoot,
2066
+ "projects.writeGitTask": buildOptionsThenProcessRoot,
1961
2067
  "agents.resolveTargetFile": buildOptionsOnly,
1962
2068
  "projects.resolveProjectTargetFile": buildOptionsOnly,
1963
- "agents.loadAgent": buildRepoRootOnly,
1964
- "agents.loadAgentPrompt": buildRepoRootOnly,
1965
- "projects.resolveImplementationPlan": (args, options, _defaultRepoRoot) => {
2069
+ "agents.loadAgent": buildProcessRootOnly,
2070
+ "agents.loadAgentPrompt": buildProcessRootOnly,
2071
+ "projects.resolveImplementationPlan": (args, options, _defaultProcessRoot) => {
1966
2072
  const invocationArgs: unknown[] = [...args];
1967
2073
  const remaining = { ...options };
1968
- const repoRoot = remaining.repoRoot;
1969
- if (typeof repoRoot === "string") {
1970
- delete remaining.repoRoot;
2074
+ const processRoot = remaining.processRoot;
2075
+ if (typeof processRoot === "string") {
2076
+ delete remaining.processRoot;
1971
2077
  }
2078
+ delete remaining.repoRoot;
1972
2079
 
1973
2080
  // This tool is a low-level helper: projects.resolveImplementationPlan(projectRoot, inputFile?, options?)
1974
2081
  // If the caller provides options but no inputFile, preserve the positional slot.
@@ -1979,17 +2086,17 @@ const toolInvocationPlans: Record<string, ToolInvoker> = {
1979
2086
  invocationArgs.push(remaining);
1980
2087
  }
1981
2088
 
1982
- // Intentionally do NOT append repoRoot: projectRoot is the first positional argument.
2089
+ // Intentionally do NOT append processRoot: projectRoot is the first positional argument.
1983
2090
  return invocationArgs;
1984
2091
  },
1985
- "agents.main": buildRepoRootOnly,
1986
- "agents.resolveAgentsRoot": buildRepoRootOnly,
1987
- "agents.listAgents": buildRepoRootOnly,
1988
- "projects.resolveProjectRoot": buildRepoRootOnly,
1989
- "projects.listProjects": buildRepoRootOnly,
1990
- "projects.listProjectDocs": buildRepoRootOnly,
1991
- "projects.readProjectDoc": buildRepoRootOnly,
1992
- "projects.main": buildRepoRootOnly,
2092
+ "agents.main": buildProcessRootOnly,
2093
+ "agents.resolveAgentsRoot": buildProcessRootOnly,
2094
+ "agents.listAgents": buildProcessRootOnly,
2095
+ "projects.resolveProjectRoot": buildProcessRootOnly,
2096
+ "projects.listProjects": buildProcessRootOnly,
2097
+ "projects.listProjectDocs": buildProcessRootOnly,
2098
+ "projects.readProjectDoc": buildProcessRootOnly,
2099
+ "projects.main": buildProcessRootOnly,
1993
2100
  };
1994
2101
 
1995
2102
  const invokeTool = async (
@@ -2005,14 +2112,14 @@ const invokeTool = async (
2005
2112
  };
2006
2113
  const invoke =
2007
2114
  toolInvocationPlans[tool.name] ??
2008
- ((rawArgs, rawOptions, _repoRoot) => {
2115
+ ((rawArgs, rawOptions, _processRoot) => {
2009
2116
  const invocationArgs = [...rawArgs];
2010
2117
  if (Object.keys(rawOptions).length > 0) {
2011
2118
  invocationArgs.push(rawOptions);
2012
2119
  }
2013
2120
  return invocationArgs;
2014
2121
  });
2015
- const invocationArgs = invoke(args, options, repoCtx.defaultRepoRoot);
2122
+ const invocationArgs = invoke(args, options, repoCtx.defaultProcessRoot);
2016
2123
 
2017
2124
  return Promise.resolve(tool.method(...invocationArgs));
2018
2125
  };
@@ -2022,13 +2129,8 @@ export interface ExampleMcpServerOptions {
2022
2129
  serverVersion?: string;
2023
2130
  toolsPrefix?: string;
2024
2131
  /**
2025
- * Optional default repo root on the server filesystem.
2026
- * If omitted, the server attempts to auto-detect by walking up from cwd.
2027
- */
2028
- repoRoot?: string;
2029
- /**
2030
- * Optional default repo name (LLM-friendly identifier).
2031
- * If omitted, defaults to the basename of the resolved repo root.
2132
+ * Optional default repo selector (for MCP repoName matching).
2133
+ * If omitted, auto-detected from git remote (fallback: workspace folder name).
2032
2134
  */
2033
2135
  repoName?: string;
2034
2136
  allowedRootEndpoints?: string[];
@@ -2162,30 +2264,24 @@ export const createExampleMcpServer = (
2162
2264
  options: ExampleMcpServerOptions = {},
2163
2265
  ): ExampleMcpServerInstance => {
2164
2266
  let toolCatalog: unknown[] = [];
2165
- const defaultRepoRoot = normalizeRepoRoot(
2166
- options.repoRoot ??
2167
- process.env.MCP_REPO_ROOT ??
2168
- process.env.F0_REPO_ROOT ??
2169
- process.cwd(),
2267
+
2268
+ const serverFileDir = path.dirname(fileURLToPath(import.meta.url));
2269
+ const defaultProcessRoot = resolveProcessRootFromGit(serverFileDir);
2270
+ const configuredRepoName = options.repoName?.trim() || null;
2271
+ const resolvedRepoIdentity = resolveRepoIdentityFromGit(defaultProcessRoot);
2272
+ const defaultRepoName = configuredRepoName || resolvedRepoIdentity.defaultRepoName;
2273
+ const defaultRepoFullName = resolvedRepoIdentity.defaultRepoFullName;
2274
+ const { repoNames, repoNameByKey } = buildRepoNameContext(
2275
+ defaultRepoName,
2276
+ defaultRepoFullName,
2170
2277
  );
2171
- const defaultRepoName =
2172
- (options.repoName ?? process.env.MCP_REPO_NAME ?? process.env.F0_REPO_NAME)?.trim() ||
2173
- path.basename(defaultRepoRoot);
2174
- const repoMapRaw = process.env.MCP_REPOS ?? process.env.F0_REPOS;
2175
- const repoMap = {
2176
- ...parseRepoMap(repoMapRaw),
2177
- [defaultRepoName]: defaultRepoRoot,
2178
- };
2179
- const repoNames = Object.keys(repoMap).sort((a, b) => a.localeCompare(b));
2180
- const repoMapByKey: Record<string, string> = {};
2181
- for (const [name, root] of Object.entries(repoMap)) {
2182
- repoMapByKey[name.toLowerCase()] = root;
2183
- }
2278
+
2184
2279
  const repoCtx: RepoResolutionContext = {
2185
- defaultRepoRoot,
2280
+ defaultProcessRoot,
2186
2281
  defaultRepoName,
2187
- repoMapByKey,
2282
+ defaultRepoFullName,
2188
2283
  repoNames,
2284
+ repoNameByKey,
2189
2285
  };
2190
2286
 
2191
2287
  const parseString = (value: unknown): string | null => {
@@ -2226,7 +2322,7 @@ export const createExampleMcpServer = (
2226
2322
  "F0 MCP helper tools:",
2227
2323
  "- mcp.listTools: returns tool catalog with access + invocation hints",
2228
2324
  "- mcp.describeTool: describe one tool by name (prefixed or unprefixed)",
2229
- "- mcp.workspace: explain server filesystem context (cwd, repoName/repoRoot, projects)",
2325
+ "- mcp.workspace: explain Git workspace context (cwd, repoName selectors, projects)",
2230
2326
  "- mcp.search: LLM-friendly search over project docs/spec (local-first)",
2231
2327
  "",
2232
2328
  'Tip: Prefer mcp.search for "search spec/docs" requests.',
@@ -2238,35 +2334,35 @@ export const createExampleMcpServer = (
2238
2334
  ? {
2239
2335
  keys: Object.keys(input),
2240
2336
  repoName: (input as any).repoName ?? null,
2241
- repoRoot: (input as any).repoRoot ?? null,
2242
- processRoot: (input as any).processRoot ?? null,
2243
2337
  }
2244
- : { keys: [], repoName: null, repoRoot: null, processRoot: null };
2338
+ : { keys: [], repoName: null };
2245
2339
  const requestedRepoName = parseString(payload.repoName);
2246
2340
  const resolved = resolveRepoSelectorOptions(payload, repoCtx);
2247
- const repoRoot =
2248
- typeof resolved.repoRoot === "string"
2249
- ? resolved.repoRoot
2250
- : repoCtx.defaultRepoRoot;
2251
- const effectiveRepoName =
2252
- requestedRepoName && !looksLikePathish(requestedRepoName)
2253
- ? requestedRepoName
2254
- : repoCtx.defaultRepoName;
2255
-
2256
- const projectsDir = path.join(repoRoot, "projects");
2257
- const apiDir = path.join(repoRoot, "api");
2258
- const agentsDir = path.join(repoRoot, "agents");
2341
+ const processRoot =
2342
+ typeof resolved.processRoot === "string"
2343
+ ? resolved.processRoot
2344
+ : repoCtx.defaultProcessRoot;
2345
+ const effectiveRepoName = normalizeRepoNameForDisplay(
2346
+ requestedRepoName,
2347
+ repoCtx,
2348
+ );
2349
+
2350
+ const projectsDir = path.join(processRoot, "projects");
2351
+ const apiDir = path.join(processRoot, "api");
2352
+ const agentsDir = path.join(processRoot, "agents");
2259
2353
  const hasProjectsDir = isDir(projectsDir);
2260
2354
  const hasApiDir = isDir(apiDir);
2261
2355
  const hasAgentsDir = isDir(agentsDir);
2262
2356
 
2263
- const projects = hasProjectsDir ? projectsApi.listProjects(repoRoot) : [];
2357
+ const projects = hasProjectsDir
2358
+ ? projectsApi.listProjects(processRoot)
2359
+ : [];
2264
2360
  const hint = (() => {
2265
2361
  if (!hasProjectsDir) {
2266
2362
  return [
2267
- "Repo does not contain /projects on the server filesystem.",
2268
- "Start the MCP server from the monorepo root (the folder that contains both /api and /projects), or configure the server with --repo-root / MCP_REPO_ROOT.",
2269
- "Tool callers should usually omit repoName and let the server use its default workspace.",
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.",
2270
2366
  ].join(" ");
2271
2367
  }
2272
2368
  if (hasProjectsDir && projects.length === 0) {
@@ -2282,9 +2378,10 @@ export const createExampleMcpServer = (
2282
2378
  received,
2283
2379
  cwd: process.cwd(),
2284
2380
  defaultRepoName: repoCtx.defaultRepoName,
2285
- defaultRepoRoot: repoCtx.defaultRepoRoot,
2381
+ defaultRepoFullName: repoCtx.defaultRepoFullName,
2382
+ workspaceRoot: repoCtx.defaultProcessRoot,
2286
2383
  repoName: effectiveRepoName,
2287
- repoRoot,
2384
+ processRoot,
2288
2385
  availableRepoNames: repoCtx.repoNames,
2289
2386
  hasProjectsDir,
2290
2387
  hasApiDir,
@@ -2334,10 +2431,10 @@ export const createExampleMcpServer = (
2334
2431
  search: async (input: unknown) => {
2335
2432
  const payload = isRecord(input) ? input : {};
2336
2433
  const resolved = resolveRepoSelectorOptions(payload, repoCtx);
2337
- const repoRoot =
2338
- typeof resolved.repoRoot === "string"
2339
- ? resolved.repoRoot
2340
- : repoCtx.defaultRepoRoot;
2434
+ const processRoot =
2435
+ typeof resolved.processRoot === "string"
2436
+ ? resolved.processRoot
2437
+ : repoCtx.defaultProcessRoot;
2341
2438
 
2342
2439
  const sectionRaw = parseString(payload.section)?.toLowerCase();
2343
2440
  const section = sectionRaw === "docs" ? "docs" : "spec";
@@ -2384,11 +2481,11 @@ export const createExampleMcpServer = (
2384
2481
 
2385
2482
  const projectName = ensureProjectName(
2386
2483
  parseString(payload.projectName),
2387
- repoRoot,
2484
+ processRoot,
2388
2485
  );
2389
2486
 
2390
2487
  const searchOptions: Record<string, unknown> = {
2391
- processRoot: repoRoot,
2488
+ processRoot,
2392
2489
  };
2393
2490
 
2394
2491
  const source = parseString(payload.source)?.toLowerCase();
@@ -2711,9 +2808,9 @@ export const createExampleMcpServer = (
2711
2808
  /projects[\\/].+projects[\\/]/i.test(message)
2712
2809
  ) {
2713
2810
  details.hint =
2714
- "You likely passed a project root as repoName (or legacy repoRoot/processRoot). Repo selection should point at the monorepo root that contains /projects.";
2811
+ 'Repo selection is invalid. repoName must be "<repo>" or "<owner>/<repo>", for example "adl" or "F0/adl".';
2715
2812
  details.suggestion =
2716
- "Call mcp.workspace to see the server’s cwd/default repoRoot, then omit repoName or pass the correct repoName.";
2813
+ "Call mcp.workspace to see accepted repoName selectors.";
2717
2814
  }
2718
2815
 
2719
2816
  if (
@@ -2724,7 +2821,7 @@ export const createExampleMcpServer = (
2724
2821
  details.hint =
2725
2822
  "Repo selection might be wrong, or the server filesystem does not contain /projects for this workspace.";
2726
2823
  details.suggestion =
2727
- "Call mcp.workspace to see the server’s cwd/default repoRoot and available repoName values.";
2824
+ "Call mcp.workspace to see workspaceRoot and available repoName values.";
2728
2825
  details.example = {
2729
2826
  tool: prefix ? `${prefix}.mcp.workspace` : "mcp.workspace",
2730
2827
  arguments: {},