Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.87.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"commander": "^14.0.3",
"gray-matter": "^4.0.3",
"ink": "^6.8.0",
Expand Down
6 changes: 5 additions & 1 deletion src/coding/agents/lead-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "path";
import { Agent } from "@/agent";
import { createSkillsMiddleware } from "@/agent/skills/skills-middleware";
import { createTodoSystem } from "@/agent/todos/todos";
import type { Model, NonSystemMessage, ToolUseContent } from "@/foundation";
import type { Model, NonSystemMessage, Tool, ToolUseContent } from "@/foundation";

import {
type ApprovalDecision,
Expand Down Expand Up @@ -35,6 +35,7 @@ export async function createCodingAgent({
askUser,
askUserQuestion,
approvalPersistence,
extraTools,
}: {
model: Model;
cwd?: string;
Expand All @@ -44,6 +45,8 @@ export async function createCodingAgent({
// eslint-disable-next-line no-unused-vars
askUserQuestion?: (params: AskUserQuestionParameters) => Promise<AskUserQuestionResult>;
approvalPersistence?: ApprovalPersistence;
/** Extra tools appended after built-ins (e.g. MCP via `helixent/community/mcp`). Built-in names win if duplicated. */
extraTools?: Tool[];
}) {
const agentsFile = Bun.file(`${cwd}/AGENTS.md`);
const messages: NonSystemMessage[] = [];
Expand Down Expand Up @@ -114,6 +117,7 @@ Use the given tools and skills to perform parallel/sequential operations and sol
applyPatchTool,
todoTool,
...(askUserQuestionTool ? [askUserQuestionTool] : []),
...(extraTools ?? []),
],
middlewares,
});
Expand Down
29 changes: 29 additions & 0 deletions src/community/mcp/__tests__/format-mcp-tool-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";

import { formatMcpCallToolResult } from "../format-mcp-tool-result";

describe("formatMcpCallToolResult", () => {
test("joins text blocks", () => {
const out = formatMcpCallToolResult({
content: [
{ type: "text", text: "hello" },
{ type: "text", text: "world" },
],
} as never);
expect(out).toBe("hello\n\nworld");
});

test("includes structuredContent when present", () => {
const out = formatMcpCallToolResult({
content: [{ type: "text", text: "ok" }],
structuredContent: { a: 1 },
} as never);
expect(out).toContain("ok");
expect(out).toContain('"a": 1');
});

test("formats toolResult branch", () => {
expect(formatMcpCallToolResult({ toolResult: "plain" } as never)).toBe("plain");
expect(formatMcpCallToolResult({ toolResult: { x: 1 } } as never)).toBe(JSON.stringify({ x: 1 }, null, 2));
});
});
20 changes: 20 additions & 0 deletions src/community/mcp/__tests__/mcp-input-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";

import { mcpInputSchemaToZod } from "../mcp-input-schema";

describe("mcpInputSchemaToZod", () => {
test("converts a simple object schema", () => {
const zod = mcpInputSchemaToZod({
type: "object",
properties: { x: { type: "string" } },
required: ["x"],
});
expect(zod.safeParse({ x: "a" }).success).toBe(true);
expect(zod.safeParse({}).success).toBe(false);
});

test("falls back on invalid schema", () => {
const zod = mcpInputSchemaToZod({ notValid: true } as Record<string, unknown>);
expect(zod.safeParse({ any: "thing" }).success).toBe(true);
});
});
37 changes: 37 additions & 0 deletions src/community/mcp/format-mcp-tool-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";

export type McpCallToolResult = Awaited<ReturnType<Client["callTool"]>>;

/**
* Pretty-print an MCP `tools/call` result for LLM tool_result text.
*/
export function formatMcpCallToolResult(result: McpCallToolResult): string {
if ("toolResult" in result && result.toolResult !== undefined) {
const tr = result.toolResult;
return typeof tr === "string" ? tr : JSON.stringify(tr, null, 2);
}

const blocks = "content" in result && Array.isArray(result.content) ? result.content : [];
const lines: string[] = [];

for (const block of blocks) {
if (block.type === "text") {
lines.push(block.text);
} else {
lines.push(JSON.stringify(block));
}
}

let out = lines.join("\n\n");

if ("structuredContent" in result && result.structuredContent !== undefined) {
const extra = JSON.stringify(result.structuredContent, null, 2);
out = out ? `${out}\n\n${extra}` : extra;
}

if ("isError" in result && result.isError) {
out = out ? `Error:\n${out}` : "Error (no message)";
}

return out || "(empty MCP tool result)";
}
3 changes: 3 additions & 0 deletions src/community/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./format-mcp-tool-result";
export * from "./mcp-input-schema";
export * from "./stdio-mcp-tools";
15 changes: 15 additions & 0 deletions src/community/mcp/mcp-input-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ZodType } from "zod";
import z from "zod";

/**
* Convert an MCP tool `inputSchema` (JSON Schema) into a Zod schema for {@link defineTool}.
* Falls back to a permissive object schema when conversion fails.
*/
export function mcpInputSchemaToZod(inputSchema: Record<string, unknown>): ZodType<Record<string, unknown>> {
try {
const schema = z.fromJSONSchema(inputSchema);
return schema as ZodType<Record<string, unknown>>;
} catch {
return z.object({}).passthrough();
}
}
88 changes: 88 additions & 0 deletions src/community/mcp/stdio-mcp-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
type StdioServerParameters,
StdioClientTransport,
} from "@modelcontextprotocol/sdk/client/stdio.js";

import type { Tool } from "@/foundation";
import { defineTool } from "@/foundation";

import { formatMcpCallToolResult } from "./format-mcp-tool-result";
import { mcpInputSchemaToZod } from "./mcp-input-schema";

export type { StdioServerParameters };

export interface StdioMcpToolsSession {
/** Helixent {@link Tool} wrappers bound to this MCP server. */
tools: Tool[];
/** Underlying MCP client (initialized after {@link createStdioMcpTools} resolves). */
client: Client;
/** Close stdio transport and release the child process. */
dispose: () => Promise<void>;
}

async function listAllMcpTools(client: Client) {
const tools: Awaited<ReturnType<Client["listTools"]>>["tools"] = [];
let cursor: string | undefined;
do {
const page = await client.listTools(cursor ? { cursor } : {});
tools.push(...page.tools);
cursor = page.nextCursor;
} while (cursor);
return tools;
}

/**
* Connect to an MCP server over stdio, list its tools, and build Helixent {@link Tool}s that forward to `tools/call`.
*
* @param server - Spawn parameters (command, args, env, cwd, etc.).
* @param options.namePrefix - Optional prefix for tool names to avoid collisions with built-in tools.
*/
export async function createStdioMcpTools(
server: StdioServerParameters,
options?: { namePrefix?: string },
): Promise<StdioMcpToolsSession> {
const namePrefix = options?.namePrefix ?? "";
const transport = new StdioClientTransport(server);
const client = new Client({ name: "helixent", version: "1.0.0" });

await client.connect(transport);

const listed = await listAllMcpTools(client);

const tools: Tool[] = listed.map((mcpTool) => {
const helixentName = `${namePrefix}${mcpTool.name}`;
const parameters = mcpInputSchemaToZod(mcpTool.inputSchema as Record<string, unknown>);

return defineTool({
name: helixentName,
description: mcpTool.description?.trim()
? `[MCP] ${mcpTool.description}`
: `[MCP tool ${mcpTool.name}]`,
parameters,
invoke: async (input, signal) => {
const parsed = parameters.safeParse(input);
if (!parsed.success) {
return `Error: Invalid arguments for MCP tool ${mcpTool.name}: ${parsed.error.message}`;
}

const result = await client.callTool(
{
name: mcpTool.name,
arguments: parsed.data as Record<string, unknown>,
},
undefined,
signal ? { signal } : undefined,
);

return formatMcpCallToolResult(result);
},
});
});

const dispose = async () => {
await transport.close();
};

return { tools, client, dispose };
}