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>
150 lines
5.3 KiB
TypeScript
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`);
|
|
}
|