feat(agent): add external MCP client support
Reuse OpenCode 2 HTTP/stdio clients with per-session read-only grants, native approval and sanitized owner-scoped MCP status. Narrow the environment inherited by trusted local MCP processes. Validated in an isolated checkout with bun run check and 20 focused tests.
This commit is contained in:
@@ -23,6 +23,7 @@ import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
|
||||
import { createChatRunState } from "./chatRunState.js";
|
||||
import { registerChatSessionRoutes } from "./chatSessionRoutes.js";
|
||||
import { registerChatStreamRoute } from "./chatStreamRoute.js";
|
||||
import { registerMcpStatusRoutes } from "./mcpRoutes.js";
|
||||
|
||||
export type BuildChatRouterOptions = {
|
||||
frontendActionCoordinator: FrontendActionCoordinator;
|
||||
@@ -132,6 +133,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
|
||||
sessionTranscriptStore,
|
||||
sessionUiStateStore,
|
||||
});
|
||||
registerMcpStatusRoutes(chatRouter, { sessionMetadataStore, runtime });
|
||||
|
||||
registerChatAuxiliaryRoutes(chatRouter, {
|
||||
activeRuns,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isMcpTool } from "../runtime/mcp-config.js";
|
||||
|
||||
// Shell and file mutation remain interactive even when ordinary read-only
|
||||
// business tools are automatically approved. Static denials stay in OpenCode.
|
||||
export function canAutomaticallyApprove(mode: string, action: string) {
|
||||
return mode === "always" && !["shell", "bash", "edit", "write", "patch", "read", "external_directory", "execute", "subagent"].includes(action);
|
||||
return mode === "always" && !isMcpTool(action) && !["shell", "bash", "edit", "write", "patch", "read", "external_directory", "execute", "subagent"].includes(action);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type Express, type Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { getLocalAgentContext } from "../context/localContext.js";
|
||||
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
|
||||
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { type McpRuntimeReport, type McpSessionPolicy } from "../runtime/mcp-config.js";
|
||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
|
||||
export function registerMcpPolicyBridge(app: Express, options: {
|
||||
internalToken: string;
|
||||
runtime: { mcpReadonlyTools(): string[] };
|
||||
}) {
|
||||
app.post("/internal/mcp/session-policy", (req, res) => {
|
||||
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
|
||||
const parsed = z.object({ session_id: z.string().min(1).max(128) }).strict().safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ message: "invalid MCP session policy request" }); return; }
|
||||
const context = getRuntimeSessionContext(parsed.data.session_id);
|
||||
if (!context) { res.status(404).json({ message: "session context not found" }); return; }
|
||||
const policy: McpSessionPolicy = {
|
||||
mode: context.learningMode === "review" ? "disabled" : context.purpose === "task_execution" ? "readonly" : "interactive",
|
||||
readonly_tools: options.runtime.mcpReadonlyTools(),
|
||||
};
|
||||
res.json(policy);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerMcpStatusRoutes(router: Router, options: {
|
||||
sessionMetadataStore: SessionMetadataStore;
|
||||
runtime: { mcpStatus(sessionId: string): Promise<McpRuntimeReport> };
|
||||
}) {
|
||||
router.get("/session/:session_id/mcp", async (req, res) => {
|
||||
try {
|
||||
const record = await options.sessionMetadataStore.get(getLocalAgentContext(req), req.params.session_id);
|
||||
if (!record) { res.status(404).json({ message: "session not found" }); return; }
|
||||
const report = await options.runtime.mcpStatus(record.sessionId);
|
||||
if (report.configuration_errors.length) { res.status(503).json({ message: "MCP configuration invalid" }); return; }
|
||||
res.json({ servers: report.servers });
|
||||
} catch { res.status(503).json({ message: "MCP runtime unavailable" }); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const mcpProtocols = ["legacy", "auto", "2026-07-28"] as const;
|
||||
export const mcpTimeoutDefaults = { startup: 10_000, catalog: 10_000, execution: 60_000 };
|
||||
const timeout = z.object({
|
||||
startup: z.number().int().positive().optional(),
|
||||
catalog: z.number().int().positive().optional(),
|
||||
execution: z.number().int().positive().optional(),
|
||||
}).strict();
|
||||
const common = {
|
||||
disabled: z.boolean().optional(), codemode: z.boolean().optional(),
|
||||
protocol: z.enum(mcpProtocols).optional(), timeout: timeout.optional(),
|
||||
};
|
||||
const oauth = z.object({
|
||||
client_id: z.string().optional(), client_secret: z.string().optional(), scope: z.string().optional(),
|
||||
callback_port: z.number().int().min(1).max(65535).optional(), redirect_uri: z.string().optional(),
|
||||
auth_server_metadata_url: z.string().optional(),
|
||||
}).strict();
|
||||
const server = z.discriminatedUnion("type", [
|
||||
z.object({ ...common, type: z.literal("local"), command: z.array(z.string()).min(1), cwd: z.string().optional(), environment: z.record(z.string()).optional() }).strict(),
|
||||
z.object({ ...common, type: z.literal("remote"), url: z.string().min(1), headers: z.record(z.string()).optional(), oauth: z.union([oauth, z.literal(false)]).optional() }).strict(),
|
||||
]);
|
||||
export type McpServerConfig = z.infer<typeof server>;
|
||||
export const isMcpTool = (name: string) => name.startsWith("mcp_") ||
|
||||
name === "opencode_list_mcp_resources" || name === "opencode_read_mcp_resource";
|
||||
|
||||
export function normalizeMcpServer(name: string, input: unknown): McpServerConfig {
|
||||
if (!/^mcp_[A-Za-z0-9_-]+$/.test(name)) throw new Error("MCP server names must start with mcp_ and contain only letters, digits, _ or -");
|
||||
const parsed = server.safeParse(input);
|
||||
if (!parsed.success) throw new Error(`invalid MCP configuration for ${name}`);
|
||||
const value = parsed.data;
|
||||
if (value.type === "local" && !value.command[0]?.trim()) throw new Error(`invalid MCP command for ${name}`);
|
||||
if (value.type === "remote" && !/\{env:[A-Za-z_][A-Za-z0-9_]*\}/.test(value.url)) {
|
||||
try {
|
||||
const url = new URL(value.url);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error();
|
||||
} catch { throw new Error(`invalid MCP endpoint for ${name}`); }
|
||||
}
|
||||
return { ...value, protocol: value.protocol ?? (value.type === "remote" ? "auto" : "legacy") };
|
||||
}
|
||||
|
||||
export function normalizeMcpConfig(input: unknown) {
|
||||
const parsed = z.object({ timeout: timeout.optional(), servers: z.record(z.unknown()).default({}) }).strict().safeParse(input ?? {});
|
||||
if (!parsed.success) throw new Error("invalid MCP configuration");
|
||||
return {
|
||||
timeout: { ...mcpTimeoutDefaults, ...parsed.data.timeout },
|
||||
servers: Object.fromEntries(Object.entries(parsed.data.servers).map(([name, value]) => [name, normalizeMcpServer(name, value)])),
|
||||
};
|
||||
}
|
||||
|
||||
type Rule = { action: string; resource: string; effect: "allow" | "deny" | "ask" };
|
||||
const matches = (pattern: string, value: string) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*").replaceAll("?", ".")}$`).test(value);
|
||||
|
||||
// Only exact, explicit service rules are an administrator's read-only grant.
|
||||
// Built-in defaults, broad allows and saved user approvals are not grants.
|
||||
export function mcpReadonlyTools(input: Record<string, unknown>) {
|
||||
const agents = input.agents as { instruction?: { permissions?: Rule[] } } | undefined;
|
||||
const rules = [...((input.permissions ?? []) as Rule[]), ...(agents?.instruction?.permissions ?? [])];
|
||||
const candidates = rules.filter((rule) => rule.effect === "allow" && /^mcp_[A-Za-z0-9_.-]+$/.test(rule.action)).map((rule) => rule.action);
|
||||
return [...new Set(candidates)].filter((name) => {
|
||||
let effect: Rule["effect"] = "ask";
|
||||
for (const rule of rules) if (matches(rule.action, name) && matches(rule.resource, "*")) effect = rule.effect;
|
||||
return effect === "allow";
|
||||
}).sort();
|
||||
}
|
||||
|
||||
const baseEnvironment = new Set(["PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"]);
|
||||
const reservedEnvironment = /^(?:AGENT_INTERNAL_TOKEN|TJWATER_AGENT_.*|OPENCODE_.*|ORCA_.*|NODE_OPTIONS|BUN_OPTIONS)$/i;
|
||||
export function mcpLocalEnvironment(parent: NodeJS.ProcessEnv, explicit: Record<string, string> = {}) {
|
||||
// Native stdio inherits its host environment. Override non-runtime entries
|
||||
// with empty values before adding credentials intentionally owned by this MCP.
|
||||
const environment = Object.fromEntries(Object.entries(parent).map(([key, value]) => [key, baseEnvironment.has(key.toUpperCase()) ? value ?? "" : ""]));
|
||||
Object.assign(environment, explicit);
|
||||
for (const key of Object.keys(environment)) if (reservedEnvironment.test(key)) environment[key] = "";
|
||||
return environment;
|
||||
}
|
||||
|
||||
export const mcpSessionPolicySchema = z.object({
|
||||
mode: z.enum(["interactive", "readonly", "disabled"]),
|
||||
readonly_tools: z.array(z.string()),
|
||||
}).strict();
|
||||
export type McpSessionPolicy = z.infer<typeof mcpSessionPolicySchema>;
|
||||
export const mcpServerStatusSchema = z.object({
|
||||
name: z.string(), type: z.enum(["local", "remote"]),
|
||||
status: z.enum(["connected", "pending", "disabled", "failed", "needs_auth"]),
|
||||
configured_protocol: z.enum(mcpProtocols), tools: z.array(z.string()),
|
||||
}).strict();
|
||||
export const mcpRuntimeReportSchema = z.object({
|
||||
servers: z.array(mcpServerStatusSchema), configuration_errors: z.array(z.string()),
|
||||
}).strict();
|
||||
export type McpRuntimeReport = z.infer<typeof mcpRuntimeReportSchema>;
|
||||
@@ -7,6 +7,7 @@ import { resolve } from "node:path";
|
||||
import { realpath, rm } from "node:fs/promises";
|
||||
import { createConversationWorkspace, resolveConversationWorkspace } from "./conversationWorkspace.js";
|
||||
import { serviceOpencodeConfig } from "./opencode2-config.js";
|
||||
import { mcpReadonlyTools, mcpRuntimeReportSchema } from "./mcp-config.js";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
@@ -95,6 +96,25 @@ export class OpencodeRuntimeAdapter {
|
||||
return { healthy: true, version: info.version };
|
||||
}
|
||||
|
||||
mcpReadonlyTools(): string[] {
|
||||
return mcpReadonlyTools(buildOpencodeConfig());
|
||||
}
|
||||
|
||||
async mcpStatus(sessionId: string) {
|
||||
const client = await this.ensureClient();
|
||||
const session = await client.session.get({ sessionID: sessionId });
|
||||
const directory = resolve(session.location.directory) === process.cwd() ? process.cwd()
|
||||
: await resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
|
||||
return this.readMcpReport(client, directory);
|
||||
}
|
||||
|
||||
private async readMcpReport(client: OpencodeClient, directory: string) {
|
||||
const response = await client.rpc.call({
|
||||
rpcID: "tjwater.tools", method: "mcp", input: {}, location: { directory },
|
||||
}, { signal: AbortSignal.timeout(15_000) });
|
||||
return mcpRuntimeReportSchema.parse(response.output);
|
||||
}
|
||||
|
||||
async resolveAvailableModel(preferredModelIds: string[]): Promise<string> {
|
||||
const client = await this.ensureClient();
|
||||
// V2 2.0.24 model.list alone does not await a cold Location's plugin setup.
|
||||
@@ -126,6 +146,8 @@ export class OpencodeRuntimeAdapter {
|
||||
if (!tools?.some((tool) => tool.id === "shell" && tool.sandboxed)) throw new Error("OpenCode shell sandbox replacement is missing");
|
||||
const actualRoot = tools.find((tool) => tool.id === "shell")?.workspaceRoot;
|
||||
if (!actualRoot || await realpath(actualRoot) !== await realpath(config.CONVERSATION_WORKSPACE_ROOT)) throw new Error("OpenCode and Agent conversation workspace roots do not match");
|
||||
const mcp = await this.readMcpReport(client, session.location.directory);
|
||||
if (mcp.configuration_errors.length) throw new Error("MCP configuration invalid");
|
||||
const models = await client.model.list({ location: { directory: session.location.directory } });
|
||||
const model = models.data.find((model) => `${model.providerID}/${model.id}` === config.OPENCODE_MODEL);
|
||||
if (!model?.enabled || !model.capabilities.tools) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { normalizeMcpConfig } from "./mcp-config.js";
|
||||
|
||||
// A conversation Location can be outside the checkout. Load only the trusted
|
||||
// service assets explicitly, rather than relying on its current directory.
|
||||
@@ -11,6 +12,7 @@ export function serviceOpencodeConfig(input: Record<string, unknown>) {
|
||||
return {
|
||||
...input,
|
||||
snapshots: false,
|
||||
mcp: normalizeMcpConfig(input.mcp),
|
||||
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin")],
|
||||
skills: [resolve(process.env.OPENCODE_SKILLS_ROOT_DIR ?? ".opencode/skills")],
|
||||
agents: { ...agents, instruction: {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { registerSandboxToolBridge } from "./routes/sandboxToolBridge.js";
|
||||
import { registerExternalInformationBridge } from "./routes/externalInformationBridge.js";
|
||||
import { probeLandlockSandbox } from "./sandbox/landlockSandbox.js";
|
||||
import { registerLearningToolBridge } from "./routes/learningToolBridge.js";
|
||||
import { registerMcpPolicyBridge } from "./routes/mcpRoutes.js";
|
||||
import { ResultReferenceResolver } from "./results/resolver.js";
|
||||
import { ResultReferenceStore } from "./results/store.js";
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
@@ -59,6 +60,7 @@ app.use(express.json({ limit: "1mb" }));
|
||||
registerSandboxToolBridge(app, { internalToken, runtime: opencodeRuntime });
|
||||
registerExternalInformationBridge(app, { internalToken });
|
||||
registerLearningToolBridge(app, { internalToken, memoryStore, skillStore: new SkillStore() });
|
||||
registerMcpPolicyBridge(app, { internalToken, runtime: opencodeRuntime });
|
||||
const projectDataClient = new ProjectDataClient();
|
||||
app.post("/internal/tools/project-data", async (req, res) => {
|
||||
if (!isTrustedInternalBridgeRequest(req, internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
|
||||
|
||||
Reference in New Issue
Block a user