@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.
- package/mcp/server.test.ts +112 -0
- package/mcp/server.ts +126 -8
- package/package.json +1 -1
package/mcp/server.test.ts
CHANGED
|
@@ -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
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
);
|
|
2171
|
-
const
|
|
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
|
-
|
|
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),
|