@foundation0/api 1.1.7 → 1.1.8

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.
@@ -432,6 +432,118 @@ describe("createExampleMcpServer request handling", () => {
432
432
  }
433
433
  });
434
434
 
435
+ it("auto-detects repo root/name from .git/config when no repoRoot/repoName are provided", async () => {
436
+ const originalCwd = process.cwd();
437
+ const tempDir = await fs.mkdtemp(
438
+ path.join(os.tmpdir(), "f0-mcp-server-git-detect-"),
439
+ );
440
+ try {
441
+ await fs.mkdir(path.join(tempDir, "api", "mcp"), { recursive: true });
442
+ await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
443
+ recursive: true,
444
+ });
445
+ await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
446
+ await fs.writeFile(
447
+ path.join(tempDir, ".git", "config"),
448
+ [
449
+ '[remote "origin"]',
450
+ "\turl = https://example.com/F0/adl.git",
451
+ "",
452
+ ].join("\n"),
453
+ );
454
+
455
+ process.chdir(path.join(tempDir, "api", "mcp"));
456
+ const instance = createExampleMcpServer();
457
+ const handler = getToolHandler(instance);
458
+
459
+ const workspace = await handler(
460
+ {
461
+ method: "tools/call",
462
+ params: {
463
+ name: "mcp.workspace",
464
+ arguments: {},
465
+ },
466
+ },
467
+ {},
468
+ );
469
+
470
+ expect(workspace.isError).toBe(false);
471
+ const payload = JSON.parse(workspace.content?.[0]?.text ?? "{}");
472
+ expect(payload.ok).toBe(true);
473
+ expect(payload.result.defaultRepoRoot).toBe(path.resolve(tempDir));
474
+ expect(payload.result.repoRoot).toBe(path.resolve(tempDir));
475
+ expect(payload.result.defaultRepoName).toBe("adl");
476
+ expect(payload.result.repoName).toBe("adl");
477
+ expect(payload.result.availableRepoNames).toContain("adl");
478
+
479
+ const list = await handler(
480
+ {
481
+ method: "tools/call",
482
+ params: {
483
+ name: "projects.listProjects",
484
+ arguments: {},
485
+ },
486
+ },
487
+ {},
488
+ );
489
+ expect(list.isError).toBe(false);
490
+ const listPayload = JSON.parse(list.content?.[0]?.text ?? "{}");
491
+ expect(listPayload.ok).toBe(true);
492
+ expect(listPayload.result).toContain("adl");
493
+ } finally {
494
+ process.chdir(originalCwd);
495
+ await fs.rm(tempDir, { recursive: true, force: true });
496
+ }
497
+ });
498
+
499
+ it("supports worktree-style .git file (gitdir: ...) when deriving repoName from config", async () => {
500
+ const originalCwd = process.cwd();
501
+ const tempDir = await fs.mkdtemp(
502
+ path.join(os.tmpdir(), "f0-mcp-server-gitfile-detect-"),
503
+ );
504
+ try {
505
+ await fs.mkdir(path.join(tempDir, "api", "mcp"), { recursive: true });
506
+ await fs.mkdir(path.join(tempDir, "projects", "adl", "docs"), {
507
+ recursive: true,
508
+ });
509
+
510
+ const gitDir = path.join(tempDir, ".gitdir");
511
+ await fs.mkdir(gitDir, { recursive: true });
512
+ await fs.writeFile(
513
+ path.join(gitDir, "config"),
514
+ [
515
+ '[remote "origin"]',
516
+ "\turl = git@github.com:F0/adl.git",
517
+ "",
518
+ ].join("\n"),
519
+ );
520
+ await fs.writeFile(path.join(tempDir, ".git"), "gitdir: .gitdir\n");
521
+
522
+ process.chdir(path.join(tempDir, "api", "mcp"));
523
+ const instance = createExampleMcpServer();
524
+ const handler = getToolHandler(instance);
525
+
526
+ const workspace = await handler(
527
+ {
528
+ method: "tools/call",
529
+ params: {
530
+ name: "mcp.workspace",
531
+ arguments: {},
532
+ },
533
+ },
534
+ {},
535
+ );
536
+
537
+ expect(workspace.isError).toBe(false);
538
+ const payload = JSON.parse(workspace.content?.[0]?.text ?? "{}");
539
+ expect(payload.ok).toBe(true);
540
+ expect(payload.result.defaultRepoName).toBe("adl");
541
+ } finally {
542
+ process.chdir(originalCwd);
543
+ await fs.rm(tempDir, { recursive: true, force: true });
544
+ }
545
+ });
546
+
435
547
  it('parses continueOnError from string "false" (fails fast)', async () => {
436
548
  const instance = createExampleMcpServer();
437
549
  const handler = getToolHandler(instance);
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 = {
@@ -176,6 +177,104 @@ const isDir = (candidate: string): boolean => {
176
177
  const looksLikeRepoRoot = (candidate: string): boolean =>
177
178
  isDir(path.join(candidate, "projects")) && isDir(path.join(candidate, "api"));
178
179
 
180
+ const fileExists = (candidate: string): boolean => {
181
+ try {
182
+ fs.statSync(candidate);
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ };
188
+
189
+ const findGitRepoRoot = (startDir: string): string | null => {
190
+ let current = path.resolve(startDir);
191
+ for (let depth = 0; depth < 32; depth += 1) {
192
+ const dotGit = path.join(current, ".git");
193
+ if (isDir(dotGit) || fileExists(dotGit)) return current;
194
+ const parent = path.dirname(current);
195
+ if (parent === current) return null;
196
+ current = parent;
197
+ }
198
+ return null;
199
+ };
200
+
201
+ const resolveGitDir = (repoRoot: string): string | null => {
202
+ const dotGit = path.join(repoRoot, ".git");
203
+ if (isDir(dotGit)) return dotGit;
204
+ if (!fileExists(dotGit)) return null;
205
+
206
+ try {
207
+ const content = fs.readFileSync(dotGit, "utf8");
208
+ const match = content.match(/^\s*gitdir:\s*(.+)\s*$/im);
209
+ if (!match) return null;
210
+ const raw = match[1].trim();
211
+ if (!raw) return null;
212
+ const resolved = path.resolve(repoRoot, raw);
213
+ return isDir(resolved) ? resolved : null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ };
218
+
219
+ const parseRepoNameFromRemoteUrl = (remoteUrl: string): string | null => {
220
+ const trimmed = remoteUrl.trim();
221
+ if (!trimmed) return null;
222
+
223
+ const withoutHash = trimmed.split("#")[0] ?? trimmed;
224
+ const withoutQuery = withoutHash.split("?")[0] ?? withoutHash;
225
+ const withoutGit = withoutQuery.endsWith(".git")
226
+ ? withoutQuery.slice(0, -4)
227
+ : withoutQuery;
228
+
229
+ const lastSep = Math.max(withoutGit.lastIndexOf("/"), withoutGit.lastIndexOf(":"));
230
+ const candidate = (lastSep >= 0 ? withoutGit.slice(lastSep + 1) : withoutGit).trim();
231
+ if (!candidate) return null;
232
+ return candidate;
233
+ };
234
+
235
+ const readGitRemoteUrl = (gitDir: string): string | null => {
236
+ const configPath = path.join(gitDir, "config");
237
+ if (!fileExists(configPath)) return null;
238
+ try {
239
+ const config = fs.readFileSync(configPath, "utf8");
240
+ let currentRemote: string | null = null;
241
+ const remoteUrls = new Map<string, string>();
242
+
243
+ for (const rawLine of config.split(/\r?\n/)) {
244
+ const line = rawLine.trim();
245
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
246
+
247
+ const sectionMatch = line.match(/^\[\s*([^\s\]]+)(?:\s+"([^"]+)")?\s*\]\s*$/);
248
+ if (sectionMatch) {
249
+ const section = sectionMatch[1].toLowerCase();
250
+ const name = sectionMatch[2] ?? null;
251
+ currentRemote = section === "remote" ? name : null;
252
+ continue;
253
+ }
254
+
255
+ if (!currentRemote) continue;
256
+ const kvMatch = line.match(/^([A-Za-z0-9][-A-Za-z0-9]*)\s*=\s*(.*)$/);
257
+ if (!kvMatch) continue;
258
+ const key = kvMatch[1].toLowerCase();
259
+ if (key !== "url") continue;
260
+ const value = kvMatch[2].trim().replace(/^"(.*)"$/, "$1").trim();
261
+ if (value) remoteUrls.set(currentRemote, value);
262
+ }
263
+
264
+ return remoteUrls.get("origin") ?? remoteUrls.values().next().value ?? null;
265
+ } catch {
266
+ return null;
267
+ }
268
+ };
269
+
270
+ const detectRepoNameFromGitConfig = (repoRoot: string): string | null => {
271
+ const gitDir = resolveGitDir(repoRoot);
272
+ if (!gitDir) return null;
273
+ const remoteUrl = readGitRemoteUrl(gitDir);
274
+ if (!remoteUrl) return null;
275
+ return parseRepoNameFromRemoteUrl(remoteUrl);
276
+ };
277
+
179
278
  const normalizeRepoRoot = (raw: string): string => {
180
279
  const resolved = path.resolve(raw);
181
280
  if (looksLikeRepoRoot(resolved)) return resolved;
@@ -2162,15 +2261,34 @@ export const createExampleMcpServer = (
2162
2261
  options: ExampleMcpServerOptions = {},
2163
2262
  ): ExampleMcpServerInstance => {
2164
2263
  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(),
2170
- );
2171
- const defaultRepoName =
2264
+
2265
+ const configuredRepoRoot =
2266
+ options.repoRoot ?? process.env.MCP_REPO_ROOT ?? process.env.F0_REPO_ROOT;
2267
+ const cwd = process.cwd();
2268
+ const cwdNormalized = normalizeRepoRoot(cwd);
2269
+ const cwdLooksLikeRepoRoot = looksLikeRepoRoot(cwdNormalized);
2270
+ const cwdGitRoot = findGitRepoRoot(cwd);
2271
+ const serverFileDir = path.dirname(fileURLToPath(import.meta.url));
2272
+ const serverFileGitRoot = findGitRepoRoot(serverFileDir);
2273
+
2274
+ const rawDefaultRepoRoot =
2275
+ configuredRepoRoot ??
2276
+ (cwdLooksLikeRepoRoot ? cwdNormalized : null) ??
2277
+ cwdGitRoot ??
2278
+ serverFileGitRoot ??
2279
+ cwd ??
2280
+ serverFileDir;
2281
+ const defaultRepoRoot = normalizeRepoRoot(rawDefaultRepoRoot);
2282
+
2283
+ const configuredRepoName =
2172
2284
  (options.repoName ?? process.env.MCP_REPO_NAME ?? process.env.F0_REPO_NAME)?.trim() ||
2173
- path.basename(defaultRepoRoot);
2285
+ null;
2286
+ const gitDerivedRepoName =
2287
+ detectRepoNameFromGitConfig(defaultRepoRoot) ??
2288
+ (cwdGitRoot ? detectRepoNameFromGitConfig(cwdGitRoot) : null) ??
2289
+ (serverFileGitRoot ? detectRepoNameFromGitConfig(serverFileGitRoot) : null);
2290
+ const defaultRepoName =
2291
+ configuredRepoName || gitDerivedRepoName || path.basename(defaultRepoRoot);
2174
2292
  const repoMapRaw = process.env.MCP_REPOS ?? process.env.F0_REPOS;
2175
2293
  const repoMap = {
2176
2294
  ...parseRepoMap(repoMapRaw),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundation0/api",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "Foundation 0 API",
5
5
  "type": "module",
6
6
  "bin": {