singularity-forge/src/mcp-server.ts
Mikael Hugo b24f426f2b batch: snapshot of in-flight v2 work
This commit captures uncommitted modifications that accumulated in the
working tree across multiple in-progress workstreams. It is a snapshot
to clear the deck before sf v3 work begins; individual workstreams
should land separately on top of this.

Notable additions:
- trace-collector.ts, traces.ts, src/tests/trace-export.test.ts —
  trace export plumbing
- biome.json — Biome linter configuration
- .gitignore — exclude native/npm/**/*.node compiled binaries

The bulk of the diff is across src/resources/extensions/sf/ (301 files)
and src/resources/extensions/sf/tests/ (277 files), reflecting the
ongoing sf extension work. Specific feature commits should follow this
snapshot rather than being archaeology'd out of it.

The 76MB native/npm/linux-x64-gnu/forge_engine.node compiled binary
was left out of the commit — it's now gitignored and built locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:42:31 +02:00

150 lines
5.3 KiB
TypeScript

/**
* Minimal tool interface matching SF's AgentTool shape.
* Avoids a direct dependency on @singularity-forge/pi-agent-core from this compiled module.
*/
export interface McpToolDef {
name: string;
description: string;
parameters: Record<string, unknown>;
execute(
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
onUpdate?: unknown,
): Promise<{
content: Array<{
type: string;
text?: string;
data?: string;
mimeType?: string;
}>;
}>;
}
// MCP SDK subpath imports use wildcard exports (./*) in @modelcontextprotocol/sdk's
// package.json export map. The wildcard maps "./foo" → "./dist/cjs/foo" (no .js
// suffix), so bare subpath specifiers like `${MCP_PKG}/server/stdio` resolve to
// a non-existent file. Historically the workaround (#3603) used createRequire so
// the CJS resolver could auto-append `.js`; that no longer works with current
// Node + SDK releases (#3914) — `_require.resolve` also fails with
// "Cannot find module .../dist/cjs/server/stdio".
//
// The reliable convention (matching packages/mcp-server/{server,cli}.ts) is to
// write the `.js` suffix explicitly on every wildcard subpath. Specifiers are
// built via a template string so TypeScript's NodeNext resolver treats them as
// `any` and skips static checking.
const MCP_PKG = "@modelcontextprotocol/sdk";
/**
* Starts a native MCP (Model Context Protocol) server over stdin/stdout.
*
* This enables SF's tools (read, write, edit, bash, grep, glob, ls, etc.)
* to be used by external AI clients such as Claude Desktop, VS Code Copilot,
* and any MCP-compatible host.
*
* The server registers all tools from the agent session's tool registry and
* maps MCP tools/list and tools/call requests to SF tool definitions and
* execution, respectively.
*
* All MCP SDK imports are dynamic to avoid subpath export resolution issues
* with TypeScript's NodeNext module resolution.
*/
export async function startMcpServer(options: {
tools: McpToolDef[];
version?: string;
}): Promise<void> {
const { tools, version = "0.0.0" } = options;
const serverMod = await import(`${MCP_PKG}/server/index.js`);
const stdioMod = await import(`${MCP_PKG}/server/stdio.js`);
const typesMod = await import(`${MCP_PKG}/types.js`);
const Server = serverMod.Server;
const StdioServerTransport = stdioMod.StdioServerTransport;
const { ListToolsRequestSchema, CallToolRequestSchema } = typesMod;
// Build a lookup map for fast tool resolution on calls
const toolMap = new Map<string, McpToolDef>();
for (const tool of tools) {
toolMap.set(tool.name, tool);
}
const server = new Server(
{ name: "sf", version },
{ capabilities: { tools: {} } },
);
// tools/list — return every registered SF tool with its JSON Schema parameters
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((t: McpToolDef) => ({
name: t.name,
description: t.description,
inputSchema: t.parameters,
})),
}));
// tools/call — execute the requested tool and return content blocks.
//
// The MCP SDK passes an `extra` argument to request handlers that includes
// an AbortSignal scoped to the RPC request (cancelled when the client
// cancels the tool call or the transport closes). Threading it into
// AgentTool.execute ensures long-running tools (Bash, WebFetch, grep on
// huge trees) actually stop when the client gives up on the result.
server.setRequestHandler(
CallToolRequestSchema,
async (request: any, extra: any) => {
const { name, arguments: args } = request.params;
const tool = toolMap.get(name);
if (!tool) {
return {
isError: true,
content: [{ type: "text" as const, text: `Unknown tool: ${name}` }],
};
}
const signal: AbortSignal | undefined = extra?.signal;
try {
const result = await tool.execute(
`mcp-${Date.now()}`,
args ?? {},
signal,
undefined, // onUpdate not yet wired — progress notifications require a progressToken round-trip
);
// Convert AgentToolResult content blocks to MCP content format.
// text and image pass through; any other shape is serialized as text
// so the client sees the payload rather than an empty response.
const content = result.content.map((block: any) => {
if (block.type === "text")
return { type: "text" as const, text: block.text ?? "" };
if (block.type === "image") {
return {
type: "image" as const,
data: block.data ?? "",
mimeType: block.mimeType ?? "image/png",
};
}
// Preserve unknown block types (resource, resource_link, audio, ...)
// by stringifying into a text block so clients see the payload.
return { type: "text" as const, text: JSON.stringify(block) };
});
return { content };
} catch (err: unknown) {
// AbortError from a cancelled tool surfaces as a normal error — MCP
// clients interpret `isError: true` as a failed call, which is the
// correct behaviour for a cancelled request.
const message = err instanceof Error ? err.message : String(err);
return {
isError: true,
content: [{ type: "text" as const, text: message }],
};
}
},
);
// Connect to stdin/stdout transport
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write(`[forge] MCP server started (v${version})\n`);
}