diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 6827e582a..455e80445 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -5,6 +5,16 @@ frontmatter. They describe roles such as reviewer, explorer, or implementer. The internal on-demand `devspace-agentd` process owns provider invocation. The CLI and MCP server use it as clients when they need agent execution. +When subagents are enabled, DevSpace also exposes provider-neutral MCP tools: +`agent_targets`, `agent_spawn`, `agent_send`, `agent_status`, `agent_wait`, +`agent_cancel`, and `agent_list`. Codex, Claude, OpenCode, Pi, and ACP-backed local +agents receive a project-scoped copy of this control plane so they can perform +bounded recursive or cross-provider delegation. Provider-native session ids +remain internal. Child authority is monotonic: `read_only < allowed < +full_access`, and a child cannot request a mode above its caller. Injected child +control planes use a daemon-signed capability, so changing environment variables +cannot widen workspace scope or write authority. + When subagents are enabled, the internal `devspace-agentd` process owns the durable agent manager and live provider runtimes. `devspace agents run` is a thin local client that starts or reuses the daemon automatically; `devspace diff --git a/package.json b/package.json index 666eb193b..aef664080 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", + "typebox": "1.1.38", "yaml": "^2.9.0", "zod": "^4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db036b93e..c10275473 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: semver: specifier: ^7.8.4 version: 7.8.4 + typebox: + specifier: 1.1.38 + version: 1.1.38 yaml: specifier: ^2.9.0 version: 2.9.0 diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index e486bd60b..e9943a5aa 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -5,6 +5,12 @@ description: Delegate focused coding, research, review, or verification work to # DevSpace subagents +When the host exposes `agent_targets`, `agent_spawn`, `agent_send`, `agent_status`, +`agent_wait`, `agent_cancel`, and `agent_list` directly, prefer those tools. +They use the same durable agent manager as the CLI below, avoid shell parsing, +and keep provider-native session ids internal. Use the CLI as the fallback +surface when those tools are not available. + Subagents are optional. Use the normal workspace tools for routine work; delegate only when a separate worker materially helps through independent context, specialization, or follow-up. Run the DevSpace CLI through the shell or process tool from the project the subagent should use. Agent commands print compact XML fragments by default. Read that output directly. Do not add `--json`. @@ -68,6 +74,10 @@ devspace agents stop Stopping is scoped to the current project and preserves the durable agent record as `stopped`. +Child agents that receive the scoped DevSpace agent MCP can delegate again. +Their child `write_mode` can be equal to or less permissive than their own +authority, never more permissive. + ## Good uses - Review a change for correctness, security, or missing tests. diff --git a/src/cli.ts b/src/cli.ts index 47c398264..d3b4057a6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,9 @@ import { parseLocalAgentRunArgs, } from "./local-agent-targets.js"; import { createLocalAgentClient } from "./local-agent-client.js"; +import { runLocalAgentMcpStdio } from "./local-agent-mcp-server.js"; +import { verifyLocalAgentMcpCapability } from "./local-agent-mcp-capability.js"; +import { ensureLocalAgentDaemonSecret, localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js"; import { toAgentErrorPayload, type LocalAgentError } from "./local-agent-errors.js"; import { formatAgentCommandError, @@ -551,6 +554,9 @@ async function runAgentsCommand(args: string[]): Promise { case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; + case "mcp": + await runAgentsMcp(commandArgs); + return; case "daemon": await runAgentsDaemon(commandArgs, json); return; @@ -565,6 +571,26 @@ async function runAgentsCommand(args: string[]): Promise { } } +async function runAgentsMcp(args: string[]): Promise { + if (args.length > 0) throw new Error("Usage: devspace agents mcp"); + const config = loadConfig(); + const token = requiredAgentMcpEnv("DEVSPACE_AGENT_MCP_CAPABILITY"); + const secret = ensureLocalAgentDaemonSecret(localAgentDaemonPaths(config.stateDir)); + const capability = verifyLocalAgentMcpCapability(secret, token); + await runLocalAgentMcpStdio(config, { + parentAgentId: capability.parentAgentId, + workspaceId: capability.workspaceId, + workspaceRoot: capability.workspaceRoot, + maxWriteMode: capability.maxWriteMode, + }); +} + +function requiredAgentMcpEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing ${name} for DevSpace agent MCP server.`); + return value; +} + async function runAgentsTargets(args: string[], json: boolean): Promise { if (args.length > 0) throw new Error("Usage: devspace agents targets [--json]"); const config = loadConfig(); diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index e84423e68..7f9507690 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -64,6 +64,9 @@ const runtime = new AcpRuntime({ }, connection); const firstResult = await runtime.run({ + agentId: "agt_cursor", + workspaceId: "ws_cursor", + mcpCapability: "cap_cursor", prompt: "first", workspaceRoot: "/tmp/project", model: "model-a", @@ -99,6 +102,13 @@ assert.equal( Object.hasOwn(requests.find(({ method }) => method === "session/new")?.params as object, "additionalDirectories"), false, ); +const newSessionParams = requests.find(({ method }) => method === "session/new")?.params as { + mcpServers?: Array<{ name: string; env: Array<{ name: string; value: string }> }>; +}; +assert.equal(newSessionParams.mcpServers?.[0]?.name, "devspace-agents-agt_cursor"); +assert.ok(newSessionParams.mcpServers?.[0]?.env.some((entry) => ( + entry.name === "DEVSPACE_AGENT_MCP_CAPABILITY" && entry.value === "cap_cursor" +))); await runtime.releaseSession("cursor_session_1"); assert.equal(queues.has("cursor_session_1"), false); diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 4f482e9a2..ab3ec334f 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -9,6 +9,7 @@ import { isProgrammerDefect, } from "./local-agent-errors.js"; import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { localAgentMcpLaunch } from "./local-agent-mcp-launch.js"; import { terminateProcessTree } from "./process-platform.js"; import { DEVSPACE_VERSION } from "./version.js"; import { @@ -249,6 +250,13 @@ export class AcpRuntime implements LocalAgentRuntime { } private async openSession(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks): Promise { + const mcp = localAgentMcpLaunch(input); + const mcpServers = mcp ? [{ + name: mcp.name, + command: mcp.command, + args: mcp.args, + env: Object.entries(mcp.env).map(([name, value]) => ({ name, value })), + }] : []; if (input.providerSessionId) { if (this.liveSessions.has(input.providerSessionId)) { this.sessionWriteModes.set(input.providerSessionId, input.writeMode ?? "allowed"); @@ -273,7 +281,7 @@ export class AcpRuntime implements LocalAgentRuntime { const response = await this.connection.agent.request("session/resume", { sessionId: input.providerSessionId, cwd: input.workspaceRoot, - mcpServers: [], + mcpServers, ...this.additionalDirectoryParams(), }); this.cacheSessionMetadata(input.providerSessionId, response); @@ -287,7 +295,7 @@ export class AcpRuntime implements LocalAgentRuntime { const response = await this.connection.agent.request("session/new", { cwd: input.workspaceRoot, - mcpServers: [], + mcpServers, ...this.additionalDirectoryParams(), }); const sessionId = readString(response, "sessionId"); diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index 2a470b8c4..de872c115 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -15,6 +15,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { model?: string; permissionModes: string[] = []; flagSettings: Array> = []; + mcpServerUpdates: Array> = []; constructor(prompt: AsyncIterable) { this.iterator = prompt[Symbol.asyncIterator](); @@ -43,6 +44,10 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { async interrupt(): Promise {} + async setMcpServers(servers: Record): Promise { + this.mcpServerUpdates.push(servers); + } + async setPermissionMode(mode: string): Promise { this.permissionModes.push(mode); } @@ -58,6 +63,8 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { const context: LocalAgentRuntimeContext = { agentId: "agt_claude", + workspaceId: "ws_claude", + mcpCapability: "cap_claude", providerInstanceId: "claude", provider: "claude", workspaceRoot: "/tmp/project", @@ -73,7 +80,7 @@ const driver = new ClaudeLocalAgentDriver(({ prompt, options }) => { lastOptions = options; query = new FakeClaudeQuery(prompt); return query; -}, { PATH: "/usr/bin" }); +}, { PATH: "/usr/bin", DEVSPACE_CONFIG_DIR: "/tmp/devspace-config" }); assert.deepEqual(driver.runtimePolicy, { scope: "agent", authority: "full_access_boundary", @@ -100,6 +107,7 @@ const first = firstResult.value; const secondResult = await runtime.run({ prompt: "second", workspaceRoot: "/tmp/project", + mcpCapability: "cap_claude_allowed", effort: "low", writeMode: "allowed", }); @@ -120,7 +128,18 @@ assert.equal(query?.model, "sonnet"); assert.equal(lastOptions?.resume, undefined); assert.equal(lastOptions?.permissionMode, "dontAsk"); assert.equal(lastOptions?.allowDangerouslySkipPermissions, undefined); -assert.deepEqual(lastOptions?.allowedTools, ["Read(/**)", "Edit(/**)", "Bash"]); +assert.deepEqual(lastOptions?.allowedTools, [ + "Read(/**)", + "Edit(/**)", + "Bash", + "mcp__devspace-agents-agt_claude__*", +]); +const mcpServers = lastOptions?.mcpServers as Record>; +assert.equal(mcpServers["devspace-agents-agt_claude"]?.type, "stdio"); +assert.deepEqual(mcpServers["devspace-agents-agt_claude"]?.env, { + DEVSPACE_CONFIG_DIR: "/tmp/devspace-config", + DEVSPACE_AGENT_MCP_CAPABILITY: "cap_claude", +}); assert.equal(lastOptions?.pathToClaudeCodeExecutable, undefined); const initialSandbox = lastOptions?.sandbox as Record; assert.equal(initialSandbox.enabled, true); @@ -160,6 +179,16 @@ assert.equal( "dontAsk", ); assert.equal(query?.flagSettings[1]?.effortLevel, "low"); +assert.equal( + ((query?.mcpServerUpdates[0]?.["devspace-agents-agt_claude"] as Record)?.env as Record) + ?.DEVSPACE_AGENT_MCP_CAPABILITY, + "cap_claude_allowed", +); +assert.equal( + ((query?.mcpServerUpdates[1]?.["devspace-agents-agt_claude"] as Record)?.env as Record) + ?.DEVSPACE_AGENT_MCP_CAPABILITY, + "cap_claude", +); assert.equal( ((query?.flagSettings[1]?.permissions as Record).deny as string[]).includes("Edit"), false, @@ -210,6 +239,7 @@ const brokenStreamQuery: ClaudeQueryLike = { }, close() {}, async interrupt() {}, + async setMcpServers() {}, async setPermissionMode() {}, async applyFlagSettings() {}, }; diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index d7e46ad90..9b9be2dc5 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -6,6 +6,7 @@ import { isProgrammerDefect, } from "./local-agent-errors.js"; import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { localAgentMcpLaunch, localAgentMcpLaunchFromContext } from "./local-agent-mcp-launch.js"; import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import type { LocalAgentDriver, @@ -30,6 +31,7 @@ const CLAUDE_WORKSPACE_ALLOWED_TOOLS = [ export interface ClaudeQueryLike extends AsyncIterable { close(): void; interrupt(): Promise; + setMcpServers(servers: Record): Promise; setPermissionMode(mode: ClaudePermissionMode): Promise; applyFlagSettings(settings: Record): Promise; setModel?(model?: string): Promise; @@ -84,13 +86,16 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { private alive = true; private closed = false; private providerSessionId?: string; + private currentMcpCapability?: string; constructor( private readonly query: ClaudeQueryLike, private readonly inputQueue: AsyncInputQueue, - context: LocalAgentRuntimeContext, + private readonly context: LocalAgentRuntimeContext, + private readonly env: NodeJS.ProcessEnv, ) { this.providerSessionId = context.providerSessionId; + this.currentMcpCapability = context.mcpCapability; this.iterator = query[Symbol.asyncIterator](); } @@ -111,6 +116,23 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { const removeAbort = bindLocalAgentAbort(input.signal, () => this.query.interrupt()); try { if (this.providerSessionId) await callbacks?.onSessionId?.(this.providerSessionId); + const mcp = localAgentMcpLaunch({ + ...input, + agentId: input.agentId ?? this.context.agentId, + workspaceId: input.workspaceId ?? this.context.workspaceId, + mcpCapability: input.mcpCapability ?? this.context.mcpCapability, + }, this.env); + if (mcp && mcp.env.DEVSPACE_AGENT_MCP_CAPABILITY !== this.currentMcpCapability) { + await this.query.setMcpServers({ + [mcp.name]: { + type: "stdio", + command: mcp.command, + args: mcp.args, + env: mcp.env, + }, + }); + this.currentMcpCapability = mcp.env.DEVSPACE_AGENT_MCP_CAPABILITY; + } const flagSettings = claudeAuthoritySettings(input.workspaceRoot, input.writeMode); if (input.effort) { Object.assign(flagSettings, { @@ -261,7 +283,7 @@ export class ClaudeLocalAgentDriver implements LocalAgentDriver { options: claudeQueryOptions(context, input, this.env), prompt: inputQueue, }); - return new ClaudeQueryRuntime(query, inputQueue, context); + return new ClaudeQueryRuntime(query, inputQueue, context, this.env); }, }); } @@ -284,6 +306,7 @@ export function claudeQueryOptions( env: NodeJS.ProcessEnv = process.env, ): Record { const executable = env.CLAUDE_COMMAND; + const mcp = localAgentMcpLaunchFromContext(context, env); const permissionMode = claudePermissionMode(input.writeMode); const authority = claudeAuthorityOptions(input.workspaceRoot, input.writeMode); return { @@ -294,9 +317,31 @@ export function claudeQueryOptions( permissionMode, // Restricted runtimes stay warm across read_only/allowed turns. Keep the // workspace capabilities static and narrow individual turns with deny rules. - ...(input.writeMode === "full_access" + ...(input.writeMode === "full_access" && !mcp ? {} - : { allowedTools: [...CLAUDE_WORKSPACE_ALLOWED_TOOLS] }), + : { + allowedTools: [ + ...(input.writeMode === "full_access" ? [] : CLAUDE_WORKSPACE_ALLOWED_TOOLS), + ...(mcp ? [`mcp__${mcp.name}__*`] : []), + ], + }), + ...(mcp + ? { + mcpServers: { + [mcp.name]: { + type: "stdio", + command: mcp.command, + args: mcp.args, + env: mcp.env, + }, + }, + systemPrompt: { + type: "preset", + preset: "claude_code", + append: "Use the DevSpace agent MCP tools for bounded delegation when separate context or specialization materially helps. Discover targets before spawning; prefer waiting over polling; never request child authority above your current mode.", + }, + } + : {}), sandbox: authority.sandbox, settings: authority.settings, ...(input.writeMode === "full_access" ? { allowDangerouslySkipPermissions: true } : {}), diff --git a/src/local-agent-codex.test.ts b/src/local-agent-codex.test.ts index 74383e882..2610c1980 100644 --- a/src/local-agent-codex.test.ts +++ b/src/local-agent-codex.test.ts @@ -50,6 +50,7 @@ if (process.platform !== "win32") { await writeFile(command, `#!/usr/bin/env node import readline from "node:readline"; let turn = 0; +let lastThreadParams = {}; const output = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); readline.createInterface({ input: process.stdin }).on("line", (line) => { const message = JSON.parse(line); @@ -58,6 +59,7 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { return; } if (message.method === "thread/start" || message.method === "thread/resume") { + lastThreadParams = message.params; output({ id: message.id, result: { thread: { id: message.params.threadId || "thread_new" } } }); return; } @@ -86,6 +88,8 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { } const item = { type: "agentMessage", text: message.params.input[0].text === "policy" ? JSON.stringify(message.params.sandboxPolicy) + : message.params.input[0].text === "mcp" + ? JSON.stringify(lastThreadParams.config?.mcp_servers || null) : "fake response " + turn }; output({ method: "item/completed", params: { threadId: message.params.threadId, turnId, item } }); output({ method: "turn/completed", params: { threadId: message.params.threadId, turn: { id: turnId, status: "completed", items: [item] } } }); @@ -163,6 +167,24 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { assert.equal(policy.isOk(), true); if (policy.isErr()) throw policy.error; assert.deepEqual(JSON.parse(policy.value.finalResponse), { type: "workspaceWrite", networkAccess: true }); + const mcp = await runtime.run({ + agentId: "agt_codex", + workspaceId: "ws_codex", + mcpCapability: "cap_codex", + prompt: "mcp", + workspaceRoot: "/tmp/project", + writeMode: "read_only", + providerSessionId: first.providerSessionId ?? undefined, + }); + assert.equal(mcp.isOk(), true); + if (mcp.isErr()) throw mcp.error; + const mcpServers = JSON.parse(mcp.value.finalResponse) as Record; + }>; + const injected = mcpServers["devspace-agents-agt_codex"]; + assert.deepEqual(injected?.args?.slice(-2), ["agents", "mcp"]); + assert.equal(injected?.env?.DEVSPACE_AGENT_MCP_CAPABILITY, "cap_codex"); await runtime.releaseSession("thread_new"); } finally { await runtime.close(); diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 134385bca..5c6ef55f7 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -9,6 +9,7 @@ import { captureAgentProviderResult, } from "./local-agent-errors.js"; import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { localAgentMcpLaunch } from "./local-agent-mcp-launch.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { terminateProcessTree } from "./process-platform.js"; import { DEVSPACE_VERSION } from "./version.js"; @@ -133,7 +134,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { } const threadResponse = await this.rpc.request( input.providerSessionId ? "thread/resume" : "thread/start", - threadParams(input), + threadParams(input, this.options.env), ); const threadId = readString(asRecord(threadResponse)?.thread, "id"); if (!threadId) { @@ -469,13 +470,27 @@ class CodexAppServerRpc { } } -function threadParams(input: LocalAgentRunInput): Record { +function threadParams(input: LocalAgentRunInput, env: NodeJS.ProcessEnv): Record { + const mcp = localAgentMcpLaunch(input, env); return { ...(input.providerSessionId ? { threadId: input.providerSessionId } : {}), cwd: input.workspaceRoot, approvalPolicy: "never", sandbox: sandboxFor(input.writeMode), ...(input.model ? { model: input.model } : {}), + ...(mcp + ? { + config: { + mcp_servers: { + [mcp.name]: { + command: mcp.command, + args: mcp.args, + env: mcp.env, + }, + }, + }, + } + : {}), }; } diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index 51a9fc477..9e93b9ad2 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -4,6 +4,7 @@ import { createLocalAgentDrivers } from "./local-agent-adapters.js"; import { loadLocalAgentProfiles } from "./local-agent-profiles.js"; import { LocalAgentDaemon, writeLocalAgentDaemonLog } from "./local-agent-daemon.js"; import { + ensureLocalAgentDaemonSecret, LocalAgentDaemonAlreadyRunningError, localAgentDaemonPaths, } from "./local-agent-daemon-lifecycle.js"; @@ -21,6 +22,7 @@ const log = ( fields: Record, ) => writeLocalAgentDaemonLog(paths, level, event, fields); const store = new LocalAgentStore(paths.stateDir); +const mcpCapabilitySecret = ensureLocalAgentDaemonSecret(paths); const manager = new LocalAgentManager({ store, drivers: createLocalAgentDrivers({ subagents: config.subagents }), @@ -30,6 +32,7 @@ const manager = new LocalAgentManager({ allowedRoots: config.allowedRoots, logger: log, subagents: config.subagents, + mcpCapabilitySecret, }); const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 5d2ea5dd7..cbc809d0c 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { LocalAgentManager } from "./local-agent-manager.js"; +import { verifyLocalAgentMcpCapability } from "./local-agent-mcp-capability.js"; import { AgentProviderCancelledError, AgentProviderExecutionError, @@ -160,6 +161,7 @@ const manager = new LocalAgentManager({ loadProfiles: async () => [profile, disabledProfile], allowedRoots: [root], subagents, + mcpCapabilitySecret: "test-mcp-secret", }); const defectStore = new LocalAgentStore(join(root, "defect-state")); @@ -213,6 +215,15 @@ const stopped = unwrap(await manager.stop(cancellable.id, scope)); assert.equal(stopped.status, "stopped"); assert.equal(store.getLatestTurn(cancellable.id)?.status, "stopped"); assert.equal(store.getLatestTurn(cancellable.id)?.errorCode, "PROVIDER_CANCELLED"); +const capability = runtimes.get(cancellable.id)?.inputs[0]?.mcpCapability; +assert.ok(capability); +assert.deepEqual(verifyLocalAgentMcpCapability("test-mcp-secret", capability!), { + version: 1, + parentAgentId: cancellable.id, + workspaceId: scope.workspaceId, + workspaceRoot: root, + maxWriteMode: "allowed", +}); const namedInstance = unwrap(await manager.start({ target: "codex-work", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index b6dd2fdd8..d4582a4c0 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -30,6 +30,7 @@ import { type LocalAgentRuntimeContext, type LocalAgentWriteMode, } from "./local-agent-runtime.js"; +import { createLocalAgentMcpCapability } from "./local-agent-mcp-capability.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { assertAllowedPath } from "./roots.js"; import { @@ -67,6 +68,7 @@ export interface LocalAgentManagerOptions { allowedRoots?: readonly string[]; logger?: LocalAgentManagerLogger; subagents: SubagentsConfig; + mcpCapabilitySecret?: string; } export type AgentStartError = AgentTargetError | AgentScopeError | AgentConflictError | AgentStoreError; @@ -102,6 +104,7 @@ export class LocalAgentManager { private readonly allowedRoots?: readonly string[]; private readonly logger?: LocalAgentManagerLogger; private readonly subagents: SubagentsConfig; + private readonly mcpCapabilitySecret?: string; private readonly activeTurns = new Map(); private accepting = true; private closePromise?: Promise; @@ -115,6 +118,7 @@ export class LocalAgentManager { this.allowedRoots = options.allowedRoots; this.logger = options.logger; this.subagents = options.subagents; + this.mcpCapabilitySecret = options.mcpCapabilitySecret; } reconcileActiveRuns(message?: string): BetterResult { @@ -415,8 +419,18 @@ export class LocalAgentManager { this.persistRunError(record, turnId, driver.error, startedAt); return; } + const mcpCapability = this.mcpCapabilitySecret && record.workspaceId + ? createLocalAgentMcpCapability(this.mcpCapabilitySecret, { + parentAgentId: record.id, + workspaceId: record.workspaceId, + workspaceRoot, + maxWriteMode: input.value.writeMode ?? "allowed", + }) + : undefined; const context: LocalAgentRuntimeContext = { agentId: record.id, + workspaceId: record.workspaceId, + ...(mcpCapability ? { mcpCapability } : {}), providerInstanceId: record.providerInstanceId, provider: driver.value.provider, workspaceRoot, @@ -435,7 +449,11 @@ export class LocalAgentManager { if (updated.isErr()) throw updated.error; }, }; - const result = await this.pool.run(driver.value, context, { ...input.value, signal }, callbacks); + const result = await this.pool.run(driver.value, context, { + ...input.value, + ...(mcpCapability ? { mcpCapability } : {}), + signal, + }, callbacks); if (result.isErr()) { this.persistRunError(record, turnId, result.error, startedAt); return; @@ -529,6 +547,8 @@ export class LocalAgentManager { const body = profile?.body.trim(); const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; return Result.ok({ + agentId: record.id, + workspaceId: record.workspaceId, prompt: fullPrompt, workspaceRoot: record.workspaceRoot, providerSessionId: record.providerSessionId, diff --git a/src/local-agent-mcp-capability.ts b/src/local-agent-mcp-capability.ts new file mode 100644 index 000000000..606e98fc3 --- /dev/null +++ b/src/local-agent-mcp-capability.ts @@ -0,0 +1,69 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; + +const CAPABILITY_VERSION = 1; + +export interface LocalAgentMcpCapability { + version: typeof CAPABILITY_VERSION; + parentAgentId: string; + workspaceId: string; + workspaceRoot: string; + maxWriteMode: LocalAgentWriteMode; +} + +export function createLocalAgentMcpCapability( + secret: string, + input: Omit, +): string { + const payload = Buffer.from(JSON.stringify({ version: CAPABILITY_VERSION, ...input }), "utf8").toString("base64url"); + return `${payload}.${signCapability(secret, payload)}`; +} + +export function verifyLocalAgentMcpCapability( + secret: string, + token: string, +): LocalAgentMcpCapability { + const separator = token.lastIndexOf("."); + if (separator <= 0 || separator === token.length - 1) throw invalidCapability(); + const payload = token.slice(0, separator); + const signature = token.slice(separator + 1); + const expected = signCapability(secret, payload); + const receivedBuffer = Buffer.from(signature, "utf8"); + const expectedBuffer = Buffer.from(expected, "utf8"); + if ( + receivedBuffer.length !== expectedBuffer.length + || !timingSafeEqual(receivedBuffer, expectedBuffer) + ) throw invalidCapability(); + + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + throw invalidCapability(); + } + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) throw invalidCapability(); + const record = decoded as Record; + const maxWriteMode = record.maxWriteMode; + if ( + record.version !== CAPABILITY_VERSION + || typeof record.parentAgentId !== "string" + || typeof record.workspaceId !== "string" + || typeof record.workspaceRoot !== "string" + || (maxWriteMode !== "read_only" && maxWriteMode !== "allowed" && maxWriteMode !== "full_access") + ) throw invalidCapability(); + return { + version: CAPABILITY_VERSION, + parentAgentId: record.parentAgentId, + workspaceId: record.workspaceId, + workspaceRoot: record.workspaceRoot, + maxWriteMode, + }; +} + +function signCapability(secret: string, payload: string): string { + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +function invalidCapability(): Error { + return new Error("Invalid DevSpace agent MCP capability."); +} diff --git a/src/local-agent-mcp-launch.ts b/src/local-agent-mcp-launch.ts new file mode 100644 index 000000000..c2753efe3 --- /dev/null +++ b/src/local-agent-mcp-launch.ts @@ -0,0 +1,48 @@ +import { fileURLToPath } from "node:url"; +import type { LocalAgentRunInput, LocalAgentRuntimeContext, LocalAgentWriteMode } from "./local-agent-runtime.js"; + +export interface LocalAgentMcpLaunch { + name: string; + command: string; + args: string[]; + env: Record; +} + +export function localAgentMcpLaunch( + input: Pick, + env: NodeJS.ProcessEnv = process.env, +): LocalAgentMcpLaunch | undefined { + if (!input.workspaceId || !input.agentId || !input.mcpCapability) return undefined; + const configDir = env.DEVSPACE_CONFIG_DIR?.trim(); + return { + name: `devspace-agents-${input.agentId}`, + command: process.execPath, + args: [fileURLToPath(new URL("../bin/devspace.js", import.meta.url)), "agents", "mcp"], + env: { + ...(configDir ? { DEVSPACE_CONFIG_DIR: configDir } : {}), + DEVSPACE_AGENT_MCP_CAPABILITY: input.mcpCapability, + }, + }; +} + +export function localAgentMcpLaunchFromContext( + context: Pick, + env: NodeJS.ProcessEnv = process.env, +): LocalAgentMcpLaunch | undefined { + return localAgentMcpLaunch(context, env); +} + +export function writeModeAtMost( + requested: LocalAgentWriteMode, + maximum: LocalAgentWriteMode, +): boolean { + return writeModeRank(requested) <= writeModeRank(maximum); +} + +function writeModeRank(mode: LocalAgentWriteMode): number { + switch (mode) { + case "read_only": return 0; + case "allowed": return 1; + case "full_access": return 2; + } +} diff --git a/src/local-agent-mcp-server.ts b/src/local-agent-mcp-server.ts new file mode 100644 index 000000000..e0e81b1b5 --- /dev/null +++ b/src/local-agent-mcp-server.ts @@ -0,0 +1,50 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { ServerConfig } from "./config.js"; +import { registerLocalAgentMcpTools } from "./local-agent-mcp-tools.js"; +import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; + +export interface LocalAgentMcpScope { + parentAgentId: string; + workspaceId: string; + workspaceRoot: string; + maxWriteMode: LocalAgentWriteMode; +} + +export function createLocalAgentMcpServer( + config: ServerConfig, + scope: LocalAgentMcpScope, +): McpServer { + const server = new McpServer( + { name: "devspace-agents", title: "DevSpace Agents", version: "1" }, + { + instructions: [ + `You are delegating from parent agent ${scope.parentAgentId}.`, + `This control plane is scoped to workspace ${scope.workspaceId}.`, + `Pass workspace_id=${scope.workspaceId} to every agent tool.`, + `You may delegate at most ${scope.maxWriteMode} authority; child write_mode cannot exceed it.`, + "Use agent_targets before choosing a target. Prefer agent_wait over polling agent_status.", + "Use agent_send only for related follow-up that benefits from the same child context.", + ].join(" "), + }, + ); + registerLocalAgentMcpTools(server, { + config, + maxWriteMode: scope.maxWriteMode, + resolveScope: async (workspaceId) => { + if (workspaceId !== scope.workspaceId) { + throw new Error(`This agent control plane is scoped to workspace ${scope.workspaceId}.`); + } + return { workspaceId: scope.workspaceId, workspaceRoot: scope.workspaceRoot }; + }, + }); + return server; +} + +export async function runLocalAgentMcpStdio( + config: ServerConfig, + scope: LocalAgentMcpScope, +): Promise { + const server = createLocalAgentMcpServer(config, scope); + await server.connect(new StdioServerTransport()); +} diff --git a/src/local-agent-mcp-tools.test.ts b/src/local-agent-mcp-tools.test.ts new file mode 100644 index 000000000..2eaf080b5 --- /dev/null +++ b/src/local-agent-mcp-tools.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import type { ServerConfig } from "./config.js"; +import { createLocalAgentMcpCapability, verifyLocalAgentMcpCapability } from "./local-agent-mcp-capability.js"; +import { localAgentMcpLaunch, writeModeAtMost } from "./local-agent-mcp-launch.js"; +import { authorizeWriteMode, registerLocalAgentMcpTools } from "./local-agent-mcp-tools.js"; + +const names: string[] = []; +const target = { + registerTool(name: string): void { names.push(name); }, +} as never; +const config = { + configDir: "/tmp/devspace-config", + stateDir: "/tmp/devspace-state", + subagents: { enabled: true, providers: [] }, +} as unknown as ServerConfig; + +registerLocalAgentMcpTools(target, { + config, + resolveScope: async (workspaceId) => ({ workspaceId, workspaceRoot: "/tmp/project" }), +}); +assert.deepEqual(names, [ + "agent_targets", + "agent_spawn", + "agent_send", + "agent_status", + "agent_wait", + "agent_cancel", + "agent_list", +]); + +assert.equal(authorizeWriteMode(undefined, "read_only").value, "read_only"); +assert.equal(authorizeWriteMode(undefined, "allowed").value, "allowed"); +assert.equal(authorizeWriteMode("read_only", "full_access").value, "read_only"); +assert.equal(authorizeWriteMode("full_access", "allowed").error?.code, "AGENT_AUTHORITY_ESCALATION"); +assert.equal(writeModeAtMost("read_only", "allowed"), true); +assert.equal(writeModeAtMost("full_access", "allowed"), false); + +const capability = createLocalAgentMcpCapability("secret", { + parentAgentId: "agt_parent", + workspaceId: "ws_parent", + workspaceRoot: "/tmp/project", + maxWriteMode: "allowed", +}); +assert.equal(verifyLocalAgentMcpCapability("secret", capability).maxWriteMode, "allowed"); +assert.throws(() => verifyLocalAgentMcpCapability("other-secret", capability)); + +const launch = localAgentMcpLaunch({ + agentId: "agt_parent", + workspaceId: "ws_parent", + workspaceRoot: "/tmp/project", + writeMode: "allowed", + mcpCapability: capability, +}, { DEVSPACE_CONFIG_DIR: "/tmp/devspace-config" }); +assert.equal(launch?.name, "devspace-agents-agt_parent"); +assert.deepEqual(launch?.env, { + DEVSPACE_CONFIG_DIR: "/tmp/devspace-config", + DEVSPACE_AGENT_MCP_CAPABILITY: capability, +}); +assert.equal(localAgentMcpLaunch({ + agentId: "agt_parent", + workspaceRoot: "/tmp/project", + writeMode: "allowed", +}), undefined); diff --git a/src/local-agent-mcp-tools.ts b/src/local-agent-mcp-tools.ts new file mode 100644 index 000000000..a7f0c56ac --- /dev/null +++ b/src/local-agent-mcp-tools.ts @@ -0,0 +1,189 @@ +import * as z from "zod/v4"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServerConfig } from "./config.js"; +import { createLocalAgentClient } from "./local-agent-client.js"; +import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js"; +import { buildLocalAgentCatalog, buildLocalAgentProviderStatuses } from "./local-agent-catalog.js"; +import { toAgentErrorPayload, type LocalAgentError } from "./local-agent-errors.js"; +import { loadLocalAgentProfiles } from "./local-agent-profiles.js"; +import { + presentAgentObservation, + presentAgentReceipt, + presentAgentSummary, + presentAgentTargetCatalog, +} from "./local-agent-presentation.js"; +import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; +import { writeModeAtMost } from "./local-agent-mcp-launch.js"; +import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; + +type RegistrationTarget = Pick; + +export interface LocalAgentMcpToolOptions { + config: ServerConfig; + resolveScope(workspaceId: string): Promise; + maxWriteMode?: LocalAgentWriteMode; +} + +const writeModeSchema = z.enum(["read_only", "allowed", "full_access"]); +const agentIdSchema = z.string().regex(/^agt_[A-Za-z0-9_-]+$/); + +export function registerLocalAgentMcpTools( + server: RegistrationTarget, + options: LocalAgentMcpToolOptions, +): void { + if (!options.config.subagents.enabled) return; + const client = createLocalAgentClient(options.config); + + server.registerTool("agent_targets", { + title: "List agent targets", + description: "List usable DevSpace subagent providers and profiles for an open workspace.", + inputSchema: { workspace_id: z.string() }, + annotations: { readOnlyHint: true }, + }, async ({ workspace_id }) => { + const scope = await options.resolveScope(workspace_id); + const profiles = await loadLocalAgentProfiles(options.config, scope.workspaceRoot); + const providers = buildLocalAgentProviderStatuses( + options.config.subagents, + getLocalAgentProviderAvailabilitySnapshot(process.env, options.config.subagents), + ); + return success(presentAgentTargetCatalog(buildLocalAgentCatalog( + options.config.subagents, + profiles, + providers, + ))); + }); + + server.registerTool("agent_spawn", { + title: "Spawn agent", + description: + "Start a bounded DevSpace subagent in this workspace. The child receives only this brief plus its profile instructions, not the parent conversation.", + inputSchema: { + workspace_id: z.string(), + target: z.string().min(1), + brief: z.string().min(1), + model: z.string().min(1).optional(), + effort: z.string().min(1).optional(), + write_mode: writeModeSchema.optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, async ({ workspace_id, target, brief, model, effort, write_mode }) => { + const scope = await options.resolveScope(workspace_id); + const authority = authorizeWriteMode(write_mode, options.maxWriteMode); + if (authority.error) return failure(authority.error); + const result = await client.start({ + target, + prompt: brief, + workspaceId: workspace_id, + workspaceRoot: scope.workspaceRoot, + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + writeMode: authority.value, + }); + return result.isErr() ? failure(toAgentErrorPayload(result.error as LocalAgentError)) : success(presentAgentReceipt(result.value)); + }); + + server.registerTool("agent_send", { + title: "Send agent follow-up", + description: "Continue an existing DevSpace subagent with a related follow-up brief.", + inputSchema: { + workspace_id: z.string(), + agent_id: agentIdSchema, + brief: z.string().min(1), + model: z.string().min(1).optional(), + effort: z.string().min(1).optional(), + write_mode: writeModeSchema.optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, async ({ workspace_id, agent_id, brief, model, effort, write_mode }) => { + const scope = await options.resolveScope(workspace_id); + const authority = authorizeWriteMode(write_mode, options.maxWriteMode); + if (authority.error) return failure(authority.error); + const result = await client.continue(agent_id, brief, { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + writeMode: authority.value, + }, scope); + return result.isErr() ? failure(toAgentErrorPayload(result.error as LocalAgentError)) : success(presentAgentReceipt(result.value)); + }); + + server.registerTool("agent_status", { + title: "Get agent status", + description: "Get the current status and completed response, if any, for one DevSpace subagent.", + inputSchema: { workspace_id: z.string(), agent_id: agentIdSchema }, + annotations: { readOnlyHint: true }, + }, async ({ workspace_id, agent_id }) => { + const scope = await options.resolveScope(workspace_id); + const result = await client.get(agent_id, scope); + return result.isErr() ? failure(toAgentErrorPayload(result.error as LocalAgentError)) : success(presentAgentObservation(result.value)); + }); + + server.registerTool("agent_wait", { + title: "Wait for agents", + description: "Wait for one or more DevSpace subagents to finish their current turns. Prefer this over polling agent_status.", + inputSchema: { + workspace_id: z.string(), + agent_ids: z.array(agentIdSchema).min(1), + timeout_seconds: z.number().nonnegative().max(3600).optional(), + }, + annotations: { readOnlyHint: true }, + }, async ({ workspace_id, agent_ids, timeout_seconds }) => { + const scope = await options.resolveScope(workspace_id); + const timeoutMs = timeout_seconds === undefined ? undefined : Math.round(timeout_seconds * 1000); + const result = await client.wait(agent_ids, scope, timeoutMs); + return result.isErr() ? failure(toAgentErrorPayload(result.error as LocalAgentError)) : success(result.value); + }); + + server.registerTool("agent_cancel", { + title: "Cancel agent turn", + description: "Stop an active DevSpace subagent turn while preserving its durable agent record.", + inputSchema: { workspace_id: z.string(), agent_id: agentIdSchema }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, + }, async ({ workspace_id, agent_id }) => { + const scope = await options.resolveScope(workspace_id); + const result = await client.stopAgent(agent_id, scope); + return result.isErr() ? failure(toAgentErrorPayload(result.error as LocalAgentError)) : success(presentAgentObservation(result.value)); + }); + + server.registerTool("agent_list", { + title: "List agents", + description: "List durable DevSpace subagents scoped to this workspace.", + inputSchema: { workspace_id: z.string() }, + annotations: { readOnlyHint: true }, + }, async ({ workspace_id }) => { + const scope = await options.resolveScope(workspace_id); + const result = await client.list(scope); + return result.isErr() + ? failure(toAgentErrorPayload(result.error as LocalAgentError)) + : success(result.value.map(presentAgentSummary)); + }); +} + +export function authorizeWriteMode( + requested: LocalAgentWriteMode | undefined, + maximum: LocalAgentWriteMode = "full_access", +): { value: LocalAgentWriteMode; error?: never } | { value?: never; error: { code: string; message: string; retryable: false } } { + const value = requested ?? (maximum === "read_only" ? "read_only" : "allowed"); + if (writeModeAtMost(value, maximum)) return { value }; + return { + error: { + code: "AGENT_AUTHORITY_ESCALATION", + retryable: false, + message: `Child write mode ${value} exceeds the caller authority ${maximum}.`, + }, + }; +} + +function success(value: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(value) }], + structuredContent: { result: value }, + }; +} + +function failure(error: { code: string; message: string; retryable?: boolean }) { + return { + isError: true, + content: [{ type: "text" as const, text: `${error.code}: ${error.message}` }], + structuredContent: { error }, + }; +} diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts index 636ecce74..b3acddbc3 100644 --- a/src/local-agent-opencode-v2.test.ts +++ b/src/local-agent-opencode-v2.test.ts @@ -18,6 +18,7 @@ const switchModelInputs: unknown[] = []; const promptInputs: unknown[] = []; const waitInputs: unknown[] = []; const messageInputs: unknown[] = []; +const mcpAdds: unknown[] = []; let closeCalls = 0; const assistant: SessionMessageAssistant = { @@ -35,6 +36,9 @@ const assistant: SessionMessageAssistant = { }; const client = { + mcp: { + async add(input: unknown) { mcpAdds.push(input); }, + }, server: { async info() { return { version: "2.0.20", pid: 1, urls: [], paths: { tmp: "/tmp" } }; }, }, @@ -205,14 +209,28 @@ const routedDriver = new OpencodeLocalAgentDriver({ const routedPool = new LocalAgentRuntimePool(); const routed = await routedPool.run(routedDriver, { agentId: "agt_v2", + workspaceId: "ws_v2", + mcpCapability: "cap_v2", providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { + agentId: "agt_v2", + workspaceId: "ws_v2", + mcpCapability: "cap_v2", prompt: "route through v2", workspaceRoot: "/tmp/project", }); assert.equal(routed.isOk(), true); assert.equal(v1FactoryCalls, 0); assert.equal(v2FactoryCalls, 1); +assert.equal((mcpAdds[0] as { server?: string }).server, "devspace-agents-agt_v2"); +const routedMcpConfig = (mcpAdds[0] as { + config?: { type?: string; command?: string[]; cwd?: string; environment?: Record; protocol?: string }; +}).config; +assert.equal(routedMcpConfig?.type, "local"); +assert.deepEqual(routedMcpConfig?.command?.slice(-2), ["agents", "mcp"]); +assert.equal(routedMcpConfig?.cwd, "/tmp/project"); +assert.equal(routedMcpConfig?.environment?.DEVSPACE_AGENT_MCP_CAPABILITY, "cap_v2"); +assert.equal(routedMcpConfig?.protocol, "auto"); await routedPool.close(); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts index 604a44191..233c356be 100644 --- a/src/local-agent-opencode-v2.ts +++ b/src/local-agent-opencode-v2.ts @@ -13,6 +13,7 @@ import { captureAgentProviderResult, } from "./local-agent-errors.js"; import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { localAgentMcpLaunch } from "./local-agent-mcp-launch.js"; import type { LocalAgentRunCallbacks, LocalAgentRunInput, @@ -28,7 +29,9 @@ import { const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; -export type OpencodeV2ClientLike = Pick; +export type OpencodeV2ClientLike = Pick & { + mcp?: Pick; +}; export type OpencodeV2Factory = ( context?: LocalAgentRuntimeContext, @@ -47,6 +50,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { constructor( private readonly client: OpencodeV2ClientLike, private readonly server: OpencodeServerLike, + private readonly env: NodeJS.ProcessEnv = process.env, private readonly promptTimeoutMs = OPENCODE_PROMPT_TIMEOUT_MS, ) {} @@ -65,6 +69,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { }); } try { + await configureOpenCodeV2Mcp(this.client, input, this.env); await this.client.server.info(); const continuing = input.providerSessionId !== undefined; const nativeSessionId = input.providerSessionId @@ -219,6 +224,26 @@ export function opencodeV2ServerEnvironment( return { ...rest, OPENCODE_PASSWORD: password }; } +export async function configureOpenCodeV2Mcp( + client: OpencodeV2ClientLike, + input: LocalAgentRunInput, + env: NodeJS.ProcessEnv, +): Promise { + const mcp = localAgentMcpLaunch(input, env); + if (!mcp || !client.mcp) return; + await client.mcp.add({ + server: mcp.name, + location: { directory: input.workspaceRoot }, + config: { + type: "local", + command: [mcp.command, ...mcp.args], + cwd: input.workspaceRoot, + environment: mcp.env, + protocol: "auto", + }, + }); +} + export function opencodeV2Permissions( writeMode: LocalAgentRunInput["writeMode"], ): PermissionRule[] { diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 9bc2d271d..73a5b86f7 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -21,8 +21,12 @@ function v1Probe(): OpenCodeRuntimeProbe { let sessionNumber = 0; const createInputs: unknown[] = []; const promptInputs: unknown[] = []; +const mcpAdds: unknown[] = []; let healthAvailable = true; const client = { + mcp: { + async add(input: unknown) { mcpAdds.push(input); return { data: {} }; }, + }, global: { async health() { if (!healthAvailable) throw new Error("server unavailable"); @@ -67,10 +71,15 @@ const pool = new LocalAgentRuntimePool(); const first = await pool.run(driver, { agentId: "agt_one", + workspaceId: "ws_one", + mcpCapability: "cap_one", providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { + agentId: "agt_one", + workspaceId: "ws_one", + mcpCapability: "cap_one", prompt: "first", workspaceRoot: "/tmp/project", model: "anthropic/sonnet", @@ -78,15 +87,24 @@ const first = await pool.run(driver, { }); const second = await pool.run(driver, { agentId: "agt_two", + workspaceId: "ws_two", + mcpCapability: "cap_two", providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { + agentId: "agt_two", + workspaceId: "ws_two", + mcpCapability: "cap_two", prompt: "second", workspaceRoot: "/tmp/project", }); assert.equal(factoryCalls, 1, "OpenCode agents share one server runtime"); +assert.deepEqual(driver.runtimePolicy, { scope: "instance", idleTimeoutMs: 5 * 60_000 }); +assert.equal(mcpAdds.length, 2); +assert.equal((mcpAdds[0] as { name?: string }).name, "devspace-agents-agt_one"); +assert.equal((mcpAdds[1] as { name?: string }).name, "devspace-agents-agt_two"); assert.equal(factoryEnv?.HARNESS_ENV, "opencode"); assert.equal(first.isOk(), true); assert.equal(second.isOk(), true); @@ -227,7 +245,7 @@ const timeoutClient = { }, }, } as unknown as OpencodeClientLike; -const timeoutRuntime = new OpencodeRuntime(timeoutClient, { close: () => undefined }, 5); +const timeoutRuntime = new OpencodeRuntime(timeoutClient, { close: () => undefined }, process.env, 5); const timedOutPrompt = await timeoutRuntime.run({ prompt: "never finishes", workspaceRoot: "/tmp/project", diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 7461d3e78..0e876a876 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -9,6 +9,7 @@ import { captureAgentProviderResult, } from "./local-agent-errors.js"; import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; +import { localAgentMcpLaunch } from "./local-agent-mcp-launch.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -19,6 +20,7 @@ import type { } from "./local-agent-runtime.js"; import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; import { + configureOpenCodeV2Mcp, defaultOpencodeV2Factory, OpencodeV2Runtime, type OpencodeV2Factory, @@ -36,7 +38,9 @@ interface OpencodeModelRef { modelID: string; } -export type OpencodeClientLike = Pick; +export type OpencodeClientLike = Pick & { + mcp?: Pick; +}; export type OpencodeFactory = ( context?: LocalAgentRuntimeContext, @@ -55,6 +59,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { constructor( private readonly client: OpencodeClientLike, private readonly server: OpencodeServerLike, + private readonly env: NodeJS.ProcessEnv = process.env, private readonly promptTimeoutMs = OPENCODE_PROMPT_TIMEOUT_MS, ) {} @@ -73,6 +78,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { }); } try { + await configureOpenCodeV1Mcp(this.client, input, this.env); await assertOpencodeHealthy(this.client); const sessionId = input.providerSessionId ? requireOpenCodeV1NativeSessionId(input.providerSessionId) @@ -195,15 +201,34 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { const runtime = await this.runtimeProbe.get(); if (runtime.generation === "v2") { const { client, server } = await this.v2Factory(context, this.env); - return new OpencodeV2Runtime(client, server); + return new OpencodeV2Runtime(client, server, this.env); } const { client, server } = await this.factory(context, this.env); - return new OpencodeRuntime(client, server); + return new OpencodeRuntime(client, server, this.env); }, }); } } +async function configureOpenCodeV1Mcp( + client: OpencodeClientLike, + input: LocalAgentRunInput, + env: NodeJS.ProcessEnv, +): Promise { + const mcp = localAgentMcpLaunch(input, env); + if (!mcp || !client.mcp) return; + await client.mcp.add({ + directory: input.workspaceRoot, + name: mcp.name, + config: { + type: "local", + command: [mcp.command, ...mcp.args], + cwd: input.workspaceRoot, + environment: mcp.env, + }, + }, { throwOnError: true }); +} + async function defaultOpencodeFactory( _context?: LocalAgentRuntimeContext, env: NodeJS.ProcessEnv = process.env, diff --git a/src/local-agent-pi-mcp.test.ts b/src/local-agent-pi-mcp.test.ts new file mode 100644 index 000000000..a7b39cd18 --- /dev/null +++ b/src/local-agent-pi-mcp.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPiMcpBridgeExtension } from "./local-agent-pi-mcp.js"; + +const directory = await mkdtemp(join(process.cwd(), ".tmp-pi-mcp-")); +const script = join(directory, "mock-mcp.mjs"); +await writeFile(script, ` +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +const server = new Server( + { name: "mock-pi-mcp", version: "1" }, + { capabilities: { tools: {} } }, +); +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [{ + name: "echo", + description: "Echo a value", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }], +})); +server.setRequestHandler(CallToolRequestSchema, async (request) => ({ + content: [{ type: "text", text: "echo:" + String(request.params.arguments?.value ?? "") }], +})); +await server.connect(new StdioServerTransport()); +`, "utf8"); + +const registered: any[] = []; +const handlers = new Map any>(); +const extension = createPiMcpBridgeExtension({ + name: "devspace-agents-test", + command: process.execPath, + args: [script], + env: {}, +}); +await extension({ + registerTool(tool: unknown) { registered.push(tool); }, + on(event: string, handler: (...args: any[]) => any) { handlers.set(event, handler); }, +} as never); + +assert.equal(registered.length, 1); +assert.equal(registered[0]?.name, "mcp__devspace-agents-test__echo"); +const result = await registered[0].execute( + "call-1", + { value: "hello" }, + new AbortController().signal, +); +assert.deepEqual(result.content, [{ type: "text", text: "echo:hello" }]); + +const beforeAgentStart = handlers.get("before_agent_start"); +assert.ok(beforeAgentStart); +assert.match( + beforeAgentStart!({ systemPrompt: "base" }).systemPrompt, + /DevSpace agent tools/, +); +await handlers.get("session_shutdown")?.({}, {}); +await rm(directory, { recursive: true, force: true }); diff --git a/src/local-agent-pi-mcp.ts b/src/local-agent-pi-mcp.ts new file mode 100644 index 000000000..03a8bb9a4 --- /dev/null +++ b/src/local-agent-pi-mcp.ts @@ -0,0 +1,103 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; +import { Unsafe } from "typebox"; +import type { LocalAgentMcpLaunch } from "./local-agent-mcp-launch.js"; + +export function createPiMcpBridgeExtension(launch: LocalAgentMcpLaunch): ExtensionFactory { + return async (pi) => { + let client: Client | undefined; + let started: Promise | undefined; + + const ensureStarted = (): Promise => { + if (started) return started; + const attempt = (async () => { + const transport = new StdioClientTransport({ + command: launch.command, + args: launch.args, + cwd: process.cwd(), + env: stringEnvironment({ ...process.env, ...launch.env }), + stderr: "pipe", + }); + const nextClient = new Client({ name: launch.name, version: "1" }); + await nextClient.connect(transport); + const listed = await nextClient.listTools(); + for (const tool of listed.tools) { + const registeredName = `mcp__${launch.name}__${tool.name}`; + const description = tool.description ?? tool.name; + pi.registerTool({ + name: registeredName, + label: tool.name, + description, + promptSnippet: description.split("\n")[0] ?? tool.name, + promptGuidelines: [ + `Use ${registeredName} for bounded DevSpace subagent delegation when separate context or specialization materially helps.`, + ], + parameters: Unsafe(tool.inputSchema), + async execute(_toolCallId, params, signal) { + const result = await nextClient.callTool( + { name: tool.name, arguments: (params ?? {}) as Record }, + undefined, + { signal }, + ); + return { + content: [{ type: "text", text: formatMcpResult(result.content) }], + details: { server: launch.name, tool: tool.name }, + ...(result.isError === true ? { isError: true } : {}), + }; + }, + }); + } + client = nextClient; + })(); + started = attempt; + void attempt.catch(() => { + if (started === attempt) started = undefined; + }); + return attempt; + }; + + await ensureStarted().catch(() => undefined); + pi.on("session_start", async (_event, context) => { + try { + await ensureStarted(); + } catch (error) { + context.ui.notify( + `DevSpace agent tools unavailable: ${error instanceof Error ? error.message : String(error)}`, + "warning", + ); + } + }); + pi.on("session_shutdown", async () => { + await client?.close().catch(() => undefined); + client = undefined; + started = undefined; + }); + pi.on("before_agent_start", (event) => ({ + systemPrompt: `${event.systemPrompt}\n\nUse the DevSpace agent tools for bounded delegation when it materially helps. Discover targets before spawning, prefer waiting over polling, and never delegate more write authority than your current mode.`, + })); + + void client; + }; +} + +function stringEnvironment(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ); +} + +function formatMcpResult(content: unknown): string { + if (!Array.isArray(content)) return JSON.stringify(content ?? null); + const text = content + .filter((item): item is { type: "text"; text: string } => ( + Boolean(item) + && typeof item === "object" + && (item as { type?: unknown }).type === "text" + && typeof (item as { text?: unknown }).text === "string" + )) + .map((item) => item.text) + .join("\n\n") + .trim(); + return text || JSON.stringify(content); +} diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 5c5243793..4336f08c1 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -12,6 +12,10 @@ import { } from "./local-agent-errors.js"; import { bindLocalAgentAbort, localAgentCancelledError } from "./local-agent-cancellation.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; +import { + localAgentMcpLaunchFromContext, + type LocalAgentMcpLaunch, +} from "./local-agent-mcp-launch.js"; import { PiRpcConnection, parsePiModelSlug, @@ -228,7 +232,7 @@ export class PiLocalAgentDriver implements LocalAgentDriver { turns: { interrupt: true }, configuration: { modelOverride: true, effortOverride: true }, permissions: { enforcement: "client-boundary" }, - mcp: { supported: false }, + mcp: { supported: true }, } as const; constructor( @@ -258,12 +262,15 @@ async function defaultPiRpcFactory( const sandbox = writeMode === "full_access" ? undefined : await materializePiSandboxExtension(context.workspaceRoot, writeMode); + const mcp = localAgentMcpLaunchFromContext(context, env); + const mcpExtension = mcp ? await materializePiMcpExtension(mcp) : undefined; const args = [ ...launch.args, "--mode", "rpc", "--approve", ...(writeMode === "full_access" ? [] : ["--extension", sandbox!.path]), + ...(mcpExtension ? ["--extension", mcpExtension.path] : []), ...(writeMode === "read_only" ? ["--tools", PI_READ_ONLY_TOOLS.join(",")] : writeMode === "allowed" @@ -278,10 +285,18 @@ async function defaultPiRpcFactory( cwd: context.workspaceRoot, env, }), - ...(sandbox ? { cleanup: sandbox.cleanup } : {}), + ...((sandbox || mcpExtension) + ? { + cleanup: async () => { + await sandbox?.cleanup(); + await mcpExtension?.cleanup(); + }, + } + : {}), }; } catch (error) { await sandbox?.cleanup(); + await mcpExtension?.cleanup(); throw error; } } @@ -321,6 +336,26 @@ async function materializePiSandboxExtension( }; } +async function materializePiMcpExtension( + launch: LocalAgentMcpLaunch, +): Promise<{ path: string; cleanup: () => Promise }> { + const directory = await mkdtemp(join(tmpdir(), "devspace-pi-mcp-")); + const extensionPath = join(directory, "devspace-agents.ts"); + const jsModule = new URL("./local-agent-pi-mcp.js", import.meta.url); + const tsModule = new URL("./local-agent-pi-mcp.ts", import.meta.url); + const moduleUrl = existsSync(fileURLToPath(jsModule)) ? jsModule : tsModule; + const source = [ + `import { createPiMcpBridgeExtension } from ${JSON.stringify(moduleUrl.href)};`, + `export default createPiMcpBridgeExtension(${JSON.stringify(launch)});`, + "", + ].join("\n"); + await writeFile(extensionPath, source, "utf8"); + return { + path: extensionPath, + cleanup: () => rm(directory, { recursive: true, force: true }), + }; +} + async function cancelPiUiRequest(connection: PiRpcRuntimeConnection, event: PiRpcRecord): Promise { if (typeof event.id !== "string") return; if (event.method === "notify" || event.method === "setStatus" || event.method === "setWidget" || event.method === "setTitle") return; diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index e73e3e7d6..33cdf4952 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -8,6 +8,9 @@ import type { export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; export interface LocalAgentRunInput { + agentId?: string; + workspaceId?: string; + mcpCapability?: string; prompt: string; workspaceRoot: string; providerSessionId?: string; @@ -37,6 +40,8 @@ export interface LocalAgentRunCallbacks { export interface LocalAgentRuntimeContext { agentId: string; + workspaceId?: string; + mcpCapability?: string; providerInstanceId: LocalAgentProviderInstanceId; provider: LocalAgentDriverKind; workspaceRoot: string; diff --git a/src/server.test.ts b/src/server.test.ts index d93515a61..77e8d49ef 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -50,6 +50,27 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { } }); +test("enabled subagents expose provider-neutral agent control tools", async (t) => { + const context = await fixture(t, { + toolMode: "codex", + uiEnabled: false, + localAgentProviders: [{ name: "codex", available: true }], + }); + const tools = await context.client.listTools(); + const names = tools.tools.map((tool) => tool.name); + for (const name of [ + "agent_targets", + "agent_spawn", + "agent_send", + "agent_status", + "agent_wait", + "agent_cancel", + "agent_list", + ]) { + assert.ok(names.includes(name), `expected ${name} in enabled subagent tool surface`); + } +}); + test("model-facing tool schemas use snake_case recursively", async (t) => { for (const toolMode of ["claude", "codex"] as const) { await t.test(toolMode, async (nested) => { diff --git a/src/server.ts b/src/server.ts index 0a2792f3f..360ce34b0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -56,6 +56,7 @@ import { formatLocalAgentProviderStatusSummary, type LocalAgentProviderStatus, } from "./local-agent-catalog.js"; +import { registerLocalAgentMcpTools } from "./local-agent-mcp-tools.js"; import { getToolSurface } from "./tool-surfaces/index.js"; import { contentText, @@ -130,13 +131,16 @@ function serverInstructions( : ""; const showChangesInstruction = " If files are modified, call show_changes once after the final related change and before the final response."; + const subagentInstruction = config.subagents.enabled + ? " For delegated work, use agent_targets to discover targets, agent_spawn to start a bounded worker, agent_wait for dependencies, and agent_send for related follow-up. Do not poll agent_status when agent_wait can express the dependency." + : ""; const skills = config.skillsEnabled ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, use ${toolNames.read} with the returned skill path before proceeding. ` : ""; const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in available_agents_files, use ${toolNames.read} to inspect that instruction file and follow it. `; const common = `Call ${toolNames.openWorkspace} when starting work in a project folder or isolated worktree without a usable workspace_id, then reuse the returned workspace_id for subsequent operations in that workspace.`; - return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; + return `${common} ${toolSurface.instructions({ agents, skills })}${subagentInstruction}${artifactInstruction}${showChangesInstruction}`; } function formatVisibleAgent(agent: { @@ -690,6 +694,14 @@ function registerMcpSurface( }, ); + registerLocalAgentMcpTools(registrationTarget, { + config, + resolveScope: async (workspaceId) => { + const workspace = await workspaces.getWorkspace(workspaceId); + return { workspaceId, workspaceRoot: workspace.root }; + }, + }); + toolSurface.register({ server: registrationTarget, config,