diff --git a/CLAUDE.md b/CLAUDE.md index 6c5ca0a..b560147 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ Universal schema guidance: - `sandbox-agent api sessions reply-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` - `sandbox-agent api sessions reject-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` - `sandbox-agent api sessions reply-permission` ↔ `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` +- `sandbox-agent api sessions reply-mcp-tunnel` ↔ `POST /v1/sessions/{sessionId}/mcp-tunnel/calls/{callId}/response` ## Post-Release Testing diff --git a/docs/cli.mdx b/docs/cli.mdx index 855bd44..c0ea31f 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -162,6 +162,7 @@ sandbox-agent api sessions create [OPTIONS] | `-m, --model ` | Model override | | `-v, --variant ` | Model variant | | `-A, --agent-version ` | Agent version | +| `--mcp-tunnel-tools ` | JSON array of MCP tool definitions | ```bash sandbox-agent api sessions create my-session \ @@ -289,6 +290,22 @@ sandbox-agent api sessions reply-permission [OPTION sandbox-agent api sessions reply-permission my-session perm1 --reply once ``` +#### Reply to MCP Tunnel Tool Call + +```bash +sandbox-agent api sessions reply-mcp-tunnel [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `-o, --output ` | Tool output (required) | +| `--is-error` | Mark tool result as error | +| `--content ` | Optional MCP content payload | + +```bash +sandbox-agent api sessions reply-mcp-tunnel my-session call-1 --output "ok" +``` + --- ## CLI to HTTP Mapping @@ -308,3 +325,4 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once | `api sessions reply-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` | | `api sessions reject-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` | | `api sessions reply-permission` | `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` | +| `api sessions reply-mcp-tunnel` | `POST /v1/sessions/{sessionId}/mcp-tunnel/calls/{callId}/response` | diff --git a/docs/openapi.json b/docs/openapi.json index 76c76f0..8031a3d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -365,6 +365,69 @@ } } }, + "/v1/sessions/{session_id}/mcp-tunnel/calls/{call_id}/response": { + "post": { + "tags": [ + "sessions" + ], + "operationId": "reply_mcp_tunnel_call", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Session id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "call_id", + "in": "path", + "description": "MCP call id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpTunnelToolResponseRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "MCP tool call responded" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/v1/sessions/{session_id}/messages": { "post": { "tags": [ @@ -1063,6 +1126,14 @@ "type": "string", "nullable": true }, + "mcpTunnel": { + "allOf": [ + { + "$ref": "#/components/schemas/McpTunnelConfig" + } + ], + "nullable": true + }, "model": { "type": "string", "nullable": true @@ -1258,6 +1329,56 @@ "failed" ] }, + "McpTunnelConfig": { + "type": "object", + "required": [ + "tools" + ], + "properties": { + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/McpTunnelTool" + } + } + } + }, + "McpTunnelTool": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "inputSchema": { + "nullable": true + }, + "name": { + "type": "string" + } + } + }, + "McpTunnelToolResponseRequest": { + "type": "object", + "required": [ + "output" + ], + "properties": { + "content": { + "nullable": true + }, + "isError": { + "type": "boolean", + "nullable": true + }, + "output": { + "type": "string" + } + } + }, "MessageRequest": { "type": "object", "required": [ diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 7f9ad95..0859c19 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -9,6 +9,7 @@ import type { EventsResponse, HealthResponse, MessageRequest, + McpTunnelToolResponseRequest, PermissionReplyRequest, ProblemDetails, QuestionReplyRequest, @@ -207,6 +208,18 @@ export class SandboxAgent { ); } + async replyMcpTunnelCall( + sessionId: string, + callId: string, + request: McpTunnelToolResponseRequest, + ): Promise { + await this.requestJson( + "POST", + `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}/mcp-tunnel/calls/${encodeURIComponent(callId)}/response`, + { body: request }, + ); + } + async terminateSession(sessionId: string): Promise { await this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}/terminate`); } diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 52816ad..4778e10 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -3,644 +3,1029 @@ * Do not make direct changes to the file. */ - export interface paths { - "/v1/agents": { - get: operations["list_agents"]; - }; - "/v1/agents/{agent}/install": { - post: operations["install_agent"]; - }; - "/v1/agents/{agent}/modes": { - get: operations["get_agent_modes"]; - }; - "/v1/health": { - get: operations["get_health"]; - }; - "/v1/sessions": { - get: operations["list_sessions"]; - }; - "/v1/sessions/{session_id}": { - post: operations["create_session"]; - }; - "/v1/sessions/{session_id}/events": { - get: operations["get_events"]; - }; - "/v1/sessions/{session_id}/events/sse": { - get: operations["get_events_sse"]; - }; - "/v1/sessions/{session_id}/messages": { - post: operations["post_message"]; - }; - "/v1/sessions/{session_id}/messages/stream": { - post: operations["post_message_stream"]; - }; - "/v1/sessions/{session_id}/permissions/{permission_id}/reply": { - post: operations["reply_permission"]; - }; - "/v1/sessions/{session_id}/questions/{question_id}/reject": { - post: operations["reject_question"]; - }; - "/v1/sessions/{session_id}/questions/{question_id}/reply": { - post: operations["reply_question"]; - }; - "/v1/sessions/{session_id}/terminate": { - post: operations["terminate_session"]; - }; -} - -export type webhooks = Record; - -export interface components { - schemas: { - AgentCapabilities: { - commandExecution: boolean; - errorEvents: boolean; - fileAttachments: boolean; - fileChanges: boolean; - images: boolean; - itemStarted: boolean; - mcpTools: boolean; - permissions: boolean; - planMode: boolean; - questions: boolean; - reasoning: boolean; - sessionLifecycle: boolean; - /** @description Whether this agent uses a shared long-running server process (vs per-turn subprocess) */ - sharedProcess: boolean; - status: boolean; - streamingDeltas: boolean; - textMessages: boolean; - toolCalls: boolean; - toolResults: boolean; - }; - AgentError: { - agent?: string | null; - details?: unknown; - message: string; - session_id?: string | null; - type: components["schemas"]["ErrorType"]; - }; - AgentInfo: { - capabilities: components["schemas"]["AgentCapabilities"]; - id: string; - installed: boolean; - path?: string | null; - serverStatus?: components["schemas"]["ServerStatusInfo"] | null; - version?: string | null; - }; - AgentInstallRequest: { - reinstall?: boolean | null; - }; - AgentListResponse: { - agents: components["schemas"]["AgentInfo"][]; - }; - AgentModeInfo: { - description: string; - id: string; - name: string; - }; - AgentModesResponse: { - modes: components["schemas"]["AgentModeInfo"][]; - }; - AgentUnparsedData: { - error: string; - location: string; - raw_hash?: string | null; - }; - ContentPart: { - text: string; - /** @enum {string} */ - type: "text"; - } | { - json: unknown; - /** @enum {string} */ - type: "json"; - } | { - arguments: string; - call_id: string; - name: string; - /** @enum {string} */ - type: "tool_call"; - } | { - call_id: string; - output: string; - /** @enum {string} */ - type: "tool_result"; - } | ({ - action: components["schemas"]["FileAction"]; - diff?: string | null; - path: string; - /** @enum {string} */ - type: "file_ref"; - }) | { - text: string; - /** @enum {string} */ - type: "reasoning"; - visibility: components["schemas"]["ReasoningVisibility"]; - } | ({ - mime?: string | null; - path: string; - /** @enum {string} */ - type: "image"; - }) | ({ - detail?: string | null; - label: string; - /** @enum {string} */ - type: "status"; - }); - CreateSessionRequest: { - agent: string; - agentMode?: string | null; - agentVersion?: string | null; - model?: string | null; - permissionMode?: string | null; - variant?: string | null; - }; - CreateSessionResponse: { - error?: components["schemas"]["AgentError"] | null; - healthy: boolean; - nativeSessionId?: string | null; - }; - ErrorData: { - code?: string | null; - details?: unknown; - message: string; - }; - /** @enum {string} */ - ErrorType: "invalid_request" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout"; - /** @enum {string} */ - EventSource: "agent" | "daemon"; - EventsQuery: { - includeRaw?: boolean | null; - /** Format: int64 */ - limit?: number | null; - /** Format: int64 */ - offset?: number | null; - }; - EventsResponse: { - events: components["schemas"]["UniversalEvent"][]; - hasMore: boolean; - }; - /** @enum {string} */ - FileAction: "read" | "write" | "patch"; - HealthResponse: { - status: string; - }; - ItemDeltaData: { - delta: string; - item_id: string; - native_item_id?: string | null; - }; - ItemEventData: { - item: components["schemas"]["UniversalItem"]; + "/v1/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_agents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** @enum {string} */ - ItemKind: "message" | "tool_call" | "tool_result" | "system" | "status" | "unknown"; - /** @enum {string} */ - ItemRole: "user" | "assistant" | "system" | "tool"; - /** @enum {string} */ - ItemStatus: "in_progress" | "completed" | "failed"; - MessageRequest: { - message: string; + "/v1/agents/{agent}/install": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["install_agent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - PermissionEventData: { - action: string; - metadata?: unknown; - permission_id: string; - status: components["schemas"]["PermissionStatus"]; + "/v1/agents/{agent}/modes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_agent_modes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** @enum {string} */ - PermissionReply: "once" | "always" | "reject"; - PermissionReplyRequest: { - reply: components["schemas"]["PermissionReply"]; + "/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** @enum {string} */ - PermissionStatus: "requested" | "approved" | "denied"; - ProblemDetails: { - detail?: string | null; - instance?: string | null; - /** Format: int32 */ - status: number; - title: string; - type: string; - [key: string]: unknown; + "/v1/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_sessions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - QuestionEventData: { - options: string[]; - prompt: string; - question_id: string; - response?: string | null; - status: components["schemas"]["QuestionStatus"]; + "/v1/sessions/{session_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["create_session"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - QuestionReplyRequest: { - answers: string[][]; + "/v1/sessions/{session_id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** @enum {string} */ - QuestionStatus: "requested" | "answered" | "rejected"; - /** @enum {string} */ - ReasoningVisibility: "public" | "private"; - /** - * @description Status of a shared server process for an agent - * @enum {string} - */ - ServerStatus: "running" | "stopped" | "error"; - ServerStatusInfo: { - baseUrl?: string | null; - lastError?: string | null; - /** Format: int64 */ - restartCount: number; - status: components["schemas"]["ServerStatus"]; - /** Format: int64 */ - uptimeMs?: number | null; + "/v1/sessions/{session_id}/events/sse": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_events_sse"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** @enum {string} */ - SessionEndReason: "completed" | "error" | "terminated"; - SessionEndedData: { - /** - * Format: int32 - * @description Process exit code when reason is Error - */ - exit_code?: number | null; - /** @description Error message when reason is Error */ - message?: string | null; - reason: components["schemas"]["SessionEndReason"]; - stderr?: components["schemas"]["StderrOutput"] | null; - terminated_by: components["schemas"]["TerminatedBy"]; + "/v1/sessions/{session_id}/mcp-tunnel/calls/{call_id}/response": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reply_mcp_tunnel_call"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - SessionInfo: { - agent: string; - agentMode: string; - ended: boolean; - /** Format: int64 */ - eventCount: number; - model?: string | null; - nativeSessionId?: string | null; - permissionMode: string; - sessionId: string; - variant?: string | null; + "/v1/sessions/{session_id}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_message"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - SessionListResponse: { - sessions: components["schemas"]["SessionInfo"][]; + "/v1/sessions/{session_id}/messages/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_message_stream"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - SessionStartedData: { - metadata?: unknown; + "/v1/sessions/{session_id}/permissions/{permission_id}/reply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reply_permission"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - StderrOutput: { - /** @description First N lines of stderr (if truncated) or full stderr (if not truncated) */ - head?: string | null; - /** @description Last N lines of stderr (only present if truncated) */ - tail?: string | null; - /** @description Total number of lines in stderr */ - total_lines?: number | null; - /** @description Whether the output was truncated */ - truncated: boolean; + "/v1/sessions/{session_id}/questions/{question_id}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reject_question"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - /** @enum {string} */ - TerminatedBy: "agent" | "daemon"; - TurnStreamQuery: { - includeRaw?: boolean | null; + "/v1/sessions/{session_id}/questions/{question_id}/reply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reply_question"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - UniversalEvent: { - data: components["schemas"]["UniversalEventData"]; - event_id: string; - native_session_id?: string | null; - raw?: unknown; - /** Format: int64 */ - sequence: number; - session_id: string; - source: components["schemas"]["EventSource"]; - synthetic: boolean; - time: string; - type: components["schemas"]["UniversalEventType"]; + "/v1/sessions/{session_id}/terminate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["terminate_session"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; }; - UniversalEventData: components["schemas"]["SessionStartedData"] | components["schemas"]["SessionEndedData"] | components["schemas"]["ItemEventData"] | components["schemas"]["ItemDeltaData"] | components["schemas"]["ErrorData"] | components["schemas"]["PermissionEventData"] | components["schemas"]["QuestionEventData"] | components["schemas"]["AgentUnparsedData"]; - /** @enum {string} */ - UniversalEventType: "session.started" | "session.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed"; - UniversalItem: { - content: components["schemas"]["ContentPart"][]; - item_id: string; - kind: components["schemas"]["ItemKind"]; - native_item_id?: string | null; - parent_id?: string | null; - role?: components["schemas"]["ItemRole"] | null; - status: components["schemas"]["ItemStatus"]; +} +export type webhooks = Record; +export interface components { + schemas: { + AgentCapabilities: { + commandExecution: boolean; + errorEvents: boolean; + fileAttachments: boolean; + fileChanges: boolean; + images: boolean; + itemStarted: boolean; + mcpTools: boolean; + permissions: boolean; + planMode: boolean; + questions: boolean; + reasoning: boolean; + sessionLifecycle: boolean; + /** @description Whether this agent uses a shared long-running server process (vs per-turn subprocess) */ + sharedProcess: boolean; + status: boolean; + streamingDeltas: boolean; + textMessages: boolean; + toolCalls: boolean; + toolResults: boolean; + }; + AgentError: { + agent?: string | null; + details?: unknown; + message: string; + session_id?: string | null; + type: components["schemas"]["ErrorType"]; + }; + AgentInfo: { + capabilities: components["schemas"]["AgentCapabilities"]; + id: string; + installed: boolean; + path?: string | null; + serverStatus?: components["schemas"]["ServerStatusInfo"] | null; + version?: string | null; + }; + AgentInstallRequest: { + reinstall?: boolean | null; + }; + AgentListResponse: { + agents: components["schemas"]["AgentInfo"][]; + }; + AgentModeInfo: { + description: string; + id: string; + name: string; + }; + AgentModesResponse: { + modes: components["schemas"]["AgentModeInfo"][]; + }; + AgentUnparsedData: { + error: string; + location: string; + raw_hash?: string | null; + }; + ContentPart: { + text: string; + /** @enum {string} */ + type: "text"; + } | { + json: unknown; + /** @enum {string} */ + type: "json"; + } | { + arguments: string; + call_id: string; + name: string; + /** @enum {string} */ + type: "tool_call"; + } | { + call_id: string; + output: string; + /** @enum {string} */ + type: "tool_result"; + } | { + action: components["schemas"]["FileAction"]; + diff?: string | null; + path: string; + /** @enum {string} */ + type: "file_ref"; + } | { + text: string; + /** @enum {string} */ + type: "reasoning"; + visibility: components["schemas"]["ReasoningVisibility"]; + } | { + mime?: string | null; + path: string; + /** @enum {string} */ + type: "image"; + } | { + detail?: string | null; + label: string; + /** @enum {string} */ + type: "status"; + }; + CreateSessionRequest: { + agent: string; + agentMode?: string | null; + agentVersion?: string | null; + mcpTunnel?: components["schemas"]["McpTunnelConfig"] | null; + model?: string | null; + permissionMode?: string | null; + variant?: string | null; + }; + CreateSessionResponse: { + error?: components["schemas"]["AgentError"] | null; + healthy: boolean; + nativeSessionId?: string | null; + }; + ErrorData: { + code?: string | null; + details?: unknown; + message: string; + }; + /** @enum {string} */ + ErrorType: "invalid_request" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout"; + /** @enum {string} */ + EventSource: "agent" | "daemon"; + EventsQuery: { + includeRaw?: boolean | null; + /** Format: int64 */ + limit?: number | null; + /** Format: int64 */ + offset?: number | null; + }; + EventsResponse: { + events: components["schemas"]["UniversalEvent"][]; + hasMore: boolean; + }; + /** @enum {string} */ + FileAction: "read" | "write" | "patch"; + HealthResponse: { + status: string; + }; + ItemDeltaData: { + delta: string; + item_id: string; + native_item_id?: string | null; + }; + ItemEventData: { + item: components["schemas"]["UniversalItem"]; + }; + /** @enum {string} */ + ItemKind: "message" | "tool_call" | "tool_result" | "system" | "status" | "unknown"; + /** @enum {string} */ + ItemRole: "user" | "assistant" | "system" | "tool"; + /** @enum {string} */ + ItemStatus: "in_progress" | "completed" | "failed"; + McpTunnelConfig: { + tools: components["schemas"]["McpTunnelTool"][]; + }; + McpTunnelTool: { + description?: string | null; + inputSchema?: unknown; + name: string; + }; + McpTunnelToolResponseRequest: { + content?: unknown; + isError?: boolean | null; + output: string; + }; + MessageRequest: { + message: string; + }; + PermissionEventData: { + action: string; + metadata?: unknown; + permission_id: string; + status: components["schemas"]["PermissionStatus"]; + }; + /** @enum {string} */ + PermissionReply: "once" | "always" | "reject"; + PermissionReplyRequest: { + reply: components["schemas"]["PermissionReply"]; + }; + /** @enum {string} */ + PermissionStatus: "requested" | "approved" | "denied"; + ProblemDetails: { + detail?: string | null; + instance?: string | null; + /** Format: int32 */ + status: number; + title: string; + type: string; + } & { + [key: string]: unknown; + }; + QuestionEventData: { + options: string[]; + prompt: string; + question_id: string; + response?: string | null; + status: components["schemas"]["QuestionStatus"]; + }; + QuestionReplyRequest: { + answers: string[][]; + }; + /** @enum {string} */ + QuestionStatus: "requested" | "answered" | "rejected"; + /** @enum {string} */ + ReasoningVisibility: "public" | "private"; + /** + * @description Status of a shared server process for an agent + * @enum {string} + */ + ServerStatus: "running" | "stopped" | "error"; + ServerStatusInfo: { + baseUrl?: string | null; + lastError?: string | null; + /** Format: int64 */ + restartCount: number; + status: components["schemas"]["ServerStatus"]; + /** Format: int64 */ + uptimeMs?: number | null; + }; + /** @enum {string} */ + SessionEndReason: "completed" | "error" | "terminated"; + SessionEndedData: { + /** + * Format: int32 + * @description Process exit code when reason is Error + */ + exit_code?: number | null; + /** @description Error message when reason is Error */ + message?: string | null; + reason: components["schemas"]["SessionEndReason"]; + stderr?: components["schemas"]["StderrOutput"] | null; + terminated_by: components["schemas"]["TerminatedBy"]; + }; + SessionInfo: { + agent: string; + agentMode: string; + ended: boolean; + /** Format: int64 */ + eventCount: number; + model?: string | null; + nativeSessionId?: string | null; + permissionMode: string; + sessionId: string; + variant?: string | null; + }; + SessionListResponse: { + sessions: components["schemas"]["SessionInfo"][]; + }; + SessionStartedData: { + metadata?: unknown; + }; + StderrOutput: { + /** @description First N lines of stderr (if truncated) or full stderr (if not truncated) */ + head?: string | null; + /** @description Last N lines of stderr (only present if truncated) */ + tail?: string | null; + /** @description Total number of lines in stderr */ + total_lines?: number | null; + /** @description Whether the output was truncated */ + truncated: boolean; + }; + /** @enum {string} */ + TerminatedBy: "agent" | "daemon"; + TurnStreamQuery: { + includeRaw?: boolean | null; + }; + UniversalEvent: { + data: components["schemas"]["UniversalEventData"]; + event_id: string; + native_session_id?: string | null; + raw?: unknown; + /** Format: int64 */ + sequence: number; + session_id: string; + source: components["schemas"]["EventSource"]; + synthetic: boolean; + time: string; + type: components["schemas"]["UniversalEventType"]; + }; + UniversalEventData: components["schemas"]["SessionStartedData"] | components["schemas"]["SessionEndedData"] | components["schemas"]["ItemEventData"] | components["schemas"]["ItemDeltaData"] | components["schemas"]["ErrorData"] | components["schemas"]["PermissionEventData"] | components["schemas"]["QuestionEventData"] | components["schemas"]["AgentUnparsedData"]; + /** @enum {string} */ + UniversalEventType: "session.started" | "session.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed"; + UniversalItem: { + content: components["schemas"]["ContentPart"][]; + item_id: string; + kind: components["schemas"]["ItemKind"]; + native_item_id?: string | null; + parent_id?: string | null; + role?: components["schemas"]["ItemRole"] | null; + status: components["schemas"]["ItemStatus"]; + }; }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; } - export type $defs = Record; - -export type external = Record; - export interface operations { - - list_agents: { - responses: { - 200: { - content: { - "application/json": components["schemas"]["AgentListResponse"]; + list_agents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentListResponse"]; + }; + }; }; - }; - }; - }; - install_agent: { - parameters: { - path: { - /** @description Agent id */ - agent: string; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AgentInstallRequest"]; - }; - }; - responses: { - /** @description Agent installed */ - 204: { - content: never; - }; - 400: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - 404: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - 500: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; - get_agent_modes: { - parameters: { - path: { - /** @description Agent id */ - agent: string; - }; }; - responses: { - 200: { - content: { - "application/json": components["schemas"]["AgentModesResponse"]; + install_agent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Agent id */ + agent: string; + }; + cookie?: never; }; - }; - 400: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; + requestBody: { + content: { + "application/json": components["schemas"]["AgentInstallRequest"]; + }; + }; + responses: { + /** @description Agent installed */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; }; - }; - }; - }; - get_health: { - responses: { - 200: { - content: { - "application/json": components["schemas"]["HealthResponse"]; - }; - }; - }; - }; - list_sessions: { - responses: { - 200: { - content: { - "application/json": components["schemas"]["SessionListResponse"]; - }; - }; - }; - }; - create_session: { - parameters: { - path: { - /** @description Client session id */ - session_id: string; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateSessionRequest"]; - }; - }; - responses: { - 200: { - content: { - "application/json": components["schemas"]["CreateSessionResponse"]; - }; - }; - 400: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - 409: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; - }; - }; - get_events: { - parameters: { - query?: { - /** @description Last seen event sequence (exclusive) */ - offset?: number | null; - /** @description Max events to return */ - limit?: number | null; - /** @description Include raw provider payloads */ - include_raw?: boolean | null; - }; - path: { - /** @description Session id */ - session_id: string; - }; }; - responses: { - 200: { - content: { - "application/json": components["schemas"]["EventsResponse"]; + get_agent_modes: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Agent id */ + agent: string; + }; + cookie?: never; }; - }; - 404: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentModesResponse"]; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; }; - }; - }; - }; - get_events_sse: { - parameters: { - query?: { - /** @description Last seen event sequence (exclusive) */ - offset?: number | null; - /** @description Include raw provider payloads */ - include_raw?: boolean | null; - }; - path: { - /** @description Session id */ - session_id: string; - }; - }; - responses: { - /** @description SSE event stream */ - 200: { - content: never; - }; - }; - }; - post_message: { - parameters: { - path: { - /** @description Session id */ - session_id: string; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["MessageRequest"]; - }; - }; - responses: { - /** @description Message accepted */ - 204: { - content: never; - }; - 404: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; }; - }; - post_message_stream: { - parameters: { - query?: { - /** @description Include raw provider payloads */ - include_raw?: boolean | null; - }; - path: { - /** @description Session id */ - session_id: string; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["MessageRequest"]; - }; + get_health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; }; - responses: { - /** @description SSE event stream */ - 200: { - content: never; - }; - 404: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; + list_sessions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionListResponse"]; + }; + }; + }; }; - }; - reply_permission: { - parameters: { - path: { - /** @description Session id */ - session_id: string; - /** @description Permission id */ - permission_id: string; - }; + create_session: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Client session id */ + session_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSessionRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateSessionResponse"]; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - requestBody: { - content: { - "application/json": components["schemas"]["PermissionReplyRequest"]; - }; + get_events: { + parameters: { + query?: { + /** @description Last seen event sequence (exclusive) */ + offset?: number | null; + /** @description Max events to return */ + limit?: number | null; + /** @description Include raw provider payloads */ + include_raw?: boolean | null; + }; + header?: never; + path: { + /** @description Session id */ + session_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventsResponse"]; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - responses: { - /** @description Permission reply accepted */ - 204: { - content: never; - }; - 404: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; + get_events_sse: { + parameters: { + query?: { + /** @description Last seen event sequence (exclusive) */ + offset?: number | null; + /** @description Include raw provider payloads */ + include_raw?: boolean | null; + }; + header?: never; + path: { + /** @description Session id */ + session_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description SSE event stream */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; }; - }; - reject_question: { - parameters: { - path: { - /** @description Session id */ - session_id: string; - /** @description Question id */ - question_id: string; - }; + reply_mcp_tunnel_call: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session id */ + session_id: string; + /** @description MCP call id */ + call_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["McpTunnelToolResponseRequest"]; + }; + }; + responses: { + /** @description MCP tool call responded */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - responses: { - /** @description Question rejected */ - 204: { - content: never; - }; - 404: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; + post_message: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session id */ + session_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MessageRequest"]; + }; + }; + responses: { + /** @description Message accepted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - }; - reply_question: { - parameters: { - path: { - /** @description Session id */ - session_id: string; - /** @description Question id */ - question_id: string; - }; + post_message_stream: { + parameters: { + query?: { + /** @description Include raw provider payloads */ + include_raw?: boolean | null; + }; + header?: never; + path: { + /** @description Session id */ + session_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MessageRequest"]; + }; + }; + responses: { + /** @description SSE event stream */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - requestBody: { - content: { - "application/json": components["schemas"]["QuestionReplyRequest"]; - }; + reply_permission: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session id */ + session_id: string; + /** @description Permission id */ + permission_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PermissionReplyRequest"]; + }; + }; + responses: { + /** @description Permission reply accepted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - responses: { - /** @description Question answered */ - 204: { - content: never; - }; - 404: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; + reject_question: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session id */ + session_id: string; + /** @description Question id */ + question_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Question rejected */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - }; - terminate_session: { - parameters: { - path: { - /** @description Session id */ - session_id: string; - }; + reply_question: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session id */ + session_id: string; + /** @description Question id */ + question_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QuestionReplyRequest"]; + }; + }; + responses: { + /** @description Question answered */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - responses: { - /** @description Session terminated */ - 204: { - content: never; - }; - 404: { - content: { - "application/json": components["schemas"]["ProblemDetails"]; - }; - }; + terminate_session: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session id */ + session_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Session terminated */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; }; - }; } diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index db8b4eb..99fa8fd 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -28,6 +28,9 @@ export type { ItemRole, ItemStatus, MessageRequest, + McpTunnelConfig, + McpTunnelTool, + McpTunnelToolResponseRequest, PermissionEventData, PermissionReply, PermissionReplyRequest, diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index e0c43df..f2c931d 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -24,6 +24,9 @@ export type ItemKind = S["ItemKind"]; export type ItemRole = S["ItemRole"]; export type ItemStatus = S["ItemStatus"]; export type MessageRequest = S["MessageRequest"]; +export type McpTunnelConfig = S["McpTunnelConfig"]; +export type McpTunnelTool = S["McpTunnelTool"]; +export type McpTunnelToolResponseRequest = S["McpTunnelToolResponseRequest"]; export type PermissionEventData = S["PermissionEventData"]; export type PermissionReply = S["PermissionReply"]; export type PermissionReplyRequest = S["PermissionReplyRequest"]; diff --git a/server/packages/sandbox-agent/src/main.rs b/server/packages/sandbox-agent/src/main.rs index 92587c2..8d4d4fc 100644 --- a/server/packages/sandbox-agent/src/main.rs +++ b/server/packages/sandbox-agent/src/main.rs @@ -13,8 +13,9 @@ use reqwest::blocking::Client as HttpClient; use reqwest::Method; use sandbox_agent::router::{build_router_with_state, shutdown_servers}; use sandbox_agent::router::{ - AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest, - PermissionReply, PermissionReplyRequest, QuestionReplyRequest, + AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, McpTunnelConfig, + McpTunnelTool, McpTunnelToolResponseRequest, MessageRequest, PermissionReply, + PermissionReplyRequest, QuestionReplyRequest, }; use sandbox_agent::router::{ AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse, @@ -172,6 +173,9 @@ enum SessionsCommand { #[command(name = "reply-permission")] /// Reply to a permission request. ReplyPermission(PermissionReplyArgs), + #[command(name = "reply-mcp-tunnel")] + /// Reply to an MCP tunnel tool call. + ReplyMcpTunnel(McpTunnelReplyArgs), } #[derive(Args, Debug, Clone)] @@ -218,6 +222,8 @@ struct CreateSessionArgs { variant: Option, #[arg(long, short = 'A')] agent_version: Option, + #[arg(long)] + mcp_tunnel_tools: Option, #[command(flatten)] client: ClientArgs, } @@ -301,6 +307,20 @@ struct PermissionReplyArgs { client: ClientArgs, } +#[derive(Args, Debug)] +struct McpTunnelReplyArgs { + session_id: String, + call_id: String, + #[arg(long, short = 'o')] + output: String, + #[arg(long)] + is_error: bool, + #[arg(long)] + content: Option, + #[command(flatten)] + client: ClientArgs, +} + #[derive(Args, Debug)] struct CredentialsExtractArgs { #[arg(long, short = 'a', value_enum)] @@ -486,6 +506,12 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> { } SessionsCommand::Create(args) => { let ctx = ClientContext::new(cli, &args.client)?; + let mcp_tunnel = if let Some(raw) = args.mcp_tunnel_tools.as_deref() { + let tools: Vec = serde_json::from_str(raw)?; + Some(McpTunnelConfig { tools }) + } else { + None + }; let body = CreateSessionRequest { agent: args.agent.clone(), agent_mode: args.agent_mode.clone(), @@ -493,6 +519,7 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> { model: args.model.clone(), variant: args.variant.clone(), agent_version: args.agent_version.clone(), + mcp_tunnel, }; let path = format!("{API_PREFIX}/sessions/{}", args.session_id); let response = ctx.post(&path, &body)?; @@ -604,6 +631,24 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> { let response = ctx.post(&path, &body)?; print_empty_response(response) } + SessionsCommand::ReplyMcpTunnel(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let content = match args.content.as_deref() { + Some(value) => Some(serde_json::from_str(value)?), + None => None, + }; + let body = McpTunnelToolResponseRequest { + output: args.output.clone(), + is_error: if args.is_error { Some(true) } else { None }, + content, + }; + let path = format!( + "{API_PREFIX}/sessions/{}/mcp-tunnel/calls/{}/response", + args.session_id, args.call_id + ); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } } } diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 5f16582..e64a398 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -117,6 +117,10 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) "/sessions/:session_id/permissions/:permission_id/reply", post(reply_permission), ) + .route( + "/sessions/:session_id/mcp-tunnel/calls/:call_id/response", + post(reply_mcp_tunnel_call), + ) .with_state(shared.clone()); if shared.auth.token.is_some() { @@ -128,13 +132,16 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) let mut router = Router::new() .route("/", get(get_root)) + .route("/mcp/:session_id", post(mcp_tunnel_request)) .nest("/v1", v1_router) .fallback(not_found); if ui::is_enabled() { - router = router.merge(ui::router()); + router = router.merge(ui::router().with_state::>(())); } + let router = router.with_state(shared.clone()); + (router.layer(TraceLayer::new_for_http()), shared) } @@ -158,7 +165,8 @@ pub async fn shutdown_servers(state: &Arc) { get_events_sse, reply_question, reject_question, - reply_permission + reply_permission, + reply_mcp_tunnel_call ), components( schemas( @@ -173,9 +181,12 @@ pub async fn shutdown_servers(state: &Arc) { SessionInfo, SessionListResponse, HealthResponse, + McpTunnelTool, + McpTunnelConfig, CreateSessionRequest, CreateSessionResponse, MessageRequest, + McpTunnelToolResponseRequest, EventsQuery, TurnStreamQuery, EventsResponse, @@ -264,6 +275,7 @@ struct SessionState { events: Vec, pending_questions: HashMap, pending_permissions: HashMap, + mcp_tunnel: Option, item_started: HashSet, item_delta_seen: HashSet, item_map: HashMap, @@ -291,6 +303,34 @@ struct PendingQuestion { options: Vec, } +#[derive(Debug)] +struct McpTunnelState { + tools: Vec, + pending_calls: HashMap, +} + +#[derive(Debug)] +struct PendingMcpCall { + responder: oneshot::Sender, +} + +#[derive(Debug, Clone)] +struct McpTunnelToolResponse { + output: String, + is_error: bool, + content: Option, +} + +impl From for McpTunnelToolResponse { + fn from(request: McpTunnelToolResponseRequest) -> Self { + Self { + output: request.output, + is_error: request.is_error.unwrap_or(false), + content: request.content, + } + } +} + impl SessionState { fn new( session_id: String, @@ -302,6 +342,16 @@ impl SessionState { request.agent_mode.as_deref(), request.permission_mode.as_deref(), )?; + let mut mcp_tool_names = HashSet::new(); + if let Some(mcp_tunnel) = request.mcp_tunnel.as_ref() { + for tool in &mcp_tunnel.tools { + if !mcp_tool_names.insert(tool.name.clone()) { + return Err(SandboxError::InvalidRequest { + message: format!("duplicate MCP tool name: {}", tool.name), + }); + } + } + } let (broadcaster, _rx) = broadcast::channel(256); Ok(Self { @@ -322,6 +372,10 @@ impl SessionState { events: Vec::new(), pending_questions: HashMap::new(), pending_permissions: HashMap::new(), + mcp_tunnel: request.mcp_tunnel.as_ref().map(|config| McpTunnelState { + tools: config.tools.clone(), + pending_calls: HashMap::new(), + }), item_started: HashSet::new(), item_delta_seen: HashSet::new(), item_map: HashMap::new(), @@ -1529,6 +1583,7 @@ impl SessionManager { model: session.model.clone(), variant: session.variant.clone(), native_session_id: None, + mcp_tunnel: session.mcp_tunnel.is_some(), }; let thread_id = self.create_codex_thread(&session_id, &snapshot).await?; session.native_session_id = Some(thread_id); @@ -1537,13 +1592,21 @@ impl SessionManager { session.native_session_id = Some(format!("mock-{session_id}")); } - let metadata = json!({ + let mut metadata = json!({ "agent": request.agent, "agentMode": session.agent_mode, "permissionMode": session.permission_mode, "model": request.model, "variant": request.variant, }); + if request.mcp_tunnel.is_some() { + if let Some(map) = metadata.as_object_mut() { + map.insert( + "mcpTunnel".to_string(), + json!({ "url": mcp_tunnel_url(&session_id) }), + ); + } + } let started = EventConversion::new( UniversalEventType::SessionStarted, UniversalEventData::SessionStarted(SessionStartedData { @@ -2189,6 +2252,222 @@ impl SessionManager { Ok(()) } + async fn handle_mcp_tunnel_request( + &self, + session_id: &str, + request: McpJsonRpcRequest, + ) -> Result, SandboxError> { + if request.id.is_none() && request.method == "initialized" { + return Ok(None); + } + + if let Some(version) = request.jsonrpc.as_deref() { + if version != "2.0" { + return Err(SandboxError::InvalidRequest { + message: format!("unsupported JSON-RPC version: {version}"), + }); + } + } + + let request_id = request.id.ok_or_else(|| SandboxError::InvalidRequest { + message: "missing JSON-RPC id".to_string(), + })?; + match request.method.as_str() { + "initialize" => { + let protocol_version = request + .params + .as_ref() + .and_then(|params| params.get("protocolVersion")) + .and_then(Value::as_str) + .unwrap_or("2024-11-05"); + let result = json!({ + "protocolVersion": protocol_version, + "serverInfo": { + "name": "sandbox-agent", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": { + "tools": {}, + } + }); + Ok(Some(mcp_jsonrpc_result(request_id, result))) + } + "tools/list" => { + let tools = self.mcp_tunnel_list_tools(session_id).await?; + let tool_defs = tools + .into_iter() + .map(|tool| { + let mut map = serde_json::Map::new(); + map.insert("name".to_string(), Value::String(tool.name)); + if let Some(description) = tool.description { + map.insert("description".to_string(), Value::String(description)); + } + if let Some(input_schema) = tool.input_schema { + map.insert("inputSchema".to_string(), input_schema); + } + Value::Object(map) + }) + .collect::>(); + Ok(Some(mcp_jsonrpc_result( + request_id, + json!({ "tools": tool_defs }), + ))) + } + "tools/call" => { + let params = request.params.ok_or_else(|| SandboxError::InvalidRequest { + message: "missing tools/call params".to_string(), + })?; + let tool_name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| SandboxError::InvalidRequest { + message: "missing tools/call name".to_string(), + })? + .to_string(); + let arguments = params.get("arguments").cloned().unwrap_or(Value::Null); + let call_id = mcp_call_id_from_request(&request_id)?; + let response = self + .mcp_tunnel_call(session_id, &call_id, tool_name, arguments) + .await?; + let content = response + .content + .clone() + .unwrap_or_else(|| json!([{ "type": "text", "text": response.output }])); + let mut result = json!({ "content": content }); + if response.is_error { + if let Some(map) = result.as_object_mut() { + map.insert("isError".to_string(), Value::Bool(true)); + } + } + Ok(Some(mcp_jsonrpc_result(request_id, result))) + } + _ => Err(SandboxError::InvalidRequest { + message: format!("unknown MCP method: {}", request.method), + }), + } + } + + async fn mcp_tunnel_list_tools( + &self, + session_id: &str, + ) -> Result, SandboxError> { + let sessions = self.sessions.lock().await; + let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + let tunnel = session.mcp_tunnel.as_ref().ok_or_else(|| SandboxError::InvalidRequest { + message: "MCP tunnel not configured".to_string(), + })?; + Ok(tunnel.tools.clone()) + } + + async fn mcp_tunnel_call( + &self, + session_id: &str, + call_id: &str, + tool_name: String, + arguments: Value, + ) -> Result { + let receiver = { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + if let Some(err) = session.ended_error() { + return Err(err); + } + let tunnel = session + .mcp_tunnel + .as_mut() + .ok_or_else(|| SandboxError::InvalidRequest { + message: "MCP tunnel not configured".to_string(), + })?; + if !tunnel.tools.iter().any(|tool| tool.name == tool_name) { + return Err(SandboxError::InvalidRequest { + message: format!("unknown MCP tool: {tool_name}"), + }); + } + if tunnel.pending_calls.contains_key(call_id) { + return Err(SandboxError::InvalidRequest { + message: format!("duplicate MCP call id: {call_id}"), + }); + } + let (tx, rx) = oneshot::channel(); + tunnel.pending_calls.insert( + call_id.to_string(), + PendingMcpCall { responder: tx }, + ); + rx + }; + + let tool_call_events = mcp_tool_call_events(call_id, &tool_name, &arguments); + let _ = self.record_conversions(session_id, tool_call_events).await?; + + let response = match tokio::time::timeout(Duration::from_secs(120), receiver).await { + Ok(Ok(response)) => response, + Ok(Err(_)) => { + self.remove_mcp_tunnel_call(session_id, call_id).await; + return Err(SandboxError::InvalidRequest { + message: "MCP call response dropped".to_string(), + }); + } + Err(_) => { + self.remove_mcp_tunnel_call(session_id, call_id).await; + return Err(SandboxError::InvalidRequest { + message: "MCP call timed out".to_string(), + }); + } + }; + + Ok(response) + } + + async fn reply_mcp_tunnel_call( + &self, + session_id: &str, + call_id: &str, + response: McpTunnelToolResponse, + ) -> Result<(), SandboxError> { + let pending = { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + let tunnel = session + .mcp_tunnel + .as_mut() + .ok_or_else(|| SandboxError::InvalidRequest { + message: "MCP tunnel not configured".to_string(), + })?; + tunnel.pending_calls.remove(call_id).ok_or_else(|| { + SandboxError::InvalidRequest { + message: format!("unknown MCP call id: {call_id}"), + } + })? + }; + + let _ = pending.responder.send(response.clone()); + let tool_result_events = mcp_tool_result_events(call_id, &response); + let _ = self.record_conversions(session_id, tool_result_events).await?; + Ok(()) + } + + async fn remove_mcp_tunnel_call(&self, session_id: &str, call_id: &str) { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id); + if let Some(session) = session { + if let Some(tunnel) = session.mcp_tunnel.as_mut() { + tunnel.pending_calls.remove(call_id); + } + } + } + /// Gets a session snapshot for sending a new message. /// Uses the `for_new_message` check which allows agents that support resumption /// (Claude, Amp, OpenCode) to continue after their process exits successfully. @@ -2232,16 +2511,63 @@ impl SessionManager { if !trimmed.is_empty() { conversions.extend(mock_user_message(&prefix, trimmed)); } - conversions.extend(mock_command_conversions(&prefix, trimmed)); + let is_mcp_command = trimmed.eq_ignore_ascii_case("mcp"); + if !is_mcp_command { + conversions.extend(mock_command_conversions(&prefix, trimmed)); + } let manager = Arc::clone(self); + let events_session_id = session_id.clone(); tokio::spawn(async move { - manager.emit_mock_events(session_id, conversions).await; + manager.emit_mock_events(events_session_id, conversions).await; }); + if is_mcp_command { + let manager = Arc::clone(self); + let session_id = session_id.clone(); + let prefix = prefix.clone(); + tokio::spawn(async move { + manager.emit_mock_mcp_call(session_id, prefix).await; + }); + } + Ok(()) } + async fn emit_mock_mcp_call(&self, session_id: String, prefix: String) { + let tool_name = { + let sessions = self.sessions.lock().await; + let session = Self::session_ref(&sessions, &session_id); + session + .and_then(|session| session.mcp_tunnel.as_ref()) + .and_then(|tunnel| tunnel.tools.first()) + .map(|tool| tool.name.clone()) + }; + + let Some(tool_name) = tool_name else { + self.record_error( + &session_id, + "mock MCP tool call requested without MCP tunnel".to_string(), + Some("mcp_tunnel".to_string()), + None, + ) + .await; + return; + }; + + let request = McpJsonRpcRequest { + jsonrpc: Some("2.0".to_string()), + id: Some(Value::String(format!("{prefix}_mcp_call"))), + method: "tools/call".to_string(), + params: Some(json!({ + "name": tool_name, + "arguments": { "query": "example" } + })), + }; + + let _ = self.handle_mcp_tunnel_request(&session_id, request).await; + } + async fn emit_mock_events( self: Arc, session_id: String, @@ -3420,6 +3746,22 @@ pub struct HealthResponse { pub status: String, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct McpTunnelTool { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct McpTunnelConfig { + pub tools: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { @@ -3434,6 +3776,8 @@ pub struct CreateSessionRequest { pub variant: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_tunnel: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] @@ -3452,6 +3796,27 @@ pub struct MessageRequest { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct McpTunnelToolResponseRequest { + pub output: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct McpJsonRpcRequest { + #[serde(default)] + jsonrpc: Option, + #[serde(default)] + id: Option, + method: String, + #[serde(default)] + params: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct EventsQuery { @@ -3589,6 +3954,30 @@ async fn get_root() -> &'static str { SERVER_INFO } +async fn mcp_tunnel_request( + State(state): State>, + Path(session_id): Path, + Json(request): Json, +) -> Result { + let request_id = request.id.clone(); + let response = state + .session_manager + .handle_mcp_tunnel_request(&session_id, request) + .await; + + match response { + Ok(Some(payload)) => Ok((StatusCode::OK, Json(payload)).into_response()), + Ok(None) => Ok(StatusCode::NO_CONTENT.into_response()), + Err(err) => { + if request_id.is_none() { + return Ok(StatusCode::NO_CONTENT.into_response()); + } + let payload = mcp_jsonrpc_error(request_id, mcp_error_code(&err), err.to_string()); + Ok((StatusCode::OK, Json(payload)).into_response()) + } + } +} + async fn not_found() -> (StatusCode, String) { ( StatusCode::NOT_FOUND, @@ -3933,6 +4322,33 @@ async fn reply_permission( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/v1/sessions/{session_id}/mcp-tunnel/calls/{call_id}/response", + request_body = McpTunnelToolResponseRequest, + responses( + (status = 204, description = "MCP tool call responded"), + (status = 400, body = ProblemDetails), + (status = 404, body = ProblemDetails) + ), + params( + ("session_id" = String, Path, description = "Session id"), + ("call_id" = String, Path, description = "MCP call id") + ), + tag = "sessions" +)] +async fn reply_mcp_tunnel_call( + State(state): State>, + Path((session_id, call_id)): Path<(String, String)>, + Json(request): Json, +) -> Result { + state + .session_manager + .reply_mcp_tunnel_call(&session_id, &call_id, request.into()) + .await?; + Ok(StatusCode::NO_CONTENT) +} + fn all_agents() -> [AgentId; 5] { [ AgentId::Claude, @@ -4313,6 +4729,11 @@ fn build_spawn_options( .entry("CODEX_API_KEY".to_string()) .or_insert(openai.api_key); } + if session.mcp_tunnel { + options.env.entry("SANDBOX_AGENT_MCP_TUNNEL_URL".to_string()).or_insert( + mcp_tunnel_url(&session.session_id), + ); + } options } @@ -5143,6 +5564,7 @@ pub mod test_utils { model: None, variant: None, agent_version: None, + mcp_tunnel: None, }; let mut session = SessionState::new(session_id.to_string(), agent, &request).expect("session"); @@ -5414,6 +5836,101 @@ fn text_delta_from_parts(parts: &[ContentPart]) -> Option { } } +fn mcp_call_id_from_request(id: &Value) -> Result { + if let Some(value) = id.as_str() { + return Ok(value.to_string()); + } + if let Some(value) = id.as_i64() { + return Ok(value.to_string()); + } + if let Some(value) = id.as_u64() { + return Ok(value.to_string()); + } + Err(SandboxError::InvalidRequest { + message: "invalid JSON-RPC id".to_string(), + }) +} + +fn mcp_arguments_to_string(arguments: &Value) -> String { + if let Some(value) = arguments.as_str() { + return value.to_string(); + } + serde_json::to_string(arguments).unwrap_or_else(|_| arguments.to_string()) +} + +fn mcp_tool_call_events(call_id: &str, tool_name: &str, arguments: &Value) -> Vec { + let call_id = call_id.to_string(); + let arguments = mcp_arguments_to_string(arguments); + let tool_call_part = ContentPart::ToolCall { + name: tool_name.to_string(), + arguments, + call_id: call_id.clone(), + }; + let item = UniversalItem { + item_id: String::new(), + native_item_id: Some(call_id.clone()), + parent_id: None, + kind: ItemKind::ToolCall, + role: Some(ItemRole::Assistant), + content: vec![tool_call_part.clone()], + status: ItemStatus::InProgress, + }; + let completed_item = UniversalItem { + status: ItemStatus::Completed, + ..item.clone() + }; + vec![ + EventConversion::new( + UniversalEventType::ItemStarted, + UniversalEventData::Item(ItemEventData { item }), + ), + EventConversion::new( + UniversalEventType::ItemCompleted, + UniversalEventData::Item(ItemEventData { + item: completed_item, + }), + ), + ] +} + +fn mcp_tool_result_events(call_id: &str, response: &McpTunnelToolResponse) -> Vec { + let result_id = format!("{call_id}_result"); + let output = response.output.clone(); + let tool_result_part = ContentPart::ToolResult { + call_id: call_id.to_string(), + output, + }; + let item = UniversalItem { + item_id: String::new(), + native_item_id: Some(result_id), + parent_id: Some(call_id.to_string()), + kind: ItemKind::ToolResult, + role: Some(ItemRole::Tool), + content: vec![tool_result_part.clone()], + status: ItemStatus::InProgress, + }; + let completed_item = UniversalItem { + status: if response.is_error { + ItemStatus::Failed + } else { + ItemStatus::Completed + }, + ..item.clone() + }; + vec![ + EventConversion::new( + UniversalEventType::ItemStarted, + UniversalEventData::Item(ItemEventData { item }), + ), + EventConversion::new( + UniversalEventType::ItemCompleted, + UniversalEventData::Item(ItemEventData { + item: completed_item, + }), + ), + ] +} + const MOCK_OK_PROMPT: &str = "Reply with exactly the single word OK."; const MOCK_FIRST_PROMPT: &str = "Reply with exactly the word FIRST."; const MOCK_SECOND_PROMPT: &str = "Reply with exactly the word SECOND."; @@ -5564,6 +6081,7 @@ fn mock_help_message(prefix: &str) -> Vec { "- demo: run a full UI coverage sequence with markers.", "- markdown: streaming markdown fixture.", "- tool: tool call + tool result with file refs.", + "- mcp: call MCP tunnel tool.", "- status: status item updates.", "- image: message with image content part.", "- unknown: item.kind=unknown example.", @@ -6311,6 +6829,33 @@ fn to_sse_event(event: UniversalEvent) -> Event { .unwrap_or_else(|_| Event::default().data("{}")) } +fn mcp_jsonrpc_result(id: Value, result: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }) +} + +fn mcp_jsonrpc_error(id: Option, code: i64, message: String) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id.unwrap_or(Value::Null), + "error": { + "code": code, + "message": message, + } + }) +} + +fn mcp_error_code(error: &SandboxError) -> i64 { + match error { + SandboxError::SessionNotFound { .. } => -32001, + SandboxError::InvalidRequest { .. } => -32602, + _ => -32603, + } +} + #[derive(Clone, Debug)] struct SessionSnapshot { session_id: String, @@ -6320,6 +6865,7 @@ struct SessionSnapshot { model: Option, variant: Option, native_session_id: Option, + mcp_tunnel: bool, } impl From<&SessionState> for SessionSnapshot { @@ -6332,6 +6878,7 @@ impl From<&SessionState> for SessionSnapshot { model: session.model.clone(), variant: session.variant.clone(), native_session_id: session.native_session_id.clone(), + mcp_tunnel: session.mcp_tunnel.is_some(), } } } @@ -6342,3 +6889,9 @@ pub fn add_token_header(headers: &mut HeaderMap, token: &str) { headers.insert(axum::http::header::AUTHORIZATION, header); } } + +fn mcp_tunnel_url(session_id: &str) -> String { + let base = std::env::var("SANDBOX_AGENT_MCP_BASE_URL") + .unwrap_or_else(|_| "http://127.0.0.1:2468".to_string()); + format!("{}/mcp/{}", base.trim_end_matches('/'), session_id) +} diff --git a/server/packages/sandbox-agent/tests/common/mod.rs b/server/packages/sandbox-agent/tests/common/mod.rs index ea98b47..8ea2974 100644 --- a/server/packages/sandbox-agent/tests/common/mod.rs +++ b/server/packages/sandbox-agent/tests/common/mod.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashMap; use std::time::{Duration, Instant}; use axum::body::Body; diff --git a/server/packages/sandbox-agent/tests/sessions/mcp_tunnel.rs b/server/packages/sandbox-agent/tests/sessions/mcp_tunnel.rs new file mode 100644 index 0000000..29e8558 --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/mcp_tunnel.rs @@ -0,0 +1,125 @@ +include!("../common/http.rs"); + +fn session_snapshot_suffix(prefix: &str) -> String { + snapshot_name(prefix, Some(AgentId::Mock)) +} + +fn assert_session_snapshot(prefix: &str, value: Value) { + insta::with_settings!({ + snapshot_suffix => session_snapshot_suffix(prefix), + }, { + insta::assert_yaml_snapshot!(value); + }); +} + +fn has_item_kind(events: &[Value], kind: &str) -> bool { + events.iter().any(|event| { + event + .get("type") + .and_then(Value::as_str) + .is_some_and(|event_type| event_type == "item.completed") + && event + .get("data") + .and_then(|data| data.get("item")) + .and_then(|item| item.get("kind")) + .and_then(Value::as_str) + .is_some_and(|item_kind| item_kind == kind) + }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_tunnel_end_to_end() { + let app = TestApp::new(); + let session_id = "mcp-tunnel"; + + let (status, _created) = send_json( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}"), + Some(json!({ + "agent": "mock", + "permissionMode": "bypass", + "mcpTunnel": { + "tools": [ + { + "name": "private.lookup", + "description": "Lookup data", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + } + ] + } + })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create session"); + + let (status, list_response) = send_json( + &app.app, + Method::POST, + &format!("/mcp/{session_id}"), + Some(json!({ + "jsonrpc": "2.0", + "id": "list", + "method": "tools/list" + })), + ) + .await; + assert_eq!(status, StatusCode::OK, "list tools"); + let tools = list_response + .get("result") + .and_then(|result| result.get("tools")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + assert_eq!(tools.len(), 1, "tools list length"); + + let call_request = json!({ + "jsonrpc": "2.0", + "id": "call-1", + "method": "tools/call", + "params": { + "name": "private.lookup", + "arguments": { "id": "123" } + } + }); + let app_clone = app.app.clone(); + let call_task = tokio::spawn(async move { + send_json( + &app_clone, + Method::POST, + &format!("/mcp/{session_id}"), + Some(call_request), + ) + .await + }); + + let _ = poll_events_until_match(&app.app, session_id, Duration::from_secs(10), |events| { + has_item_kind(events, "tool_call") + }) + .await; + + let status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/mcp-tunnel/calls/call-1/response"), + Some(json!({ "output": "lookup ok" })), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "reply mcp tunnel"); + + let (status, call_response) = call_task.await.expect("call task"); + assert_eq!(status, StatusCode::OK, "mcp call response"); + assert!(call_response.get("result").is_some(), "mcp result missing"); + + let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(10), |events| { + has_item_kind(events, "tool_result") + }) + .await; + assert_session_snapshot("mcp_tunnel", normalize_events(&events)); +} diff --git a/server/packages/sandbox-agent/tests/sessions/mod.rs b/server/packages/sandbox-agent/tests/sessions/mod.rs index c4ade0b..b98337e 100644 --- a/server/packages/sandbox-agent/tests/sessions/mod.rs +++ b/server/packages/sandbox-agent/tests/sessions/mod.rs @@ -1,4 +1,5 @@ mod multi_turn; +mod mcp_tunnel; mod permissions; mod questions; mod reasoning; diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__mcp_tunnel__assert_session_snapshot@mcp_tunnel_mock.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__mcp_tunnel__assert_session_snapshot@mcp_tunnel_mock.snap new file mode 100644 index 0000000..7fc4929 --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__mcp_tunnel__assert_session_snapshot@mcp_tunnel_mock.snap @@ -0,0 +1,40 @@ +--- +source: server/packages/sandbox-agent/tests/sessions/mcp_tunnel.rs +expression: value +--- +- metadata: true + seq: 1 + session: started + type: session.started +- item: + content_types: + - tool_call + kind: tool_call + role: assistant + status: in_progress + seq: 2 + type: item.started +- item: + content_types: + - tool_call + kind: tool_call + role: assistant + status: completed + seq: 3 + type: item.completed +- item: + content_types: + - tool_result + kind: tool_result + role: tool + status: in_progress + seq: 4 + type: item.started +- item: + content_types: + - tool_result + kind: tool_result + role: tool + status: completed + seq: 5 + type: item.completed