Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"build": "tsup"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@prismshadow/agenthub": "^0.4.1",
"@prismshadow/penguin-skills": "workspace:*",
"smol-toml": "^1.3.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ export class Agent {
// into command subprocesses (shared by createSession and resumeSession; the
// caller reads the current agent_state/.vault.toml); a child Agent loads **its
// own** vault via createAgent rather than inheriting the parent's.
const environment = new Environment({
const environment = await Environment.create({
workspaceDir,
toolConfig,
// The Session's generic scratchpad root; Environment derives its truncated-tool-output
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/environment/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
} from "../interfaces.js";
import type { BuiltinTool, ToolResult } from "./tools/types.js";
import { BUILTIN_TOOL_FACTORIES } from "./tools/registry.js";
import { McpToolAdapter } from "./mcp/client.js";
import { CommandSessionManager } from "./tools/command/index.js";
import { SubagentSessionManager } from "./tools/subagent/index.js";
import {
Expand Down Expand Up @@ -93,6 +94,8 @@ export class Environment implements EnvironmentInterface {
private readonly truncatedToolOutputArchive: TruncatedToolOutputArchive | null;
/** Assembled built-in tools: tool name -> BuiltinTool. Only tools supported by the registry and present in config. */
private readonly tools: Map<string, BuiltinTool>;
/** MCP adapter (connects declared mcpServers, exposes their tools as BuiltinTool). Null when none configured. */
private mcpAdapter: McpToolAdapter | null = null;
/** Long-running command session registry: constructed within this Environment and shared between exec_command / input_command. */
private readonly commandSessions: CommandSessionManager;
/** Background subagent session registry: constructed within this Environment and shared between run_subagent / input_subagent. */
Expand Down Expand Up @@ -128,10 +131,35 @@ export class Environment implements EnvironmentInterface {
}
}

/**
* Async factory: connects any declared MCP servers before the Environment is usable. The
* constructor stays synchronous (callers that don't configure mcpServers can keep using it),
* but when mcpServers is non-empty you MUST use create() so the adapter can connect.
*/
static async create(config: EnvironmentConfig): Promise<Environment> {
const env = new Environment(config);
const servers = config.toolConfig.mcpServers ?? [];
if (servers.length > 0) {
const adapter = new McpToolAdapter(servers);
await adapter.init();
env.mcpAdapter = adapter;
const services = {
...config.services,
commandSessions: env.commandSessions,
subagentSessions: env.subagentSessions,
};
for (const def of adapter.listToolDefinitions()) {
env.tools.set(def.name, adapter.toBuiltinTool(def));
}
}
return env;
}

/** Releases runtime resources held by Environment: finalizes all managed background sessions (command and subagent). Idempotent. */
dispose(): void {
this.commandSessions.dispose();
this.subagentSessions.dispose();
this.mcpAdapter?.dispose();
}

/**
Expand All @@ -147,13 +175,16 @@ export class Environment implements EnvironmentInterface {
* is left to a later adapter layer.
*/
async listTools(): Promise<ToolDefinition[]> {
return this.toolConfig.customTools
const builtins = this.toolConfig.customTools
.filter((tool) => this.tools.has(tool.name))
.map((tool) => ({
name: tool.name,
description: tool.description,
...(tool.parameters !== undefined ? { parameters: tool.parameters } : {}),
}));
// Merge in enumerated MCP tool definitions (the "later adapter layer").
const mcp = this.mcpAdapter ? this.mcpAdapter.listToolDefinitions() : [];
return [...builtins, ...mcp];
}

/** Looks up a tool's permission level (for the frontend's permission-mode decisions); returns undefined for an unknown tool. */
Expand Down
188 changes: 188 additions & 0 deletions packages/core/src/environment/mcp/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* MCP adapter — connects declared MCP servers and exposes their tools as PenguinHarness
* BuiltinTool instances. This is the "later adapter layer" referenced in environment.ts and the
* docs (tools.en.md / configuration.en.md: "enumerating concrete MCP tools is reserved for a
* later adapter layer").
*
* Design: keep MCP entirely behind this module. Environment only knows about BuiltinTool, so we
* wrap each enumerated MCP tool in a BuiltinTool whose execute() delegates to the connected
* client. Tool names are namespaced as `mcp__<serverName>__<toolName>` to avoid collisions.
*
* Depends on @modelcontextprotocol/sdk — add it to packages/core/package.json:
* "dependencies": { "@modelcontextprotocol/sdk": "^1.x" }
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { partialToolCallOutput } from "../../omnimessage/index.js";
import type { OmniMessage } from "../../omnimessage/index.js";
import type { MCPServerConfig, ToolDefinition, ToolDefinitionConfig } from "../../interfaces.js";
import type { BuiltinTool, ToolExecutionContext, ToolResult } from "../tools/types.js";

const MCP_NAME_PREFIX = "mcp__";

interface McpToolEntry {
serverName: string;
originalName: string;
client: Client;
definition: ToolDefinitionConfig;
}

/**
* Parse the free-form MCPServerConfig.config into a concrete transport.
* Expected shapes:
* stdio: { command: string, args?: string[], env?: Record<string,string>, cwd?: string }
* sse: { url: string, headers?: Record<string,string> }
*/
function buildTransport(serverName: string, cfg: Record<string, unknown>): StdioClientTransport | SSEClientTransport {
if (typeof cfg.command === "string") {
return new StdioClientTransport({
command: cfg.command,
args: Array.isArray(cfg.args) ? (cfg.args as string[]) : undefined,
env: (cfg.env as Record<string, string> | undefined) ?? undefined,
cwd: typeof cfg.cwd === "string" ? cfg.cwd : undefined,
// Carry the server name on the transport so the connected Client can self-identify
// (used by the mock Client in tests, ignored by the real SDK's StdioClientTransport).
...{ config: { serverName } } as Partial<StdioServerParameters>,
});
}
if (typeof cfg.url === "string") {
const headers = (cfg.headers as Record<string, string> | undefined) ?? undefined;
return new SSEClientTransport(
new URL(cfg.url),
headers ? { requestInit: { headers } } : {},
);
}
throw new Error(
`MCP server config must specify either "command" (stdio) or "url" (sse); got: ${JSON.stringify(cfg)}`,
);
}

export class McpToolAdapter {
private readonly servers: MCPServerConfig[];
private readonly clients = new Map<string, Client>();
private readonly entries: McpToolEntry[] = [];

constructor(servers: MCPServerConfig[]) {
this.servers = servers;
}

/** Connect every declared server and enumerate its tools. Per-server failures are isolated. */
async init(): Promise<void> {
for (const server of this.servers) {
try {
const client = new Client({ name: "penguin-harness", version: "0.2.1" });
await client.connect(buildTransport(server.name, server.config));
this.clients.set(server.name, client);
const { tools } = await client.listTools();
for (const tool of tools) {
const namespaced = `${MCP_NAME_PREFIX}${server.name}__${tool.name}`;
const definition: ToolDefinitionConfig = {
name: namespaced,
description: tool.description ?? `MCP tool ${tool.name} from ${server.name}`,
// MCP inputSchema IS JSON Schema; ToolDefinitionConfig.parameters is an open Record,
// so it drops straight in with no conversion layer.
parameters: tool.inputSchema as Record<string, unknown>,
permission: "rw",
};
this.entries.push({
serverName: server.name,
originalName: tool.name,
client,
definition,
});
}
} catch (err) {
process.stderr.write(
`[penguin] MCP server "${server.name}" failed to connect: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
}
}
}

/** Tool definitions (namespaced) for Environment.listTools(). */
listToolDefinitions(): ToolDefinition[] {
return this.entries.map((e) => ({
name: e.definition.name,
description: e.definition.description,
...(e.definition.parameters !== undefined ? { parameters: e.definition.parameters } : {}),
}));
}

/** Wrap a namespaced MCP tool as a BuiltinTool delegating to the connected client. */
toBuiltinTool(def: ToolDefinitionConfig): BuiltinTool {
const entry = this.entries.find((e) => e.definition.name === def.name);
if (!entry) throw new Error(`MCP tool not found: ${def.name}`);
return {
name: def.name,
definition: def,
execute: (args, ctx) => this.execute(entry, args, ctx),
};
}

private async *execute(
entry: McpToolEntry,
args: Record<string, unknown>,
ctx: ToolExecutionContext,
): AsyncGenerator<OmniMessage, ToolResult | void> {
try {
// Forward the caller's abort signal into the MCP call so user-interrupt / timeout
// cancels an in-flight request to the server (not just the surrounding stream).
const options: { signal?: AbortSignal } = {};
if (ctx.signal) options.signal = ctx.signal;
const result = await entry.client.callTool(
{
name: entry.originalName,
arguments: args,
},
undefined,
options,
);
const images: string[] = [];
let text = "";
const blocks = (result.content ?? []) as Array<{
type: string;
text?: string;
data?: string;
mimeType?: string;
}>;
for (const block of blocks) {
if (block.type === "text" && block.text) {
text += block.text;
yield partialToolCallOutput({
eventType: "delta",
output: block.text,
toolCallId: ctx.toolCallId,
});
} else if (block.type === "image" && block.data) {
const mime = block.mimeType ?? "image/png";
images.push(`data:${mime};base64,${block.data}`);
}
}
if (result.isError) {
return { stopReason: "failed", note: text ? undefined : "[MCP tool returned error]" };
}
return images.length > 0 ? { images } : undefined;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
yield partialToolCallOutput({
eventType: "delta",
output: `[MCP tool error] ${message}`,
toolCallId: ctx.toolCallId,
});
return { stopReason: "failed" };
}
}

/** Close all connected clients. Idempotent. */
dispose(): void {
for (const client of this.clients.values()) {
client.close().catch(() => {});
}
this.clients.clear();
this.entries.length = 0;
}
}
40 changes: 40 additions & 0 deletions packages/core/test/fixtures/echo-mcp-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Minimal real MCP server (stdio) for the penguin-core MCP adapter e2e test.
// Exposes one tool `echo` that returns its input text. Uses the real @modelcontextprotocol/sdk.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
{ name: "echo-mcp-server", version: "0.0.1" },
{ capabilities: { tools: {} } },
);

server.setRequestHandler(
ListToolsRequestSchema,
async () => ({
tools: [
{
name: "echo",
description: "Echo back the provided text.",
inputSchema: {
type: "object",
properties: { text: { type: "string", description: "Text to echo" } },
required: ["text"],
},
},
],
}),
);

server.setRequestHandler(
CallToolRequestSchema,
async (request) => {
const text = (request.params?.arguments?.text ?? "").toString();
return {
content: [{ type: "text", text: `echo: ${text}` }],
};
},
);

const transport = new StdioServerTransport();
await server.connect(transport);
Loading
Loading