From 1611b5291eec82a2fb2b887739acdacff7ce8072 Mon Sep 17 00:00:00 2001 From: daytona-agent Date: Thu, 30 Jul 2026 09:35:05 +0000 Subject: [PATCH 1/2] feat: SSH-equivalent sandbox access over HTTPS (WebSocket exec + MCP) Closes #2 Daemon (toolbox): - GET /process/exec/connect: WebSocket exec channel with SSH channel semantics. Client frames start/stdin/signal/resize/stdin_eof; server frames stdout/stderr/exit/error. With a command it streams output and reports the exit code (SIGINT on sleep 60 -> 130); without one it spawns an interactive login shell (bash -l, fallback sh) on a PTY with control-char signal delivery and resize support. The exit frame is always sent before close. Built on SessionService + cmdWrapperFormat (no parallel command runner); PTY support is additive-only. - POST /mcp: stateless streamable-HTTP MCP endpoint with tools exec_command, fs_read_file, fs_write_file, fs_list_files. Adds exactly one new dependency: github.com/modelcontextprotocol/go-sdk. Proxy: - SSH access tokens (Authorization: Bearer or ?token= query) are accepted for /{sandboxId}/process/exec/connect and /{sandboxId}/mcp. Tokens are validated per connection via /sandbox/ssh-access/validate (no caching, so revocation blocks new connections immediately) and non-started sandboxes are rejected with an explicit state message, matching the SSH gateway. Sandbox activity keepalive piggybacks on the existing last-activity polling (on connect + interval), same as ssh-gateway. API: - validateSshAccess now allows the proxy via OrGuard (same pattern as the other proxy-reachable endpoints); auth spec updated. Generated/docs: - swag init regenerated toolbox swagger docs; toolbox API clients (go, ts, java, python, python-async) regenerated with the new routes; api clients regenerated with zero diff. New docs page "SSH over HTTPS" documents the wire protocol. Verification: - go build ./apps/daemon/... ./apps/proxy/... - go test ./apps/daemon/... (incl. new unit tests for the WS frame protocol start/stdin/signal/exit and the MCP tool handlers) - golangci-lint run: 0 issues for daemon and proxy - npx nx test api: 54 suites / 562 tests pass - Live smoke over real TCP: exec stdout/stderr/exit, SIGINT -> 130, interactive shell state, concurrent connections, MCP initialize/tools/list/stateless tools/call, fs roundtrip --- .../sandbox.controller.auth.spec.ts | 5 +- .../sandbox/controllers/sandbox.controller.ts | 2 +- apps/daemon/go.mod | 1 + apps/daemon/go.sum | 3 + apps/daemon/pkg/session/exec_support.go | 149 ++++++++ apps/daemon/pkg/session/execute.go | 3 + apps/daemon/pkg/toolbox/docs/docs.go | 45 +++ apps/daemon/pkg/toolbox/docs/swagger.json | 36 ++ apps/daemon/pkg/toolbox/docs/swagger.yaml | 40 +++ apps/daemon/pkg/toolbox/mcp/server.go | 85 +++++ apps/daemon/pkg/toolbox/mcp/tools.go | 303 ++++++++++++++++ apps/daemon/pkg/toolbox/mcp/tools_test.go | 236 +++++++++++++ .../pkg/toolbox/process/exec/controller.go | 284 +++++++++++++++ .../toolbox/process/exec/controller_test.go | 334 ++++++++++++++++++ apps/daemon/pkg/toolbox/process/exec/demux.go | 100 ++++++ .../pkg/toolbox/process/exec/demux_test.go | 104 ++++++ .../pkg/toolbox/process/exec/session_exec.go | 235 ++++++++++++ .../pkg/toolbox/process/exec/shell_exec.go | 172 +++++++++ apps/daemon/pkg/toolbox/process/exec/types.go | 77 ++++ .../pkg/toolbox/process/pty/controller.go | 2 + .../pkg/toolbox/process/pty/ephemeral.go | 92 +++++ .../daemon/pkg/toolbox/process/pty/session.go | 7 + apps/daemon/pkg/toolbox/process/pty/types.go | 9 + .../pkg/toolbox/process/pty/websocket.go | 13 + apps/daemon/pkg/toolbox/server.go | 12 + .../src/content/docs/en/ssh-over-https.mdx | 114 ++++++ apps/docs/src/content/i18n/en.json | 2 + apps/docs/src/content/i18n/ja.json | 2 + apps/docs/src/sidebar-config.ts | 9 + apps/proxy/pkg/proxy/agent_access.go | 87 +++++ apps/proxy/pkg/proxy/auth.go | 33 +- apps/proxy/pkg/proxy/get_sandbox_target.go | 2 +- .../.openapi-generator/FILES | 1 + libs/toolbox-api-client-go/api/openapi.yaml | 39 ++ libs/toolbox-api-client-go/api_mcp.go | 127 +++++++ libs/toolbox-api-client-go/api_process.go | 111 ++++++ libs/toolbox-api-client-go/client.go | 3 + .../.openapi-generator/FILES | 2 + .../io/daytona/toolbox/client/api/McpApi.java | 186 ++++++++++ .../toolbox/client/api/ProcessApi.java | 120 +++++++ .../toolbox/client/api/McpApiTest.java | 46 +++ .../toolbox/client/api/ProcessApiTest.java | 14 + .../.openapi-generator/FILES | 1 + .../__init__.py | 3 + .../api/__init__.py | 2 + .../api/mcp_api.py | 272 ++++++++++++++ .../api/process_api.py | 255 +++++++++++++ .../.openapi-generator/FILES | 1 + .../daytona_toolbox_api_client/__init__.py | 3 + .../api/__init__.py | 2 + .../daytona_toolbox_api_client/api/mcp_api.py | 272 ++++++++++++++ .../api/process_api.py | 255 +++++++++++++ .../src/.openapi-generator/FILES | 1 + libs/toolbox-api-client/src/api.ts | 1 + libs/toolbox-api-client/src/api/mcp-api.ts | 114 ++++++ .../toolbox-api-client/src/api/process-api.ts | 68 ++++ 56 files changed, 4493 insertions(+), 4 deletions(-) create mode 100644 apps/daemon/pkg/session/exec_support.go create mode 100644 apps/daemon/pkg/toolbox/mcp/server.go create mode 100644 apps/daemon/pkg/toolbox/mcp/tools.go create mode 100644 apps/daemon/pkg/toolbox/mcp/tools_test.go create mode 100644 apps/daemon/pkg/toolbox/process/exec/controller.go create mode 100644 apps/daemon/pkg/toolbox/process/exec/controller_test.go create mode 100644 apps/daemon/pkg/toolbox/process/exec/demux.go create mode 100644 apps/daemon/pkg/toolbox/process/exec/demux_test.go create mode 100644 apps/daemon/pkg/toolbox/process/exec/session_exec.go create mode 100644 apps/daemon/pkg/toolbox/process/exec/shell_exec.go create mode 100644 apps/daemon/pkg/toolbox/process/exec/types.go create mode 100644 apps/daemon/pkg/toolbox/process/pty/ephemeral.go create mode 100644 apps/docs/src/content/docs/en/ssh-over-https.mdx create mode 100644 apps/proxy/pkg/proxy/agent_access.go create mode 100644 libs/toolbox-api-client-go/api_mcp.go create mode 100644 libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/McpApi.java create mode 100644 libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/McpApiTest.java create mode 100644 libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/mcp_api.py create mode 100644 libs/toolbox-api-client-python/daytona_toolbox_api_client/api/mcp_api.py create mode 100644 libs/toolbox-api-client/src/api/mcp-api.ts diff --git a/apps/api/src/sandbox/controllers/sandbox.controller.auth.spec.ts b/apps/api/src/sandbox/controllers/sandbox.controller.auth.spec.ts index b8eb43caf0..19d03502bc 100644 --- a/apps/api/src/sandbox/controllers/sandbox.controller.auth.spec.ts +++ b/apps/api/src/sandbox/controllers/sandbox.controller.auth.spec.ts @@ -421,7 +421,10 @@ describe('[AUTH] SandboxController', () => { const methodName = trackMethod('validateSshAccess') expect(isPublicEndpoint(SandboxController, methodName)).toBe(false) expectArrayMatch(getAllowedAuthStrategies(SandboxController, methodName), [AuthStrategyType.API_KEY]) - expectArrayMatch(getAuthContextGuards(SandboxController, methodName), [SshGatewayAuthContextGuard]) + expectArrayMatch(getAuthContextGuards(SandboxController, methodName), [ + SshGatewayAuthContextGuard, + ProxyAuthContextGuard, + ]) }) it('getToolboxProxyUrl', () => { diff --git a/apps/api/src/sandbox/controllers/sandbox.controller.ts b/apps/api/src/sandbox/controllers/sandbox.controller.ts index b6dd28dee1..2c2d22a9ac 100644 --- a/apps/api/src/sandbox/controllers/sandbox.controller.ts +++ b/apps/api/src/sandbox/controllers/sandbox.controller.ts @@ -1449,7 +1449,7 @@ export class SandboxController { type: SshAccessValidationDto, }) @AuthStrategy(AuthStrategyType.API_KEY) - @UseGuards(SshGatewayAuthContextGuard) + @UseGuards(OrGuard([SshGatewayAuthContextGuard, ProxyAuthContextGuard])) async validateSshAccess(@Query('token') token: string): Promise { const result = await this.sandboxService.validateSshAccess(token) return SshAccessValidationDto.fromValidationResult(result.valid, result.sandboxId) diff --git a/apps/daemon/go.mod b/apps/daemon/go.mod index f0f68f2cb3..4c7362bc86 100644 --- a/apps/daemon/go.mod +++ b/apps/daemon/go.mod @@ -23,6 +23,7 @@ require ( github.com/kelseyhightower/envconfig v1.4.0 github.com/lmittmann/tint v1.1.2 github.com/mattn/go-isatty v0.0.20 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/orcaman/concurrent-map/v2 v2.0.1 github.com/pkg/sftp v1.13.6 github.com/ramr/go-reaper v0.3.1 diff --git a/apps/daemon/go.sum b/apps/daemon/go.sum index d255060394..3af321543b 100644 --- a/apps/daemon/go.sum +++ b/apps/daemon/go.sum @@ -161,6 +161,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -321,6 +323,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= diff --git a/apps/daemon/pkg/session/exec_support.go b/apps/daemon/pkg/session/exec_support.go new file mode 100644 index 0000000000..df8946885a --- /dev/null +++ b/apps/daemon/pkg/session/exec_support.go @@ -0,0 +1,149 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package session + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + common_errors "github.com/daytonaio/common-go/pkg/errors" +) + +// inputHolderPidFileName is written by cmdWrapperFormat next to the command's +// log file and holds the PID of the async stdin-holder process. +const inputHolderPidFileName = "input_holder.pid" + +// CommandLogPaths returns the log file and exit-code file paths for a +// command, so streaming consumers (e.g. the exec WebSocket endpoint) can tail +// output and detect completion without going through the REST endpoints. +func (s *SessionService) CommandLogPaths(sessionId, commandId string) (logPath, exitCodePath string, err error) { + session, ok := s.sessions.Get(sessionId) + if !ok { + return "", "", common_errors.NewNotFoundError(errors.New("session not found")) + } + + command, ok := session.commands.Get(commandId) + if !ok { + return "", "", common_errors.NewNotFoundError(errors.New("command not found")) + } + + logPath, exitCodePath = command.LogFilePath(session.Dir(s.configDir)) + return logPath, exitCodePath, nil +} + +// WriteInput writes raw bytes to a running command's stdin FIFO. Unlike +// SendInput it adds no trailing newline and does not echo into the log — +// semantics required by byte-exact protocols (exec-over-WebSocket stdin +// frames). The FIFO is opened non-blocking first so a missing reader +// (command already gone) fails fast instead of hanging the caller. +func (s *SessionService) WriteInput(sessionId, commandId string, data []byte) error { + session, ok := s.sessions.Get(sessionId) + if !ok { + return common_errors.NewNotFoundError(errors.New("session not found")) + } + + if session.cmd.ProcessState != nil && session.cmd.ProcessState.Exited() { + return common_errors.NewGoneError(errors.New("session process has exited")) + } + + command, ok := session.commands.Get(commandId) + if !ok { + return common_errors.NewNotFoundError(errors.New("command not found")) + } + + if command.ExitCode != nil { + return common_errors.NewGoneError(fmt.Errorf("command has already completed with exit code %d", *command.ExitCode)) + } + + inputFilePath := command.InputFilePath(session.Dir(s.configDir)) + + fd, err := syscall.Open(inputFilePath, syscall.O_WRONLY|syscall.O_NONBLOCK, 0) + if err != nil { + if errors.Is(err, syscall.ENXIO) || os.IsNotExist(err) { + return common_errors.NewGoneError(errors.New("command stdin is closed")) + } + return common_errors.NewInternalServerError(fmt.Errorf("failed to open input pipe: %w", err)) + } + defer func() { _ = syscall.Close(fd) }() + + // Restore blocking semantics for the write itself so large frames don't + // fail with EAGAIN on a full pipe buffer. + if err := syscall.SetNonblock(fd, false); err != nil { + return common_errors.NewInternalServerError(fmt.Errorf("failed to configure input pipe: %w", err)) + } + + if _, err := syscall.Write(fd, data); err != nil { + return common_errors.NewInternalServerError(fmt.Errorf("failed to write to input pipe: %w", err)) + } + + return nil +} + +// CloseInput delivers stdin EOF to a running command by tearing down the +// input-holder process that cmdWrapperFormat keeps alive for async commands. +// Once the holder (and its current `sleep` child, which inherits the FIFO's +// write end) is gone, the command's stdin sees EOF — SSH channel EOF +// semantics. Best effort: if the holder is not up yet or already gone, the +// command's stdin stays as-is and nil is returned. +func (s *SessionService) CloseInput(sessionId, commandId string) error { + session, ok := s.sessions.Get(sessionId) + if !ok { + return common_errors.NewNotFoundError(errors.New("session not found")) + } + + command, ok := session.commands.Get(commandId) + if !ok { + return common_errors.NewNotFoundError(errors.New("command not found")) + } + + if command.ExitCode != nil { + return common_errors.NewGoneError(fmt.Errorf("command has already completed with exit code %d", *command.ExitCode)) + } + + pidFilePath := filepath.Join(session.Dir(s.configDir), commandId, inputHolderPidFileName) + pidBytes, err := os.ReadFile(pidFilePath) + if err != nil { + return nil + } + + pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))) + if err != nil || pid <= 0 { + return nil + } + + // Kill the holder's children first (the current `sleep 3600` inherits the + // FIFO's write end and would keep stdin open), then the holder itself. + _ = s.signalProcessTree(pid, syscall.SIGKILL) + if holder, err := os.FindProcess(pid); err == nil { + _ = holder.Signal(syscall.SIGKILL) + } + + return nil +} + +// SignalDescendants delivers sig to every descendant of the session's shell +// process — i.e. the currently running command pipeline (command subshell, +// labelers, stdin holder) — without touching the shell itself, so the wrapper +// survives to record the command's exit code (e.g. 130 for SIGINT). +func (s *SessionService) SignalDescendants(sessionId string, sig syscall.Signal) error { + session, ok := s.sessions.Get(sessionId) + if !ok { + return common_errors.NewNotFoundError(errors.New("session not found")) + } + + if session.cmd == nil || session.cmd.Process == nil { + return common_errors.NewGoneError(errors.New("session process is not running")) + } + + if session.cmd.ProcessState != nil && session.cmd.ProcessState.Exited() { + return common_errors.NewGoneError(errors.New("session process has exited")) + } + + return s.signalProcessTree(session.cmd.Process.Pid, sig) +} diff --git a/apps/daemon/pkg/session/execute.go b/apps/daemon/pkg/session/execute.go index b970366b42..b2d6d7c454 100644 --- a/apps/daemon/pkg/session/execute.go +++ b/apps/daemon/pkg/session/execute.go @@ -216,6 +216,9 @@ var cmdWrapperFormat string = ` %s ip_pid=$! + # Record the input-holder PID so CloseInput can deliver stdin EOF later. + echo "$ip_pid" > "$dir/input_holder.pid" 2>/dev/null || true + # Run your command from file (avoids heredoc parsing issues with pipe-fed shells) { . %q; } < "$ip" > "$sp" 2> "$ep" _ec=$? diff --git a/apps/daemon/pkg/toolbox/docs/docs.go b/apps/daemon/pkg/toolbox/docs/docs.go index 910b5a353b..38212fa9b2 100644 --- a/apps/daemon/pkg/toolbox/docs/docs.go +++ b/apps/daemon/pkg/toolbox/docs/docs.go @@ -2452,6 +2452,28 @@ const docTemplate = `{ } } }, + "/mcp": { + "post": { + "description": "Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer \u003ctoken\u003e) exactly like /process/exec/connect.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json", + " text/event-stream" + ], + "tags": [ + "mcp" + ], + "summary": "MCP endpoint (streamable HTTP)", + "operationId": "MCP", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/port": { "get": { "description": "Get a list of all currently active ports", @@ -2538,6 +2560,29 @@ const docTemplate = `{ } } }, + "/process/exec/connect": { + "get": { + "description": "SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare ` + "`" + `ssh host` + "`" + `). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection.", + "tags": [ + "process" + ], + "summary": "Execute a command or open a shell over a single WebSocket connection", + "operationId": "ExecConnect", + "parameters": [ + { + "type": "string", + "description": "SSH access token (alternative to the Authorization header for WS clients that cannot set headers)", + "name": "token", + "in": "query" + } + ], + "responses": { + "101": { + "description": "Switching Protocols - WebSocket connection established" + } + } + } + }, "/process/execute": { "post": { "description": "Execute a shell command and return the output and exit code", diff --git a/apps/daemon/pkg/toolbox/docs/swagger.json b/apps/daemon/pkg/toolbox/docs/swagger.json index fc73b10380..32a9577b4e 100644 --- a/apps/daemon/pkg/toolbox/docs/swagger.json +++ b/apps/daemon/pkg/toolbox/docs/swagger.json @@ -2137,6 +2137,21 @@ } } }, + "/mcp": { + "post": { + "description": "Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer \u003ctoken\u003e) exactly like /process/exec/connect.", + "consumes": ["application/json"], + "produces": ["application/json", " text/event-stream"], + "tags": ["mcp"], + "summary": "MCP endpoint (streamable HTTP)", + "operationId": "MCP", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/port": { "get": { "description": "Get a list of all currently active ports", @@ -2209,6 +2224,27 @@ } } }, + "/process/exec/connect": { + "get": { + "description": "SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection.", + "tags": ["process"], + "summary": "Execute a command or open a shell over a single WebSocket connection", + "operationId": "ExecConnect", + "parameters": [ + { + "type": "string", + "description": "SSH access token (alternative to the Authorization header for WS clients that cannot set headers)", + "name": "token", + "in": "query" + } + ], + "responses": { + "101": { + "description": "Switching Protocols - WebSocket connection established" + } + } + } + }, "/process/execute": { "post": { "description": "Execute a shell command and return the output and exit code", diff --git a/apps/daemon/pkg/toolbox/docs/swagger.yaml b/apps/daemon/pkg/toolbox/docs/swagger.yaml index 98e7bc4b26..9ad13f3872 100644 --- a/apps/daemon/pkg/toolbox/docs/swagger.yaml +++ b/apps/daemon/pkg/toolbox/docs/swagger.yaml @@ -2771,6 +2771,25 @@ paths: summary: Get workspace symbols tags: - lsp + /mcp: + post: + consumes: + - application/json + description: 'Model Context Protocol endpoint (streamable-HTTP transport) exposing + sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST + sends JSON-RPC messages (responses are SSE events per the transport); GET + opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: + Bearer ) exactly like /process/exec/connect.' + operationId: MCP + produces: + - application/json + - ' text/event-stream' + responses: + '200': + description: OK + summary: MCP endpoint (streamable HTTP) + tags: + - mcp /port: get: description: Get a list of all currently active ports @@ -2829,6 +2848,27 @@ paths: summary: Execute code tags: - process + /process/exec/connect: + get: + description: 'SSH-equivalent exec channel over HTTPS. After the upgrade the + client sends a start frame: {"type":"start","command":"...","cwd":"...","env":{...},"cols":...,"rows":...}. + When command is omitted, an interactive login shell is started (like bare + `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server + frames: stdout, stderr, exit (always last, before close), error. One connection + = one exec; shell state persists for the lifetime of the connection.' + operationId: ExecConnect + parameters: + - description: SSH access token (alternative to the Authorization header for + WS clients that cannot set headers) + in: query + name: token + type: string + responses: + '101': + description: Switching Protocols - WebSocket connection established + summary: Execute a command or open a shell over a single WebSocket connection + tags: + - process /process/execute: post: consumes: diff --git a/apps/daemon/pkg/toolbox/mcp/server.go b/apps/daemon/pkg/toolbox/mcp/server.go new file mode 100644 index 0000000000..f68a798781 --- /dev/null +++ b/apps/daemon/pkg/toolbox/mcp/server.go @@ -0,0 +1,85 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package mcp + +import ( + "log/slog" + "net/http" + + "github.com/daytonaio/daemon/internal" + session_svc "github.com/daytonaio/daemon/pkg/session" + "github.com/gin-gonic/gin" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// MCPServer exposes sandbox tools (command execution + filesystem) over the +// Model Context Protocol, so MCP-native agents can use a Daytona sandbox by +// pointing their MCP client at a single URL + SSH access token. +type MCPServer struct { + logger *slog.Logger + workDir string + sessionService *session_svc.SessionService + handler http.Handler +} + +// NewMCPServer builds the MCP endpoint: a single streamable-HTTP handler +// (POST for JSON-RPC messages, GET for the SSE stream per the MCP +// streamable-HTTP transport) serving the v1 toolset. The handler is +// stateless: every POST is self-contained, so plain HTTP clients can call +// tools without the initialize handshake. +func NewMCPServer(logger *slog.Logger, workDir string, sessionService *session_svc.SessionService) *MCPServer { + m := &MCPServer{ + logger: logger.With(slog.String("component", "mcp_server")), + workDir: workDir, + sessionService: sessionService, + } + + server := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: "daytona-toolbox", + Version: internal.Version, + }, nil) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "exec_command", + Description: "Execute a shell command inside the sandbox and return its stdout, stderr and exit code.", + }, m.execCommand) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "fs_read_file", + Description: "Read a file from the sandbox filesystem. Returns UTF-8 text, or base64 for binary files.", + }, m.readFile) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "fs_write_file", + Description: "Write text content to a file on the sandbox filesystem, creating parent directories as needed.", + }, m.writeFile) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "fs_list_files", + Description: "List files and directories at a path on the sandbox filesystem.", + }, m.listFiles) + + m.handler = mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { + return server + }, &mcpsdk.StreamableHTTPOptions{ + Stateless: true, + }) + + return m +} + +// HandleMCP godoc +// +// @Summary MCP endpoint (streamable HTTP) +// @Description Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. +// @Tags mcp +// @Accept json +// @Produce json, text/event-stream +// @Success 200 +// @Router /mcp [post] +// +// @id MCP +func (m *MCPServer) HandleMCP(c *gin.Context) { + m.handler.ServeHTTP(c.Writer, c.Request) +} diff --git a/apps/daemon/pkg/toolbox/mcp/tools.go b/apps/daemon/pkg/toolbox/mcp/tools.go new file mode 100644 index 0000000000..bf948b4384 --- /dev/null +++ b/apps/daemon/pkg/toolbox/mcp/tools.go @@ -0,0 +1,303 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package mcp + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "time" + "unicode/utf8" + + "github.com/daytonaio/daemon/internal/util" + session_svc "github.com/daytonaio/daemon/pkg/session" + execws "github.com/daytonaio/daemon/pkg/toolbox/process/exec" + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + defaultExecTimeoutSec = 120 + maxExecTimeoutSec = 3600 + maxReadFileBytes = 10 * 1024 * 1024 + execPollInterval = 50 * time.Millisecond +) + +// --- exec_command --- + +type execCommandArgs struct { + Command string `json:"command" jsonschema:"The shell command to execute"` + Cwd string `json:"cwd,omitempty" jsonschema:"Working directory for the command (defaults to the sandbox work dir)"` + Env map[string]string `json:"env,omitempty" jsonschema:"Additional environment variables for the command"` + Timeout int `json:"timeout,omitempty" jsonschema:"Timeout in seconds (default 120, max 3600)"` +} + +type execCommandResult struct { + Stdout string `json:"stdout" jsonschema:"Standard output of the command"` + Stderr string `json:"stderr" jsonschema:"Standard error of the command"` + ExitCode int `json:"exitCode" jsonschema:"Process exit code (128+signal when killed by a signal)"` +} + +func (m *MCPServer) execCommand(ctx context.Context, _ *mcpsdk.CallToolRequest, args execCommandArgs) (*mcpsdk.CallToolResult, execCommandResult, error) { + if strings.TrimSpace(args.Command) == "" { + return toolError("command is required"), execCommandResult{}, nil + } + + timeout := args.Timeout + if timeout <= 0 { + timeout = defaultExecTimeoutSec + } + if timeout > maxExecTimeoutSec { + timeout = maxExecTimeoutSec + } + + sessionId := "mcp-" + uuid.NewString() + if err := m.sessionService.Create(sessionId, false); err != nil { + return nil, execCommandResult{}, fmt.Errorf("failed to create session: %w", err) + } + defer func() { + deleteCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := m.sessionService.Delete(deleteCtx, sessionId); err != nil { + m.logger.Debug("failed to delete mcp exec session", "sessionId", sessionId, "error", err) + } + }() + + // Reuse the session command wrapper so output demux and exit-code + // handling behave exactly like the REST/session endpoints. + script := execws.BuildCommandScript(args.Cwd, args.Env, args.Command, m.workDir) + + result, err := m.sessionService.Execute(sessionId, util.EmptyCommandID, script, true, false, true, true) + if err != nil { + return nil, execCommandResult{}, fmt.Errorf("failed to execute command: %w", err) + } + + logPath, exitCodePath, err := m.sessionService.CommandLogPaths(sessionId, result.CommandId) + if err != nil { + return nil, execCommandResult{}, err + } + + deadline := time.Now().Add(time.Duration(timeout) * time.Second) + for { + select { + case <-ctx.Done(): + return nil, execCommandResult{}, ctx.Err() + default: + } + + if exitCode, ok := readExitCode(exitCodePath); ok { + stdout, stderr := demuxLogFile(logPath) + out := execCommandResult{Stdout: stdout, Stderr: stderr, ExitCode: exitCode} + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: execResultText(out)}}, + }, out, nil + } + + if time.Now().After(deadline) { + _ = m.sessionService.SignalDescendants(sessionId, syscall.SIGKILL) + // Give the wrapper a moment to flush output to the log. + time.Sleep(200 * time.Millisecond) + stdout, stderr := demuxLogFile(logPath) + out := execCommandResult{Stdout: stdout, Stderr: stderr, ExitCode: -1} + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{ + Text: fmt.Sprintf("command timed out after %ds\n%s", timeout, execResultText(out)), + }}, + }, out, nil + } + + time.Sleep(execPollInterval) + } +} + +func execResultText(out execCommandResult) string { + var b strings.Builder + b.WriteString(out.Stdout) + if out.Stderr != "" { + if b.Len() > 0 && !strings.HasSuffix(out.Stdout, "\n") { + b.WriteString("\n") + } + b.WriteString(out.Stderr) + } + fmt.Fprintf(&b, "\nexitCode: %d\n", out.ExitCode) + return b.String() +} + +func readExitCode(exitCodePath string) (int, bool) { + exitCodeBytes, err := os.ReadFile(exitCodePath) + if err != nil { + return 0, false + } + var exitCode int + if _, err := fmt.Sscanf(strings.TrimSpace(string(exitCodeBytes)), "%d", &exitCode); err != nil { + return 0, false + } + return exitCode, true +} + +func demuxLogFile(logPath string) (stdout, stderr string) { + logBytes, err := os.ReadFile(logPath) + if err != nil { + return "", "" + } + stdoutBytes, stderrBytes := session_svc.DemuxLogBytes(logBytes) + return string(stdoutBytes), string(stderrBytes) +} + +// --- fs_read_file --- + +type readFileArgs struct { + Path string `json:"path" jsonschema:"Absolute path of the file to read"` +} + +type readFileResult struct { + Path string `json:"path" jsonschema:"The path that was read"` + Content string `json:"content" jsonschema:"File content (UTF-8 text, or base64 when encoding is base64)"` + Encoding string `json:"encoding,omitempty" jsonschema:"Set to base64 for binary files"` + Size int64 `json:"size" jsonschema:"File size in bytes"` +} + +func (m *MCPServer) readFile(_ context.Context, _ *mcpsdk.CallToolRequest, args readFileArgs) (*mcpsdk.CallToolResult, readFileResult, error) { + if args.Path == "" { + return toolError("path is required"), readFileResult{}, nil + } + + info, err := os.Stat(args.Path) + if err != nil { + return toolError(fmt.Sprintf("failed to stat file: %v", err)), readFileResult{}, nil + } + if info.IsDir() { + return toolError("path is a directory, use fs_list_files instead"), readFileResult{}, nil + } + if info.Size() > maxReadFileBytes { + return toolError(fmt.Sprintf("file too large (%d bytes, max %d)", info.Size(), maxReadFileBytes)), readFileResult{}, nil + } + + content, err := os.ReadFile(args.Path) + if err != nil { + return toolError(fmt.Sprintf("failed to read file: %v", err)), readFileResult{}, nil + } + + out := readFileResult{Path: args.Path, Size: info.Size()} + if utf8.Valid(content) { + out.Content = string(content) + } else { + out.Content = base64.StdEncoding.EncodeToString(content) + out.Encoding = "base64" + } + + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: out.Content}}, + }, out, nil +} + +// --- fs_write_file --- + +type writeFileArgs struct { + Path string `json:"path" jsonschema:"Absolute path of the file to write"` + Content string `json:"content" jsonschema:"Text content to write to the file"` +} + +type writeFileResult struct { + Path string `json:"path" jsonschema:"The path that was written"` + BytesWritten int `json:"bytesWritten" jsonschema:"Number of bytes written"` +} + +func (m *MCPServer) writeFile(_ context.Context, _ *mcpsdk.CallToolRequest, args writeFileArgs) (*mcpsdk.CallToolResult, writeFileResult, error) { + if args.Path == "" { + return toolError("path is required"), writeFileResult{}, nil + } + + if dir := filepath.Dir(args.Path); dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return toolError(fmt.Sprintf("failed to create parent directories: %v", err)), writeFileResult{}, nil + } + } + + if err := os.WriteFile(args.Path, []byte(args.Content), 0644); err != nil { + return toolError(fmt.Sprintf("failed to write file: %v", err)), writeFileResult{}, nil + } + + out := writeFileResult{Path: args.Path, BytesWritten: len(args.Content)} + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{&mcpsdk.TextContent{ + Text: fmt.Sprintf("wrote %d bytes to %s", out.BytesWritten, out.Path), + }}, + }, out, nil +} + +// --- fs_list_files --- + +type listFilesArgs struct { + Path string `json:"path,omitempty" jsonschema:"Directory path to list (defaults to the current directory)"` +} + +type fileEntry struct { + Name string `json:"name" jsonschema:"File or directory name"` + Size int64 `json:"size" jsonschema:"Size in bytes"` + IsDir bool `json:"isDir" jsonschema:"Whether this entry is a directory"` + Mode string `json:"mode" jsonschema:"File mode string"` + ModifiedAt time.Time `json:"modifiedAt" jsonschema:"Last modification time"` +} + +type listFilesResult struct { + Path string `json:"path" jsonschema:"The directory that was listed"` + Files []fileEntry `json:"files" jsonschema:"Directory entries"` +} + +func (m *MCPServer) listFiles(_ context.Context, _ *mcpsdk.CallToolRequest, args listFilesArgs) (*mcpsdk.CallToolResult, listFilesResult, error) { + path := args.Path + if path == "" { + path = "." + } + + entries, err := os.ReadDir(path) + if err != nil { + return toolError(fmt.Sprintf("failed to list files: %v", err)), listFilesResult{}, nil + } + + files := make([]fileEntry, 0, len(entries)) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, fileEntry{ + Name: entry.Name(), + Size: info.Size(), + IsDir: entry.IsDir(), + Mode: info.Mode().String(), + ModifiedAt: info.ModTime(), + }) + } + + out := listFilesResult{Path: path, Files: files} + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: listFilesText(out)}}, + }, out, nil +} + +func listFilesText(out listFilesResult) string { + var b strings.Builder + for _, f := range out.Files { + typeChar := "-" + if f.IsDir { + typeChar = "d" + } + fmt.Fprintf(&b, "%s %10d %s %s\n", typeChar, f.Size, f.ModifiedAt.Format(time.RFC3339), f.Name) + } + return b.String() +} + +func toolError(message string) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: message}}, + } +} diff --git a/apps/daemon/pkg/toolbox/mcp/tools_test.go b/apps/daemon/pkg/toolbox/mcp/tools_test.go new file mode 100644 index 0000000000..aaf0fd66f7 --- /dev/null +++ b/apps/daemon/pkg/toolbox/mcp/tools_test.go @@ -0,0 +1,236 @@ +// Copyright Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package mcp + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + session_svc "github.com/daytonaio/daemon/pkg/session" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func newTestMCPServer(t *testing.T) *MCPServer { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + sessionService := session_svc.NewSessionService(logger, t.TempDir(), 250*time.Millisecond, 25*time.Millisecond) + return NewMCPServer(logger, t.TempDir(), sessionService) +} + +func textOf(t *testing.T, result *mcpsdk.CallToolResult) string { + t.Helper() + var b strings.Builder + for _, content := range result.Content { + if tc, ok := content.(*mcpsdk.TextContent); ok { + b.WriteString(tc.Text) + } + } + return b.String() +} + +func TestExecCommandTool(t *testing.T) { + m := newTestMCPServer(t) + + result, out, err := m.execCommand(context.Background(), nil, execCommandArgs{Command: "echo hi"}) + if err != nil { + t.Fatalf("execCommand failed: %v", err) + } + if result.IsError { + t.Fatalf("expected success, got error result: %s", textOf(t, result)) + } + if out.ExitCode != 0 { + t.Fatalf("expected exit code 0, got %d", out.ExitCode) + } + if !strings.Contains(out.Stdout, "hi") { + t.Fatalf("expected stdout to contain 'hi', got %q", out.Stdout) + } + if !strings.Contains(textOf(t, result), "hi") { + t.Fatalf("expected MCP content to contain 'hi', got %q", textOf(t, result)) + } +} + +func TestExecCommandToolStderrAndExitCode(t *testing.T) { + m := newTestMCPServer(t) + + _, out, err := m.execCommand(context.Background(), nil, execCommandArgs{Command: "echo oops >&2; exit 42"}) + if err != nil { + t.Fatalf("execCommand failed: %v", err) + } + if out.ExitCode != 42 { + t.Fatalf("expected exit code 42, got %d", out.ExitCode) + } + if !strings.Contains(out.Stderr, "oops") { + t.Fatalf("expected stderr to contain 'oops', got %q", out.Stderr) + } +} + +func TestExecCommandToolCwdAndEnv(t *testing.T) { + m := newTestMCPServer(t) + dir := t.TempDir() + + _, out, err := m.execCommand(context.Background(), nil, execCommandArgs{ + Command: "pwd && echo $FOO", + Cwd: dir, + Env: map[string]string{"FOO": "bar"}, + }) + if err != nil { + t.Fatalf("execCommand failed: %v", err) + } + if !strings.Contains(out.Stdout, dir) { + t.Fatalf("expected stdout to contain cwd %q, got %q", dir, out.Stdout) + } + if !strings.Contains(out.Stdout, "bar") { + t.Fatalf("expected stdout to contain env value 'bar', got %q", out.Stdout) + } +} + +func TestExecCommandToolTimeout(t *testing.T) { + m := newTestMCPServer(t) + + start := time.Now() + result, _, err := m.execCommand(context.Background(), nil, execCommandArgs{Command: "sleep 30", Timeout: 1}) + if err != nil { + t.Fatalf("execCommand failed: %v", err) + } + if !result.IsError { + t.Fatalf("expected timeout error result, got %s", textOf(t, result)) + } + if !strings.Contains(textOf(t, result), "timed out") { + t.Fatalf("expected timeout message, got %q", textOf(t, result)) + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("timeout took too long: %s", elapsed) + } +} + +func TestFsWriteAndReadFileTools(t *testing.T) { + m := newTestMCPServer(t) + path := filepath.Join(t.TempDir(), "sub", "dir", "test.txt") + + writeResult, writeOut, err := m.writeFile(context.Background(), nil, writeFileArgs{ + Path: path, + Content: "hello world", + }) + if err != nil { + t.Fatalf("writeFile failed: %v", err) + } + if writeResult.IsError { + t.Fatalf("expected success, got %s", textOf(t, writeResult)) + } + if writeOut.BytesWritten != len("hello world") { + t.Fatalf("expected %d bytes written, got %d", len("hello world"), writeOut.BytesWritten) + } + + readResult, readOut, err := m.readFile(context.Background(), nil, readFileArgs{Path: path}) + if err != nil { + t.Fatalf("readFile failed: %v", err) + } + if readResult.IsError { + t.Fatalf("expected success, got %s", textOf(t, readResult)) + } + if readOut.Content != "hello world" { + t.Fatalf("expected 'hello world', got %q", readOut.Content) + } +} + +func TestFsReadFileToolNotFound(t *testing.T) { + m := newTestMCPServer(t) + + result, _, err := m.readFile(context.Background(), nil, readFileArgs{Path: filepath.Join(t.TempDir(), "nope.txt")}) + if err != nil { + t.Fatalf("readFile failed: %v", err) + } + if !result.IsError { + t.Fatalf("expected error result for missing file") + } +} + +func TestFsListFilesTool(t *testing.T) { + m := newTestMCPServer(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil { + t.Fatal(err) + } + + result, out, err := m.listFiles(context.Background(), nil, listFilesArgs{Path: dir}) + if err != nil { + t.Fatalf("listFiles failed: %v", err) + } + if result.IsError { + t.Fatalf("expected success, got %s", textOf(t, result)) + } + if len(out.Files) != 2 { + t.Fatalf("expected 2 entries, got %d", len(out.Files)) + } + + var sawFile, sawDir bool + for _, f := range out.Files { + if f.Name == "a.txt" && !f.IsDir { + sawFile = true + } + if f.Name == "subdir" && f.IsDir { + sawDir = true + } + } + if !sawFile || !sawDir { + t.Fatalf("expected file and dir entries, got %+v", out.Files) + } +} + +// TestMCPHTTPEndpoint exercises the full streamable-HTTP transport the way +// the issue's acceptance criteria do: initialize + tools/list + tools/call +// over plain POSTs. +func TestMCPHTTPEndpoint(t *testing.T) { + m := newTestMCPServer(t) + httpServer := httptest.NewServer(m.handler) + t.Cleanup(httpServer.Close) + + post := func(payload string) string { + t.Helper() + req, err := http.NewRequest(http.MethodPost, httpServer.URL, strings.NewReader(payload)) + if err != nil { + t.Fatalf("build MCP request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := httpServer.Client().Do(req) + if err != nil { + t.Fatalf("MCP POST failed: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read MCP response: %v", err) + } + return string(body) + } + + initResp := post(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}`) + if !strings.Contains(initResp, "daytona-toolbox") { + t.Fatalf("expected initialize response to name the server, got %q", initResp) + } + + listResp := post(`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`) + for _, tool := range []string{"exec_command", "fs_read_file", "fs_write_file", "fs_list_files"} { + if !strings.Contains(listResp, tool) { + t.Fatalf("expected tools/list to contain %q, got %q", tool, listResp) + } + } + + callResp := post(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"exec_command","arguments":{"command":"echo hi"}}}`) + if !strings.Contains(callResp, "hi") { + t.Fatalf("expected tools/call response to contain 'hi', got %q", callResp) + } +} diff --git a/apps/daemon/pkg/toolbox/process/exec/controller.go b/apps/daemon/pkg/toolbox/process/exec/controller.go new file mode 100644 index 0000000000..9a8a04dc6d --- /dev/null +++ b/apps/daemon/pkg/toolbox/process/exec/controller.go @@ -0,0 +1,284 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package exec + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "syscall" + "time" + + "github.com/daytonaio/daemon/internal/util" + session_svc "github.com/daytonaio/daemon/pkg/session" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + // startFrameTimeout bounds how long a client may take to send the initial + // start frame after the WebSocket upgrade completes. + startFrameTimeout = 30 * time.Second + + maxFrameCols = 1000 + maxFrameRows = 1000 +) + +// execSession abstracts a single exec connection regardless of mode: +// one-shot command (session-backed) or interactive login shell (PTY-backed). +type execSession interface { + // Start launches the command/shell and spawns the goroutines that emit + // stdout/stderr/exit frames via emit(frame, final). final=true marks the + // exit/error frame after which the connection is closed. + Start(ctx context.Context, start StartFrame, emit func(frame any, final bool)) error + // WriteStdin delivers raw stdin bytes to the command/shell. + WriteStdin(data []byte) error + // CloseStdin delivers stdin EOF (SSH channel EOF semantics). + CloseStdin() error + // Signal delivers a signal to the foreground command/shell. + Signal(sig syscall.Signal) error + // Resize changes the terminal window size (no-op without a PTY). + Resize(cols, rows uint16) error + // Kill tears down the session and every process it spawned. + Kill() +} + +type ExecController struct { + logger *slog.Logger + workDir string + sessionService *session_svc.SessionService +} + +func NewExecController(logger *slog.Logger, workDir string, sessionService *session_svc.SessionService) *ExecController { + return &ExecController{ + logger: logger.With(slog.String("component", "exec_controller")), + workDir: workDir, + sessionService: sessionService, + } +} + +// Connect godoc +// +// @Summary Execute a command or open a shell over a single WebSocket connection +// @Description SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {"type":"start","command":"...","cwd":"...","env":{...},"cols":...,"rows":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. +// @Tags process +// @Param token query string false "SSH access token (alternative to the Authorization header for WS clients that cannot set headers)" +// @Success 101 "Switching Protocols - WebSocket connection established" +// @Router /process/exec/connect [get] +// +// @id ExecConnect +func (e *ExecController) Connect(c *gin.Context) { + ws, err := util.UpgradeToWebSocket(c.Writer, c.Request) + if err != nil { + e.logger.Error("ws upgrade failed", "error", err) + return + } + + e.handleConnection(ws) +} + +// outboundFrame is a unit of work for the single writer goroutine; close=true +// closes the WebSocket after the frame has been written. +type outboundFrame struct { + payload any + close bool +} + +func (e *ExecController) handleConnection(ws *websocket.Conn) { + ctx, cancel := context.WithCancel(context.Background()) + logger := e.logger + + frames := make(chan outboundFrame, 64) + pongCh := util.SetupWSKeepAlive(ws, logger) + + writerDone := make(chan struct{}) + go func() { + defer close(writerDone) + for { + select { + case frame := <-frames: + util.WritePendingPongs(ws, pongCh, time.Second, logger) + + data, err := json.Marshal(frame.payload) + if err != nil { + logger.Error("failed to marshal exec frame", "error", err) + continue + } + _ = ws.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := ws.WriteMessage(websocket.TextMessage, data); err != nil { + logger.Debug("exec ws write error", "error", err) + return + } + if frame.close { + _ = ws.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second)) + return + } + case <-ctx.Done(): + return + } + } + }() + + emit := func(payload any, final bool) { + select { + case frames <- outboundFrame{payload: payload, close: final}: + case <-ctx.Done(): + } + } + + var sess execSession + + defer func() { + // Cancel first: emit() selects on ctx.Done, so session goroutines + // stop enqueueing frames; the writer goroutine exits on ctx.Done. + // The frames channel is deliberately never closed — that would race + // with in-flight emitters and cause a send-on-closed-channel panic. + cancel() + if sess != nil { + sess.Kill() + } + <-writerDone + _ = ws.Close() + }() + + fail := func(err error) { + emit(ErrorFrame{Type: FrameTypeError, Message: err.Error()}, true) + } + + // The first client frame must be the start frame. + _ = ws.SetReadDeadline(time.Now().Add(startFrameTimeout)) + start, err := readStartFrame(ws) + if err != nil { + fail(err) + return + } + // No read deadline for the rest of the session — long-running commands + // must not be killed by an idle stdin. + _ = ws.SetReadDeadline(time.Time{}) + + if start.Cols > maxFrameCols || start.Rows > maxFrameRows { + fail(fmt.Errorf("invalid value for cols/rows - must be less than %d", maxFrameCols)) + return + } + + if strings.TrimSpace(start.Command) == "" { + sess = newShellSession(logger, e.workDir) + } else { + sess = newCommandSession(logger, e.workDir, e.sessionService) + } + + if err := sess.Start(ctx, *start, emit); err != nil { + logger.Error("failed to start exec session", "error", err) + fail(fmt.Errorf("failed to start exec session: %w", err)) + return + } + + // Read loop: dispatch client control frames until disconnect. + for { + _, data, err := ws.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + logger.Debug("exec ws read error", "error", err) + } + return + } + + var frame ClientFrame + if err := json.Unmarshal(data, &frame); err != nil { + emit(ErrorFrame{Type: FrameTypeError, Message: fmt.Sprintf("invalid frame: %v", err)}, false) + continue + } + + switch frame.Type { + case FrameTypeStdin: + if err := sess.WriteStdin([]byte(frame.Data)); err != nil { + logger.Debug("stdin write failed", "error", err) + } + case FrameTypeStdinEOF: + if err := sess.CloseStdin(); err != nil { + logger.Debug("stdin close failed", "error", err) + } + case FrameTypeSignal: + sig, ok := parseSignal(frame.Signal) + if !ok { + emit(ErrorFrame{Type: FrameTypeError, Message: fmt.Sprintf("unknown signal: %q", frame.Signal)}, false) + continue + } + if err := sess.Signal(sig); err != nil { + logger.Debug("signal failed", "signal", frame.Signal, "error", err) + } + case FrameTypeResize: + if frame.Cols > maxFrameCols || frame.Rows > maxFrameRows { + emit(ErrorFrame{Type: FrameTypeError, Message: fmt.Sprintf("invalid value for cols/rows - must be less than %d", maxFrameCols)}, false) + continue + } + if frame.Cols == 0 || frame.Rows == 0 { + continue + } + if err := sess.Resize(frame.Cols, frame.Rows); err != nil { + logger.Debug("resize failed", "error", err) + } + case FrameTypeStart: + emit(ErrorFrame{Type: FrameTypeError, Message: "session already started"}, false) + default: + emit(ErrorFrame{Type: FrameTypeError, Message: fmt.Sprintf("unknown frame type: %q", frame.Type)}, false) + } + } +} + +func readStartFrame(ws *websocket.Conn) (*StartFrame, error) { + _, data, err := ws.ReadMessage() + if err != nil { + return nil, fmt.Errorf("failed to read start frame: %w", err) + } + + var frame StartFrame + if err := json.Unmarshal(data, &frame); err != nil { + return nil, fmt.Errorf("invalid start frame: %w", err) + } + if frame.Type != FrameTypeStart { + return nil, fmt.Errorf("first frame must be of type %q, got %q", FrameTypeStart, frame.Type) + } + if frame.Env == nil { + frame.Env = map[string]string{} + } + + return &frame, nil +} + +// parseSignal maps SSH-style signal names to syscall signals. +func parseSignal(name string) (syscall.Signal, bool) { + sig, ok := signalNames[strings.ToUpper(name)] + return sig, ok +} + +var signalNames = map[string]syscall.Signal{ + "SIGHUP": syscall.SIGHUP, + "SIGINT": syscall.SIGINT, + "SIGQUIT": syscall.SIGQUIT, + "SIGKILL": syscall.SIGKILL, + "SIGALRM": syscall.SIGALRM, + "SIGTERM": syscall.SIGTERM, + "SIGUSR1": syscall.SIGUSR1, + "SIGUSR2": syscall.SIGUSR2, + "SIGPIPE": syscall.SIGPIPE, + "SIGSTOP": syscall.SIGSTOP, + "SIGTSTP": syscall.SIGTSTP, + "SIGCONT": syscall.SIGCONT, + // Bare names, mirroring `kill -s NAME` convenience + "HUP": syscall.SIGHUP, + "INT": syscall.SIGINT, + "QUIT": syscall.SIGQUIT, + "KILL": syscall.SIGKILL, + "ALRM": syscall.SIGALRM, + "TERM": syscall.SIGTERM, + "USR1": syscall.SIGUSR1, + "USR2": syscall.SIGUSR2, + "PIPE": syscall.SIGPIPE, + "STOP": syscall.SIGSTOP, + "TSTP": syscall.SIGTSTP, + "CONT": syscall.SIGCONT, +} diff --git a/apps/daemon/pkg/toolbox/process/exec/controller_test.go b/apps/daemon/pkg/toolbox/process/exec/controller_test.go new file mode 100644 index 0000000000..7b596da643 --- /dev/null +++ b/apps/daemon/pkg/toolbox/process/exec/controller_test.go @@ -0,0 +1,334 @@ +// Copyright Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package exec + +import ( + "encoding/json" + "io" + "log/slog" + "net/http/httptest" + "strings" + "testing" + "time" + + session_svc "github.com/daytonaio/daemon/pkg/session" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func newTestServer(t *testing.T) (*httptest.Server, *session_svc.SessionService) { + t.Helper() + + gin.SetMode(gin.TestMode) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + sessionService := session_svc.NewSessionService(logger, t.TempDir(), 250*time.Millisecond, 25*time.Millisecond) + controller := NewExecController(logger, t.TempDir(), sessionService) + + r := gin.New() + r.GET("/process/exec/connect", controller.Connect) + + server := httptest.NewServer(r) + t.Cleanup(server.Close) + + return server, sessionService +} + +func dialExec(t *testing.T, server *httptest.Server) *websocket.Conn { + t.Helper() + + url := "ws" + strings.TrimPrefix(server.URL, "http") + "/process/exec/connect" + ws, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatalf("failed to dial exec endpoint: %v", err) + } + t.Cleanup(func() { _ = ws.Close() }) + + return ws +} + +type serverFrame struct { + Type string `json:"type"` + Data string `json:"data"` + ExitCode int `json:"exitCode"` + Message string `json:"message"` +} + +// collectFrames reads frames until the exit frame (or timeout) and returns +// all frames received. +func collectFrames(t *testing.T, ws *websocket.Conn, timeout time.Duration) []serverFrame { + t.Helper() + + deadline := time.Now().Add(timeout) + var frames []serverFrame + + for time.Now().Before(deadline) { + _ = ws.SetReadDeadline(deadline) + _, data, err := ws.ReadMessage() + if err != nil { + break + } + + var frame serverFrame + if err := json.Unmarshal(data, &frame); err != nil { + t.Fatalf("invalid server frame: %v (%s)", err, string(data)) + } + frames = append(frames, frame) + + if frame.Type == FrameTypeExit { + return frames + } + } + + t.Fatalf("did not receive exit frame within %s; frames: %+v", timeout, frames) + return nil +} + +func stdoutOf(frames []serverFrame) string { + var b strings.Builder + for _, f := range frames { + if f.Type == FrameTypeStdout { + b.WriteString(f.Data) + } + } + return b.String() +} + +func stderrOf(frames []serverFrame) string { + var b strings.Builder + for _, f := range frames { + if f.Type == FrameTypeStderr { + b.WriteString(f.Data) + } + } + return b.String() +} + +func exitCodeOf(t *testing.T, frames []serverFrame) int { + t.Helper() + for i := len(frames) - 1; i >= 0; i-- { + if frames[i].Type == FrameTypeExit { + return frames[i].ExitCode + } + } + t.Fatal("no exit frame received") + return -1 +} + +func sendFrame(t *testing.T, ws *websocket.Conn, frame any) { + t.Helper() + data, err := json.Marshal(frame) + if err != nil { + t.Fatalf("marshal frame: %v", err) + } + if err := ws.WriteMessage(websocket.TextMessage, data); err != nil { + t.Fatalf("write frame: %v", err) + } +} + +func TestExecCommandStreamsStdoutAndExitCode(t *testing.T) { + server, _ := newTestServer(t) + ws := dialExec(t, server) + + sendFrame(t, ws, StartFrame{Type: FrameTypeStart, Command: "echo hello && pwd", Cwd: "/tmp"}) + + frames := collectFrames(t, ws, 10*time.Second) + + if out := stdoutOf(frames); !strings.Contains(out, "hello") || !strings.Contains(out, "/tmp") { + t.Fatalf("expected stdout to contain 'hello' and '/tmp', got %q", out) + } + if code := exitCodeOf(t, frames); code != 0 { + t.Fatalf("expected exit code 0, got %d", code) + } +} + +func TestExecCommandSeparatesStderr(t *testing.T) { + server, _ := newTestServer(t) + ws := dialExec(t, server) + + sendFrame(t, ws, StartFrame{Type: FrameTypeStart, Command: "echo out; echo err >&2; exit 3"}) + + frames := collectFrames(t, ws, 10*time.Second) + + if out := stdoutOf(frames); !strings.Contains(out, "out") { + t.Fatalf("expected stdout to contain 'out', got %q", out) + } + if errOut := stderrOf(frames); !strings.Contains(errOut, "err") { + t.Fatalf("expected stderr to contain 'err', got %q", errOut) + } + if code := exitCodeOf(t, frames); code != 3 { + t.Fatalf("expected exit code 3, got %d", code) + } +} + +func TestExecCommandStdinAndStdinEOF(t *testing.T) { + server, _ := newTestServer(t) + ws := dialExec(t, server) + + sendFrame(t, ws, StartFrame{Type: FrameTypeStart, Command: "cat"}) + + // Give the command a moment to come up before writing stdin. + time.Sleep(300 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeStdin, Data: "ping\n"}) + time.Sleep(300 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeStdinEOF}) + + frames := collectFrames(t, ws, 10*time.Second) + + if out := stdoutOf(frames); !strings.Contains(out, "ping") { + t.Fatalf("expected stdout to contain echoed stdin 'ping', got %q", out) + } + if code := exitCodeOf(t, frames); code != 0 { + t.Fatalf("expected exit code 0 after stdin_eof, got %d", code) + } +} + +func TestExecCommandSigintDeliversExitCode130(t *testing.T) { + server, _ := newTestServer(t) + ws := dialExec(t, server) + + sendFrame(t, ws, StartFrame{Type: FrameTypeStart, Command: "sleep 60"}) + + // Let sleep start before signaling. + time.Sleep(500 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeSignal, Signal: "SIGINT"}) + + frames := collectFrames(t, ws, 10*time.Second) + + if code := exitCodeOf(t, frames); code != 130 { + t.Fatalf("expected exit code 130 after SIGINT, got %d (frames: %+v)", code, frames) + } +} + +func TestExecShellModePersistsShellState(t *testing.T) { + server, _ := newTestServer(t) + ws := dialExec(t, server) + + sendFrame(t, ws, StartFrame{Type: FrameTypeStart}) + + // Interactive login shell takes a moment to initialize. + time.Sleep(700 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeStdin, Data: "export FOO=bar\n"}) + time.Sleep(300 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeStdin, Data: "echo value:$FOO\n"}) + time.Sleep(500 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeStdin, Data: "exit\n"}) + + frames := collectFrames(t, ws, 15*time.Second) + + if out := stdoutOf(frames); !strings.Contains(out, "value:bar") { + t.Fatalf("expected shell stdout to contain 'value:bar', got %q", out) + } + if code := exitCodeOf(t, frames); code != 0 { + t.Fatalf("expected exit code 0 from shell exit, got %d", code) + } +} + +func TestExecShellModeResize(t *testing.T) { + server, _ := newTestServer(t) + ws := dialExec(t, server) + + sendFrame(t, ws, StartFrame{Type: FrameTypeStart, Cols: 100, Rows: 30}) + + time.Sleep(700 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeResize, Cols: 132, Rows: 43}) + time.Sleep(300 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeStdin, Data: "stty size\n"}) + time.Sleep(500 * time.Millisecond) + sendFrame(t, ws, ClientFrame{Type: FrameTypeStdin, Data: "exit\n"}) + + frames := collectFrames(t, ws, 15*time.Second) + + if out := stdoutOf(frames); !strings.Contains(out, "43 132") { + t.Fatalf("expected stty size to report '43 132' after resize, got %q", out) + } +} + +func TestExecRejectsNonStartFirstFrame(t *testing.T) { + server, _ := newTestServer(t) + ws := dialExec(t, server) + + sendFrame(t, ws, ClientFrame{Type: FrameTypeStdin, Data: "nope"}) + + _ = ws.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err := ws.ReadMessage() + if err != nil { + t.Fatalf("expected error frame, got read error: %v", err) + } + + var frame serverFrame + if err := json.Unmarshal(data, &frame); err != nil { + t.Fatalf("invalid frame: %v", err) + } + if frame.Type != FrameTypeError { + t.Fatalf("expected error frame, got %+v", frame) + } +} + +func TestExecConcurrentConnectionsAreIndependent(t *testing.T) { + server, _ := newTestServer(t) + + type result struct { + stdout string + exitCode int + err error + } + results := make(chan result, 2) + + for i := 0; i < 2; i++ { + go func(i int) { + url := "ws" + strings.TrimPrefix(server.URL, "http") + "/process/exec/connect" + ws, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + results <- result{err: err} + return + } + defer ws.Close() + + start, _ := json.Marshal(StartFrame{Type: FrameTypeStart, Command: "echo conn"}) + if err := ws.WriteMessage(websocket.TextMessage, start); err != nil { + results <- result{err: err} + return + } + + var stdout strings.Builder + exitCode := -1 + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) && exitCode < 0 { + _ = ws.SetReadDeadline(deadline) + _, data, err := ws.ReadMessage() + if err != nil { + break + } + var frame serverFrame + if err := json.Unmarshal(data, &frame); err != nil { + continue + } + if frame.Type == FrameTypeStdout { + stdout.WriteString(frame.Data) + } + if frame.Type == FrameTypeExit { + exitCode = frame.ExitCode + } + } + results <- result{stdout: stdout.String(), exitCode: exitCode} + }(i) + } + + for i := 0; i < 2; i++ { + select { + case res := <-results: + if res.err != nil { + t.Fatalf("concurrent connection failed: %v", res.err) + } + if !strings.Contains(res.stdout, "conn") { + t.Fatalf("expected 'conn' output, got %q", res.stdout) + } + if res.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", res.exitCode) + } + case <-time.After(15 * time.Second): + t.Fatal("concurrent exec timed out") + } + } +} diff --git a/apps/daemon/pkg/toolbox/process/exec/demux.go b/apps/daemon/pkg/toolbox/process/exec/demux.go new file mode 100644 index 0000000000..7f0b346ec3 --- /dev/null +++ b/apps/daemon/pkg/toolbox/process/exec/demux.go @@ -0,0 +1,100 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package exec + +import ( + "bytes" + + "github.com/daytonaio/common-go/pkg/log" +) + +type streamKind int + +const ( + streamStdout streamKind = iota + streamStderr +) + +// streamDemux incrementally demultiplexes the command wrapper's labeled +// output (see cmdWrapperFormat: every line is prefixed with STDOUT_PREFIX or +// STDERR_PREFIX) into per-stream content. It mirrors session.DemuxLogBytes +// but works on streaming chunks: a marker split across chunk boundaries is +// held back until the next Write or Flush. +type streamDemux struct { + emit func(kind streamKind, data []byte) + current streamKind + pending []byte // trailing bytes that may be the start of a split marker +} + +func newStreamDemux(emit func(kind streamKind, data []byte)) *streamDemux { + return &streamDemux{emit: emit, current: streamStdout} +} + +func (d *streamDemux) Write(chunk []byte) { + if len(chunk) == 0 { + return + } + + buf := make([]byte, 0, len(d.pending)+len(chunk)) + buf = append(buf, d.pending...) + buf = append(buf, chunk...) + d.pending = nil + + segStart := 0 + i := 0 + for i < len(buf) { + kind, isMarker, isPartial := matchMarkerAt(buf, i) + switch { + case isMarker: + d.emitRange(buf[segStart:i]) + d.current = kind + i += len(log.STDOUT_PREFIX) + segStart = i + case isPartial: + // Tail of the buffer may be a marker split across chunks — keep + // it in pending and emit everything before it. + d.emitRange(buf[segStart:i]) + d.pending = append(d.pending, buf[i:]...) + return + default: + i++ + } + } + d.emitRange(buf[segStart:]) +} + +// Flush emits any held-back partial marker bytes as regular content. It must +// be called when the stream is known to be complete (exit code written). +func (d *streamDemux) Flush() { + if len(d.pending) > 0 { + d.emitRange(d.pending) + d.pending = nil + } +} + +func (d *streamDemux) emitRange(data []byte) { + if len(data) == 0 { + return + } + d.emit(d.current, data) +} + +// matchMarkerAt reports whether buf[i:] starts with a complete stream marker +// (isMarker), or whether it is a proper prefix of a marker at the tail of the +// buffer (isPartial) and should be held back for the next chunk. +func matchMarkerAt(buf []byte, i int) (kind streamKind, isMarker, isPartial bool) { + rest := buf[i:] + if bytes.HasPrefix(rest, log.STDOUT_PREFIX) { + return streamStdout, true, false + } + if bytes.HasPrefix(rest, log.STDERR_PREFIX) { + return streamStderr, true, false + } + if len(rest) < len(log.STDOUT_PREFIX) { + if bytes.HasPrefix(log.STDOUT_PREFIX, rest) || bytes.HasPrefix(log.STDERR_PREFIX, rest) { + return streamStdout, false, true + } + } + return streamStdout, false, false +} diff --git a/apps/daemon/pkg/toolbox/process/exec/demux_test.go b/apps/daemon/pkg/toolbox/process/exec/demux_test.go new file mode 100644 index 0000000000..8500dcd008 --- /dev/null +++ b/apps/daemon/pkg/toolbox/process/exec/demux_test.go @@ -0,0 +1,104 @@ +// Copyright Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package exec + +import ( + "strings" + "testing" + + "github.com/daytonaio/common-go/pkg/log" +) + +type demuxCapture struct { + stdout strings.Builder + stderr strings.Builder +} + +func (c *demuxCapture) emit(kind streamKind, data []byte) { + if kind == streamStderr { + c.stderr.Write(data) + } else { + c.stdout.Write(data) + } +} + +func prefixed(prefix, s string) string { + return prefix + s + "\n" +} + +func TestStreamDemuxBasic(t *testing.T) { + capture := &demuxCapture{} + d := newStreamDemux(capture.emit) + + input := prefixed(string(log.STDOUT_PREFIX), "hello") + + prefixed(string(log.STDERR_PREFIX), "oops") + + prefixed(string(log.STDOUT_PREFIX), "world") + + d.Write([]byte(input)) + d.Flush() + + if got := capture.stdout.String(); got != "hello\nworld\n" { + t.Fatalf("unexpected stdout %q", got) + } + if got := capture.stderr.String(); got != "oops\n" { + t.Fatalf("unexpected stderr %q", got) + } +} + +func TestStreamDemuxSplitMarkerAcrossChunks(t *testing.T) { + capture := &demuxCapture{} + d := newStreamDemux(capture.emit) + + full := prefixed(string(log.STDOUT_PREFIX), "one") + string(log.STDERR_PREFIX) + "two\n" + + // Feed byte by byte — every possible marker split is exercised. + for i := 0; i < len(full); i++ { + d.Write([]byte{full[i]}) + } + d.Flush() + + if got := capture.stdout.String(); got != "one\n" { + t.Fatalf("unexpected stdout %q", got) + } + if got := capture.stderr.String(); got != "two\n" { + t.Fatalf("unexpected stderr %q", got) + } +} + +func TestStreamDemuxMatchesReferenceDemux(t *testing.T) { + capture := &demuxCapture{} + d := newStreamDemux(capture.emit) + + chunks := []string{ + prefixed(string(log.STDOUT_PREFIX), "line one"), + prefixed(string(log.STDOUT_PREFIX), "line two"), + prefixed(string(log.STDERR_PREFIX), "error line"), + prefixed(string(log.STDOUT_PREFIX), "line three"), + } + var full string + for _, c := range chunks { + full += c + } + + // Write in awkward chunk sizes. + const chunkSize = 7 + for i := 0; i < len(full); i += chunkSize { + end := i + chunkSize + if end > len(full) { + end = len(full) + } + d.Write([]byte(full[i:end])) + } + d.Flush() + + wantStdout := "line one\nline two\nline three\n" + wantStderr := "error line\n" + + if got := capture.stdout.String(); got != wantStdout { + t.Fatalf("stdout %q, want %q", got, wantStdout) + } + if got := capture.stderr.String(); got != wantStderr { + t.Fatalf("stderr %q, want %q", got, wantStderr) + } +} diff --git a/apps/daemon/pkg/toolbox/process/exec/session_exec.go b/apps/daemon/pkg/toolbox/process/exec/session_exec.go new file mode 100644 index 0000000000..249617f35b --- /dev/null +++ b/apps/daemon/pkg/toolbox/process/exec/session_exec.go @@ -0,0 +1,235 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package exec + +import ( + "context" + "fmt" + "log/slog" + "os" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/daytonaio/daemon/internal/util" + session_svc "github.com/daytonaio/daemon/pkg/session" + "github.com/google/uuid" +) + +const ( + // execPollInterval mirrors the exit-code poll cadence of SessionService.Execute. + execPollInterval = 50 * time.Millisecond +) + +// commandSession runs a one-shot command on top of a dedicated SessionService +// session, reusing the existing command wrapper (cmdWrapperFormat): the +// wrapper's stdout/stderr labelers feed a per-command log file that this +// session tails and demultiplexes into stdout/stderr frames, and its +// exit-code file delivers the SSH-style exit status. +type commandSession struct { + logger *slog.Logger + workDir string + sessionService *session_svc.SessionService + + sessionId string + commandId string + killOnce sync.Once +} + +func newCommandSession(logger *slog.Logger, workDir string, sessionService *session_svc.SessionService) *commandSession { + return &commandSession{ + logger: logger, + workDir: workDir, + sessionService: sessionService, + } +} + +func (s *commandSession) Start(ctx context.Context, start StartFrame, emit func(frame any, final bool)) error { + s.sessionId = "exec-" + uuid.NewString() + + if err := s.sessionService.Create(s.sessionId, false); err != nil { + return fmt.Errorf("failed to create exec session: %w", err) + } + + script := BuildCommandScript(start.Cwd, start.Env, start.Command, s.workDir) + + // Async execute: the command wrapper keeps stdin open on a FIFO (stdin + // frames), prefixes stdout/stderr into the command log (output frames) + // and finally records the exit code (exit frame). + result, err := s.sessionService.Execute(s.sessionId, util.EmptyCommandID, script, true, false, true, true) + if err != nil { + s.Kill() + return fmt.Errorf("failed to execute command: %w", err) + } + s.commandId = result.CommandId + + logPath, exitCodePath, err := s.sessionService.CommandLogPaths(s.sessionId, s.commandId) + if err != nil { + s.Kill() + return fmt.Errorf("failed to resolve command log paths: %w", err) + } + + go s.pump(ctx, logPath, exitCodePath, emit) + return nil +} + +// pump tails the command log, demultiplexes the wrapper's stdout/stderr +// stream markers into frames, and emits the exit frame once the wrapper +// records the exit code. The wrapper guarantees the exit-code file is written +// only after all output has been flushed to the log, so once it appears the +// remaining log content can be drained to EOF before exiting. +func (s *commandSession) pump(ctx context.Context, logPath, exitCodePath string, emit func(frame any, final bool)) { + var logFile *os.File + defer func() { + if logFile != nil { + _ = logFile.Close() + } + }() + + var offset int64 + buf := make([]byte, 32*1024) + demux := newStreamDemux(func(kind streamKind, data []byte) { + frameType := FrameTypeStdout + if kind == streamStderr { + frameType = FrameTypeStderr + } + emit(OutputFrame{Type: frameType, Data: string(data)}, false) + }) + + readAvailable := func() { + if logFile == nil { + f, err := os.Open(logPath) + if err != nil { + return // not created yet + } + logFile = f + } + for { + n, err := logFile.ReadAt(buf, offset) + if n > 0 { + offset += int64(n) + demux.Write(buf[:n]) + } + if err != nil { + return + } + } + } + + for { + select { + case <-ctx.Done(): + return + default: + } + + readAvailable() + + exitCode, ok := readExitCodeFile(exitCodePath) + if ok { + // The wrapper flushed all output before writing the exit code; + // drain whatever is left and terminate the protocol. + readAvailable() + demux.Flush() + emit(ExitFrame{Type: FrameTypeExit, ExitCode: exitCode}, true) + return + } + + time.Sleep(execPollInterval) + } +} + +func (s *commandSession) WriteStdin(data []byte) error { + return s.sessionService.WriteInput(s.sessionId, s.commandId, data) +} + +func (s *commandSession) CloseStdin() error { + return s.sessionService.CloseInput(s.sessionId, s.commandId) +} + +func (s *commandSession) Signal(sig syscall.Signal) error { + return s.sessionService.SignalDescendants(s.sessionId, sig) +} + +// Resize is a no-op for session-backed exec (no PTY); the frame is accepted +// to keep the protocol uniform across modes. +func (s *commandSession) Resize(_, _ uint16) error { + return nil +} + +func (s *commandSession) Kill() { + s.killOnce.Do(func() { + if s.sessionId == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := s.sessionService.Delete(ctx, s.sessionId); err != nil { + s.logger.Debug("failed to delete exec session", "sessionId", s.sessionId, "error", err) + } + }) +} + +func readExitCodeFile(exitCodePath string) (int, bool) { + exitCodeBytes, err := os.ReadFile(exitCodePath) + if err != nil { + return 0, false + } + exitCode, err := strconv.Atoi(strings.TrimRight(string(exitCodeBytes), "\n")) + if err != nil { + return 0, false + } + return exitCode, true +} + +var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// BuildCommandScript renders a command script for the session command wrapper +// that applies cwd and env before running the command itself. +// +// The script is wrapped in a subshell on purpose: the session command +// wrapper sources the script in the session shell ({ . cmdfile; }), so a +// bare `exit` in the command — or a failing `cd` — would kill the session +// shell before the wrapper could record the exit code. The subshell confines +// any `exit` and surfaces its status as the command's exit code instead. +func BuildCommandScript(cwd string, env map[string]string, command, fallbackCwd string) string { + var b strings.Builder + b.WriteString("(\n") + + dir := cwd + if dir == "" { + dir = fallbackCwd + } + if dir != "" { + b.WriteString("cd " + shellQuote(dir) + " || exit 1\n") + } + + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if !envKeyRegex.MatchString(k) { + continue + } + b.WriteString("export " + k + "=" + shellQuote(env[k]) + "\n") + } + + b.WriteString(command) + if !strings.HasSuffix(command, "\n") { + b.WriteString("\n") + } + b.WriteString(")\n") + + return b.String() +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/apps/daemon/pkg/toolbox/process/exec/shell_exec.go b/apps/daemon/pkg/toolbox/process/exec/shell_exec.go new file mode 100644 index 0000000000..0e93d42cf2 --- /dev/null +++ b/apps/daemon/pkg/toolbox/process/exec/shell_exec.go @@ -0,0 +1,172 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package exec + +import ( + "context" + "fmt" + "log/slog" + "syscall" + "time" + + "github.com/daytonaio/daemon/pkg/toolbox/process/pty" + "github.com/google/uuid" +) + +const ( + defaultShellCols = 80 + defaultShellRows = 24 + + // exitDrainTimeout bounds how long buffered PTY output is flushed after + // the shell process has exited, before the exit frame is emitted. + exitDrainTimeout = 250 * time.Millisecond +) + +// shellSignalControlChars maps signals to their terminal control characters; +// writing them to the PTY master makes the line discipline deliver the signal +// to the foreground process group — exactly like a terminal would. +var shellSignalControlChars = map[syscall.Signal]byte{ + syscall.SIGINT: 0x03, // ^C (INTR) + syscall.SIGQUIT: 0x1c, // ^\ (QUIT) + syscall.SIGTSTP: 0x1a, // ^Z (TSTP) +} + +// shellSession is an interactive login shell backed by a PTY (the same +// machinery as the toolbox PTY endpoints): a real terminal with job control, +// so signals arrive as control characters and resize maps to TIOCSWINSZ. +type shellSession struct { + logger *slog.Logger + workDir string + session *pty.PTYSession +} + +func newShellSession(logger *slog.Logger, workDir string) *shellSession { + return &shellSession{ + logger: logger, + workDir: workDir, + } +} + +func (s *shellSession) Start(ctx context.Context, start StartFrame, emit func(frame any, final bool)) error { + cwd := start.Cwd + if cwd == "" { + cwd = s.workDir + } + + envs := make(map[string]string, len(start.Env)+1) + for k, v := range start.Env { + envs[k] = v + } + if envs["TERM"] == "" { + envs["TERM"] = "xterm-256color" + } + + cols := start.Cols + if cols == 0 { + cols = defaultShellCols + } + rows := start.Rows + if rows == 0 { + rows = defaultShellRows + } + + s.session = pty.NewEphemeralPTYSession(s.logger, pty.PTYSessionInfo{ + ID: "exec-" + uuid.NewString(), + Cwd: cwd, + Envs: envs, + Cols: cols, + Rows: rows, + CreatedAt: time.Now(), + }) + + out, unsubscribe := s.session.SubscribeOutput(256) + + if err := s.session.Start(); err != nil { + unsubscribe() + return fmt.Errorf("failed to start shell: %w", err) + } + + go s.forward(ctx, out, emit) + return nil +} + +// forward is the single emitter for the shell session: it forwards PTY output +// as stdout frames (a terminal merges stdout/stderr, like SSH shell channels) +// and, once the shell exits, drains the remaining buffer and emits the exit +// frame — preserving frame ordering. +func (s *shellSession) forward(ctx context.Context, out <-chan []byte, emit func(frame any, final bool)) { + exitCh := make(chan int, 1) + go func() { + exitCh <- s.session.WaitExit() + }() + + for { + select { + case <-ctx.Done(): + return + case chunk, ok := <-out: + if !ok { + // Subscriber was dropped as a slow consumer; keep the + // protocol alive — the exit frame is still delivered. + out = nil + continue + } + emit(OutputFrame{Type: FrameTypeStdout, Data: string(chunk)}, false) + case exitCode := <-exitCh: + // The shell process exited; flush whatever output is still + // buffered, then terminate the protocol. + drainTimer := time.NewTimer(exitDrainTimeout) + defer drainTimer.Stop() + drain: + for { + select { + case chunk, ok := <-out: + if !ok { + out = nil + continue + } + emit(OutputFrame{Type: FrameTypeStdout, Data: string(chunk)}, false) + case <-drainTimer.C: + break drain + case <-ctx.Done(): + break drain + } + } + emit(ExitFrame{Type: FrameTypeExit, ExitCode: exitCode}, true) + return + } + } +} + +func (s *shellSession) WriteStdin(data []byte) error { + return s.session.WriteInput(data) +} + +// CloseStdin delivers stdin EOF as ^D (EOT), like a terminal would. +func (s *shellSession) CloseStdin() error { + return s.session.WriteInput([]byte{0x04}) +} + +func (s *shellSession) Signal(sig syscall.Signal) error { + if ch, ok := shellSignalControlChars[sig]; ok { + return s.session.WriteInput([]byte{ch}) + } + + // Other signals go to the shell's process group — the PTY child is a + // session leader (Setsid), so its PGID equals its PID. + if pid := s.session.ShellPid(); pid > 0 { + return syscall.Kill(-pid, sig) + } + return nil +} + +func (s *shellSession) Resize(cols, rows uint16) error { + return s.session.Resize(cols, rows) +} + +func (s *shellSession) Kill() { + if s.session != nil { + s.session.Kill() + } +} diff --git a/apps/daemon/pkg/toolbox/process/exec/types.go b/apps/daemon/pkg/toolbox/process/exec/types.go new file mode 100644 index 0000000000..1d6ff4e4b0 --- /dev/null +++ b/apps/daemon/pkg/toolbox/process/exec/types.go @@ -0,0 +1,77 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package exec + +// Frame protocol for GET /process/exec/connect — an SSH-equivalent exec +// channel over a single WebSocket connection. +// +// Client -> server frames: +// +// {"type":"start","command":"echo hi","cwd":"/home/daytona","env":{"FOO":"bar"},"cols":120,"rows":40} +// {"type":"stdin","data":"..."} +// {"type":"signal","signal":"SIGINT"} +// {"type":"resize","cols":120,"rows":40} +// {"type":"stdin_eof"} +// +// Server -> client frames: +// +// {"type":"stdout","data":"..."} +// {"type":"stderr","data":"..."} +// {"type":"exit","exitCode":0} +// {"type":"error","message":"..."} +// +// The first client frame must be "start". When "command" is omitted, an +// interactive login shell is started instead of a one-shot command — exactly +// like bare `ssh host`. The "exit" frame is always the last server frame and +// is delivered before the connection is closed (SSH exit-status semantics). +const ( + FrameTypeStart = "start" + FrameTypeStdin = "stdin" + FrameTypeSignal = "signal" + FrameTypeResize = "resize" + FrameTypeStdinEOF = "stdin_eof" + + FrameTypeStdout = "stdout" + FrameTypeStderr = "stderr" + FrameTypeExit = "exit" + FrameTypeError = "error" +) + +// StartFrame is the first (and only) configuration frame sent by the client. +type StartFrame struct { + Type string `json:"type"` + Command string `json:"command,omitempty"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + Cols uint16 `json:"cols,omitempty"` + Rows uint16 `json:"rows,omitempty"` +} // @name ExecStartFrame + +// ClientFrame is any control frame sent by the client after "start". +type ClientFrame struct { + Type string `json:"type"` + Data string `json:"data,omitempty"` + Signal string `json:"signal,omitempty"` + Cols uint16 `json:"cols,omitempty"` + Rows uint16 `json:"rows,omitempty"` +} // @name ExecClientFrame + +// OutputFrame carries stdout or stderr data from the command/shell. +type OutputFrame struct { + Type string `json:"type"` + Data string `json:"data"` +} // @name ExecOutputFrame + +// ExitFrame terminates the protocol; exitCode uses SSH exit-status semantics +// (128+signal when the command was killed by a signal, e.g. 130 for SIGINT). +type ExitFrame struct { + Type string `json:"type"` + ExitCode int `json:"exitCode"` +} // @name ExecExitFrame + +// ErrorFrame reports a fatal protocol error; the connection closes after it. +type ErrorFrame struct { + Type string `json:"type"` + Message string `json:"message"` +} // @name ExecErrorFrame diff --git a/apps/daemon/pkg/toolbox/process/pty/controller.go b/apps/daemon/pkg/toolbox/process/pty/controller.go index 60e12dba2c..00fa4e9520 100644 --- a/apps/daemon/pkg/toolbox/process/pty/controller.go +++ b/apps/daemon/pkg/toolbox/process/pty/controller.go @@ -90,6 +90,8 @@ func (p *PTYController) CreatePTYSession(c *gin.Context) { LazyStart: req.LazyStart, }, clients: cmap.New[*wsClient](), + outSubs: cmap.New[chan []byte](), + done: make(chan struct{}), logger: p.logger.With(slog.String("sessionId", req.ID)), } diff --git a/apps/daemon/pkg/toolbox/process/pty/ephemeral.go b/apps/daemon/pkg/toolbox/process/pty/ephemeral.go new file mode 100644 index 0000000000..672513c216 --- /dev/null +++ b/apps/daemon/pkg/toolbox/process/pty/ephemeral.go @@ -0,0 +1,92 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package pty + +import ( + "log/slog" + "time" + + "github.com/google/uuid" + cmap "github.com/orcaman/concurrent-map/v2" +) + +// NewEphemeralPTYSession creates a PTY session that is NOT registered with +// the global PTY manager: it is owned and torn down by the caller (e.g. the +// exec-over-WebSocket controller) and is invisible to the PTY REST endpoints. +// It reuses the exact same start/read/write/resize/kill machinery as +// manager-registered sessions. +func NewEphemeralPTYSession(logger *slog.Logger, info PTYSessionInfo) *PTYSession { + if info.ID == "" { + info.ID = uuid.NewString() + } + if info.CreatedAt.IsZero() { + info.CreatedAt = time.Now() + } + + return &PTYSession{ + info: info, + clients: cmap.New[*wsClient](), + outSubs: cmap.New[chan []byte](), + done: make(chan struct{}), + logger: logger.With(slog.String("sessionId", info.ID)), + } +} + +// Start launches the PTY process (same semantics as CreatePTYSession with +// LazyStart=false). +func (s *PTYSession) Start() error { + return s.start() +} + +// SubscribeOutput registers a channel that receives the same raw PTY output +// broadcast as attached WebSocket clients. The returned unsubscribe function +// detaches the subscriber. A subscriber whose buffer stays full is dropped +// (its channel is closed), mirroring the slow-consumer policy for WebSocket +// clients. +func (s *PTYSession) SubscribeOutput(buffer int) (<-chan []byte, func()) { + if buffer <= 0 { + buffer = 256 + } + ch := make(chan []byte, buffer) + key := uuid.NewString() + + s.outSubs.Set(key, ch) + + return ch, func() { + s.outSubs.Remove(key) + } +} + +// WriteInput writes raw bytes to the PTY (stdin). +func (s *PTYSession) WriteInput(data []byte) error { + return s.sendToPTY(data) +} + +// Resize changes the PTY window size (TIOCSWINSZ). +func (s *PTYSession) Resize(cols, rows uint16) error { + return s.resize(cols, rows) +} + +// Kill terminates the PTY session and its whole process tree. +func (s *PTYSession) Kill() { + s.kill() +} + +// WaitExit blocks until the PTY process has exited and returns its exit code +// (128+signal when killed by a signal, e.g. 130 for SIGINT). +func (s *PTYSession) WaitExit() int { + <-s.done + return s.exitCode +} + +// ShellPid returns the PID of the PTY's shell process, or 0 if not running. +// The PTY child is a session leader, so its process group ID equals its PID. +func (s *PTYSession) ShellPid() int { + s.mu.Lock() + defer s.mu.Unlock() + if s.cmd != nil && s.cmd.Process != nil { + return s.cmd.Process.Pid + } + return 0 +} diff --git a/apps/daemon/pkg/toolbox/process/pty/session.go b/apps/daemon/pkg/toolbox/process/pty/session.go index c0f195879b..5aa3fbd273 100644 --- a/apps/daemon/pkg/toolbox/process/pty/session.go +++ b/apps/daemon/pkg/toolbox/process/pty/session.go @@ -106,9 +106,16 @@ func (s *PTYSession) start() error { s.mu.Lock() s.info.Active = false + s.exitCode = exitCode sessionID := s.info.ID s.mu.Unlock() + // Unblock WaitExit callers (e.g. exec-over-WS) — the reaper runs at + // most once per session because start() refuses to restart. + if s.done != nil { + close(s.done) + } + // Close WebSocket connections with exit code and reason s.closeClientsWithExitCode(exitCode, exitReason) diff --git a/apps/daemon/pkg/toolbox/process/pty/types.go b/apps/daemon/pkg/toolbox/process/pty/types.go index 1e92ebfd9a..7bae38fa6f 100644 --- a/apps/daemon/pkg/toolbox/process/pty/types.go +++ b/apps/daemon/pkg/toolbox/process/pty/types.go @@ -57,6 +57,15 @@ type PTYSession struct { clients cmap.ConcurrentMap[string, *wsClient] clientsMu sync.RWMutex + // output subscribers — non-WebSocket consumers (e.g. exec-over-WS + // bridging) that receive the same broadcast stream as attached clients + outSubs cmap.ConcurrentMap[string, chan []byte] + + // done is closed by the reaper once the PTY process has exited; + // exitCode holds the resulting exit code (SSH exit-status semantics). + done chan struct{} + exitCode int + // funnel of all client inputs -> single PTY writer (preserves ordering) inCh chan []byte diff --git a/apps/daemon/pkg/toolbox/process/pty/websocket.go b/apps/daemon/pkg/toolbox/process/pty/websocket.go index 40da7ef2a9..b1fd81fc60 100644 --- a/apps/daemon/pkg/toolbox/process/pty/websocket.go +++ b/apps/daemon/pkg/toolbox/process/pty/websocket.go @@ -114,6 +114,19 @@ func (s *PTYSession) broadcast(b []byte) { } } s.clientsMu.RUnlock() + + // Fan out to non-WebSocket output subscribers; drop slow ones (same + // policy as WebSocket clients) to avoid stalling the PTY read loop. + // broadcast is only called from ptyReadLoop, so removing/closing a slow + // subscriber here cannot race with a concurrent send on the same channel. + for key, ch := range s.outSubs.Items() { + select { + case ch <- b: + default: + s.outSubs.Remove(key) + close(ch) + } + } } // closeClientsWithExitCode closes all WebSocket connections with structured exit data diff --git a/apps/daemon/pkg/toolbox/server.go b/apps/daemon/pkg/toolbox/server.go index 7ee4258ac6..6bcad2bf72 100644 --- a/apps/daemon/pkg/toolbox/server.go +++ b/apps/daemon/pkg/toolbox/server.go @@ -38,9 +38,11 @@ import ( "github.com/daytonaio/daemon/pkg/toolbox/fs" "github.com/daytonaio/daemon/pkg/toolbox/git" "github.com/daytonaio/daemon/pkg/toolbox/lsp" + toolboxmcp "github.com/daytonaio/daemon/pkg/toolbox/mcp" "github.com/daytonaio/daemon/pkg/toolbox/port" "github.com/daytonaio/daemon/pkg/toolbox/process" "github.com/daytonaio/daemon/pkg/toolbox/process/coderun" + execws "github.com/daytonaio/daemon/pkg/toolbox/process/exec" "github.com/daytonaio/daemon/pkg/toolbox/process/interpreter" "github.com/daytonaio/daemon/pkg/toolbox/process/pty" "github.com/daytonaio/daemon/pkg/toolbox/process/session" @@ -167,6 +169,12 @@ func (s *server) Start() error { r.GET("/version", s.GetVersion) + // MCP endpoint (streamable HTTP) — v1 sandbox toolset for MCP-native agents + mcpServer := toolboxmcp.NewMCPServer(s.logger, s.WorkDir, s.sessionService) + r.POST("/mcp", mcpServer.HandleMCP) + r.GET("/mcp", mcpServer.HandleMCP) + r.DELETE("/mcp", mcpServer.HandleMCP) + // keep /project-dir old behavior for backward compatibility r.GET("/project-dir", s.GetUserHomeDir) r.GET("/user-home-dir", s.GetUserHomeDir) @@ -202,6 +210,10 @@ func (s *server) Start() error { processController.POST("/execute", process.ExecuteCommand(processLogger)) processController.POST("/code-run", coderun.CodeRun(processLogger)) + // SSH-equivalent exec over a single WebSocket connection + execController := execws.NewExecController(s.logger, s.WorkDir, s.sessionService) + processController.GET("/exec/connect", execController.Connect) + sessionController := session.NewSessionController(s.logger, s.configDir, s.sessionService) sessionGroup := processController.Group("/session") { diff --git a/apps/docs/src/content/docs/en/ssh-over-https.mdx b/apps/docs/src/content/docs/en/ssh-over-https.mdx new file mode 100644 index 0000000000..edf8cadd43 --- /dev/null +++ b/apps/docs/src/content/docs/en/ssh-over-https.mdx @@ -0,0 +1,114 @@ +--- +title: SSH over HTTPS +description: Give agents SSH-equivalent sandbox access over plain HTTPS using SSH access tokens — a WebSocket exec channel and an MCP endpoint exposed by the sandbox toolbox. +--- + +Daytona sandboxes expose two HTTPS endpoints that provide SSH-equivalent access using the same token-based authentication as [SSH Access](/docs/en/ssh-access). They are designed for AI agents and automation that cannot open a raw SSH connection but can speak WebSocket or HTTP: + +- **WebSocket exec** (`/process/exec/connect`) — an interactive exec channel with SSH channel semantics: run a command and stream `stdin`/`stdout`/`stderr`, send signals, resize the terminal, or drop into a full login shell. +- **MCP** (`/mcp`) — a [Model Context Protocol](https://modelcontextprotocol.io) streamable-HTTP endpoint with tools for command execution and file operations. + +Both endpoints are proxied per sandbox and require an SSH access token on every connection. + +## Get an SSH access token + +Create a token via the [Dashboard, CLI, or API](/docs/en/ssh-access) — for example: + +```bash +curl -X POST "https://app.daytona.io/api/sandboxes/{SANDBOX_ID}/ssh-access?expiresInMinutes=360" \ + -H "Authorization: Bearer $DAYTONA_API_KEY" +``` + +The response contains a `token` and a ready-to-use SSH command. The same token authenticates the HTTPS endpoints below. + +Tokens are validated on **every** new connection, so revoking a token (or letting it expire) immediately blocks new connections. + +## Endpoints + +| Endpoint | URL | +| -------- | --- | +| WebSocket exec | `wss:///toolbox/{SANDBOX_ID}/process/exec/connect` | +| MCP (streamable HTTP) | `https:///toolbox/{SANDBOX_ID}/mcp` | + +Authenticate with either: + +- `Authorization: Bearer ` header, or +- `?token=` query parameter (for WebSocket clients that cannot set headers, e.g. browsers) + +The sandbox must be started. Connecting to a sandbox in any other state fails with an explicit message, e.g.: + +``` +Sandbox is not started (state: stopped). Please start the sandbox before attempting to connect. +``` + +Sandbox activity is reported when the connection opens and periodically while it stays open, keeping the sandbox from idling out — same as an SSH session. + +## WebSocket exec protocol + +The connection exchanges JSON text frames. The first client frame must be `start`; the server closes the connection after sending the terminal `exit` (or `error`) frame. + +### Client → server + +| Frame | Fields | Description | +| ----- | ------ | ----------- | +| `start` | `command?`, `cwd?`, `env?`, `cols?`, `rows?` | Starts the session. With `command`, runs it non-interactively. Without `command`, spawns an interactive login shell (`bash -l`, falling back to `sh`). `cols`/`rows` set the shell terminal size (default 80×24). | +| `stdin` | `data` | Bytes written to the process stdin (or the shell terminal). | +| `signal` | `signal` | Signal name or number (`SIGINT`, `SIGTERM`, `9`, ...). In shell mode, `SIGINT`/`SIGQUIT`/`SIGTSTP` are delivered as terminal control characters (`^C`/`^\`/`^Z`). | +| `resize` | `cols`, `rows` | Changes the shell terminal size (shell mode only). | +| `stdin_eof` | — | Closes stdin (sends EOF). | + +### Server → client + +| Frame | Fields | Description | +| ----- | ------ | ----------- | +| `stdout` | `data` | Chunk of standard output (shell mode: merged terminal output). | +| `stderr` | `data` | Chunk of standard error (command mode only). | +| `exit` | `exitCode` | Process exited. Always the last frame before the server closes the connection. A command interrupted by `SIGINT` reports `130`, matching SSH. | +| `error` | `message` | Protocol or startup error. Terminal. | + +### Example (Node.js) + +```js +import WebSocket from 'ws' + +const ws = new WebSocket( + `wss://proxy.daytona.works/toolbox/${sandboxId}/process/exec/connect?token=${sshToken}`, +) + +ws.on('open', () => { + ws.send(JSON.stringify({ type: 'start', command: 'sleep 60' })) + setTimeout(() => ws.send(JSON.stringify({ type: 'signal', signal: 'SIGINT' })), 1000) +}) + +ws.on('message', (data) => { + const frame = JSON.parse(data) + if (frame.type === 'exit') console.log('exit code:', frame.exitCode) // 130 +}) +``` + +Multiple connections to the same sandbox are independent — each gets its own process or shell. + +## MCP endpoint + +`POST /toolbox/{SANDBOX_ID}/mcp` speaks the MCP streamable-HTTP transport. Requests must accept both JSON and server-sent events: + +```bash +curl -X POST "https://proxy.daytona.works/toolbox/$SANDBOX_ID/mcp" \ + -H "Authorization: Bearer $SSH_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"exec_command","arguments":{"command":"echo hello"}}}' +``` + +The server is stateless: `tools/call` works directly without an `initialize` handshake, which keeps plain-HTTP clients simple. + +### Tools + +| Tool | Arguments | Description | +| ---- | --------- | ----------- | +| `exec_command` | `command`, `cwd?`, `env?`, `timeout?` | Runs a shell command and returns its stdout, stderr, and exit code. Default timeout 120s (max 3600s). | +| `fs_read_file` | `path` | Reads a file (UTF-8 text, base64 for binary). | +| `fs_write_file` | `path`, `content` | Writes a file, creating parent directories as needed. | +| `fs_list_files` | `path?` | Lists a directory with name, size, type, mode, and modification time. | + +Any MCP client that supports streamable-HTTP servers can connect by pointing at the endpoint with the token in the `Authorization` header. diff --git a/apps/docs/src/content/i18n/en.json b/apps/docs/src/content/i18n/en.json index 1703fb5c15..73963c72f6 100644 --- a/apps/docs/src/content/i18n/en.json +++ b/apps/docs/src/content/i18n/en.json @@ -109,6 +109,8 @@ "sidebarconfig.vpnConnection": "VPN Connections", "sidebarconfig.vpnConnectionDescription": "Connect Daytona Sandboxes to VPN networks.", "sidebarconfig.sshAccessDescription": "SSH access to Daytona Sandboxes.", + "sidebarconfig.sshOverHttps": "SSH over HTTPS", + "sidebarconfig.sshOverHttpsDescription": "SSH-equivalent sandbox access over HTTPS (WebSocket exec + MCP).", "sidebarconfig.playground": "Playground", "sidebarconfig.playgroundDescription": "Playground for Daytona Sandboxes.", "sidebarconfig.preview": "Preview", diff --git a/apps/docs/src/content/i18n/ja.json b/apps/docs/src/content/i18n/ja.json index 4505d34bf3..27b67de789 100644 --- a/apps/docs/src/content/i18n/ja.json +++ b/apps/docs/src/content/i18n/ja.json @@ -80,6 +80,8 @@ "sidebarconfig.webTerminalDescription": "Daytona サンドボックス(Daytonaが管理する隔離された一時的な実行環境)へのWebターミナルアクセス。", "sidebarconfig.sshAccess": "SSHアクセス", "sidebarconfig.sshAccessDescription": "Daytona サンドボックス(Daytonaが管理する隔離された一時的な実行環境)へのSSHアクセス。", + "sidebarconfig.sshOverHttps": "HTTPS 経由の SSH", + "sidebarconfig.sshOverHttpsDescription": "HTTPS 経由の SSH 相当サンドボックスアクセス(WebSocket exec + MCP)。", "sidebarconfig.previewAuthentication": "プレビューと認証", "sidebarconfig.previewAuthenticationDescription": "プレビューURLと認証トークン。", "sidebarconfig.customDomainAuthentication": "カスタムプレビュープロキシ", diff --git a/apps/docs/src/sidebar-config.ts b/apps/docs/src/sidebar-config.ts index 5de6682920..6a3e7fb3b5 100644 --- a/apps/docs/src/sidebar-config.ts +++ b/apps/docs/src/sidebar-config.ts @@ -318,6 +318,15 @@ export const getSidebarConfig = ( icon: 'terminal.svg', }, }, + { + type: 'link', + href: localizePath('/docs/ssh-over-https', locale), + label: t('sidebarconfig.sshOverHttps'), + description: t('sidebarconfig.sshOverHttpsDescription'), + attrs: { + icon: 'terminal.svg', + }, + }, { type: 'link', href: localizePath('/docs/vnc-access', locale), diff --git a/apps/proxy/pkg/proxy/agent_access.go b/apps/proxy/pkg/proxy/agent_access.go new file mode 100644 index 0000000000..00c1f6d9f4 --- /dev/null +++ b/apps/proxy/pkg/proxy/agent_access.go @@ -0,0 +1,87 @@ +// Copyright 2025 Daytona Platforms Inc. +// SPDX-License-Identifier: AGPL-3.0 + +package proxy + +import ( + "context" + "fmt" + "net/http" + "strings" + + common_errors "github.com/daytonaio/common-go/pkg/errors" + "github.com/daytonaio/common-go/pkg/utils" + apiclient "github.com/daytonaio/daytona/libs/api-client-go" +) + +// SSH_ACCESS_TOKEN_QUERY_PARAM carries the SSH access token on WebSocket +// handshakes, where clients cannot set an Authorization header. +const SSH_ACCESS_TOKEN_QUERY_PARAM = "token" + +// Agent access paths accept SSH access tokens (the same tokens the SSH +// gateway issues) as an alternative credential, giving agents SSH-equivalent +// sandbox access over plain HTTPS. +var agentAccessPaths = map[string]bool{ + "/process/exec/connect": true, + "/mcp": true, +} + +// isAgentAccessPath reports whether the toolbox target path is one of the +// agent-access endpoints that accept SSH access tokens. +func isAgentAccessPath(targetPath string) bool { + return agentAccessPaths[strings.TrimSuffix(targetPath, "/")] +} + +// getSshAccessTokenValid validates an SSH access token against the API. +// Unlike the preview-token validators, the result is intentionally NOT +// cached: revoking a token must block new connections immediately. +func (p *Proxy) getSshAccessTokenValid(ctx context.Context, sandboxId string, token string) (*bool, error) { + isValid := false + err := utils.RetryWithExponentialBackoff(ctx, "getSshAccessTokenValid", proxyMaxRetries, proxyBaseDelay, proxyMaxDelay, func() error { + validation, resp, err := p.apiclient.SandboxAPI.ValidateSshAccess(context.Background()).Token(token).Execute() + if resp != nil && resp.StatusCode == http.StatusOK { + isValid = validation != nil && validation.Valid && validation.SandboxId == sandboxId + return nil + } + openapiErr := common_errors.ConvertOpenAPIError(err) + + if openapiErr != nil { + if resp != nil && resp.StatusCode >= 400 && resp.StatusCode < 500 && + resp.StatusCode != http.StatusRequestTimeout && resp.StatusCode != http.StatusTooManyRequests { + isValid = false + return nil + } + if !common_errors.IsRetryableOpenAPIError(openapiErr) { + return &utils.NonRetryableError{Err: openapiErr} + } + return openapiErr + } + isValid = false + return nil + }) + if err != nil { + return nil, err + } + + return &isValid, nil +} + +// ensureSandboxStarted mirrors the SSH gateway: connecting to a sandbox that +// is not started fails fast with an explicit state message instead of an +// opaque upstream error. +func (p *Proxy) ensureSandboxStarted(ctx context.Context, sandboxId string) error { + sandbox, _, err := p.apiclient.SandboxAPI.GetSandbox(ctx, sandboxId).Execute() + if err != nil { + return common_errors.NewBadRequestError(fmt.Errorf("failed to verify sandbox state: %w", err)) + } + + if sandbox.State == nil || *sandbox.State != apiclient.SANDBOXSTATE_STARTED { + state := "unknown" + if sandbox.State != nil { + state = string(*sandbox.State) + } + return common_errors.NewBadRequestError(fmt.Errorf("sandbox is not started (state: %s). Please start the sandbox before attempting to connect", state)) + } + + return nil +} diff --git a/apps/proxy/pkg/proxy/auth.go b/apps/proxy/pkg/proxy/auth.go index e30b82e5d7..45c7aaebcf 100644 --- a/apps/proxy/pkg/proxy/auth.go +++ b/apps/proxy/pkg/proxy/auth.go @@ -14,7 +14,7 @@ import ( "github.com/gin-gonic/gin" ) -func (p *Proxy) Authenticate(ctx *gin.Context, sandboxIdOrSignedToken string, port float32) (sandboxId string, didRedirect bool, err error) { +func (p *Proxy) Authenticate(ctx *gin.Context, sandboxIdOrSignedToken string, port float32, allowSshAccessToken bool) (sandboxId string, didRedirect bool, err error) { var authErrors []string // Try Authorization header with Bearer token @@ -32,6 +32,37 @@ func (p *Proxy) Authenticate(ctx *gin.Context, sandboxIdOrSignedToken string, po } } + // Agent-access endpoints additionally accept SSH access tokens (Bearer or + // ?token= query for WebSocket clients that cannot set headers). + if allowSshAccessToken { + sshToken, fromQuery := bearerToken, false + if sshToken == "" { + sshToken, fromQuery = ctx.Query(SSH_ACCESS_TOKEN_QUERY_PARAM), true + } + if sshToken != "" { + isValid, err := p.getSshAccessTokenValid(ctx.Request.Context(), sandboxIdOrSignedToken, sshToken) + if err != nil { + authErrors = append(authErrors, fmt.Sprintf("SSH access token validation error: %v", err)) + } else if isValid != nil && *isValid { + // A valid token was presented: enforce the same started-state + // check as the SSH gateway and fail fast on its result. + if err := p.ensureSandboxStarted(ctx.Request.Context(), sandboxIdOrSignedToken); err != nil { + return sandboxIdOrSignedToken, false, err + } + // Do not forward the credential to the sandbox + ctx.Request.Header.Del("Authorization") + if fromQuery { + newQuery := ctx.Request.URL.Query() + newQuery.Del(SSH_ACCESS_TOKEN_QUERY_PARAM) + ctx.Request.URL.RawQuery = newQuery.Encode() + } + return sandboxIdOrSignedToken, false, nil + } else { + authErrors = append(authErrors, "SSH access token is invalid") + } + } + } + // Try auth key from header authKey := ctx.Request.Header.Get(SANDBOX_AUTH_KEY_HEADER) if authKey != "" { diff --git a/apps/proxy/pkg/proxy/get_sandbox_target.go b/apps/proxy/pkg/proxy/get_sandbox_target.go index 6831bdd8a8..a500449161 100644 --- a/apps/proxy/pkg/proxy/get_sandbox_target.go +++ b/apps/proxy/pkg/proxy/get_sandbox_target.go @@ -70,7 +70,7 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*url.URL, map[string]string, e return nil, nil, fmt.Errorf("failed to parse target port: %w", err) } var didRedirect bool - sandboxId, didRedirect, err = p.Authenticate(ctx, sandboxIdOrSignedToken, float32(portFloat)) + sandboxId, didRedirect, err = p.Authenticate(ctx, sandboxIdOrSignedToken, float32(portFloat), isAgentAccessPath(targetPath)) if err != nil { if !didRedirect { ctx.Error(err) diff --git a/libs/toolbox-api-client-go/.openapi-generator/FILES b/libs/toolbox-api-client-go/.openapi-generator/FILES index 8746a874f9..74461fdeb6 100644 --- a/libs/toolbox-api-client-go/.openapi-generator/FILES +++ b/libs/toolbox-api-client-go/.openapi-generator/FILES @@ -6,6 +6,7 @@ api_git.go api_info.go api_interpreter.go api_lsp.go +api_mcp.go api_port.go api_process.go api_server.go diff --git a/libs/toolbox-api-client-go/api/openapi.yaml b/libs/toolbox-api-client-go/api/openapi.yaml index f511011df9..60673dc256 100644 --- a/libs/toolbox-api-client-go/api/openapi.yaml +++ b/libs/toolbox-api-client-go/api/openapi.yaml @@ -1724,6 +1724,21 @@ paths: summary: Get workspace symbols tags: - lsp + /mcp: + post: + description: "Model Context Protocol endpoint (streamable-HTTP transport) exposing\ + \ sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files.\ + \ POST sends JSON-RPC messages (responses are SSE events per the transport);\ + \ GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization:\ + \ Bearer ) exactly like /process/exec/connect." + operationId: MCP + responses: + "200": + content: {} + description: OK + summary: MCP endpoint (streamable HTTP) + tags: + - mcp /port: get: description: Get a list of all currently active ports @@ -1782,6 +1797,30 @@ paths: tags: - process x-codegen-request-body-name: request + /process/exec/connect: + get: + description: "SSH-equivalent exec channel over HTTPS. After the upgrade the\ + \ client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\"\ + :\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted,\ + \ an interactive login shell is started (like bare `ssh host`). Subsequent\ + \ client frames: stdin, signal, resize, stdin_eof. Server frames: stdout,\ + \ stderr, exit (always last, before close), error. One connection = one exec;\ + \ shell state persists for the lifetime of the connection." + operationId: ExecConnect + parameters: + - description: SSH access token (alternative to the Authorization header for + WS clients that cannot set headers) + in: query + name: token + schema: + type: string + responses: + "101": + content: {} + description: Switching Protocols - WebSocket connection established + summary: Execute a command or open a shell over a single WebSocket connection + tags: + - process /process/execute: post: description: Execute a shell command and return the output and exit code diff --git a/libs/toolbox-api-client-go/api_mcp.go b/libs/toolbox-api-client-go/api_mcp.go new file mode 100644 index 0000000000..3996f2ce81 --- /dev/null +++ b/libs/toolbox-api-client-go/api_mcp.go @@ -0,0 +1,127 @@ +/* +Daytona Toolbox API + +Daytona Toolbox API. The base URL comes from the sandbox's `toolboxProxyUrl` field (returned in sandbox DTO by the main Daytona API) plus the sandbox ID: `{toolboxProxyUrl}/{sandboxId}/{endpoint}`. Default for Daytona Cloud: `https://proxy.app.daytona.io/toolbox/{sandboxId}`. + +API version: v0.0.0-dev +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package toolbox + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" +) + + +type McpAPI interface { + + /* + MCP MCP endpoint (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return McpAPIMCPRequest + */ + MCP(ctx context.Context) McpAPIMCPRequest + + // MCPExecute executes the request + MCPExecute(r McpAPIMCPRequest) (*http.Response, error) +} + +// McpAPIService McpAPI service +type McpAPIService service + +type McpAPIMCPRequest struct { + ctx context.Context + ApiService McpAPI +} + +func (r McpAPIMCPRequest) Execute() (*http.Response, error) { + return r.ApiService.MCPExecute(r) +} + +/* +MCP MCP endpoint (streamable HTTP) + +Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return McpAPIMCPRequest +*/ +func (a *McpAPIService) MCP(ctx context.Context) McpAPIMCPRequest { + return McpAPIMCPRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +func (a *McpAPIService) MCPExecute(r McpAPIMCPRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "McpAPIService.MCP") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/mcp" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} diff --git a/libs/toolbox-api-client-go/api_process.go b/libs/toolbox-api-client-go/api_process.go index b93cfc9abc..0e830e2734 100644 --- a/libs/toolbox-api-client-go/api_process.go +++ b/libs/toolbox-api-client-go/api_process.go @@ -106,6 +106,19 @@ type ProcessAPI interface { // DeleteSessionExecute executes the request DeleteSessionExecute(r ProcessAPIDeleteSessionRequest) (*http.Response, error) + /* + ExecConnect Execute a command or open a shell over a single WebSocket connection + + SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {"type":"start","command":"...","cwd":"...","env":{...},"cols":...,"rows":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ProcessAPIExecConnectRequest + */ + ExecConnect(ctx context.Context) ProcessAPIExecConnectRequest + + // ExecConnectExecute executes the request + ExecConnectExecute(r ProcessAPIExecConnectRequest) (*http.Response, error) + /* ExecuteCommand Execute a command @@ -896,6 +909,104 @@ func (a *ProcessAPIService) DeleteSessionExecute(r ProcessAPIDeleteSessionReques return localVarHTTPResponse, nil } +type ProcessAPIExecConnectRequest struct { + ctx context.Context + ApiService ProcessAPI + token *string +} + +// SSH access token (alternative to the Authorization header for WS clients that cannot set headers) +func (r ProcessAPIExecConnectRequest) Token(token string) ProcessAPIExecConnectRequest { + r.token = &token + return r +} + +func (r ProcessAPIExecConnectRequest) Execute() (*http.Response, error) { + return r.ApiService.ExecConnectExecute(r) +} + +/* +ExecConnect Execute a command or open a shell over a single WebSocket connection + +SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {"type":"start","command":"...","cwd":"...","env":{...},"cols":...,"rows":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ProcessAPIExecConnectRequest +*/ +func (a *ProcessAPIService) ExecConnect(ctx context.Context) ProcessAPIExecConnectRequest { + return ProcessAPIExecConnectRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +func (a *ProcessAPIService) ExecConnectExecute(r ProcessAPIExecConnectRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "ProcessAPIService.ExecConnect") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/process/exec/connect" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.token != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "token", r.token, "", "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + type ProcessAPIExecuteCommandRequest struct { ctx context.Context ApiService ProcessAPI diff --git a/libs/toolbox-api-client-go/client.go b/libs/toolbox-api-client-go/client.go index 51347a49e4..ba4f2451b6 100644 --- a/libs/toolbox-api-client-go/client.go +++ b/libs/toolbox-api-client-go/client.go @@ -61,6 +61,8 @@ type APIClient struct { LspAPI LspAPI + McpAPI McpAPI + PortAPI PortAPI ProcessAPI ProcessAPI @@ -90,6 +92,7 @@ func NewAPIClient(cfg *Configuration) *APIClient { c.InfoAPI = (*InfoAPIService)(&c.common) c.InterpreterAPI = (*InterpreterAPIService)(&c.common) c.LspAPI = (*LspAPIService)(&c.common) + c.McpAPI = (*McpAPIService)(&c.common) c.PortAPI = (*PortAPIService)(&c.common) c.ProcessAPI = (*ProcessAPIService)(&c.common) c.ServerAPI = (*ServerAPIService)(&c.common) diff --git a/libs/toolbox-api-client-java/.openapi-generator/FILES b/libs/toolbox-api-client-java/.openapi-generator/FILES index 7d8bde5845..f316487342 100644 --- a/libs/toolbox-api-client-java/.openapi-generator/FILES +++ b/libs/toolbox-api-client-java/.openapi-generator/FILES @@ -23,6 +23,7 @@ src/main/java/io/daytona/toolbox/client/api/GitApi.java src/main/java/io/daytona/toolbox/client/api/InfoApi.java src/main/java/io/daytona/toolbox/client/api/InterpreterApi.java src/main/java/io/daytona/toolbox/client/api/LspApi.java +src/main/java/io/daytona/toolbox/client/api/McpApi.java src/main/java/io/daytona/toolbox/client/api/PortApi.java src/main/java/io/daytona/toolbox/client/api/ProcessApi.java src/main/java/io/daytona/toolbox/client/api/ServerApi.java @@ -130,6 +131,7 @@ src/test/java/io/daytona/toolbox/client/api/GitApiTest.java src/test/java/io/daytona/toolbox/client/api/InfoApiTest.java src/test/java/io/daytona/toolbox/client/api/InterpreterApiTest.java src/test/java/io/daytona/toolbox/client/api/LspApiTest.java +src/test/java/io/daytona/toolbox/client/api/McpApiTest.java src/test/java/io/daytona/toolbox/client/api/PortApiTest.java src/test/java/io/daytona/toolbox/client/api/ProcessApiTest.java src/test/java/io/daytona/toolbox/client/api/ServerApiTest.java diff --git a/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/McpApi.java b/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/McpApi.java new file mode 100644 index 0000000000..4ab625eb7e --- /dev/null +++ b/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/McpApi.java @@ -0,0 +1,186 @@ +/* + * Daytona Toolbox API + * Daytona Toolbox API. The base URL comes from the sandbox's `toolboxProxyUrl` field (returned in sandbox DTO by the main Daytona API) plus the sandbox ID: `{toolboxProxyUrl}/{sandboxId}/{endpoint}`. Default for Daytona Cloud: `https://proxy.app.daytona.io/toolbox/{sandboxId}`. + * + * The version of the OpenAPI document: v0.0.0-dev + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +package io.daytona.toolbox.client.api; + +import io.daytona.toolbox.client.ApiCallback; +import io.daytona.toolbox.client.ApiClient; +import io.daytona.toolbox.client.ApiException; +import io.daytona.toolbox.client.ApiResponse; +import io.daytona.toolbox.client.Configuration; +import io.daytona.toolbox.client.Pair; +import io.daytona.toolbox.client.ProgressRequestBody; +import io.daytona.toolbox.client.ProgressResponseBody; + +import com.google.gson.reflect.TypeToken; + +import java.io.IOException; + + + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class McpApi { + private ApiClient localVarApiClient; + private int localHostIndex; + private String localCustomBaseUrl; + + public McpApi() { + this(Configuration.getDefaultApiClient()); + } + + public McpApi(ApiClient apiClient) { + this.localVarApiClient = apiClient; + } + + public ApiClient getApiClient() { + return localVarApiClient; + } + + public void setApiClient(ApiClient apiClient) { + this.localVarApiClient = apiClient; + } + + public int getHostIndex() { + return localHostIndex; + } + + public void setHostIndex(int hostIndex) { + this.localHostIndex = hostIndex; + } + + public String getCustomBaseUrl() { + return localCustomBaseUrl; + } + + public void setCustomBaseUrl(String customBaseUrl) { + this.localCustomBaseUrl = customBaseUrl; + } + + /** + * Build call for mCP + * @param _callback Callback for upload/download progress + * @return Call to execute + * @throws ApiException If fail to serialize the request body object + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 OK -
+ */ + public okhttp3.Call mCPCall(final ApiCallback _callback) throws ApiException { + String basePath = null; + // Operation Servers + String[] localBasePaths = new String[] { }; + + // Determine Base Path to Use + if (localCustomBaseUrl != null){ + basePath = localCustomBaseUrl; + } else if ( localBasePaths.length > 0 ) { + basePath = localBasePaths[localHostIndex]; + } else { + basePath = null; + } + + Object localVarPostBody = null; + + // create path and map variables + String localVarPath = "/mcp"; + + List localVarQueryParams = new ArrayList(); + List localVarCollectionQueryParams = new ArrayList(); + Map localVarHeaderParams = new HashMap(); + Map localVarCookieParams = new HashMap(); + Map localVarFormParams = new HashMap(); + + final String[] localVarAccepts = { + }; + final String localVarAccept = localVarApiClient.selectHeaderAccept(localVarAccepts); + if (localVarAccept != null) { + localVarHeaderParams.put("Accept", localVarAccept); + } + + final String[] localVarContentTypes = { + }; + final String localVarContentType = localVarApiClient.selectHeaderContentType(localVarContentTypes); + if (localVarContentType != null) { + localVarHeaderParams.put("Content-Type", localVarContentType); + } + + String[] localVarAuthNames = new String[] { }; + return localVarApiClient.buildCall(basePath, localVarPath, "POST", localVarQueryParams, localVarCollectionQueryParams, localVarPostBody, localVarHeaderParams, localVarCookieParams, localVarFormParams, localVarAuthNames, _callback); + } + + @SuppressWarnings("rawtypes") + private okhttp3.Call mCPValidateBeforeCall(final ApiCallback _callback) throws ApiException { + return mCPCall(_callback); + + } + + /** + * MCP endpoint (streamable HTTP) + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. + * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 OK -
+ */ + public void mCP() throws ApiException { + mCPWithHttpInfo(); + } + + /** + * MCP endpoint (streamable HTTP) + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. + * @return ApiResponse<Void> + * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 OK -
+ */ + public ApiResponse mCPWithHttpInfo() throws ApiException { + okhttp3.Call localVarCall = mCPValidateBeforeCall(null); + return localVarApiClient.execute(localVarCall); + } + + /** + * MCP endpoint (streamable HTTP) (asynchronously) + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. + * @param _callback The callback to be executed when the API call finishes + * @return The request call + * @throws ApiException If fail to process the API call, e.g. serializing the request body object + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 OK -
+ */ + public okhttp3.Call mCPAsync(final ApiCallback _callback) throws ApiException { + + okhttp3.Call localVarCall = mCPValidateBeforeCall(_callback); + localVarApiClient.executeAsync(localVarCall, _callback); + return localVarCall; + } +} diff --git a/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/ProcessApi.java b/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/ProcessApi.java index 3320386aa9..b4bdd0991a 100644 --- a/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/ProcessApi.java +++ b/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/ProcessApi.java @@ -834,6 +834,126 @@ public okhttp3.Call deleteSessionAsync(@javax.annotation.Nonnull String sessionI localVarApiClient.executeAsync(localVarCall, _callback); return localVarCall; } + /** + * Build call for execConnect + * @param token SSH access token (alternative to the Authorization header for WS clients that cannot set headers) (optional) + * @param _callback Callback for upload/download progress + * @return Call to execute + * @throws ApiException If fail to serialize the request body object + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
101 Switching Protocols - WebSocket connection established -
+ */ + public okhttp3.Call execConnectCall(@javax.annotation.Nullable String token, final ApiCallback _callback) throws ApiException { + String basePath = null; + // Operation Servers + String[] localBasePaths = new String[] { }; + + // Determine Base Path to Use + if (localCustomBaseUrl != null){ + basePath = localCustomBaseUrl; + } else if ( localBasePaths.length > 0 ) { + basePath = localBasePaths[localHostIndex]; + } else { + basePath = null; + } + + Object localVarPostBody = null; + + // create path and map variables + String localVarPath = "/process/exec/connect"; + + List localVarQueryParams = new ArrayList(); + List localVarCollectionQueryParams = new ArrayList(); + Map localVarHeaderParams = new HashMap(); + Map localVarCookieParams = new HashMap(); + Map localVarFormParams = new HashMap(); + + if (token != null) { + localVarQueryParams.addAll(localVarApiClient.parameterToPair("token", token)); + } + + final String[] localVarAccepts = { + }; + final String localVarAccept = localVarApiClient.selectHeaderAccept(localVarAccepts); + if (localVarAccept != null) { + localVarHeaderParams.put("Accept", localVarAccept); + } + + final String[] localVarContentTypes = { + }; + final String localVarContentType = localVarApiClient.selectHeaderContentType(localVarContentTypes); + if (localVarContentType != null) { + localVarHeaderParams.put("Content-Type", localVarContentType); + } + + String[] localVarAuthNames = new String[] { }; + return localVarApiClient.buildCall(basePath, localVarPath, "GET", localVarQueryParams, localVarCollectionQueryParams, localVarPostBody, localVarHeaderParams, localVarCookieParams, localVarFormParams, localVarAuthNames, _callback); + } + + @SuppressWarnings("rawtypes") + private okhttp3.Call execConnectValidateBeforeCall(@javax.annotation.Nullable String token, final ApiCallback _callback) throws ApiException { + return execConnectCall(token, _callback); + + } + + /** + * Execute a command or open a shell over a single WebSocket connection + * SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + * @param token SSH access token (alternative to the Authorization header for WS clients that cannot set headers) (optional) + * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
101 Switching Protocols - WebSocket connection established -
+ */ + public void execConnect(@javax.annotation.Nullable String token) throws ApiException { + execConnectWithHttpInfo(token); + } + + /** + * Execute a command or open a shell over a single WebSocket connection + * SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + * @param token SSH access token (alternative to the Authorization header for WS clients that cannot set headers) (optional) + * @return ApiResponse<Void> + * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
101 Switching Protocols - WebSocket connection established -
+ */ + public ApiResponse execConnectWithHttpInfo(@javax.annotation.Nullable String token) throws ApiException { + okhttp3.Call localVarCall = execConnectValidateBeforeCall(token, null); + return localVarApiClient.execute(localVarCall); + } + + /** + * Execute a command or open a shell over a single WebSocket connection (asynchronously) + * SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + * @param token SSH access token (alternative to the Authorization header for WS clients that cannot set headers) (optional) + * @param _callback The callback to be executed when the API call finishes + * @return The request call + * @throws ApiException If fail to process the API call, e.g. serializing the request body object + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
101 Switching Protocols - WebSocket connection established -
+ */ + public okhttp3.Call execConnectAsync(@javax.annotation.Nullable String token, final ApiCallback _callback) throws ApiException { + + okhttp3.Call localVarCall = execConnectValidateBeforeCall(token, _callback); + localVarApiClient.executeAsync(localVarCall, _callback); + return localVarCall; + } /** * Build call for executeCommand * @param request Command execution request (required) diff --git a/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/McpApiTest.java b/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/McpApiTest.java new file mode 100644 index 0000000000..ef3405b592 --- /dev/null +++ b/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/McpApiTest.java @@ -0,0 +1,46 @@ +/* + * Daytona Toolbox API + * Daytona Toolbox API. The base URL comes from the sandbox's `toolboxProxyUrl` field (returned in sandbox DTO by the main Daytona API) plus the sandbox ID: `{toolboxProxyUrl}/{sandboxId}/{endpoint}`. Default for Daytona Cloud: `https://proxy.app.daytona.io/toolbox/{sandboxId}`. + * + * The version of the OpenAPI document: v0.0.0-dev + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +package io.daytona.toolbox.client.api; + +import io.daytona.toolbox.client.ApiException; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * API tests for McpApi + */ +@Disabled +public class McpApiTest { + + private final McpApi api = new McpApi(); + + /** + * MCP endpoint (streamable HTTP) + * + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. + * + * @throws ApiException if the Api call fails + */ + @Test + public void mCPTest() throws ApiException { + api.mCP(); + // TODO: test validations + } + +} diff --git a/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/ProcessApiTest.java b/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/ProcessApiTest.java index 689409b1f1..743078668d 100644 --- a/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/ProcessApiTest.java +++ b/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/ProcessApiTest.java @@ -130,6 +130,20 @@ public void deleteSessionTest() throws ApiException { // TODO: test validations } + /** + * Execute a command or open a shell over a single WebSocket connection + * + * SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + * + * @throws ApiException if the Api call fails + */ + @Test + public void execConnectTest() throws ApiException { + String token = null; + api.execConnect(token); + // TODO: test validations + } + /** * Execute a command * diff --git a/libs/toolbox-api-client-python-async/.openapi-generator/FILES b/libs/toolbox-api-client-python-async/.openapi-generator/FILES index f4366af7d7..7ed34dd4ae 100644 --- a/libs/toolbox-api-client-python-async/.openapi-generator/FILES +++ b/libs/toolbox-api-client-python-async/.openapi-generator/FILES @@ -7,6 +7,7 @@ daytona_toolbox_api_client_async/api/git_api.py daytona_toolbox_api_client_async/api/info_api.py daytona_toolbox_api_client_async/api/interpreter_api.py daytona_toolbox_api_client_async/api/lsp_api.py +daytona_toolbox_api_client_async/api/mcp_api.py daytona_toolbox_api_client_async/api/port_api.py daytona_toolbox_api_client_async/api/process_api.py daytona_toolbox_api_client_async/api/server_api.py diff --git a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/__init__.py b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/__init__.py index e503899759..67ca1ad2b4 100644 --- a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/__init__.py +++ b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/__init__.py @@ -28,6 +28,7 @@ from daytona_toolbox_api_client_async.api.info_api import InfoApi from daytona_toolbox_api_client_async.api.interpreter_api import InterpreterApi from daytona_toolbox_api_client_async.api.lsp_api import LspApi + from daytona_toolbox_api_client_async.api.mcp_api import McpApi from daytona_toolbox_api_client_async.api.port_api import PortApi from daytona_toolbox_api_client_async.api.process_api import ProcessApi from daytona_toolbox_api_client_async.api.server_api import ServerApi @@ -145,6 +146,7 @@ "InfoApi": "daytona_toolbox_api_client_async.api.info_api", "InterpreterApi": "daytona_toolbox_api_client_async.api.interpreter_api", "LspApi": "daytona_toolbox_api_client_async.api.lsp_api", + "McpApi": "daytona_toolbox_api_client_async.api.mcp_api", "PortApi": "daytona_toolbox_api_client_async.api.port_api", "ProcessApi": "daytona_toolbox_api_client_async.api.process_api", "ServerApi": "daytona_toolbox_api_client_async.api.server_api", @@ -286,6 +288,7 @@ def __dir__() -> list[str]: "InfoApi", "InterpreterApi", "LspApi", + "McpApi", "PortApi", "ProcessApi", "ServerApi", diff --git a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/__init__.py b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/__init__.py index 05f06763ba..1df8cf4776 100644 --- a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/__init__.py +++ b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/__init__.py @@ -13,6 +13,7 @@ from daytona_toolbox_api_client_async.api.info_api import InfoApi from daytona_toolbox_api_client_async.api.interpreter_api import InterpreterApi from daytona_toolbox_api_client_async.api.lsp_api import LspApi + from daytona_toolbox_api_client_async.api.mcp_api import McpApi from daytona_toolbox_api_client_async.api.port_api import PortApi from daytona_toolbox_api_client_async.api.process_api import ProcessApi from daytona_toolbox_api_client_async.api.server_api import ServerApi @@ -25,6 +26,7 @@ "InfoApi": "daytona_toolbox_api_client_async.api.info_api", "InterpreterApi": "daytona_toolbox_api_client_async.api.interpreter_api", "LspApi": "daytona_toolbox_api_client_async.api.lsp_api", + "McpApi": "daytona_toolbox_api_client_async.api.mcp_api", "PortApi": "daytona_toolbox_api_client_async.api.port_api", "ProcessApi": "daytona_toolbox_api_client_async.api.process_api", "ServerApi": "daytona_toolbox_api_client_async.api.server_api", diff --git a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/mcp_api.py b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/mcp_api.py new file mode 100644 index 0000000000..d886b659b4 --- /dev/null +++ b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/mcp_api.py @@ -0,0 +1,272 @@ +""" + Daytona Toolbox API + + Daytona Toolbox API. The base URL comes from the sandbox's `toolboxProxyUrl` field (returned in sandbox DTO by the main Daytona API) plus the sandbox ID: `{toolboxProxyUrl}/{sandboxId}/{endpoint}`. Default for Daytona Cloud: `https://proxy.app.daytona.io/toolbox/{sandboxId}`. + + The version of the OpenAPI document: v0.0.0-dev + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +import warnings +from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt +from typing import Any, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + + +from daytona_toolbox_api_client_async.api_client import ApiClient, RequestSerialized +from daytona_toolbox_api_client_async.api_response import ApiResponse +from daytona_toolbox_api_client_async.rest import RESTResponseType + + +class McpApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + + @validate_call + async def m_cp( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """MCP endpoint (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def m_cp_with_http_info( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """MCP endpoint (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def m_cp_without_preload_content( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """MCP endpoint (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _m_cp_serialize( + self, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/mcp', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/process_api.py b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/process_api.py index d44d4aadcd..fec65c13d1 100644 --- a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/process_api.py +++ b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/process_api.py @@ -1631,6 +1631,261 @@ def _delete_session_serialize( + @validate_call + async def exec_connect( + self, + token: Annotated[Optional[StrictStr], Field(description="SSH access token (alternative to the Authorization header for WS clients that cannot set headers)")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """Execute a command or open a shell over a single WebSocket connection + + SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + + :param token: SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + :type token: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._exec_connect_serialize( + token=token, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '101': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def exec_connect_with_http_info( + self, + token: Annotated[Optional[StrictStr], Field(description="SSH access token (alternative to the Authorization header for WS clients that cannot set headers)")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """Execute a command or open a shell over a single WebSocket connection + + SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + + :param token: SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + :type token: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._exec_connect_serialize( + token=token, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '101': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def exec_connect_without_preload_content( + self, + token: Annotated[Optional[StrictStr], Field(description="SSH access token (alternative to the Authorization header for WS clients that cannot set headers)")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Execute a command or open a shell over a single WebSocket connection + + SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + + :param token: SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + :type token: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._exec_connect_serialize( + token=token, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '101': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _exec_connect_serialize( + self, + token, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + if token is not None: + + _query_params.append(('token', token)) + + # process the header parameters + # process the form parameters + # process the body parameter + + + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/process/exec/connect', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def execute_command( self, diff --git a/libs/toolbox-api-client-python/.openapi-generator/FILES b/libs/toolbox-api-client-python/.openapi-generator/FILES index cb9d78b46f..44ae5291b4 100644 --- a/libs/toolbox-api-client-python/.openapi-generator/FILES +++ b/libs/toolbox-api-client-python/.openapi-generator/FILES @@ -7,6 +7,7 @@ daytona_toolbox_api_client/api/git_api.py daytona_toolbox_api_client/api/info_api.py daytona_toolbox_api_client/api/interpreter_api.py daytona_toolbox_api_client/api/lsp_api.py +daytona_toolbox_api_client/api/mcp_api.py daytona_toolbox_api_client/api/port_api.py daytona_toolbox_api_client/api/process_api.py daytona_toolbox_api_client/api/server_api.py diff --git a/libs/toolbox-api-client-python/daytona_toolbox_api_client/__init__.py b/libs/toolbox-api-client-python/daytona_toolbox_api_client/__init__.py index a38754a0aa..19c2815739 100644 --- a/libs/toolbox-api-client-python/daytona_toolbox_api_client/__init__.py +++ b/libs/toolbox-api-client-python/daytona_toolbox_api_client/__init__.py @@ -28,6 +28,7 @@ from daytona_toolbox_api_client.api.info_api import InfoApi from daytona_toolbox_api_client.api.interpreter_api import InterpreterApi from daytona_toolbox_api_client.api.lsp_api import LspApi + from daytona_toolbox_api_client.api.mcp_api import McpApi from daytona_toolbox_api_client.api.port_api import PortApi from daytona_toolbox_api_client.api.process_api import ProcessApi from daytona_toolbox_api_client.api.server_api import ServerApi @@ -145,6 +146,7 @@ "InfoApi": "daytona_toolbox_api_client.api.info_api", "InterpreterApi": "daytona_toolbox_api_client.api.interpreter_api", "LspApi": "daytona_toolbox_api_client.api.lsp_api", + "McpApi": "daytona_toolbox_api_client.api.mcp_api", "PortApi": "daytona_toolbox_api_client.api.port_api", "ProcessApi": "daytona_toolbox_api_client.api.process_api", "ServerApi": "daytona_toolbox_api_client.api.server_api", @@ -286,6 +288,7 @@ def __dir__() -> list[str]: "InfoApi", "InterpreterApi", "LspApi", + "McpApi", "PortApi", "ProcessApi", "ServerApi", diff --git a/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/__init__.py b/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/__init__.py index 8273ff3e31..78c2f338f3 100644 --- a/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/__init__.py +++ b/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/__init__.py @@ -13,6 +13,7 @@ from daytona_toolbox_api_client.api.info_api import InfoApi from daytona_toolbox_api_client.api.interpreter_api import InterpreterApi from daytona_toolbox_api_client.api.lsp_api import LspApi + from daytona_toolbox_api_client.api.mcp_api import McpApi from daytona_toolbox_api_client.api.port_api import PortApi from daytona_toolbox_api_client.api.process_api import ProcessApi from daytona_toolbox_api_client.api.server_api import ServerApi @@ -25,6 +26,7 @@ "InfoApi": "daytona_toolbox_api_client.api.info_api", "InterpreterApi": "daytona_toolbox_api_client.api.interpreter_api", "LspApi": "daytona_toolbox_api_client.api.lsp_api", + "McpApi": "daytona_toolbox_api_client.api.mcp_api", "PortApi": "daytona_toolbox_api_client.api.port_api", "ProcessApi": "daytona_toolbox_api_client.api.process_api", "ServerApi": "daytona_toolbox_api_client.api.server_api", diff --git a/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/mcp_api.py b/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/mcp_api.py new file mode 100644 index 0000000000..bdc24563c3 --- /dev/null +++ b/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/mcp_api.py @@ -0,0 +1,272 @@ +""" + Daytona Toolbox API + + Daytona Toolbox API. The base URL comes from the sandbox's `toolboxProxyUrl` field (returned in sandbox DTO by the main Daytona API) plus the sandbox ID: `{toolboxProxyUrl}/{sandboxId}/{endpoint}`. Default for Daytona Cloud: `https://proxy.app.daytona.io/toolbox/{sandboxId}`. + + The version of the OpenAPI document: v0.0.0-dev + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +import warnings +from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt +from typing import Any, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + + +from daytona_toolbox_api_client.api_client import ApiClient, RequestSerialized +from daytona_toolbox_api_client.api_response import ApiResponse +from daytona_toolbox_api_client.rest import RESTResponseType + + +class McpApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + + @validate_call + def m_cp( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """MCP endpoint (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + def m_cp_with_http_info( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """MCP endpoint (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + def m_cp_without_preload_content( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """MCP endpoint (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _m_cp_serialize( + self, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/mcp', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/process_api.py b/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/process_api.py index b68334a27c..f3fc87ce53 100644 --- a/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/process_api.py +++ b/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/process_api.py @@ -1631,6 +1631,261 @@ def _delete_session_serialize( + @validate_call + def exec_connect( + self, + token: Annotated[Optional[StrictStr], Field(description="SSH access token (alternative to the Authorization header for WS clients that cannot set headers)")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """Execute a command or open a shell over a single WebSocket connection + + SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + + :param token: SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + :type token: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._exec_connect_serialize( + token=token, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '101': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + def exec_connect_with_http_info( + self, + token: Annotated[Optional[StrictStr], Field(description="SSH access token (alternative to the Authorization header for WS clients that cannot set headers)")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """Execute a command or open a shell over a single WebSocket connection + + SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + + :param token: SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + :type token: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._exec_connect_serialize( + token=token, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '101': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + def exec_connect_without_preload_content( + self, + token: Annotated[Optional[StrictStr], Field(description="SSH access token (alternative to the Authorization header for WS clients that cannot set headers)")] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Execute a command or open a shell over a single WebSocket connection + + SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + + :param token: SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + :type token: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._exec_connect_serialize( + token=token, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '101': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _exec_connect_serialize( + self, + token, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + if token is not None: + + _query_params.append(('token', token)) + + # process the header parameters + # process the form parameters + # process the body parameter + + + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/process/exec/connect', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call def execute_command( self, diff --git a/libs/toolbox-api-client/src/.openapi-generator/FILES b/libs/toolbox-api-client/src/.openapi-generator/FILES index 2854a6e56b..ab0b553318 100644 --- a/libs/toolbox-api-client/src/.openapi-generator/FILES +++ b/libs/toolbox-api-client/src/.openapi-generator/FILES @@ -7,6 +7,7 @@ api/git-api.ts api/info-api.ts api/interpreter-api.ts api/lsp-api.ts +api/mcp-api.ts api/port-api.ts api/process-api.ts api/server-api.ts diff --git a/libs/toolbox-api-client/src/api.ts b/libs/toolbox-api-client/src/api.ts index e3f506f716..d7ce7ebc75 100644 --- a/libs/toolbox-api-client/src/api.ts +++ b/libs/toolbox-api-client/src/api.ts @@ -20,6 +20,7 @@ export * from './api/git-api'; export * from './api/info-api'; export * from './api/interpreter-api'; export * from './api/lsp-api'; +export * from './api/mcp-api'; export * from './api/port-api'; export * from './api/process-api'; export * from './api/server-api'; diff --git a/libs/toolbox-api-client/src/api/mcp-api.ts b/libs/toolbox-api-client/src/api/mcp-api.ts new file mode 100644 index 0000000000..2bdb5f4a57 --- /dev/null +++ b/libs/toolbox-api-client/src/api/mcp-api.ts @@ -0,0 +1,114 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Daytona Toolbox API + * Daytona Toolbox API. The base URL comes from the sandbox\'s `toolboxProxyUrl` field (returned in sandbox DTO by the main Daytona API) plus the sandbox ID: `{toolboxProxyUrl}/{sandboxId}/{endpoint}`. Default for Daytona Cloud: `https://proxy.app.daytona.io/toolbox/{sandboxId}`. + * + * The version of the OpenAPI document: v0.0.0-dev + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +/** + * McpApi - axios parameter creator + */ +export const McpApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + * @summary MCP endpoint (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mCP: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/mcp`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * McpApi - functional programming interface + */ +export const McpApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = McpApiAxiosParamCreator(configuration) + return { + /** + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + * @summary MCP endpoint (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async mCP(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.mCP(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['McpApi.mCP']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * McpApi - factory interface + */ +export const McpApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = McpApiFp(configuration) + return { + /** + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + * @summary MCP endpoint (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mCP(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.mCP(options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * McpApi - object-oriented interface + */ +export class McpApi extends BaseAPI { + /** + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + * @summary MCP endpoint (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public mCP(options?: RawAxiosRequestConfig) { + return McpApiFp(this.configuration).mCP(options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/libs/toolbox-api-client/src/api/process-api.ts b/libs/toolbox-api-client/src/api/process-api.ts index 8efe9c05a1..62d400ddfb 100644 --- a/libs/toolbox-api-client/src/api/process-api.ts +++ b/libs/toolbox-api-client/src/api/process-api.ts @@ -253,6 +253,40 @@ export const ProcessApiAxiosParamCreator = function (configuration?: Configurati const localVarQueryParameter = {} as any; + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + * @summary Execute a command or open a shell over a single WebSocket connection + * @param {string} [token] SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + execConnect: async (token?: string, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/process/exec/connect`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (token !== undefined) { + localVarQueryParameter['token'] = token; + } + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -778,6 +812,19 @@ export const ProcessApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ProcessApi.deleteSession']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + * @summary Execute a command or open a shell over a single WebSocket connection + * @param {string} [token] SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async execConnect(token?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.execConnect(token, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProcessApi.execConnect']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Execute a shell command and return the output and exit code * @summary Execute a command @@ -1007,6 +1054,16 @@ export const ProcessApiFactory = function (configuration?: Configuration, basePa deleteSession(sessionId: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.deleteSession(sessionId, options).then((request) => request(axios, basePath)); }, + /** + * SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + * @summary Execute a command or open a shell over a single WebSocket connection + * @param {string} [token] SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + execConnect(token?: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.execConnect(token, options).then((request) => request(axios, basePath)); + }, /** * Execute a shell command and return the output and exit code * @summary Execute a command @@ -1204,6 +1261,17 @@ export class ProcessApi extends BaseAPI { return ProcessApiFp(this.configuration).deleteSession(sessionId, options).then((request) => request(this.axios, this.basePath)); } + /** + * SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + * @summary Execute a command or open a shell over a single WebSocket connection + * @param {string} [token] SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public execConnect(token?: string, options?: RawAxiosRequestConfig) { + return ProcessApiFp(this.configuration).execConnect(token, options).then((request) => request(this.axios, this.basePath)); + } + /** * Execute a shell command and return the output and exit code * @summary Execute a command From 3f0c12e81eb37a408812b9aa244d97e53c01b293 Mon Sep 17 00:00:00 2001 From: ARRRRNY Date: Thu, 30 Jul 2026 16:01:33 +0300 Subject: [PATCH 2/2] fix: address CodeRabbit review on SSH-over-HTTPS (PR #3) Daemon: - session: guard nil session.cmd in WriteInput (panic window) - session: loop syscall.Write until all stdin bytes are written (single write(2) can short-write, truncating large frames) - session: make the async stdin holder a single process (exec tail -f /dev/null) so killing the recorded PID drops the FIFO's last writer and CloseInput delivers EOF immediately; previously the sleep child kept stdin open for up to an hour - mcp: fs_read_file opens the file first, rejects non-regular files (e.g. /dev/zero) and reads through LimitReader so growth or special files cannot cause unbounded allocation Proxy: - pass the request context to ValidateSshAccess instead of context.Background() so disconnected clients cancel validation - enforce ensureSandboxStarted for regular Bearer tokens on agent-access paths, matching the SSH-token path Exec WS protocol: - emit error frames on stdin/stdin_eof/signal/resize failures instead of only debug-logging them MCP/swagger contract: - split HandleMCP into per-method handlers so the spec models POST (JSON-RPC body, json+SSE), GET (SSE stream) and DELETE with unique operation IDs; fix malformed ' text/event-stream' media type; regenerate swagger + Go/TS/Java/Python/Ruby clients (MCP clients now accept a message body) Tests: daemon (session/mcp/exec) go test green incl. new /dev/zero and demux-EOF regression tests, nx test api green, alpine/dash smoke for FIFO EOF on holder kill. Co-Authored-By: Claude Fable 5 --- apps/daemon/pkg/session/exec_support.go | 26 +- apps/daemon/pkg/session/execute.go | 6 +- apps/daemon/pkg/toolbox/docs/docs.go | 55 +- apps/daemon/pkg/toolbox/docs/swagger.json | 49 +- apps/daemon/pkg/toolbox/docs/swagger.yaml | 52 +- apps/daemon/pkg/toolbox/mcp/server.go | 42 +- apps/daemon/pkg/toolbox/mcp/tools.go | 22 +- apps/daemon/pkg/toolbox/mcp/tools_test.go | 17 + .../pkg/toolbox/process/exec/controller.go | 4 + .../pkg/toolbox/process/exec/demux_test.go | 18 + apps/daemon/pkg/toolbox/server.go | 6 +- apps/proxy/pkg/proxy/agent_access.go | 2 +- apps/proxy/pkg/proxy/auth.go | 7 + libs/toolbox-api-client-go/api/openapi.yaml | 51 +- libs/toolbox-api-client-go/api_mcp.go | 248 +++++++- .../io/daytona/toolbox/client/api/McpApi.java | 274 ++++++++- .../toolbox/client/api/McpApiTest.java | 35 +- .../api/mcp_api.py | 533 +++++++++++++++++- .../daytona_toolbox_api_client/api/mcp_api.py | 533 +++++++++++++++++- .../.openapi-generator/FILES | 1 + .../lib/daytona_toolbox_api_client.rb | 1 + .../daytona_toolbox_api_client/api/mcp_api.rb | 198 +++++++ .../api/process_api.rb | 58 ++ libs/toolbox-api-client/src/api/mcp-api.ts | 160 +++++- 24 files changed, 2268 insertions(+), 130 deletions(-) create mode 100644 libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client/api/mcp_api.rb diff --git a/apps/daemon/pkg/session/exec_support.go b/apps/daemon/pkg/session/exec_support.go index df8946885a..a2d04841f6 100644 --- a/apps/daemon/pkg/session/exec_support.go +++ b/apps/daemon/pkg/session/exec_support.go @@ -48,6 +48,10 @@ func (s *SessionService) WriteInput(sessionId, commandId string, data []byte) er return common_errors.NewNotFoundError(errors.New("session not found")) } + if session.cmd == nil || session.cmd.Process == nil { + return common_errors.NewGoneError(errors.New("session process is not running")) + } + if session.cmd.ProcessState != nil && session.cmd.ProcessState.Exited() { return common_errors.NewGoneError(errors.New("session process has exited")) } @@ -78,8 +82,14 @@ func (s *SessionService) WriteInput(sessionId, commandId string, data []byte) er return common_errors.NewInternalServerError(fmt.Errorf("failed to configure input pipe: %w", err)) } - if _, err := syscall.Write(fd, data); err != nil { - return common_errors.NewInternalServerError(fmt.Errorf("failed to write to input pipe: %w", err)) + // write(2) may return fewer bytes than requested (partial write), so loop + // until the whole frame has been delivered. + for remaining := data; len(remaining) > 0; { + n, err := syscall.Write(fd, remaining) + if err != nil { + return common_errors.NewInternalServerError(fmt.Errorf("failed to write to input pipe: %w", err)) + } + remaining = remaining[n:] } return nil @@ -87,8 +97,8 @@ func (s *SessionService) WriteInput(sessionId, commandId string, data []byte) er // CloseInput delivers stdin EOF to a running command by tearing down the // input-holder process that cmdWrapperFormat keeps alive for async commands. -// Once the holder (and its current `sleep` child, which inherits the FIFO's -// write end) is gone, the command's stdin sees EOF — SSH channel EOF +// The holder is a single process (see execute.go), so killing it drops the +// FIFO's last writer and the command's stdin sees EOF — SSH channel EOF // semantics. Best effort: if the holder is not up yet or already gone, the // command's stdin stays as-is and nil is returned. func (s *SessionService) CloseInput(sessionId, commandId string) error { @@ -117,13 +127,17 @@ func (s *SessionService) CloseInput(sessionId, commandId string) error { return nil } - // Kill the holder's children first (the current `sleep 3600` inherits the - // FIFO's write end and would keep stdin open), then the holder itself. + // Kill the holder (and any descendants, defensively) so no process keeps + // the FIFO's write end open. _ = s.signalProcessTree(pid, syscall.SIGKILL) if holder, err := os.FindProcess(pid); err == nil { _ = holder.Signal(syscall.SIGKILL) } + // The pid file is single-use: remove it so a later CloseInput doesn't + // signal a recycled PID. + _ = os.Remove(pidFilePath) + return nil } diff --git a/apps/daemon/pkg/session/execute.go b/apps/daemon/pkg/session/execute.go index b2d6d7c454..f16a1703c0 100644 --- a/apps/daemon/pkg/session/execute.go +++ b/apps/daemon/pkg/session/execute.go @@ -62,7 +62,11 @@ func (s *SessionService) Execute(sessionId, cmdId, cmd string, async, isCombined inputPipeCommand := `cat /dev/null > "$ip" &` if async { - inputPipeCommand = `while :; do sleep 3600; done > "$ip" &` + // The holder must be a single process: killing the recorded PID alone + // must drop the FIFO's last writer so CloseInput delivers EOF + // immediately. A `while :; do sleep; done` loop leaves its sleep child + // holding the FIFO open after the loop shell is killed. + inputPipeCommand = `exec tail -f /dev/null > "$ip" &` } cmdToExec := fmt.Sprintf(cmdWrapperFormat+"\n", diff --git a/apps/daemon/pkg/toolbox/docs/docs.go b/apps/daemon/pkg/toolbox/docs/docs.go index 38212fa9b2..e6c045bfbc 100644 --- a/apps/daemon/pkg/toolbox/docs/docs.go +++ b/apps/daemon/pkg/toolbox/docs/docs.go @@ -1170,6 +1170,7 @@ const docTemplate = `{ }, { "type": "number", + "format": "float64", "description": "Scale factor (0.1-1.0)", "name": "scale", "in": "query" @@ -1302,6 +1303,7 @@ const docTemplate = `{ }, { "type": "number", + "format": "float64", "description": "Scale factor (0.1-1.0)", "name": "scale", "in": "query" @@ -2453,23 +2455,63 @@ const docTemplate = `{ } }, "/mcp": { + "get": { + "description": "Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST.", + "produces": [ + "text/event-stream" + ], + "tags": [ + "mcp" + ], + "summary": "MCP endpoint — open the SSE stream (streamable HTTP)", + "operationId": "MCPGet", + "responses": { + "200": { + "description": "SSE event stream" + } + } + }, "post": { - "description": "Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer \u003ctoken\u003e) exactly like /process/exec/connect.", + "description": "Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer \u003ctoken\u003e) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport.", "consumes": [ "application/json" ], "produces": [ "application/json", - " text/event-stream" + "text/event-stream" ], "tags": [ "mcp" ], - "summary": "MCP endpoint (streamable HTTP)", - "operationId": "MCP", + "summary": "MCP endpoint — send JSON-RPC messages (streamable HTTP)", + "operationId": "MCPPost", + "parameters": [ + { + "description": "JSON-RPC 2.0 request message (e.g. tools/call)", + "name": "message", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], "responses": { "200": { - "description": "OK" + "description": "JSON-RPC response or SSE event stream" + } + } + }, + "delete": { + "description": "Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance.", + "tags": [ + "mcp" + ], + "summary": "MCP endpoint — terminate the session (streamable HTTP)", + "operationId": "MCPDelete", + "responses": { + "202": { + "description": "Session terminated" } } } @@ -3525,7 +3567,8 @@ const docTemplate = `{ "items": { "type": "array", "items": { - "type": "number" + "type": "number", + "format": "float64" } } }, diff --git a/apps/daemon/pkg/toolbox/docs/swagger.json b/apps/daemon/pkg/toolbox/docs/swagger.json index 32a9577b4e..a2b2202792 100644 --- a/apps/daemon/pkg/toolbox/docs/swagger.json +++ b/apps/daemon/pkg/toolbox/docs/swagger.json @@ -1027,6 +1027,7 @@ }, { "type": "number", + "format": "float64", "description": "Scale factor (0.1-1.0)", "name": "scale", "in": "query" @@ -1151,6 +1152,7 @@ }, { "type": "number", + "format": "float64", "description": "Scale factor (0.1-1.0)", "name": "scale", "in": "query" @@ -2138,16 +2140,50 @@ } }, "/mcp": { + "get": { + "description": "Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST.", + "produces": ["text/event-stream"], + "tags": ["mcp"], + "summary": "MCP endpoint — open the SSE stream (streamable HTTP)", + "operationId": "MCPGet", + "responses": { + "200": { + "description": "SSE event stream" + } + } + }, "post": { - "description": "Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer \u003ctoken\u003e) exactly like /process/exec/connect.", + "description": "Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer \u003ctoken\u003e) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport.", "consumes": ["application/json"], - "produces": ["application/json", " text/event-stream"], + "produces": ["application/json", "text/event-stream"], "tags": ["mcp"], - "summary": "MCP endpoint (streamable HTTP)", - "operationId": "MCP", + "summary": "MCP endpoint — send JSON-RPC messages (streamable HTTP)", + "operationId": "MCPPost", + "parameters": [ + { + "description": "JSON-RPC 2.0 request message (e.g. tools/call)", + "name": "message", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], "responses": { "200": { - "description": "OK" + "description": "JSON-RPC response or SSE event stream" + } + } + }, + "delete": { + "description": "Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance.", + "tags": ["mcp"], + "summary": "MCP endpoint — terminate the session (streamable HTTP)", + "operationId": "MCPDelete", + "responses": { + "202": { + "description": "Session terminated" } } } @@ -3073,7 +3109,8 @@ "items": { "type": "array", "items": { - "type": "number" + "type": "number", + "format": "float64" } } }, diff --git a/apps/daemon/pkg/toolbox/docs/swagger.yaml b/apps/daemon/pkg/toolbox/docs/swagger.yaml index 9ad13f3872..fbc440c82e 100644 --- a/apps/daemon/pkg/toolbox/docs/swagger.yaml +++ b/apps/daemon/pkg/toolbox/docs/swagger.yaml @@ -113,6 +113,7 @@ definitions: points: items: items: + format: float64 type: number type: array type: array @@ -1909,6 +1910,7 @@ paths: name: quality type: integer - description: Scale factor (0.1-1.0) + format: float64 in: query name: scale type: number @@ -1999,6 +2001,7 @@ paths: name: quality type: integer - description: Scale factor (0.1-1.0) + format: float64 in: query name: scale type: number @@ -2772,22 +2775,55 @@ paths: tags: - lsp /mcp: + delete: + description: Terminates the MCP session per the streamable-HTTP transport. The + handler is stateless, so this is a no-op acknowledged for transport compliance. + operationId: MCPDelete + responses: + '202': + description: Session terminated + summary: MCP endpoint — terminate the session (streamable HTTP) + tags: + - mcp + get: + description: Opens the server-sent-event stream of the MCP streamable-HTTP transport. + Stateless deployments do not emit unsolicited events, so most clients only + need POST. + operationId: MCPGet + produces: + - text/event-stream + responses: + '200': + description: SSE event stream + summary: MCP endpoint — open the SSE stream (streamable HTTP) + tags: + - mcp post: consumes: - application/json description: 'Model Context Protocol endpoint (streamable-HTTP transport) exposing - sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST - sends JSON-RPC messages (responses are SSE events per the transport); GET - opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: - Bearer ) exactly like /process/exec/connect.' - operationId: MCP + sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The + request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, + ...); the response is a JSON-RPC response or an SSE event stream per the transport. + The handler is stateless: plain HTTP clients can call tools without the initialize + handshake. Authenticate with a scoped SSH access token (Authorization: Bearer + ) exactly like /process/exec/connect. NOTE: MCP clients should speak + JSON-RPC directly — generated REST clients cannot express the MCP transport.' + operationId: MCPPost + parameters: + - description: JSON-RPC 2.0 request message (e.g. tools/call) + in: body + name: message + required: true + schema: + type: object produces: - application/json - - ' text/event-stream' + - text/event-stream responses: '200': - description: OK - summary: MCP endpoint (streamable HTTP) + description: JSON-RPC response or SSE event stream + summary: MCP endpoint — send JSON-RPC messages (streamable HTTP) tags: - mcp /port: diff --git a/apps/daemon/pkg/toolbox/mcp/server.go b/apps/daemon/pkg/toolbox/mcp/server.go index f68a798781..c516f6724a 100644 --- a/apps/daemon/pkg/toolbox/mcp/server.go +++ b/apps/daemon/pkg/toolbox/mcp/server.go @@ -69,17 +69,45 @@ func NewMCPServer(logger *slog.Logger, workDir string, sessionService *session_s return m } -// HandleMCP godoc +// HandleMCPPost godoc // -// @Summary MCP endpoint (streamable HTTP) -// @Description Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. +// @Summary MCP endpoint — send JSON-RPC messages (streamable HTTP) +// @Description Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. // @Tags mcp // @Accept json -// @Produce json, text/event-stream -// @Success 200 +// @Produce json,text/event-stream +// @Param message body object true "JSON-RPC 2.0 request message (e.g. tools/call)" +// @Success 200 "JSON-RPC response or SSE event stream" // @Router /mcp [post] // -// @id MCP -func (m *MCPServer) HandleMCP(c *gin.Context) { +// @id MCPPost +func (m *MCPServer) HandleMCPPost(c *gin.Context) { + m.handler.ServeHTTP(c.Writer, c.Request) +} + +// HandleMCPGet godoc +// +// @Summary MCP endpoint — open the SSE stream (streamable HTTP) +// @Description Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. +// @Tags mcp +// @Produce text/event-stream +// @Success 200 "SSE event stream" +// @Router /mcp [get] +// +// @id MCPGet +func (m *MCPServer) HandleMCPGet(c *gin.Context) { + m.handler.ServeHTTP(c.Writer, c.Request) +} + +// HandleMCPDelete godoc +// +// @Summary MCP endpoint — terminate the session (streamable HTTP) +// @Description Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. +// @Tags mcp +// @Success 202 "Session terminated" +// @Router /mcp [delete] +// +// @id MCPDelete +func (m *MCPServer) HandleMCPDelete(c *gin.Context) { m.handler.ServeHTTP(c.Writer, c.Request) } diff --git a/apps/daemon/pkg/toolbox/mcp/tools.go b/apps/daemon/pkg/toolbox/mcp/tools.go index bf948b4384..f92ef40e81 100644 --- a/apps/daemon/pkg/toolbox/mcp/tools.go +++ b/apps/daemon/pkg/toolbox/mcp/tools.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "fmt" + "io" "os" "path/filepath" "strings" @@ -168,23 +169,38 @@ func (m *MCPServer) readFile(_ context.Context, _ *mcpsdk.CallToolRequest, args return toolError("path is required"), readFileResult{}, nil } - info, err := os.Stat(args.Path) + // Open first and validate the opened descriptor: a pre-read os.Stat can be + // bypassed by file growth, and special files (e.g. /dev/zero, size 0) + // would otherwise make the read allocate without bound. + file, err := os.Open(args.Path) + if err != nil { + return toolError(fmt.Sprintf("failed to open file: %v", err)), readFileResult{}, nil + } + defer file.Close() + + info, err := file.Stat() if err != nil { return toolError(fmt.Sprintf("failed to stat file: %v", err)), readFileResult{}, nil } if info.IsDir() { return toolError("path is a directory, use fs_list_files instead"), readFileResult{}, nil } + if !info.Mode().IsRegular() { + return toolError("path is not a regular file"), readFileResult{}, nil + } if info.Size() > maxReadFileBytes { return toolError(fmt.Sprintf("file too large (%d bytes, max %d)", info.Size(), maxReadFileBytes)), readFileResult{}, nil } - content, err := os.ReadFile(args.Path) + content, err := io.ReadAll(io.LimitReader(file, maxReadFileBytes+1)) if err != nil { return toolError(fmt.Sprintf("failed to read file: %v", err)), readFileResult{}, nil } + if len(content) > maxReadFileBytes { + return toolError(fmt.Sprintf("file too large (max %d bytes)", maxReadFileBytes)), readFileResult{}, nil + } - out := readFileResult{Path: args.Path, Size: info.Size()} + out := readFileResult{Path: args.Path, Size: int64(len(content))} if utf8.Valid(content) { out.Content = string(content) } else { diff --git a/apps/daemon/pkg/toolbox/mcp/tools_test.go b/apps/daemon/pkg/toolbox/mcp/tools_test.go index aaf0fd66f7..313e725fc2 100644 --- a/apps/daemon/pkg/toolbox/mcp/tools_test.go +++ b/apps/daemon/pkg/toolbox/mcp/tools_test.go @@ -154,6 +154,23 @@ func TestFsReadFileToolNotFound(t *testing.T) { } } +func TestFsReadFileToolRejectsSpecialFile(t *testing.T) { + m := newTestMCPServer(t) + + // /dev/zero reports size 0, so a pre-read Stat check alone would pass and + // an unbounded read would allocate until OOM. + result, _, err := m.readFile(context.Background(), nil, readFileArgs{Path: "/dev/zero"}) + if err != nil { + t.Fatalf("readFile failed: %v", err) + } + if !result.IsError { + t.Fatalf("expected error result for special file") + } + if got := textOf(t, result); !strings.Contains(got, "not a regular file") { + t.Fatalf("expected 'not a regular file' error, got %q", got) + } +} + func TestFsListFilesTool(t *testing.T) { m := newTestMCPServer(t) dir := t.TempDir() diff --git a/apps/daemon/pkg/toolbox/process/exec/controller.go b/apps/daemon/pkg/toolbox/process/exec/controller.go index 9a8a04dc6d..5bad53bd1f 100644 --- a/apps/daemon/pkg/toolbox/process/exec/controller.go +++ b/apps/daemon/pkg/toolbox/process/exec/controller.go @@ -196,10 +196,12 @@ func (e *ExecController) handleConnection(ws *websocket.Conn) { case FrameTypeStdin: if err := sess.WriteStdin([]byte(frame.Data)); err != nil { logger.Debug("stdin write failed", "error", err) + emit(ErrorFrame{Type: FrameTypeError, Message: fmt.Sprintf("stdin write failed: %v", err)}, false) } case FrameTypeStdinEOF: if err := sess.CloseStdin(); err != nil { logger.Debug("stdin close failed", "error", err) + emit(ErrorFrame{Type: FrameTypeError, Message: fmt.Sprintf("stdin close failed: %v", err)}, false) } case FrameTypeSignal: sig, ok := parseSignal(frame.Signal) @@ -209,6 +211,7 @@ func (e *ExecController) handleConnection(ws *websocket.Conn) { } if err := sess.Signal(sig); err != nil { logger.Debug("signal failed", "signal", frame.Signal, "error", err) + emit(ErrorFrame{Type: FrameTypeError, Message: fmt.Sprintf("signal failed: %v", err)}, false) } case FrameTypeResize: if frame.Cols > maxFrameCols || frame.Rows > maxFrameRows { @@ -220,6 +223,7 @@ func (e *ExecController) handleConnection(ws *websocket.Conn) { } if err := sess.Resize(frame.Cols, frame.Rows); err != nil { logger.Debug("resize failed", "error", err) + emit(ErrorFrame{Type: FrameTypeError, Message: fmt.Sprintf("resize failed: %v", err)}, false) } case FrameTypeStart: emit(ErrorFrame{Type: FrameTypeError, Message: "session already started"}, false) diff --git a/apps/daemon/pkg/toolbox/process/exec/demux_test.go b/apps/daemon/pkg/toolbox/process/exec/demux_test.go index 8500dcd008..057c2b6879 100644 --- a/apps/daemon/pkg/toolbox/process/exec/demux_test.go +++ b/apps/daemon/pkg/toolbox/process/exec/demux_test.go @@ -66,6 +66,24 @@ func TestStreamDemuxSplitMarkerAcrossChunks(t *testing.T) { } } +func TestStreamDemuxIncompleteMarkerAtEOF(t *testing.T) { + capture := &demuxCapture{} + d := newStreamDemux(capture.emit) + + // A trailing marker prefix that never completes must be emitted as + // regular stream content on Flush, not discarded or treated as a marker. + prefix := string(log.STDOUT_PREFIX) + d.Write([]byte("content" + prefix[:len(prefix)-1])) + d.Flush() + + if got := capture.stdout.String(); got != "content"+prefix[:len(prefix)-1] { + t.Fatalf("unexpected stdout %q", got) + } + if got := capture.stderr.String(); got != "" { + t.Fatalf("unexpected stderr %q", got) + } +} + func TestStreamDemuxMatchesReferenceDemux(t *testing.T) { capture := &demuxCapture{} d := newStreamDemux(capture.emit) diff --git a/apps/daemon/pkg/toolbox/server.go b/apps/daemon/pkg/toolbox/server.go index 6bcad2bf72..0909f51919 100644 --- a/apps/daemon/pkg/toolbox/server.go +++ b/apps/daemon/pkg/toolbox/server.go @@ -171,9 +171,9 @@ func (s *server) Start() error { // MCP endpoint (streamable HTTP) — v1 sandbox toolset for MCP-native agents mcpServer := toolboxmcp.NewMCPServer(s.logger, s.WorkDir, s.sessionService) - r.POST("/mcp", mcpServer.HandleMCP) - r.GET("/mcp", mcpServer.HandleMCP) - r.DELETE("/mcp", mcpServer.HandleMCP) + r.POST("/mcp", mcpServer.HandleMCPPost) + r.GET("/mcp", mcpServer.HandleMCPGet) + r.DELETE("/mcp", mcpServer.HandleMCPDelete) // keep /project-dir old behavior for backward compatibility r.GET("/project-dir", s.GetUserHomeDir) diff --git a/apps/proxy/pkg/proxy/agent_access.go b/apps/proxy/pkg/proxy/agent_access.go index 00c1f6d9f4..b7edc9ab09 100644 --- a/apps/proxy/pkg/proxy/agent_access.go +++ b/apps/proxy/pkg/proxy/agent_access.go @@ -38,7 +38,7 @@ func isAgentAccessPath(targetPath string) bool { func (p *Proxy) getSshAccessTokenValid(ctx context.Context, sandboxId string, token string) (*bool, error) { isValid := false err := utils.RetryWithExponentialBackoff(ctx, "getSshAccessTokenValid", proxyMaxRetries, proxyBaseDelay, proxyMaxDelay, func() error { - validation, resp, err := p.apiclient.SandboxAPI.ValidateSshAccess(context.Background()).Token(token).Execute() + validation, resp, err := p.apiclient.SandboxAPI.ValidateSshAccess(ctx).Token(token).Execute() if resp != nil && resp.StatusCode == http.StatusOK { isValid = validation != nil && validation.Valid && validation.SandboxId == sandboxId return nil diff --git a/apps/proxy/pkg/proxy/auth.go b/apps/proxy/pkg/proxy/auth.go index 45c7aaebcf..3ee7a89c97 100644 --- a/apps/proxy/pkg/proxy/auth.go +++ b/apps/proxy/pkg/proxy/auth.go @@ -24,6 +24,13 @@ func (p *Proxy) Authenticate(ctx *gin.Context, sandboxIdOrSignedToken string, po if err != nil { authErrors = append(authErrors, fmt.Sprintf("Bearer token validation error: %v", err)) } else if isValid != nil && *isValid { + // Agent-access endpoints enforce the same started-state check as + // the SSH gateway, regardless of which credential was presented. + if allowSshAccessToken { + if err := p.ensureSandboxStarted(ctx.Request.Context(), sandboxIdOrSignedToken); err != nil { + return sandboxIdOrSignedToken, false, err + } + } // If authentication successful, remove the Authorization header to prevent it from being forwarded to the sandbox ctx.Request.Header.Del("Authorization") return sandboxIdOrSignedToken, false, nil diff --git a/libs/toolbox-api-client-go/api/openapi.yaml b/libs/toolbox-api-client-go/api/openapi.yaml index 60673dc256..85f3f2feee 100644 --- a/libs/toolbox-api-client-go/api/openapi.yaml +++ b/libs/toolbox-api-client-go/api/openapi.yaml @@ -846,6 +846,7 @@ paths: in: query name: scale schema: + format: float64 type: number responses: "200": @@ -949,6 +950,7 @@ paths: in: query name: scale schema: + format: float64 type: number responses: "200": @@ -1725,20 +1727,56 @@ paths: tags: - lsp /mcp: + delete: + description: "Terminates the MCP session per the streamable-HTTP transport.\ + \ The handler is stateless, so this is a no-op acknowledged for transport\ + \ compliance." + operationId: MCPDelete + responses: + "202": + content: {} + description: Session terminated + summary: MCP endpoint — terminate the session (streamable HTTP) + tags: + - mcp + get: + description: "Opens the server-sent-event stream of the MCP streamable-HTTP\ + \ transport. Stateless deployments do not emit unsolicited events, so most\ + \ clients only need POST." + operationId: MCPGet + responses: + "200": + content: {} + description: SSE event stream + summary: MCP endpoint — open the SSE stream (streamable HTTP) + tags: + - mcp post: description: "Model Context Protocol endpoint (streamable-HTTP transport) exposing\ \ sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files.\ - \ POST sends JSON-RPC messages (responses are SSE events per the transport);\ - \ GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization:\ - \ Bearer ) exactly like /process/exec/connect." - operationId: MCP + \ The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call,\ + \ ...); the response is a JSON-RPC response or an SSE event stream per the\ + \ transport. The handler is stateless: plain HTTP clients can call tools without\ + \ the initialize handshake. Authenticate with a scoped SSH access token (Authorization:\ + \ Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should\ + \ speak JSON-RPC directly — generated REST clients cannot express the MCP\ + \ transport." + operationId: MCPPost + requestBody: + content: + application/json: + schema: + type: object + description: JSON-RPC 2.0 request message (e.g. tools/call) + required: true responses: "200": content: {} - description: OK - summary: MCP endpoint (streamable HTTP) + description: JSON-RPC response or SSE event stream + summary: MCP endpoint — send JSON-RPC messages (streamable HTTP) tags: - mcp + x-codegen-request-body-name: message /port: get: description: Get a list of all currently active ports @@ -2651,6 +2689,7 @@ components: points: items: items: + format: float64 type: number type: array type: array diff --git a/libs/toolbox-api-client-go/api_mcp.go b/libs/toolbox-api-client-go/api_mcp.go index 3996f2ce81..5e37d18a09 100644 --- a/libs/toolbox-api-client-go/api_mcp.go +++ b/libs/toolbox-api-client-go/api_mcp.go @@ -22,55 +22,169 @@ import ( type McpAPI interface { /* - MCP MCP endpoint (streamable HTTP) + MCPDelete MCP endpoint — terminate the session (streamable HTTP) - Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - @return McpAPIMCPRequest + @return McpAPIMCPDeleteRequest */ - MCP(ctx context.Context) McpAPIMCPRequest + MCPDelete(ctx context.Context) McpAPIMCPDeleteRequest - // MCPExecute executes the request - MCPExecute(r McpAPIMCPRequest) (*http.Response, error) + // MCPDeleteExecute executes the request + MCPDeleteExecute(r McpAPIMCPDeleteRequest) (*http.Response, error) + + /* + MCPGet MCP endpoint — open the SSE stream (streamable HTTP) + + Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return McpAPIMCPGetRequest + */ + MCPGet(ctx context.Context) McpAPIMCPGetRequest + + // MCPGetExecute executes the request + MCPGetExecute(r McpAPIMCPGetRequest) (*http.Response, error) + + /* + MCPPost MCP endpoint — send JSON-RPC messages (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return McpAPIMCPPostRequest + */ + MCPPost(ctx context.Context) McpAPIMCPPostRequest + + // MCPPostExecute executes the request + MCPPostExecute(r McpAPIMCPPostRequest) (*http.Response, error) } // McpAPIService McpAPI service type McpAPIService service -type McpAPIMCPRequest struct { +type McpAPIMCPDeleteRequest struct { ctx context.Context ApiService McpAPI } -func (r McpAPIMCPRequest) Execute() (*http.Response, error) { - return r.ApiService.MCPExecute(r) +func (r McpAPIMCPDeleteRequest) Execute() (*http.Response, error) { + return r.ApiService.MCPDeleteExecute(r) } /* -MCP MCP endpoint (streamable HTTP) +MCPDelete MCP endpoint — terminate the session (streamable HTTP) -Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. +Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). - @return McpAPIMCPRequest + @return McpAPIMCPDeleteRequest */ -func (a *McpAPIService) MCP(ctx context.Context) McpAPIMCPRequest { - return McpAPIMCPRequest{ +func (a *McpAPIService) MCPDelete(ctx context.Context) McpAPIMCPDeleteRequest { + return McpAPIMCPDeleteRequest{ ApiService: a, ctx: ctx, } } // Execute executes the request -func (a *McpAPIService) MCPExecute(r McpAPIMCPRequest) (*http.Response, error) { +func (a *McpAPIService) MCPDeleteExecute(r McpAPIMCPDeleteRequest) (*http.Response, error) { var ( - localVarHTTPMethod = http.MethodPost + localVarHTTPMethod = http.MethodDelete + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "McpAPIService.MCPDelete") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/mcp" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + +type McpAPIMCPGetRequest struct { + ctx context.Context + ApiService McpAPI +} + +func (r McpAPIMCPGetRequest) Execute() (*http.Response, error) { + return r.ApiService.MCPGetExecute(r) +} + +/* +MCPGet MCP endpoint — open the SSE stream (streamable HTTP) + +Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return McpAPIMCPGetRequest +*/ +func (a *McpAPIService) MCPGet(ctx context.Context) McpAPIMCPGetRequest { + return McpAPIMCPGetRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +func (a *McpAPIService) MCPGetExecute(r McpAPIMCPGetRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet localVarPostBody interface{} formFiles []formFile ) - localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "McpAPIService.MCP") + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "McpAPIService.MCPGet") if err != nil { return nil, &GenericOpenAPIError{error: err.Error()} } @@ -125,3 +239,103 @@ func (a *McpAPIService) MCPExecute(r McpAPIMCPRequest) (*http.Response, error) { return localVarHTTPResponse, nil } + +type McpAPIMCPPostRequest struct { + ctx context.Context + ApiService McpAPI + message *map[string]interface{} +} + +// JSON-RPC 2.0 request message (e.g. tools/call) +func (r McpAPIMCPPostRequest) Message(message map[string]interface{}) McpAPIMCPPostRequest { + r.message = &message + return r +} + +func (r McpAPIMCPPostRequest) Execute() (*http.Response, error) { + return r.ApiService.MCPPostExecute(r) +} + +/* +MCPPost MCP endpoint — send JSON-RPC messages (streamable HTTP) + +Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return McpAPIMCPPostRequest +*/ +func (a *McpAPIService) MCPPost(ctx context.Context) McpAPIMCPPostRequest { + return McpAPIMCPPostRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +func (a *McpAPIService) MCPPostExecute(r McpAPIMCPPostRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "McpAPIService.MCPPost") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/mcp" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.message == nil { + return nil, reportError("message is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.message + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} diff --git a/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/McpApi.java b/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/McpApi.java index 4ab625eb7e..4b7a137565 100644 --- a/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/McpApi.java +++ b/libs/toolbox-api-client-java/src/main/java/io/daytona/toolbox/client/api/McpApi.java @@ -72,7 +72,7 @@ public void setCustomBaseUrl(String customBaseUrl) { } /** - * Build call for mCP + * Build call for mCPDelete * @param _callback Callback for upload/download progress * @return Call to execute * @throws ApiException If fail to serialize the request body object @@ -80,10 +80,10 @@ public void setCustomBaseUrl(String customBaseUrl) { - +
Response Details
Status Code Description Response Headers
200 OK -
202 Session terminated -
*/ - public okhttp3.Call mCPCall(final ApiCallback _callback) throws ApiException { + public okhttp3.Call mCPDeleteCall(final ApiCallback _callback) throws ApiException { String basePath = null; // Operation Servers String[] localBasePaths = new String[] { }; @@ -122,51 +122,285 @@ public okhttp3.Call mCPCall(final ApiCallback _callback) throws ApiException { localVarHeaderParams.put("Content-Type", localVarContentType); } + String[] localVarAuthNames = new String[] { }; + return localVarApiClient.buildCall(basePath, localVarPath, "DELETE", localVarQueryParams, localVarCollectionQueryParams, localVarPostBody, localVarHeaderParams, localVarCookieParams, localVarFormParams, localVarAuthNames, _callback); + } + + @SuppressWarnings("rawtypes") + private okhttp3.Call mCPDeleteValidateBeforeCall(final ApiCallback _callback) throws ApiException { + return mCPDeleteCall(_callback); + + } + + /** + * MCP endpoint — terminate the session (streamable HTTP) + * Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
202 Session terminated -
+ */ + public void mCPDelete() throws ApiException { + mCPDeleteWithHttpInfo(); + } + + /** + * MCP endpoint — terminate the session (streamable HTTP) + * Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + * @return ApiResponse<Void> + * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
202 Session terminated -
+ */ + public ApiResponse mCPDeleteWithHttpInfo() throws ApiException { + okhttp3.Call localVarCall = mCPDeleteValidateBeforeCall(null); + return localVarApiClient.execute(localVarCall); + } + + /** + * MCP endpoint — terminate the session (streamable HTTP) (asynchronously) + * Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + * @param _callback The callback to be executed when the API call finishes + * @return The request call + * @throws ApiException If fail to process the API call, e.g. serializing the request body object + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
202 Session terminated -
+ */ + public okhttp3.Call mCPDeleteAsync(final ApiCallback _callback) throws ApiException { + + okhttp3.Call localVarCall = mCPDeleteValidateBeforeCall(_callback); + localVarApiClient.executeAsync(localVarCall, _callback); + return localVarCall; + } + /** + * Build call for mCPGet + * @param _callback Callback for upload/download progress + * @return Call to execute + * @throws ApiException If fail to serialize the request body object + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 SSE event stream -
+ */ + public okhttp3.Call mCPGetCall(final ApiCallback _callback) throws ApiException { + String basePath = null; + // Operation Servers + String[] localBasePaths = new String[] { }; + + // Determine Base Path to Use + if (localCustomBaseUrl != null){ + basePath = localCustomBaseUrl; + } else if ( localBasePaths.length > 0 ) { + basePath = localBasePaths[localHostIndex]; + } else { + basePath = null; + } + + Object localVarPostBody = null; + + // create path and map variables + String localVarPath = "/mcp"; + + List localVarQueryParams = new ArrayList(); + List localVarCollectionQueryParams = new ArrayList(); + Map localVarHeaderParams = new HashMap(); + Map localVarCookieParams = new HashMap(); + Map localVarFormParams = new HashMap(); + + final String[] localVarAccepts = { + }; + final String localVarAccept = localVarApiClient.selectHeaderAccept(localVarAccepts); + if (localVarAccept != null) { + localVarHeaderParams.put("Accept", localVarAccept); + } + + final String[] localVarContentTypes = { + }; + final String localVarContentType = localVarApiClient.selectHeaderContentType(localVarContentTypes); + if (localVarContentType != null) { + localVarHeaderParams.put("Content-Type", localVarContentType); + } + + String[] localVarAuthNames = new String[] { }; + return localVarApiClient.buildCall(basePath, localVarPath, "GET", localVarQueryParams, localVarCollectionQueryParams, localVarPostBody, localVarHeaderParams, localVarCookieParams, localVarFormParams, localVarAuthNames, _callback); + } + + @SuppressWarnings("rawtypes") + private okhttp3.Call mCPGetValidateBeforeCall(final ApiCallback _callback) throws ApiException { + return mCPGetCall(_callback); + + } + + /** + * MCP endpoint — open the SSE stream (streamable HTTP) + * Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 SSE event stream -
+ */ + public void mCPGet() throws ApiException { + mCPGetWithHttpInfo(); + } + + /** + * MCP endpoint — open the SSE stream (streamable HTTP) + * Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + * @return ApiResponse<Void> + * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 SSE event stream -
+ */ + public ApiResponse mCPGetWithHttpInfo() throws ApiException { + okhttp3.Call localVarCall = mCPGetValidateBeforeCall(null); + return localVarApiClient.execute(localVarCall); + } + + /** + * MCP endpoint — open the SSE stream (streamable HTTP) (asynchronously) + * Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + * @param _callback The callback to be executed when the API call finishes + * @return The request call + * @throws ApiException If fail to process the API call, e.g. serializing the request body object + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 SSE event stream -
+ */ + public okhttp3.Call mCPGetAsync(final ApiCallback _callback) throws ApiException { + + okhttp3.Call localVarCall = mCPGetValidateBeforeCall(_callback); + localVarApiClient.executeAsync(localVarCall, _callback); + return localVarCall; + } + /** + * Build call for mCPPost + * @param message JSON-RPC 2.0 request message (e.g. tools/call) (required) + * @param _callback Callback for upload/download progress + * @return Call to execute + * @throws ApiException If fail to serialize the request body object + * @http.response.details + + + + +
Response Details
Status Code Description Response Headers
200 JSON-RPC response or SSE event stream -
+ */ + public okhttp3.Call mCPPostCall(@javax.annotation.Nonnull Object message, final ApiCallback _callback) throws ApiException { + String basePath = null; + // Operation Servers + String[] localBasePaths = new String[] { }; + + // Determine Base Path to Use + if (localCustomBaseUrl != null){ + basePath = localCustomBaseUrl; + } else if ( localBasePaths.length > 0 ) { + basePath = localBasePaths[localHostIndex]; + } else { + basePath = null; + } + + Object localVarPostBody = message; + + // create path and map variables + String localVarPath = "/mcp"; + + List localVarQueryParams = new ArrayList(); + List localVarCollectionQueryParams = new ArrayList(); + Map localVarHeaderParams = new HashMap(); + Map localVarCookieParams = new HashMap(); + Map localVarFormParams = new HashMap(); + + final String[] localVarAccepts = { + }; + final String localVarAccept = localVarApiClient.selectHeaderAccept(localVarAccepts); + if (localVarAccept != null) { + localVarHeaderParams.put("Accept", localVarAccept); + } + + final String[] localVarContentTypes = { + "application/json" + }; + final String localVarContentType = localVarApiClient.selectHeaderContentType(localVarContentTypes); + if (localVarContentType != null) { + localVarHeaderParams.put("Content-Type", localVarContentType); + } + String[] localVarAuthNames = new String[] { }; return localVarApiClient.buildCall(basePath, localVarPath, "POST", localVarQueryParams, localVarCollectionQueryParams, localVarPostBody, localVarHeaderParams, localVarCookieParams, localVarFormParams, localVarAuthNames, _callback); } @SuppressWarnings("rawtypes") - private okhttp3.Call mCPValidateBeforeCall(final ApiCallback _callback) throws ApiException { - return mCPCall(_callback); + private okhttp3.Call mCPPostValidateBeforeCall(@javax.annotation.Nonnull Object message, final ApiCallback _callback) throws ApiException { + // verify the required parameter 'message' is set + if (message == null) { + throw new ApiException("Missing the required parameter 'message' when calling mCPPost(Async)"); + } + + return mCPPostCall(message, _callback); } /** - * MCP endpoint (streamable HTTP) - * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. + * MCP endpoint — send JSON-RPC messages (streamable HTTP) + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + * @param message JSON-RPC 2.0 request message (e.g. tools/call) (required) * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body * @http.response.details - +
Response Details
Status Code Description Response Headers
200 OK -
200 JSON-RPC response or SSE event stream -
*/ - public void mCP() throws ApiException { - mCPWithHttpInfo(); + public void mCPPost(@javax.annotation.Nonnull Object message) throws ApiException { + mCPPostWithHttpInfo(message); } /** - * MCP endpoint (streamable HTTP) - * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. + * MCP endpoint — send JSON-RPC messages (streamable HTTP) + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + * @param message JSON-RPC 2.0 request message (e.g. tools/call) (required) * @return ApiResponse<Void> * @throws ApiException If fail to call the API, e.g. server error or cannot deserialize the response body * @http.response.details - +
Response Details
Status Code Description Response Headers
200 OK -
200 JSON-RPC response or SSE event stream -
*/ - public ApiResponse mCPWithHttpInfo() throws ApiException { - okhttp3.Call localVarCall = mCPValidateBeforeCall(null); + public ApiResponse mCPPostWithHttpInfo(@javax.annotation.Nonnull Object message) throws ApiException { + okhttp3.Call localVarCall = mCPPostValidateBeforeCall(message, null); return localVarApiClient.execute(localVarCall); } /** - * MCP endpoint (streamable HTTP) (asynchronously) - * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. + * MCP endpoint — send JSON-RPC messages (streamable HTTP) (asynchronously) + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + * @param message JSON-RPC 2.0 request message (e.g. tools/call) (required) * @param _callback The callback to be executed when the API call finishes * @return The request call * @throws ApiException If fail to process the API call, e.g. serializing the request body object @@ -174,12 +408,12 @@ public ApiResponse mCPWithHttpInfo() throws ApiException { - +
Response Details
Status Code Description Response Headers
200 OK -
200 JSON-RPC response or SSE event stream -
*/ - public okhttp3.Call mCPAsync(final ApiCallback _callback) throws ApiException { + public okhttp3.Call mCPPostAsync(@javax.annotation.Nonnull Object message, final ApiCallback _callback) throws ApiException { - okhttp3.Call localVarCall = mCPValidateBeforeCall(_callback); + okhttp3.Call localVarCall = mCPPostValidateBeforeCall(message, _callback); localVarApiClient.executeAsync(localVarCall, _callback); return localVarCall; } diff --git a/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/McpApiTest.java b/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/McpApiTest.java index ef3405b592..186bef435d 100644 --- a/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/McpApiTest.java +++ b/libs/toolbox-api-client-java/src/test/java/io/daytona/toolbox/client/api/McpApiTest.java @@ -31,15 +31,42 @@ public class McpApiTest { private final McpApi api = new McpApi(); /** - * MCP endpoint (streamable HTTP) + * MCP endpoint — terminate the session (streamable HTTP) * - * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. + * Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. * * @throws ApiException if the Api call fails */ @Test - public void mCPTest() throws ApiException { - api.mCP(); + public void mCPDeleteTest() throws ApiException { + api.mCPDelete(); + // TODO: test validations + } + + /** + * MCP endpoint — open the SSE stream (streamable HTTP) + * + * Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + * + * @throws ApiException if the Api call fails + */ + @Test + public void mCPGetTest() throws ApiException { + api.mCPGet(); + // TODO: test validations + } + + /** + * MCP endpoint — send JSON-RPC messages (streamable HTTP) + * + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + * + * @throws ApiException if the Api call fails + */ + @Test + public void mCPPostTest() throws ApiException { + Object message = null; + api.mCPPost(message); // TODO: test validations } diff --git a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/mcp_api.py b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/mcp_api.py index d886b659b4..fa3901135d 100644 --- a/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/mcp_api.py +++ b/libs/toolbox-api-client-python-async/daytona_toolbox_api_client_async/api/mcp_api.py @@ -15,6 +15,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated +from pydantic import Field +from typing import Any, Dict +from typing_extensions import Annotated from daytona_toolbox_api_client_async.api_client import ApiClient, RequestSerialized from daytona_toolbox_api_client_async.api_response import ApiResponse @@ -35,7 +38,245 @@ def __init__(self, api_client=None) -> None: @validate_call - async def m_cp( + async def m_cp_delete( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """MCP endpoint — terminate the session (streamable HTTP) + + Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_delete_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '202': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def m_cp_delete_with_http_info( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """MCP endpoint — terminate the session (streamable HTTP) + + Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_delete_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '202': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def m_cp_delete_without_preload_content( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """MCP endpoint — terminate the session (streamable HTTP) + + Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_delete_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '202': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _m_cp_delete_serialize( + self, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='DELETE', + resource_path='/mcp', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def m_cp_get( self, _request_timeout: Union[ None, @@ -50,9 +291,9 @@ async def m_cp( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> None: - """MCP endpoint (streamable HTTP) + """MCP endpoint — open the SSE stream (streamable HTTP) - Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -76,7 +317,7 @@ async def m_cp( :return: Returns the result object. """ # noqa: E501 - _param = self._m_cp_serialize( + _param = self._m_cp_get_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -98,7 +339,7 @@ async def m_cp( @validate_call - async def m_cp_with_http_info( + async def m_cp_get_with_http_info( self, _request_timeout: Union[ None, @@ -113,9 +354,9 @@ async def m_cp_with_http_info( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> ApiResponse[None]: - """MCP endpoint (streamable HTTP) + """MCP endpoint — open the SSE stream (streamable HTTP) - Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -139,7 +380,7 @@ async def m_cp_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._m_cp_serialize( + _param = self._m_cp_get_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -161,7 +402,7 @@ async def m_cp_with_http_info( @validate_call - async def m_cp_without_preload_content( + async def m_cp_get_without_preload_content( self, _request_timeout: Union[ None, @@ -176,9 +417,9 @@ async def m_cp_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """MCP endpoint (streamable HTTP) + """MCP endpoint — open the SSE stream (streamable HTTP) - Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -202,7 +443,7 @@ async def m_cp_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._m_cp_serialize( + _param = self._m_cp_get_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -219,7 +460,7 @@ async def m_cp_without_preload_content( return response_data.response - def _m_cp_serialize( + def _m_cp_get_serialize( self, _request_auth, _content_type, @@ -250,6 +491,272 @@ def _m_cp_serialize( + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/mcp', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def m_cp_post( + self, + message: Annotated[Dict[str, Any], Field(description="JSON-RPC 2.0 request message (e.g. tools/call)")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """MCP endpoint — send JSON-RPC messages (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + + :param message: JSON-RPC 2.0 request message (e.g. tools/call) (required) + :type message: object + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_post_serialize( + message=message, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def m_cp_post_with_http_info( + self, + message: Annotated[Dict[str, Any], Field(description="JSON-RPC 2.0 request message (e.g. tools/call)")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """MCP endpoint — send JSON-RPC messages (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + + :param message: JSON-RPC 2.0 request message (e.g. tools/call) (required) + :type message: object + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_post_serialize( + message=message, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def m_cp_post_without_preload_content( + self, + message: Annotated[Dict[str, Any], Field(description="JSON-RPC 2.0 request message (e.g. tools/call)")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """MCP endpoint — send JSON-RPC messages (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + + :param message: JSON-RPC 2.0 request message (e.g. tools/call) (required) + :type message: object + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_post_serialize( + message=message, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _m_cp_post_serialize( + self, + message, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if message is not None: + _body_params = message + + + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + # authentication setting _auth_settings: List[str] = [ ] diff --git a/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/mcp_api.py b/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/mcp_api.py index bdc24563c3..11c481f2eb 100644 --- a/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/mcp_api.py +++ b/libs/toolbox-api-client-python/daytona_toolbox_api_client/api/mcp_api.py @@ -15,6 +15,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated +from pydantic import Field +from typing import Any, Dict +from typing_extensions import Annotated from daytona_toolbox_api_client.api_client import ApiClient, RequestSerialized from daytona_toolbox_api_client.api_response import ApiResponse @@ -35,7 +38,245 @@ def __init__(self, api_client=None) -> None: @validate_call - def m_cp( + def m_cp_delete( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """MCP endpoint — terminate the session (streamable HTTP) + + Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_delete_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '202': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + def m_cp_delete_with_http_info( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """MCP endpoint — terminate the session (streamable HTTP) + + Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_delete_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '202': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + def m_cp_delete_without_preload_content( + self, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """MCP endpoint — terminate the session (streamable HTTP) + + Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_delete_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '202': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _m_cp_delete_serialize( + self, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='DELETE', + resource_path='/mcp', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + def m_cp_get( self, _request_timeout: Union[ None, @@ -50,9 +291,9 @@ def m_cp( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> None: - """MCP endpoint (streamable HTTP) + """MCP endpoint — open the SSE stream (streamable HTTP) - Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -76,7 +317,7 @@ def m_cp( :return: Returns the result object. """ # noqa: E501 - _param = self._m_cp_serialize( + _param = self._m_cp_get_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -98,7 +339,7 @@ def m_cp( @validate_call - def m_cp_with_http_info( + def m_cp_get_with_http_info( self, _request_timeout: Union[ None, @@ -113,9 +354,9 @@ def m_cp_with_http_info( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> ApiResponse[None]: - """MCP endpoint (streamable HTTP) + """MCP endpoint — open the SSE stream (streamable HTTP) - Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -139,7 +380,7 @@ def m_cp_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._m_cp_serialize( + _param = self._m_cp_get_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -161,7 +402,7 @@ def m_cp_with_http_info( @validate_call - def m_cp_without_preload_content( + def m_cp_get_without_preload_content( self, _request_timeout: Union[ None, @@ -176,9 +417,9 @@ def m_cp_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """MCP endpoint (streamable HTTP) + """MCP endpoint — open the SSE stream (streamable HTTP) - Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. + Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -202,7 +443,7 @@ def m_cp_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._m_cp_serialize( + _param = self._m_cp_get_serialize( _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -219,7 +460,7 @@ def m_cp_without_preload_content( return response_data.response - def _m_cp_serialize( + def _m_cp_get_serialize( self, _request_auth, _content_type, @@ -250,6 +491,272 @@ def _m_cp_serialize( + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/mcp', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + def m_cp_post( + self, + message: Annotated[Dict[str, Any], Field(description="JSON-RPC 2.0 request message (e.g. tools/call)")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> None: + """MCP endpoint — send JSON-RPC messages (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + + :param message: JSON-RPC 2.0 request message (e.g. tools/call) (required) + :type message: object + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_post_serialize( + message=message, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + def m_cp_post_with_http_info( + self, + message: Annotated[Dict[str, Any], Field(description="JSON-RPC 2.0 request message (e.g. tools/call)")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[None]: + """MCP endpoint — send JSON-RPC messages (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + + :param message: JSON-RPC 2.0 request message (e.g. tools/call) (required) + :type message: object + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_post_serialize( + message=message, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + def m_cp_post_without_preload_content( + self, + message: Annotated[Dict[str, Any], Field(description="JSON-RPC 2.0 request message (e.g. tools/call)")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """MCP endpoint — send JSON-RPC messages (streamable HTTP) + + Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + + :param message: JSON-RPC 2.0 request message (e.g. tools/call) (required) + :type message: object + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._m_cp_post_serialize( + message=message, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': None, + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _m_cp_post_serialize( + self, + message, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if message is not None: + _body_params = message + + + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + # authentication setting _auth_settings: List[str] = [ ] diff --git a/libs/toolbox-api-client-ruby/.openapi-generator/FILES b/libs/toolbox-api-client-ruby/.openapi-generator/FILES index d211543927..b00a74f13d 100644 --- a/libs/toolbox-api-client-ruby/.openapi-generator/FILES +++ b/libs/toolbox-api-client-ruby/.openapi-generator/FILES @@ -10,6 +10,7 @@ lib/daytona_toolbox_api_client/api/git_api.rb lib/daytona_toolbox_api_client/api/info_api.rb lib/daytona_toolbox_api_client/api/interpreter_api.rb lib/daytona_toolbox_api_client/api/lsp_api.rb +lib/daytona_toolbox_api_client/api/mcp_api.rb lib/daytona_toolbox_api_client/api/port_api.rb lib/daytona_toolbox_api_client/api/process_api.rb lib/daytona_toolbox_api_client/api/server_api.rb diff --git a/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client.rb b/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client.rb index 067498466e..1c25906213 100644 --- a/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client.rb +++ b/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client.rb @@ -119,6 +119,7 @@ require 'daytona_toolbox_api_client/api/info_api' require 'daytona_toolbox_api_client/api/interpreter_api' require 'daytona_toolbox_api_client/api/lsp_api' +require 'daytona_toolbox_api_client/api/mcp_api' require 'daytona_toolbox_api_client/api/port_api' require 'daytona_toolbox_api_client/api/process_api' require 'daytona_toolbox_api_client/api/server_api' diff --git a/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client/api/mcp_api.rb b/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client/api/mcp_api.rb new file mode 100644 index 0000000000..737ca9d2e0 --- /dev/null +++ b/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client/api/mcp_api.rb @@ -0,0 +1,198 @@ +=begin +#Daytona Toolbox API + +#Daytona Toolbox API. The base URL comes from the sandbox's `toolboxProxyUrl` field (returned in sandbox DTO by the main Daytona API) plus the sandbox ID: `{toolboxProxyUrl}/{sandboxId}/{endpoint}`. Default for Daytona Cloud: `https://proxy.app.daytona.io/toolbox/{sandboxId}`. + +The version of the OpenAPI document: v0.0.0-dev + +Generated by: https://openapi-generator.tech +Generator version: 7.21.0 + +=end + +require 'cgi' + +module DaytonaToolboxApiClient + class McpApi + attr_accessor :api_client + + def initialize(api_client = ApiClient.default) + @api_client = api_client + end + # MCP endpoint — terminate the session (streamable HTTP) + # Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + # @param [Hash] opts the optional parameters + # @return [nil] + def m_cp_delete(opts = {}) + m_cp_delete_with_http_info(opts) + nil + end + + # MCP endpoint — terminate the session (streamable HTTP) + # Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + # @param [Hash] opts the optional parameters + # @return [Array<(nil, Integer, Hash)>] nil, response status code and response headers + def m_cp_delete_with_http_info(opts = {}) + if @api_client.config.debugging + @api_client.config.logger.debug 'Calling API: McpApi.m_cp_delete ...' + end + # resource path + local_var_path = '/mcp' + + # query parameters + query_params = opts[:query_params] || {} + + # header parameters + header_params = opts[:header_params] || {} + + # form parameters + form_params = opts[:form_params] || {} + + # http body (model) + post_body = opts[:debug_body] + + # return_type + return_type = opts[:debug_return_type] + + # auth_names + auth_names = opts[:debug_auth_names] || [] + + new_options = opts.merge( + :operation => :"McpApi.m_cp_delete", + :header_params => header_params, + :query_params => query_params, + :form_params => form_params, + :body => post_body, + :auth_names => auth_names, + :return_type => return_type + ) + + data, status_code, headers = @api_client.call_api(:DELETE, local_var_path, new_options) + if @api_client.config.debugging + @api_client.config.logger.debug "API called: McpApi#m_cp_delete\nData: #{data.inspect}\nStatus code: #{status_code}\nHeaders: #{headers}" + end + return data, status_code, headers + end + + # MCP endpoint — open the SSE stream (streamable HTTP) + # Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + # @param [Hash] opts the optional parameters + # @return [nil] + def m_cp_get(opts = {}) + m_cp_get_with_http_info(opts) + nil + end + + # MCP endpoint — open the SSE stream (streamable HTTP) + # Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + # @param [Hash] opts the optional parameters + # @return [Array<(nil, Integer, Hash)>] nil, response status code and response headers + def m_cp_get_with_http_info(opts = {}) + if @api_client.config.debugging + @api_client.config.logger.debug 'Calling API: McpApi.m_cp_get ...' + end + # resource path + local_var_path = '/mcp' + + # query parameters + query_params = opts[:query_params] || {} + + # header parameters + header_params = opts[:header_params] || {} + + # form parameters + form_params = opts[:form_params] || {} + + # http body (model) + post_body = opts[:debug_body] + + # return_type + return_type = opts[:debug_return_type] + + # auth_names + auth_names = opts[:debug_auth_names] || [] + + new_options = opts.merge( + :operation => :"McpApi.m_cp_get", + :header_params => header_params, + :query_params => query_params, + :form_params => form_params, + :body => post_body, + :auth_names => auth_names, + :return_type => return_type + ) + + data, status_code, headers = @api_client.call_api(:GET, local_var_path, new_options) + if @api_client.config.debugging + @api_client.config.logger.debug "API called: McpApi#m_cp_get\nData: #{data.inspect}\nStatus code: #{status_code}\nHeaders: #{headers}" + end + return data, status_code, headers + end + + # MCP endpoint — send JSON-RPC messages (streamable HTTP) + # Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + # @param message [Object] JSON-RPC 2.0 request message (e.g. tools/call) + # @param [Hash] opts the optional parameters + # @return [nil] + def m_cp_post(message, opts = {}) + m_cp_post_with_http_info(message, opts) + nil + end + + # MCP endpoint — send JSON-RPC messages (streamable HTTP) + # Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer <token>) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + # @param message [Object] JSON-RPC 2.0 request message (e.g. tools/call) + # @param [Hash] opts the optional parameters + # @return [Array<(nil, Integer, Hash)>] nil, response status code and response headers + def m_cp_post_with_http_info(message, opts = {}) + if @api_client.config.debugging + @api_client.config.logger.debug 'Calling API: McpApi.m_cp_post ...' + end + # verify the required parameter 'message' is set + if @api_client.config.client_side_validation && message.nil? + fail ArgumentError, "Missing the required parameter 'message' when calling McpApi.m_cp_post" + end + # resource path + local_var_path = '/mcp' + + # query parameters + query_params = opts[:query_params] || {} + + # header parameters + header_params = opts[:header_params] || {} + # HTTP header 'Content-Type' + content_type = @api_client.select_header_content_type(['application/json']) + if !content_type.nil? + header_params['Content-Type'] = content_type + end + + # form parameters + form_params = opts[:form_params] || {} + + # http body (model) + post_body = opts[:debug_body] || @api_client.object_to_http_body(message) + + # return_type + return_type = opts[:debug_return_type] + + # auth_names + auth_names = opts[:debug_auth_names] || [] + + new_options = opts.merge( + :operation => :"McpApi.m_cp_post", + :header_params => header_params, + :query_params => query_params, + :form_params => form_params, + :body => post_body, + :auth_names => auth_names, + :return_type => return_type + ) + + data, status_code, headers = @api_client.call_api(:POST, local_var_path, new_options) + if @api_client.config.debugging + @api_client.config.logger.debug "API called: McpApi#m_cp_post\nData: #{data.inspect}\nStatus code: #{status_code}\nHeaders: #{headers}" + end + return data, status_code, headers + end + end +end diff --git a/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client/api/process_api.rb b/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client/api/process_api.rb index 5319385bc9..da38adcca3 100644 --- a/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client/api/process_api.rb +++ b/libs/toolbox-api-client-ruby/lib/daytona_toolbox_api_client/api/process_api.rb @@ -406,6 +406,64 @@ def delete_session_with_http_info(session_id, opts = {}) return data, status_code, headers end + # Execute a command or open a shell over a single WebSocket connection + # SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + # @param [Hash] opts the optional parameters + # @option opts [String] :token SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + # @return [nil] + def exec_connect(opts = {}) + exec_connect_with_http_info(opts) + nil + end + + # Execute a command or open a shell over a single WebSocket connection + # SSH-equivalent exec channel over HTTPS. After the upgrade the client sends a start frame: {\"type\":\"start\",\"command\":\"...\",\"cwd\":\"...\",\"env\":{...},\"cols\":...,\"rows\":...}. When command is omitted, an interactive login shell is started (like bare `ssh host`). Subsequent client frames: stdin, signal, resize, stdin_eof. Server frames: stdout, stderr, exit (always last, before close), error. One connection = one exec; shell state persists for the lifetime of the connection. + # @param [Hash] opts the optional parameters + # @option opts [String] :token SSH access token (alternative to the Authorization header for WS clients that cannot set headers) + # @return [Array<(nil, Integer, Hash)>] nil, response status code and response headers + def exec_connect_with_http_info(opts = {}) + if @api_client.config.debugging + @api_client.config.logger.debug 'Calling API: ProcessApi.exec_connect ...' + end + # resource path + local_var_path = '/process/exec/connect' + + # query parameters + query_params = opts[:query_params] || {} + query_params[:'token'] = opts[:'token'] if !opts[:'token'].nil? + + # header parameters + header_params = opts[:header_params] || {} + + # form parameters + form_params = opts[:form_params] || {} + + # http body (model) + post_body = opts[:debug_body] + + # return_type + return_type = opts[:debug_return_type] + + # auth_names + auth_names = opts[:debug_auth_names] || [] + + new_options = opts.merge( + :operation => :"ProcessApi.exec_connect", + :header_params => header_params, + :query_params => query_params, + :form_params => form_params, + :body => post_body, + :auth_names => auth_names, + :return_type => return_type + ) + + data, status_code, headers = @api_client.call_api(:GET, local_var_path, new_options) + if @api_client.config.debugging + @api_client.config.logger.debug "API called: ProcessApi#exec_connect\nData: #{data.inspect}\nStatus code: #{status_code}\nHeaders: #{headers}" + end + return data, status_code, headers + end + # Execute a command # Execute a shell command and return the output and exit code # @param request [ExecuteRequest] Command execution request diff --git a/libs/toolbox-api-client/src/api/mcp-api.ts b/libs/toolbox-api-client/src/api/mcp-api.ts index 2bdb5f4a57..37cfb5d7ff 100644 --- a/libs/toolbox-api-client/src/api/mcp-api.ts +++ b/libs/toolbox-api-client/src/api/mcp-api.ts @@ -27,12 +27,73 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError export const McpApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. - * @summary MCP endpoint (streamable HTTP) + * Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + * @summary MCP endpoint — terminate the session (streamable HTTP) * @param {*} [options] Override http request option. * @throws {RequiredError} */ - mCP: async (options: RawAxiosRequestConfig = {}): Promise => { + mCPDelete: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/mcp`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + * @summary MCP endpoint — open the SSE stream (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mCPGet: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/mcp`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + * @summary MCP endpoint — send JSON-RPC messages (streamable HTTP) + * @param {object} message JSON-RPC 2.0 request message (e.g. tools/call) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mCPPost: async (message: object, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'message' is not null or undefined + assertParamExists('mCPPost', 'message', message) const localVarPath = `/mcp`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -45,10 +106,12 @@ export const McpApiAxiosParamCreator = function (configuration?: Configuration) const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + localVarHeaderParameter['Content-Type'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(message, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -65,15 +128,40 @@ export const McpApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = McpApiAxiosParamCreator(configuration) return { /** - * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. - * @summary MCP endpoint (streamable HTTP) + * Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + * @summary MCP endpoint — terminate the session (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async mCPDelete(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.mCPDelete(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['McpApi.mCPDelete']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + * @summary MCP endpoint — open the SSE stream (streamable HTTP) * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async mCP(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.mCP(options); + async mCPGet(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.mCPGet(options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['McpApi.mCP']?.[localVarOperationServerIndex]?.url; + const localVarOperationServerBasePath = operationServerMap['McpApi.mCPGet']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + * @summary MCP endpoint — send JSON-RPC messages (streamable HTTP) + * @param {object} message JSON-RPC 2.0 request message (e.g. tools/call) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async mCPPost(message: object, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.mCPPost(message, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['McpApi.mCPPost']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, } @@ -86,13 +174,32 @@ export const McpApiFactory = function (configuration?: Configuration, basePath?: const localVarFp = McpApiFp(configuration) return { /** - * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. - * @summary MCP endpoint (streamable HTTP) + * Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + * @summary MCP endpoint — terminate the session (streamable HTTP) * @param {*} [options] Override http request option. * @throws {RequiredError} */ - mCP(options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.mCP(options).then((request) => request(axios, basePath)); + mCPDelete(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.mCPDelete(options).then((request) => request(axios, basePath)); + }, + /** + * Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + * @summary MCP endpoint — open the SSE stream (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mCPGet(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.mCPGet(options).then((request) => request(axios, basePath)); + }, + /** + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + * @summary MCP endpoint — send JSON-RPC messages (streamable HTTP) + * @param {object} message JSON-RPC 2.0 request message (e.g. tools/call) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mCPPost(message: object, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.mCPPost(message, options).then((request) => request(axios, basePath)); }, }; }; @@ -102,13 +209,34 @@ export const McpApiFactory = function (configuration?: Configuration, basePath?: */ export class McpApi extends BaseAPI { /** - * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. POST sends JSON-RPC messages (responses are SSE events per the transport); GET opens the SSE stream. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. - * @summary MCP endpoint (streamable HTTP) + * Terminates the MCP session per the streamable-HTTP transport. The handler is stateless, so this is a no-op acknowledged for transport compliance. + * @summary MCP endpoint — terminate the session (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public mCPDelete(options?: RawAxiosRequestConfig) { + return McpApiFp(this.configuration).mCPDelete(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Opens the server-sent-event stream of the MCP streamable-HTTP transport. Stateless deployments do not emit unsolicited events, so most clients only need POST. + * @summary MCP endpoint — open the SSE stream (streamable HTTP) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public mCPGet(options?: RawAxiosRequestConfig) { + return McpApiFp(this.configuration).mCPGet(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Model Context Protocol endpoint (streamable-HTTP transport) exposing sandbox tools: exec_command, fs_read_file, fs_write_file, fs_list_files. The request body is a JSON-RPC 2.0 message (initialize, tools/list, tools/call, ...); the response is a JSON-RPC response or an SSE event stream per the transport. The handler is stateless: plain HTTP clients can call tools without the initialize handshake. Authenticate with a scoped SSH access token (Authorization: Bearer ) exactly like /process/exec/connect. NOTE: MCP clients should speak JSON-RPC directly — generated REST clients cannot express the MCP transport. + * @summary MCP endpoint — send JSON-RPC messages (streamable HTTP) + * @param {object} message JSON-RPC 2.0 request message (e.g. tools/call) * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public mCP(options?: RawAxiosRequestConfig) { - return McpApiFp(this.configuration).mCP(options).then((request) => request(this.axios, this.basePath)); + public mCPPost(message: object, options?: RawAxiosRequestConfig) { + return McpApiFp(this.configuration).mCPPost(message, options).then((request) => request(this.axios, this.basePath)); } }