From fe3430fd47dbfd538449bfdd821b395c479dc146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 05:56:17 +0000 Subject: [PATCH 01/18] task: add mcp connection custom headers Co-Authored-By: Claude Opus 5.5 --- ...026-09-29-mcp-connection-custom-headers.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tasks/active/2026-09-29-mcp-connection-custom-headers.md diff --git a/tasks/active/2026-09-29-mcp-connection-custom-headers.md b/tasks/active/2026-09-29-mcp-connection-custom-headers.md new file mode 100644 index 000000000..e2ad1db02 --- /dev/null +++ b/tasks/active/2026-09-29-mcp-connection-custom-headers.md @@ -0,0 +1,186 @@ +# Custom HTTP Headers for Bring-Your-Own MCP Servers + +## Problem + +Bring-your-own MCP servers (`mcp_connections`, PR #1892) support only two auth shapes: +a bearer token (`Authorization: Bearer `) or no auth, where the credential is +embedded in the URL. Composio's MCP endpoints now require a custom API-key header: +`x-api-key` for single-toolkit MCP, and `x-consumer-api-key` for Composio Connect. SAM +cannot express that today, and the public guide says so under Limitations: "Custom auth +headers (for example `X-API-Key`) are not yet supported." + +Raphaël (2026-09-29): "I need to be able to add headers to MCP servers in SAM... Let's add +the ability to manage headers as well (composio requires this)." + +SAM task: `01M3NTSE4PAGHVDJPKKZ0AZ2DH`. Idea: `01M0QDASJCK3YWVX1GETZTSFWZ` (BYO MCP; custom +headers were deferred there). + +## Research Findings + +### Current data path (verified) + +- Shared types: `packages/shared/src/types/mcp-connection.ts`. Auth types are `['none','bearer']`. + The API response omits url/token, returns `urlHost` + `hasToken`. +- D1: `mcp_connections` (`apps/api/src/db/schema.ts:2072`, migration `0120_mcp_connections.sql`). + URL and token are AES-256-GCM encrypted. +- CRUD + validation: `apps/api/src/services/mcp-connections.ts` (367 lines). + Valibot structural schemas: `apps/api/src/schemas/mcp-connections.ts`. + Routes: `apps/api/src/routes/mcp-connections.ts` (personal + project scope, `secret:write`). +- Resolution at session start: `apps/api/src/services/mcp-connection-resolution.ts`. + `toEntry` decrypts per row and skips + warns on failure (rules 41/50). + `buildSessionMcpServers` is the single composition point. It is called from + `agent-session-bootstrap.ts` (VM + cf-container) and `routes/workspaces/agent-sessions.ts`. +- Control plane → vm-agent: `apps/api/src/services/node-agent.ts` `McpServerConfig` + + `serializeMcpServers` (single choke point), shared contract `McpServerEntrySchema` in + `packages/shared/src/vm-agent-contract.ts`. +- vm-agent entry: `acp.McpServerEntry{URL,Token,Name}` (`internal/acp/gateway.go:297`). Three + field-by-field copies: + - `normalizeMcpServers` (`internal/server/workspaces.go:1219`), which rejects the WHOLE request + on an invalid entry. + - `registerSessionMcpServers` (acp → persistence). + - agent_ws prefetch (persistence → acp, `internal/server/agent_ws.go:248`). +- Persistence: `session_mcp_servers` SQLite table (`internal/persistence/store.go`, migrateV5/V12), + token stored in plaintext on the VM (existing behaviour). +- Per-harness injection: + - ACP HTTP inline servers (Claude Code, Gemini, OpenCode…): `buildAcpMcpServers` + (`internal/acp/session_host.go:76`). It already emits `[]acpsdk.HttpHeader`, but only + `Authorization`. + - Amp: `buildAmpMcpServer` bridges through `npx mcp-remote@0.1.38 --header + Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env rather than argv. + - Codex: `generateCodexMcpConfig` (`gateway.go:1393`) writes `[mcp_servers.] url` + + `bearer_token_env_var`, and exports env vars for docker exec. + - Vibe: `generateVibeConfig` (`gateway.go:1481`) writes `headers = { Authorization = "Bearer …" }`. + +### External constraints (verified 2026-09-29) + +- **mcp-remote 0.1.38** (Amp bridge) parses `--header` with `/^([A-Za-z0-9_-]+):\s*(.*)$/` + (`dist/chunk-65X3S4HB.js:20713`). Names outside that charset are silently ignored, and + `${ENV}` is expanded in values (`:20851`). **So SAM header names must be `[A-Za-z0-9_-]`.** + That charset is also exactly the TOML bare-key charset. +- **Codex config** (learn.chatgpt.com/docs/config-file/config-reference): + `mcp_servers..env_http_headers` is a `map` of header name to env var name. + That keeps secret header values out of `~/.codex/config.toml`, as `bearer_token_env_var` does today. +- **Codex env var naming**: `isSecretEnvVar` (`internal/acp/process.go:109`) keeps only + `_KEY`/`_TOKEN`/`_SECRET` names out of docker exec argv. The bearer var is + `SAM_MCP__TOKEN`. A header var must use a DIFFERENT suffix: server `x`'s header var + `SAM_MCP_X_HEADER_0_TOKEN` would collide with the bearer var of a server named `x-header-0`. + Use `SAM_MCP__HEADER__SECRET`. +- **Composio**: `x-api-key` (single-toolkit MCP; required by default for new orgs) and + `x-consumer-api-key` (Composio Connect). + Sources: docs.composio.dev/docs/single-toolkit-mcp, composio.dev/toolkits/composio/framework/codex. + +### Design decisions + +- **Headers are orthogonal to `authType`**: `none|bearer` stays, and any connection may add + custom headers. Composio is `none` + `x-api-key`. A custom `Authorization` header is allowed + only when `authType` is `none` (for non-Bearer schemes); with `bearer` it conflicts and is + rejected. +- **Header names**: `^[A-Za-z0-9_-]{1,64}$` (mcp-remote + TOML bare-key safe), unique + case-insensitively. Transport-managed names are reserved: host, content-length, + content-type, transfer-encoding, connection, accept, mcp-session-id, + mcp-protocol-version, last-event-id. +- **Header values are secrets**: trimmed, non-empty, no control characters, byte-capped. They + are never returned by any read path; the API returns `headerNames` only. +- **Storage**: the full `[{name,value}]` list is AES-GCM encrypted in `encrypted_headers` + + `headers_iv`, and is the only thing injection reads. `header_names` (plaintext JSON) is the + display projection, written by the same helper. +- **PATCH semantics**: `headers` omitted = unchanged. Present = the full desired set. An entry + without `value` keeps the stored value for that (case-insensitive) name, so the UI can + remove/add/rotate one header without re-entering the others. A value-less entry for an + unknown name is a 400. +- **Limits** (configurable, Principle XI): `MAX_MCP_CONNECTION_HEADERS` (default 10), + `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (default 8192). Name length 64 is a + non-configurable safety ceiling, like the server-name length. +- **vm-agent validation**: `normalizeMcpServers` re-validates the header name charset and + value control characters, with an index-only error (never the value), because this is where + values reach TOML/argv. Resolution also validates each decrypted row and skips + warns, so + one bad row cannot fail session start. +- **Rollout (rule 54)**: `headers` is additive. The control plane sends it only when non-empty, + and old agents ignore unknown JSON keys. New sessions only land on nodes running the + current VM-agent release. +- **UI**: the MCP server form gains a Headers editor (name + masked value rows), and each row + gets an **Edit** action. Edit mode keeps the URL, token and header values unless new ones + are typed. The row shows header names. +- **File size (rule 18)**: extract the touched code instead of growing large files: + - `acp/mcp_servers.go`: entry type + ACP/Amp builders + - `acp/codex_config.go`, `acp/vibe_config.go`: config generators out of `gateway.go` + - `server/mcp_servers.go`: normalize/register/convert out of `workspaces.go` + - `persistence/session_mcp_servers.go`: out of `store.go` + - `services/mcp-connection-headers.ts` + - Web form/headers components out of `McpServersManager.tsx` + +## Implementation Checklist + +### Shared +- [ ] `mcp-connection.ts`: `McpConnectionHeader`, `McpConnectionHeaderUpdate`, `headerNames` on + `McpConnection`, `headers` on create/update requests, header name pattern/rule/max + length, reserved header names +- [ ] `defaults.ts`: `DEFAULT_MAX_MCP_CONNECTION_HEADERS`, `DEFAULT_MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` +- [ ] `vm-agent-contract.ts`: optional `headers` on `McpServerEntrySchema` +- [ ] Contract fixture `mcp-server-name-contract.json`: `headerNames` valid/invalid block, + consumed by the TS test and the Go test + +### API +- [ ] Migration `0175_mcp_connection_headers.sql` (ADD COLUMN ×3, additive) + `schema.ts` columns +- [ ] `services/mcp-connection-headers.ts`: validate, merge-for-update, seal/open, display names +- [ ] `services/mcp-connections.ts`: create/update/response use the header module +- [ ] `schemas/mcp-connections.ts`: structural `headers` for create/update +- [ ] `routes/mcp-connections.ts`: pass headers + new limits +- [ ] `services/limits.ts` + `env.ts`: two new limits +- [ ] `services/mcp-connection-resolution.ts`: decrypt + validate headers per row (skip on failure) +- [ ] `services/node-agent.ts`: `McpServerConfig.headers`, `serializeMcpServers` sends only when non-empty + +### vm-agent +- [ ] Refactor commit: extract `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, + `server/mcp_servers.go`, `persistence/session_mcp_servers.go` (pure moves) +- [ ] `McpHeader` + `McpServerEntry.Headers`; header name/value validators in acp +- [ ] `normalizeMcpServers` validates + copies headers; persistence conversion helpers used by + register + agent_ws prefetch +- [ ] Persistence `migrateV18` (`headers` JSON column) + upsert/get +- [ ] ACP: custom headers after Authorization +- [ ] Amp: `--header name:${SAM_MCP_HEADER_}` with values in the server env, not argv +- [ ] Codex: `env_http_headers` + `SAM_MCP__HEADER__SECRET` env vars +- [ ] Vibe: custom headers in the `headers` inline table + +### Web +- [ ] Split `McpServersManager.tsx` into list + `McpServerForm` + `McpServerHeadersField` +- [ ] Headers editor in the create form; Edit action with keep-semantics payload; header names in the row +- [ ] Unit tests (create payload, edit payload keep/replace/remove, rendering) +- [ ] Playwright audit: headers form + edit form + rows with many/long headers, 375 and 1280 + +### Tests +- [ ] API: header validation, encryption at rest, never-returned values, PATCH keep/replace/remove, + authType/Authorization conflict, malformed `header_names` tolerated on list +- [ ] API vertical slice: mock MCP server requiring `x-api-key` authorizes the resolved entry, + and rejects without it +- [ ] API: resolution skips a row with undecryptable headers, others still resolve +- [ ] API: node-agent contract serializes headers only when present +- [ ] Go: ACP/Amp/Codex/Vibe header output; normalize rejects bad header without leaking the value; + full round trip incl. restart backfill; migrateV18 upgrade of existing rows +- [ ] Contract fixture consumed on both sides + +### Docs +- [ ] `apps/www/.../guides/mcp-servers.md`: headers field, Composio row, editing, remove limitation, Amp note +- [ ] `apps/www/.../reference/configuration.md` + `apps/api/.env.example`: new limits +- [ ] `.claude/skills/changelog/SKILL.md` entry; env-reference skill if it lists MCP limits + +## Acceptance Criteria + +- [ ] A user can add an MCP server with one or more custom headers (e.g. `x-api-key`) in + Settings → MCP Servers and in Project Settings → Runtime +- [ ] A user can edit an existing server to add, rotate, or remove headers without re-entering + the URL, the token, or other header values +- [ ] Header values are encrypted at rest and never returned by any API response; names are shown +- [ ] Every harness receives the headers: ACP HTTP (Claude Code etc.), Codex + (`env_http_headers`), Vibe, and Amp (mcp-remote) +- [ ] Invalid header names/values are rejected at write time with a clear message; a bad stored + row cannot break session start for the scope +- [ ] Existing connections without headers behave exactly as before +- [ ] Staging: an agent session reaches a real MCP server that requires a custom header, and + successfully calls a tool + +## References + +- `.claude/rules/54` (vm-agent rollout), `41`/`50` (per-row isolation), `28` (real SQL for + scoping), `62`/`73` (field copies must not drop), `18` (file size), `23` (cross-boundary contract) +- `tasks/archive/2026-08-23-byo-mcp-servers.md` From 2f95c25eda1bae4da57741aaf11caf1419651955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 06:03:24 +0000 Subject: [PATCH 02/18] refactor(vm-agent): extract MCP server, Codex and Vibe config code into dedicated files Pure moves, no behaviour change. Prepares for custom MCP headers without growing files already over the rule-18 ceiling: - acp/mcp_servers.go: McpServerEntry + ACP/Amp server builders (from gateway.go, session_host.go) - acp/codex_config.go: Codex config.toml generation and writers (from gateway.go) - acp/vibe_config.go: Vibe config.toml generation and writer (from gateway.go) - server/mcp_servers.go: normalizeMcpServers + registerSessionMcpServers (from workspaces.go) - persistence/session_mcp_servers.go: session_mcp_servers table + CRUD (from store.go) Co-Authored-By: Claude Opus 5.5 --- .../vm-agent/internal/acp/codex_config.go | 316 ++++++++++++ packages/vm-agent/internal/acp/gateway.go | 456 ------------------ packages/vm-agent/internal/acp/mcp_servers.go | 90 ++++ .../vm-agent/internal/acp/session_host.go | 68 --- packages/vm-agent/internal/acp/vibe_config.go | 142 ++++++ .../persistence/session_mcp_servers.go | 143 ++++++ .../vm-agent/internal/persistence/store.go | 137 ------ .../vm-agent/internal/server/mcp_servers.go | 63 +++ .../vm-agent/internal/server/workspaces.go | 53 -- 9 files changed, 754 insertions(+), 714 deletions(-) create mode 100644 packages/vm-agent/internal/acp/codex_config.go create mode 100644 packages/vm-agent/internal/acp/mcp_servers.go create mode 100644 packages/vm-agent/internal/acp/vibe_config.go create mode 100644 packages/vm-agent/internal/persistence/session_mcp_servers.go create mode 100644 packages/vm-agent/internal/server/mcp_servers.go diff --git a/packages/vm-agent/internal/acp/codex_config.go b/packages/vm-agent/internal/acp/codex_config.go new file mode 100644 index 000000000..8dc0d66d4 --- /dev/null +++ b/packages/vm-agent/internal/acp/codex_config.go @@ -0,0 +1,316 @@ +package acp + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +const ( + codexManagedMcpStartMarker = "# BEGIN SAM MANAGED MCP" + codexManagedMcpEndMarker = "# END SAM MANAGED MCP" + codexProxyProviderID = "sam-openai" + codexProxyProviderEnvKey = "OPENAI_API_KEY" +) + +type codexProxyProviderConfig struct { + baseURL string + model string +} + +// codexMcpTokenEnvVar derives the env var Codex reads a server's bearer token from. +// +// The "_TOKEN" suffix is required, not stylistic: isSecretEnvVar in process.go classifies +// secrets by that substring, and an unclassified value would be passed through docker exec +// argv and become visible in /proc/*/cmdline. +// +// The two legacy shapes ("sam-mcp" and "sam-mcp-") keep their historical env var names so +// unnamed entries produce byte-identical config to before this field existed. +func codexMcpTokenEnvVar(name string) string { + if name == SamMcpServerName { + return "SAM_MCP_TOKEN" + } + if suffix, ok := strings.CutPrefix(name, SamMcpServerName+"-"); ok && isAllDigits(suffix) { + return "SAM_MCP_TOKEN_" + suffix + } + return fmt.Sprintf("SAM_MCP_%s_TOKEN", McpServerEnvVarSuffix(name)) +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func removeManagedCodexMcpBlock(existing string) string { + for { + start := strings.Index(existing, codexManagedMcpStartMarker) + if start == -1 { + return existing + } + endRel := strings.Index(existing[start:], codexManagedMcpEndMarker) + if endRel == -1 { + return existing[:start] + } + end := start + endRel + len(codexManagedMcpEndMarker) + if end < len(existing) && existing[end] == '\n' { + end++ + } + existing = existing[:start] + existing[end:] + } +} + +func mergeManagedCodexMcpConfig(existing, managed string) string { + cleaned := removeManagedCodexMcpBlock(existing) + managed = strings.TrimSpace(managed) + managedTopLevelKeys := codexTopLevelAssignmentKeys(managed) + if len(managedTopLevelKeys) > 0 { + lines := strings.Split(cleaned, "\n") + filtered := lines[:0] + atTopLevel := true + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") { + atTopLevel = false + } + if atTopLevel { + if key, ok := codexAssignmentKey(trimmed); ok && managedTopLevelKeys[key] { + continue + } + } + filtered = append(filtered, line) + } + cleaned = strings.Join(filtered, "\n") + } + cleaned = strings.TrimRight(cleaned, "\n") + + switch { + case cleaned == "" && managed == "": + return "" + case cleaned == "": + return managed + "\n" + case managed == "": + return cleaned + "\n" + default: + lines := strings.Split(cleaned, "\n") + firstTable := len(lines) + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "[") { + firstTable = i + break + } + } + topLevel := strings.TrimSpace(strings.Join(lines[:firstTable], "\n")) + tables := strings.TrimSpace(strings.Join(lines[firstTable:], "\n")) + sections := make([]string, 0, 3) + if topLevel != "" { + sections = append(sections, topLevel) + } + sections = append(sections, managed) + if tables != "" { + sections = append(sections, tables) + } + return strings.Join(sections, "\n\n") + "\n" + } +} + +func codexTopLevelAssignmentKeys(config string) map[string]bool { + keys := make(map[string]bool) + for _, line := range strings.Split(config, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") { + break + } + if key, ok := codexAssignmentKey(trimmed); ok { + keys[key] = true + } + } + return keys +} + +func codexAssignmentKey(line string) (string, bool) { + if line == "" || strings.HasPrefix(line, "#") { + return "", false + } + var assignment map[string]any + if err := toml.Unmarshal([]byte(line), &assignment); err != nil || len(assignment) != 1 { + return "", false + } + for key := range assignment { + return key, true + } + return "", false +} + +func codexProxyProviderConfigFromCredential(cred *agentCredential, callbackToken string) *codexProxyProviderConfig { + if cred == nil || cred.inferenceConfig == nil { + return nil + } + // Auth-file credentials (OAuth tokens) use auth.json injection, not env-var-based + // proxy providers. Generating a proxy provider config here would produce a + // config.toml entry with env_key = "OPENAI_API_KEY" that is never set, + // causing Codex to crash immediately. + if cred.credentialKind == "oauth-token" { + return nil + } + if cred.inferenceConfig.Provider != "openai-proxy" && cred.inferenceConfig.Provider != "openai-passthrough" { + return nil + } + baseURL := strings.ReplaceAll(cred.inferenceConfig.BaseURL, "{wstoken}", callbackToken) + if baseURL == "" || strings.ContainsAny(baseURL, "\n\r") { + return nil + } + model := cred.inferenceConfig.Model + if strings.ContainsAny(model, "\n\r") { + model = "" + } + return &codexProxyProviderConfig{baseURL: baseURL, model: model} +} + +func generateCodexProxyProviderConfig(config *codexProxyProviderConfig) string { + if config == nil { + return "" + } + + var b strings.Builder + b.WriteString("# SAM-managed Codex provider for proxy-backed sessions.\n") + if config.model != "" { + b.WriteString(fmt.Sprintf("model = \"%s\"\n", tomlEscapeBasicString(config.model))) + } + b.WriteString(fmt.Sprintf("model_provider = \"%s\"\n\n", codexProxyProviderID)) + b.WriteString(fmt.Sprintf("[model_providers.%s]\n", codexProxyProviderID)) + b.WriteString("name = \"SAM OpenAI Proxy\"\n") + b.WriteString(fmt.Sprintf("base_url = \"%s\"\n", tomlEscapeBasicString(config.baseURL))) + b.WriteString(fmt.Sprintf("env_key = \"%s\"\n", codexProxyProviderEnvKey)) + b.WriteString("wire_api = \"responses\"\n\n") + return b.String() +} + +func normalizeCodexEffort(effort string) string { + trimmed := strings.TrimSpace(effort) + switch trimmed { + case "low", "medium", "high", "xhigh": + return trimmed + default: + return "" + } +} + +// generateCodexMcpConfig produces a managed TOML block for Codex MCP server +// configuration plus the environment variables referenced by +// bearer_token_env_var. Codex natively supports streamable HTTP MCP servers +// via ~/.codex/config.toml. +func generateCodexMcpConfig(mcpServers []McpServerEntry, proxyProvider *codexProxyProviderConfig, effort string) (string, []string) { + providerConfig := generateCodexProxyProviderConfig(proxyProvider) + codexEffort := normalizeCodexEffort(effort) + validServers := make([]McpServerEntry, 0, len(mcpServers)) + for i, server := range mcpServers { + if strings.ContainsAny(server.URL, "\n\r") || strings.ContainsAny(server.Token, "\n\r") { + slog.Warn("Skipping Codex MCP server with control characters in URL or token", + "index", i, "url_length", len(server.URL)) + continue + } + validServers = append(validServers, server) + } + var config strings.Builder + envVars := make([]string, 0, len(validServers)) + + config.WriteString(codexManagedMcpStartMarker) + config.WriteString("\n# Added by SAM vm-agent for Codex ACP sessions.\n") + config.WriteString("sandbox_mode = \"danger-full-access\"\n") + config.WriteString("approval_policy = \"never\"\n") + if codexEffort != "" { + config.WriteString(fmt.Sprintf("model_reasoning_effort = \"%s\"\n", codexEffort)) + } + config.WriteString(providerConfig) + + // Names are resolved over validServers (post-filter) so the positional fallback matches + // the keys actually written; dropping a server renumbers the rest, which is the + // pre-existing behaviour. + names := ResolveMcpServerNames(validServers) + for i, server := range validServers { + name := names[i] + config.WriteString(fmt.Sprintf("[mcp_servers.%s]\n", name)) + config.WriteString(fmt.Sprintf("url = \"%s\"\n", tomlEscapeBasicString(server.URL))) + if server.Token != "" { + tokenEnvVar := codexMcpTokenEnvVar(name) + config.WriteString(fmt.Sprintf("bearer_token_env_var = \"%s\"\n", tokenEnvVar)) + envVars = append(envVars, fmt.Sprintf("%s=%s", tokenEnvVar, server.Token)) + } + config.WriteString("\n") + } + + config.WriteString(codexManagedMcpEndMarker) + config.WriteString("\n") + return config.String(), envVars +} + +// writeCodexConfigToContainer updates ~/.codex/config.toml with a SAM-managed +// MCP block. Existing non-SAM config is preserved, and prior SAM-managed blocks +// are replaced so resumed or restarted sessions do not accumulate stale tokens. +func writeCodexConfigToContainer(ctx context.Context, containerID, user string, mcpServers []McpServerEntry, proxyProvider *codexProxyProviderConfig, effort string) ([]string, error) { + managedConfig, envVars := generateCodexMcpConfig(mcpServers, proxyProvider, effort) + existingConfig, err := readOptionalFileFromContainer(ctx, containerID, user, ".codex/config.toml") + if err != nil { + return nil, err + } + mergedConfig := mergeManagedCodexMcpConfig(existingConfig, managedConfig) + if mergedConfig == "" { + return nil, nil + } + if err := writeAuthFileToContainer(ctx, containerID, user, ".codex/config.toml", mergedConfig); err != nil { + return nil, err + } + return envVars, nil +} + +// writeCodexConfigLocally updates ~/.codex/config.toml on the local filesystem +// with a SAM-managed MCP block. Used for standalone/cf-container sessions where +// no Docker container is available. Mirrors writeCodexConfigToContainer. +func writeCodexConfigLocally(mcpServers []McpServerEntry, proxyProvider *codexProxyProviderConfig, effort string) ([]string, error) { + managedConfig, envVars := generateCodexMcpConfig(mcpServers, proxyProvider, effort) + + configPath, err := resolveLocalAuthFileTargetPath(".codex/config.toml") + if err != nil { + return nil, fmt.Errorf("resolve codex config path: %w", err) + } + + var existingConfig string + data, err := os.ReadFile(configPath) + if err == nil { + existingConfig = string(data) + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("read existing codex config: %w", err) + } + + mergedConfig := mergeManagedCodexMcpConfig(existingConfig, managedConfig) + if mergedConfig == "" { + return nil, nil + } + + dir := filepath.Dir(configPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create codex config directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return nil, fmt.Errorf("chmod codex config directory: %w", err) + } + if err := os.WriteFile(configPath, []byte(mergedConfig), 0o600); err != nil { + return nil, fmt.Errorf("write codex config.toml: %w", err) + } + if err := os.Chmod(configPath, 0o600); err != nil { + return nil, fmt.Errorf("chmod codex config.toml: %w", err) + } + return envVars, nil +} diff --git a/packages/vm-agent/internal/acp/gateway.go b/packages/vm-agent/internal/acp/gateway.go index be2e98a20..044e24c0a 100644 --- a/packages/vm-agent/internal/acp/gateway.go +++ b/packages/vm-agent/internal/acp/gateway.go @@ -11,13 +11,11 @@ import ( "os" "os/exec" "path" - "path/filepath" "strings" "sync" "time" "github.com/gorilla/websocket" - "github.com/pelletier/go-toml/v2" ) const localShellPath = "/bin/sh" @@ -283,23 +281,6 @@ type GatewayConfig struct { HTTPClient *http.Client } -// McpServerEntry is a lightweight MCP server config passed from the control -// plane for injection into ACP sessions. It represents an HTTP MCP server with -// optional bearer token authentication. -// -// An empty Token means "no auth" — several MCP providers issue pre-signed URLs -// that carry the credential in the URL itself. Every harness below omits the -// auth header in that case. -// -// Name is the agent-visible server name; tools are namespaced by it. It is -// optional because a control plane older than this field does not send one, in -// which case ResolveMcpServerNames falls back to the legacy positional scheme. -type McpServerEntry struct { - URL string `json:"url"` - Token string `json:"token"` - Name string `json:"name,omitempty"` -} - // Gateway is a thin per-WebSocket relay between a browser and a SessionHost. // It reads messages from the WebSocket and routes them to the SessionHost. // It does NOT own the agent process — that responsibility belongs to SessionHost. @@ -1180,192 +1161,6 @@ func tomlEscapeBasicString(s string) string { return s } -const ( - codexManagedMcpStartMarker = "# BEGIN SAM MANAGED MCP" - codexManagedMcpEndMarker = "# END SAM MANAGED MCP" - codexProxyProviderID = "sam-openai" - codexProxyProviderEnvKey = "OPENAI_API_KEY" -) - -type codexProxyProviderConfig struct { - baseURL string - model string -} - -// codexMcpTokenEnvVar derives the env var Codex reads a server's bearer token from. -// -// The "_TOKEN" suffix is required, not stylistic: isSecretEnvVar in process.go classifies -// secrets by that substring, and an unclassified value would be passed through docker exec -// argv and become visible in /proc/*/cmdline. -// -// The two legacy shapes ("sam-mcp" and "sam-mcp-") keep their historical env var names so -// unnamed entries produce byte-identical config to before this field existed. -func codexMcpTokenEnvVar(name string) string { - if name == SamMcpServerName { - return "SAM_MCP_TOKEN" - } - if suffix, ok := strings.CutPrefix(name, SamMcpServerName+"-"); ok && isAllDigits(suffix) { - return "SAM_MCP_TOKEN_" + suffix - } - return fmt.Sprintf("SAM_MCP_%s_TOKEN", McpServerEnvVarSuffix(name)) -} - -func isAllDigits(s string) bool { - if s == "" { - return false - } - for _, r := range s { - if r < '0' || r > '9' { - return false - } - } - return true -} - -func removeManagedCodexMcpBlock(existing string) string { - for { - start := strings.Index(existing, codexManagedMcpStartMarker) - if start == -1 { - return existing - } - endRel := strings.Index(existing[start:], codexManagedMcpEndMarker) - if endRel == -1 { - return existing[:start] - } - end := start + endRel + len(codexManagedMcpEndMarker) - if end < len(existing) && existing[end] == '\n' { - end++ - } - existing = existing[:start] + existing[end:] - } -} - -func mergeManagedCodexMcpConfig(existing, managed string) string { - cleaned := removeManagedCodexMcpBlock(existing) - managed = strings.TrimSpace(managed) - managedTopLevelKeys := codexTopLevelAssignmentKeys(managed) - if len(managedTopLevelKeys) > 0 { - lines := strings.Split(cleaned, "\n") - filtered := lines[:0] - atTopLevel := true - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") { - atTopLevel = false - } - if atTopLevel { - if key, ok := codexAssignmentKey(trimmed); ok && managedTopLevelKeys[key] { - continue - } - } - filtered = append(filtered, line) - } - cleaned = strings.Join(filtered, "\n") - } - cleaned = strings.TrimRight(cleaned, "\n") - - switch { - case cleaned == "" && managed == "": - return "" - case cleaned == "": - return managed + "\n" - case managed == "": - return cleaned + "\n" - default: - lines := strings.Split(cleaned, "\n") - firstTable := len(lines) - for i, line := range lines { - if strings.HasPrefix(strings.TrimSpace(line), "[") { - firstTable = i - break - } - } - topLevel := strings.TrimSpace(strings.Join(lines[:firstTable], "\n")) - tables := strings.TrimSpace(strings.Join(lines[firstTable:], "\n")) - sections := make([]string, 0, 3) - if topLevel != "" { - sections = append(sections, topLevel) - } - sections = append(sections, managed) - if tables != "" { - sections = append(sections, tables) - } - return strings.Join(sections, "\n\n") + "\n" - } -} - -func codexTopLevelAssignmentKeys(config string) map[string]bool { - keys := make(map[string]bool) - for _, line := range strings.Split(config, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") { - break - } - if key, ok := codexAssignmentKey(trimmed); ok { - keys[key] = true - } - } - return keys -} - -func codexAssignmentKey(line string) (string, bool) { - if line == "" || strings.HasPrefix(line, "#") { - return "", false - } - var assignment map[string]any - if err := toml.Unmarshal([]byte(line), &assignment); err != nil || len(assignment) != 1 { - return "", false - } - for key := range assignment { - return key, true - } - return "", false -} - -func codexProxyProviderConfigFromCredential(cred *agentCredential, callbackToken string) *codexProxyProviderConfig { - if cred == nil || cred.inferenceConfig == nil { - return nil - } - // Auth-file credentials (OAuth tokens) use auth.json injection, not env-var-based - // proxy providers. Generating a proxy provider config here would produce a - // config.toml entry with env_key = "OPENAI_API_KEY" that is never set, - // causing Codex to crash immediately. - if cred.credentialKind == "oauth-token" { - return nil - } - if cred.inferenceConfig.Provider != "openai-proxy" && cred.inferenceConfig.Provider != "openai-passthrough" { - return nil - } - baseURL := strings.ReplaceAll(cred.inferenceConfig.BaseURL, "{wstoken}", callbackToken) - if baseURL == "" || strings.ContainsAny(baseURL, "\n\r") { - return nil - } - model := cred.inferenceConfig.Model - if strings.ContainsAny(model, "\n\r") { - model = "" - } - return &codexProxyProviderConfig{baseURL: baseURL, model: model} -} - -func generateCodexProxyProviderConfig(config *codexProxyProviderConfig) string { - if config == nil { - return "" - } - - var b strings.Builder - b.WriteString("# SAM-managed Codex provider for proxy-backed sessions.\n") - if config.model != "" { - b.WriteString(fmt.Sprintf("model = \"%s\"\n", tomlEscapeBasicString(config.model))) - } - b.WriteString(fmt.Sprintf("model_provider = \"%s\"\n\n", codexProxyProviderID)) - b.WriteString(fmt.Sprintf("[model_providers.%s]\n", codexProxyProviderID)) - b.WriteString("name = \"SAM OpenAI Proxy\"\n") - b.WriteString(fmt.Sprintf("base_url = \"%s\"\n", tomlEscapeBasicString(config.baseURL))) - b.WriteString(fmt.Sprintf("env_key = \"%s\"\n", codexProxyProviderEnvKey)) - b.WriteString("wire_api = \"responses\"\n\n") - return b.String() -} - func normalizeAgentEffort(effort string) string { trimmed := strings.TrimSpace(effort) switch trimmed { @@ -1376,188 +1171,6 @@ func normalizeAgentEffort(effort string) string { } } -func normalizeCodexEffort(effort string) string { - trimmed := strings.TrimSpace(effort) - switch trimmed { - case "low", "medium", "high", "xhigh": - return trimmed - default: - return "" - } -} - -// generateCodexMcpConfig produces a managed TOML block for Codex MCP server -// configuration plus the environment variables referenced by -// bearer_token_env_var. Codex natively supports streamable HTTP MCP servers -// via ~/.codex/config.toml. -func generateCodexMcpConfig(mcpServers []McpServerEntry, proxyProvider *codexProxyProviderConfig, effort string) (string, []string) { - providerConfig := generateCodexProxyProviderConfig(proxyProvider) - codexEffort := normalizeCodexEffort(effort) - validServers := make([]McpServerEntry, 0, len(mcpServers)) - for i, server := range mcpServers { - if strings.ContainsAny(server.URL, "\n\r") || strings.ContainsAny(server.Token, "\n\r") { - slog.Warn("Skipping Codex MCP server with control characters in URL or token", - "index", i, "url_length", len(server.URL)) - continue - } - validServers = append(validServers, server) - } - var config strings.Builder - envVars := make([]string, 0, len(validServers)) - - config.WriteString(codexManagedMcpStartMarker) - config.WriteString("\n# Added by SAM vm-agent for Codex ACP sessions.\n") - config.WriteString("sandbox_mode = \"danger-full-access\"\n") - config.WriteString("approval_policy = \"never\"\n") - if codexEffort != "" { - config.WriteString(fmt.Sprintf("model_reasoning_effort = \"%s\"\n", codexEffort)) - } - config.WriteString(providerConfig) - - // Names are resolved over validServers (post-filter) so the positional fallback matches - // the keys actually written; dropping a server renumbers the rest, which is the - // pre-existing behaviour. - names := ResolveMcpServerNames(validServers) - for i, server := range validServers { - name := names[i] - config.WriteString(fmt.Sprintf("[mcp_servers.%s]\n", name)) - config.WriteString(fmt.Sprintf("url = \"%s\"\n", tomlEscapeBasicString(server.URL))) - if server.Token != "" { - tokenEnvVar := codexMcpTokenEnvVar(name) - config.WriteString(fmt.Sprintf("bearer_token_env_var = \"%s\"\n", tokenEnvVar)) - envVars = append(envVars, fmt.Sprintf("%s=%s", tokenEnvVar, server.Token)) - } - config.WriteString("\n") - } - - config.WriteString(codexManagedMcpEndMarker) - config.WriteString("\n") - return config.String(), envVars -} - -// vibeDefaultActiveModel is the model alias used when no user model override -// is configured. Defaults to Mistral Large (their most capable model). -// Override at deployment via VIBE_DEFAULT_ACTIVE_MODEL env var. -var vibeDefaultActiveModel = func() string { - if v := os.Getenv("VIBE_DEFAULT_ACTIVE_MODEL"); v != "" { - return v - } - return "mistral-large" -}() - -// sanitizeVibeModelAlias validates and sanitizes a model alias string to -// prevent TOML injection. Aliases must be alphanumeric with hyphens only. -// Returns the sanitized alias, or the default if the input is invalid. -func sanitizeVibeModelAlias(alias string) string { - if alias == "" { - return vibeDefaultActiveModel - } - for _, c := range alias { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') { - slog.Warn("Invalid Vibe model alias, falling back to default", - "requested", alias, "default", vibeDefaultActiveModel) - return vibeDefaultActiveModel - } - } - return alias -} - -// vibeBuiltinAliases lists the aliases that are always defined in the -// generated config. If the user selects one of these, no extra entry is needed. -var vibeBuiltinAliases = map[string]bool{ - "mistral-large": true, - "devstral-2": true, - "codestral": true, -} - -// generateVibeConfig produces a TOML config for ~/.vibe/config.toml that -// defines model aliases so users can select models beyond the built-in -// defaults. The activeModel parameter sets which alias is active. -// If activeModel doesn't match a built-in alias, a dynamic [[models]] entry -// is generated using the value as both the alias and the Mistral API model name. -// This allows the UI model catalog to use raw Mistral API IDs without needing -// vm-agent changes when new models are released. -// If mcpServers is provided, it includes MCP server configurations for tool discovery. -func generateVibeConfig(activeModel string, mcpServers []McpServerEntry) string { - activeModel = sanitizeVibeModelAlias(activeModel) - - config := fmt.Sprintf(`# Generated by SAM vm-agent — do not edit manually. -# This config defines model aliases and MCP servers for Mistral Vibe ACP sessions. - -active_model = "%s" - -# Mistral Large — most capable model -[[models]] -name = "mistral-large-latest" -provider = "mistral" -alias = "mistral-large" -temperature = 0.2 - -# Devstral 2 — default coding model -[[models]] -name = "mistral-vibe-cli-latest" -provider = "mistral" -alias = "devstral-2" -temperature = 0.2 - -# Codestral — code-specialized model -[[models]] -name = "codestral-latest" -provider = "mistral" -alias = "codestral" -temperature = 0.2 -`, activeModel) - - // If the active model isn't a built-in alias, generate a dynamic entry - // using the model ID as both alias and API name. This lets the UI catalog - // list raw Mistral API model IDs (e.g. "mistral-medium-3-5-2604") without - // requiring vm-agent updates for each new model. - if activeModel != vibeDefaultActiveModel && !vibeBuiltinAliases[activeModel] { - config += fmt.Sprintf(` -# Dynamic model entry (from SAM user settings) -[[models]] -name = "%s" -provider = "mistral" -alias = "%s" -temperature = 0.2 -`, activeModel, activeModel) - } - - // Append MCP server configurations if provided. - // - // Names come from the shared resolver rather than a local "sam-mcp-%d" — this call site - // used to always index-suffix, so a single server was named "sam-mcp-0" here while ACP - // and Codex called the same server "sam-mcp". Using the shared resolver fixes that - // divergence as well as honouring user-chosen names. - names := ResolveMcpServerNames(mcpServers) - for i, server := range mcpServers { - // Skip entries with control characters that would corrupt TOML - if strings.ContainsAny(server.URL, "\n\r") || strings.ContainsAny(server.Token, "\n\r") { - slog.Warn("Skipping MCP server with control characters in URL or token", - "index", i, "url_length", len(server.URL)) - continue - } - safeURL := tomlEscapeBasicString(server.URL) - config += fmt.Sprintf("\n[[mcp_servers]]\nname = \"%s\"\ntransport = \"http\"\nurl = \"%s\"\n", names[i], safeURL) - if server.Token != "" { - safeToken := tomlEscapeBasicString(server.Token) - config += fmt.Sprintf("headers = { Authorization = \"Bearer %s\" }\n", safeToken) - } - } - - return config -} - -// resolveVibeActiveModel determines which model alias to use for a Mistral -// Vibe session. Returns the user's model override if set, otherwise the -// platform default (Mistral Large). -func resolveVibeActiveModel(settings *agentSettingsPayload) string { - if settings != nil && settings.Model != "" { - return settings.Model - } - return vibeDefaultActiveModel -} - // Default values for OpenCode provider configuration. // Each has an env-var override so operators can change them without rebuilding the binary. const ( @@ -1744,16 +1357,6 @@ func sanitizeModelAlias(model string) string { return model } -// writeVibeConfigToContainer writes a .vibe/config.toml into the container -// for the Mistral Vibe agent. This is necessary because VIBE_ACTIVE_MODEL -// expects a config alias (not a raw API model name), and only "devstral-2" -// is defined by default. If mcpServers is provided, it includes MCP server -// configurations for tool discovery. -func writeVibeConfigToContainer(ctx context.Context, containerID, user, activeModel string, mcpServers []McpServerEntry) error { - config := generateVibeConfig(activeModel, mcpServers) - return writeAuthFileToContainer(ctx, containerID, user, ".vibe/config.toml", config) -} - // readOptionalFileFromContainer reads a file inside a container if it exists, // returning an empty string when the file is absent. func readOptionalFileFromContainer(ctx context.Context, containerID, user, filePath string) (string, error) { @@ -1802,62 +1405,3 @@ func readOptionalFileFromContainer(ctx context.Context, containerID, user, fileP } return buf.String(), nil } - -// writeCodexConfigToContainer updates ~/.codex/config.toml with a SAM-managed -// MCP block. Existing non-SAM config is preserved, and prior SAM-managed blocks -// are replaced so resumed or restarted sessions do not accumulate stale tokens. -func writeCodexConfigToContainer(ctx context.Context, containerID, user string, mcpServers []McpServerEntry, proxyProvider *codexProxyProviderConfig, effort string) ([]string, error) { - managedConfig, envVars := generateCodexMcpConfig(mcpServers, proxyProvider, effort) - existingConfig, err := readOptionalFileFromContainer(ctx, containerID, user, ".codex/config.toml") - if err != nil { - return nil, err - } - mergedConfig := mergeManagedCodexMcpConfig(existingConfig, managedConfig) - if mergedConfig == "" { - return nil, nil - } - if err := writeAuthFileToContainer(ctx, containerID, user, ".codex/config.toml", mergedConfig); err != nil { - return nil, err - } - return envVars, nil -} - -// writeCodexConfigLocally updates ~/.codex/config.toml on the local filesystem -// with a SAM-managed MCP block. Used for standalone/cf-container sessions where -// no Docker container is available. Mirrors writeCodexConfigToContainer. -func writeCodexConfigLocally(mcpServers []McpServerEntry, proxyProvider *codexProxyProviderConfig, effort string) ([]string, error) { - managedConfig, envVars := generateCodexMcpConfig(mcpServers, proxyProvider, effort) - - configPath, err := resolveLocalAuthFileTargetPath(".codex/config.toml") - if err != nil { - return nil, fmt.Errorf("resolve codex config path: %w", err) - } - - var existingConfig string - data, err := os.ReadFile(configPath) - if err == nil { - existingConfig = string(data) - } else if !os.IsNotExist(err) { - return nil, fmt.Errorf("read existing codex config: %w", err) - } - - mergedConfig := mergeManagedCodexMcpConfig(existingConfig, managedConfig) - if mergedConfig == "" { - return nil, nil - } - - dir := filepath.Dir(configPath) - if err := os.MkdirAll(dir, 0o700); err != nil { - return nil, fmt.Errorf("create codex config directory: %w", err) - } - if err := os.Chmod(dir, 0o700); err != nil { - return nil, fmt.Errorf("chmod codex config directory: %w", err) - } - if err := os.WriteFile(configPath, []byte(mergedConfig), 0o600); err != nil { - return nil, fmt.Errorf("write codex config.toml: %w", err) - } - if err := os.Chmod(configPath, 0o600); err != nil { - return nil, fmt.Errorf("chmod codex config.toml: %w", err) - } - return envVars, nil -} diff --git a/packages/vm-agent/internal/acp/mcp_servers.go b/packages/vm-agent/internal/acp/mcp_servers.go new file mode 100644 index 000000000..2c065e2c8 --- /dev/null +++ b/packages/vm-agent/internal/acp/mcp_servers.go @@ -0,0 +1,90 @@ +package acp + +import ( + acpsdk "github.com/coder/acp-go-sdk" +) + +const ( + ampMcpRemotePackage = "mcp-remote@0.1.38" + ampMcpTokenEnvVar = "SAM_MCP_TOKEN" +) + +// McpServerEntry is a lightweight MCP server config passed from the control +// plane for injection into ACP sessions. It represents an HTTP MCP server with +// optional bearer token authentication. +// +// An empty Token means "no auth" — several MCP providers issue pre-signed URLs +// that carry the credential in the URL itself. Every harness below omits the +// auth header in that case. +// +// Name is the agent-visible server name; tools are namespaced by it. It is +// optional because a control plane older than this field does not send one, in +// which case ResolveMcpServerNames falls back to the legacy positional scheme. +type McpServerEntry struct { + URL string `json:"url"` + Token string `json:"token"` + Name string `json:"name,omitempty"` +} + +// buildAcpMcpServers converts McpServerEntry configs into acpsdk.McpServer +// entries for NewSession/LoadSession requests. +func buildAcpMcpServers(entries []McpServerEntry, agentType string) []acpsdk.McpServer { + if len(entries) == 0 { + return []acpsdk.McpServer{} + } + servers := make([]acpsdk.McpServer, 0, len(entries)) + names := ResolveMcpServerNames(entries) + for i, e := range entries { + name := names[i] + if agentType == "amp" { + servers = append(servers, buildAmpMcpServer(name, e)) + continue + } + var headers []acpsdk.HttpHeader + if e.Token != "" { + headers = append(headers, acpsdk.HttpHeader{ + Name: "Authorization", + Value: "Bearer " + e.Token, + }) + } + servers = append(servers, acpsdk.McpServer{ + Http: &acpsdk.McpServerHttpInline{ + Name: name, + // Type is set to "http" by McpServer.MarshalJSON regardless of this field. + Url: e.URL, + Headers: headers, + }, + }) + } + return servers +} + +// KNOWN EXPOSURE (idea 01M0QQ7PTBDPG0DVR10XMKB679): entry.URL is passed as a positional CLI +// argument, so it is visible in /proc//cmdline to anything running as the same container +// user. That is fine for SAM's own static endpoint but NOT for a bring-your-own connection, +// where the URL can itself be a credential (pre-signed MCP URLs). The token below is already +// kept out of argv for exactly this reason; the URL should get the same treatment once it is +// verified how mcp-remote accepts a URL from the environment. +func buildAmpMcpServer(name string, entry McpServerEntry) acpsdk.McpServer { + var env []acpsdk.EnvVariable + args := []string{"-y", ampMcpRemotePackage, entry.URL} + if entry.Token != "" { + env = append(env, acpsdk.EnvVariable{ + Name: ampMcpTokenEnvVar, + Value: entry.Token, + }) + // mcp-remote expands ${ENV_VAR} references in --header values internally. + // The token is passed via env var (not in CLI args) to avoid /proc visibility. + args = append(args, "--header", "Authorization:Bearer ${"+ampMcpTokenEnvVar+"}") + } + args = append(args, "--silent") + + return acpsdk.McpServer{ + Stdio: &acpsdk.McpServerStdio{ + Name: name, + Command: "npx", + Args: args, + Env: env, + }, + } +} diff --git a/packages/vm-agent/internal/acp/session_host.go b/packages/vm-agent/internal/acp/session_host.go index a9582feec..9c93cd6e6 100644 --- a/packages/vm-agent/internal/acp/session_host.go +++ b/packages/vm-agent/internal/acp/session_host.go @@ -62,78 +62,10 @@ const ( defaultControlPlaneHTTPTimeout = 30 * time.Second ) -const ( - ampMcpRemotePackage = "mcp-remote@0.1.38" - ampMcpTokenEnvVar = "SAM_MCP_TOKEN" -) - // DefaultStderrBufferBytes is the default maximum agent stderr captured for // crash reports. Override via ACP_STDERR_BUFFER_BYTES. const DefaultStderrBufferBytes = 4096 -// buildAcpMcpServers converts McpServerEntry configs into acpsdk.McpServer -// entries for NewSession/LoadSession requests. -func buildAcpMcpServers(entries []McpServerEntry, agentType string) []acpsdk.McpServer { - if len(entries) == 0 { - return []acpsdk.McpServer{} - } - servers := make([]acpsdk.McpServer, 0, len(entries)) - names := ResolveMcpServerNames(entries) - for i, e := range entries { - name := names[i] - if agentType == "amp" { - servers = append(servers, buildAmpMcpServer(name, e)) - continue - } - var headers []acpsdk.HttpHeader - if e.Token != "" { - headers = append(headers, acpsdk.HttpHeader{ - Name: "Authorization", - Value: "Bearer " + e.Token, - }) - } - servers = append(servers, acpsdk.McpServer{ - Http: &acpsdk.McpServerHttpInline{ - Name: name, - // Type is set to "http" by McpServer.MarshalJSON regardless of this field. - Url: e.URL, - Headers: headers, - }, - }) - } - return servers -} - -// KNOWN EXPOSURE (idea 01M0QQ7PTBDPG0DVR10XMKB679): entry.URL is passed as a positional CLI -// argument, so it is visible in /proc//cmdline to anything running as the same container -// user. That is fine for SAM's own static endpoint but NOT for a bring-your-own connection, -// where the URL can itself be a credential (pre-signed MCP URLs). The token below is already -// kept out of argv for exactly this reason; the URL should get the same treatment once it is -// verified how mcp-remote accepts a URL from the environment. -func buildAmpMcpServer(name string, entry McpServerEntry) acpsdk.McpServer { - var env []acpsdk.EnvVariable - args := []string{"-y", ampMcpRemotePackage, entry.URL} - if entry.Token != "" { - env = append(env, acpsdk.EnvVariable{ - Name: ampMcpTokenEnvVar, - Value: entry.Token, - }) - // mcp-remote expands ${ENV_VAR} references in --header values internally. - // The token is passed via env var (not in CLI args) to avoid /proc visibility. - args = append(args, "--header", "Authorization:Bearer ${"+ampMcpTokenEnvVar+"}") - } - args = append(args, "--silent") - - return acpsdk.McpServer{ - Stdio: &acpsdk.McpServerStdio{ - Name: name, - Command: "npx", - Args: args, - Env: env, - }, - } -} - // DefaultMessageBufferSize is the default maximum number of messages buffered // per session for late-join replay. Override via ACP_MESSAGE_BUFFER_SIZE. const DefaultMessageBufferSize = 5000 diff --git a/packages/vm-agent/internal/acp/vibe_config.go b/packages/vm-agent/internal/acp/vibe_config.go new file mode 100644 index 000000000..9d49f6287 --- /dev/null +++ b/packages/vm-agent/internal/acp/vibe_config.go @@ -0,0 +1,142 @@ +package acp + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" +) + +// vibeDefaultActiveModel is the model alias used when no user model override +// is configured. Defaults to Mistral Large (their most capable model). +// Override at deployment via VIBE_DEFAULT_ACTIVE_MODEL env var. +var vibeDefaultActiveModel = func() string { + if v := os.Getenv("VIBE_DEFAULT_ACTIVE_MODEL"); v != "" { + return v + } + return "mistral-large" +}() + +// sanitizeVibeModelAlias validates and sanitizes a model alias string to +// prevent TOML injection. Aliases must be alphanumeric with hyphens only. +// Returns the sanitized alias, or the default if the input is invalid. +func sanitizeVibeModelAlias(alias string) string { + if alias == "" { + return vibeDefaultActiveModel + } + for _, c := range alias { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') { + slog.Warn("Invalid Vibe model alias, falling back to default", + "requested", alias, "default", vibeDefaultActiveModel) + return vibeDefaultActiveModel + } + } + return alias +} + +// vibeBuiltinAliases lists the aliases that are always defined in the +// generated config. If the user selects one of these, no extra entry is needed. +var vibeBuiltinAliases = map[string]bool{ + "mistral-large": true, + "devstral-2": true, + "codestral": true, +} + +// generateVibeConfig produces a TOML config for ~/.vibe/config.toml that +// defines model aliases so users can select models beyond the built-in +// defaults. The activeModel parameter sets which alias is active. +// If activeModel doesn't match a built-in alias, a dynamic [[models]] entry +// is generated using the value as both the alias and the Mistral API model name. +// This allows the UI model catalog to use raw Mistral API IDs without needing +// vm-agent changes when new models are released. +// If mcpServers is provided, it includes MCP server configurations for tool discovery. +func generateVibeConfig(activeModel string, mcpServers []McpServerEntry) string { + activeModel = sanitizeVibeModelAlias(activeModel) + + config := fmt.Sprintf(`# Generated by SAM vm-agent — do not edit manually. +# This config defines model aliases and MCP servers for Mistral Vibe ACP sessions. + +active_model = "%s" + +# Mistral Large — most capable model +[[models]] +name = "mistral-large-latest" +provider = "mistral" +alias = "mistral-large" +temperature = 0.2 + +# Devstral 2 — default coding model +[[models]] +name = "mistral-vibe-cli-latest" +provider = "mistral" +alias = "devstral-2" +temperature = 0.2 + +# Codestral — code-specialized model +[[models]] +name = "codestral-latest" +provider = "mistral" +alias = "codestral" +temperature = 0.2 +`, activeModel) + + // If the active model isn't a built-in alias, generate a dynamic entry + // using the model ID as both alias and API name. This lets the UI catalog + // list raw Mistral API model IDs (e.g. "mistral-medium-3-5-2604") without + // requiring vm-agent updates for each new model. + if activeModel != vibeDefaultActiveModel && !vibeBuiltinAliases[activeModel] { + config += fmt.Sprintf(` +# Dynamic model entry (from SAM user settings) +[[models]] +name = "%s" +provider = "mistral" +alias = "%s" +temperature = 0.2 +`, activeModel, activeModel) + } + + // Append MCP server configurations if provided. + // + // Names come from the shared resolver rather than a local "sam-mcp-%d" — this call site + // used to always index-suffix, so a single server was named "sam-mcp-0" here while ACP + // and Codex called the same server "sam-mcp". Using the shared resolver fixes that + // divergence as well as honouring user-chosen names. + names := ResolveMcpServerNames(mcpServers) + for i, server := range mcpServers { + // Skip entries with control characters that would corrupt TOML + if strings.ContainsAny(server.URL, "\n\r") || strings.ContainsAny(server.Token, "\n\r") { + slog.Warn("Skipping MCP server with control characters in URL or token", + "index", i, "url_length", len(server.URL)) + continue + } + safeURL := tomlEscapeBasicString(server.URL) + config += fmt.Sprintf("\n[[mcp_servers]]\nname = \"%s\"\ntransport = \"http\"\nurl = \"%s\"\n", names[i], safeURL) + if server.Token != "" { + safeToken := tomlEscapeBasicString(server.Token) + config += fmt.Sprintf("headers = { Authorization = \"Bearer %s\" }\n", safeToken) + } + } + + return config +} + +// resolveVibeActiveModel determines which model alias to use for a Mistral +// Vibe session. Returns the user's model override if set, otherwise the +// platform default (Mistral Large). +func resolveVibeActiveModel(settings *agentSettingsPayload) string { + if settings != nil && settings.Model != "" { + return settings.Model + } + return vibeDefaultActiveModel +} + +// writeVibeConfigToContainer writes a .vibe/config.toml into the container +// for the Mistral Vibe agent. This is necessary because VIBE_ACTIVE_MODEL +// expects a config alias (not a raw API model name), and only "devstral-2" +// is defined by default. If mcpServers is provided, it includes MCP server +// configurations for tool discovery. +func writeVibeConfigToContainer(ctx context.Context, containerID, user, activeModel string, mcpServers []McpServerEntry) error { + config := generateVibeConfig(activeModel, mcpServers) + return writeAuthFileToContainer(ctx, containerID, user, ".vibe/config.toml", config) +} diff --git a/packages/vm-agent/internal/persistence/session_mcp_servers.go b/packages/vm-agent/internal/persistence/session_mcp_servers.go new file mode 100644 index 000000000..9e50c67c6 --- /dev/null +++ b/packages/vm-agent/internal/persistence/session_mcp_servers.go @@ -0,0 +1,143 @@ +package persistence + +import ( + "database/sql" + "fmt" +) + +// McpServer represents a persisted MCP server config for an ACP session. +// It mirrors acp.McpServerEntry and is stored independently to avoid an +// import cycle between the persistence and acp packages. +type McpServer struct { + URL string `json:"url"` + Token string `json:"token"` + // Name is the agent-visible server name. Empty for rows written before the + // name column existed; callers fall back to positional naming. + Name string `json:"name,omitempty"` +} + +// migrateV5 creates the session_mcp_servers table for persisting MCP server +// configs registered per ACP session so they survive VM agent restarts. +func migrateV5(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS session_mcp_servers ( + workspace_id TEXT NOT NULL, + session_id TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + url TEXT NOT NULL, + token TEXT NOT NULL DEFAULT '', + PRIMARY KEY (workspace_id, session_id, sort_order) + ); + CREATE INDEX IF NOT EXISTS idx_session_mcp_workspace ON session_mcp_servers(workspace_id); + `) + return err +} + +// migrateV12 adds the agent-visible name for injected MCP servers. +// +// Additive column with a default so pre-existing rows remain valid: an empty name means +// "unnamed", and acp.ResolveMcpServerNames falls back to the legacy positional scheme for +// those, preserving the behaviour of sessions registered before this column existed. +func migrateV12(db *sql.DB) error { + _, err := db.Exec(` + ALTER TABLE session_mcp_servers ADD COLUMN name TEXT NOT NULL DEFAULT ''; + `) + return err +} + +// UpsertSessionMcpServers replaces all MCP server entries for a session. +// Passing an empty slice removes all servers for the session without error. +// This is intentionally a full replace (delete + insert) so that the +// persisted list always exactly mirrors the in-memory sessionMcpServers map. +func (s *Store) UpsertSessionMcpServers(workspaceID, sessionID string, servers []McpServer) error { + s.mu.Lock() + defer s.mu.Unlock() + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("upsert session mcp servers: begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + if _, err := tx.Exec( + "DELETE FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ?", + workspaceID, sessionID, + ); err != nil { + return fmt.Errorf("upsert session mcp servers: delete old rows: %w", err) + } + + for i, srv := range servers { + if _, err := tx.Exec( + "INSERT INTO session_mcp_servers (workspace_id, session_id, sort_order, url, token, name) VALUES (?, ?, ?, ?, ?, ?)", + workspaceID, sessionID, i, srv.URL, srv.Token, srv.Name, + ); err != nil { + return fmt.Errorf("upsert session mcp servers: insert row %d: %w", i, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("upsert session mcp servers: commit: %w", err) + } + return nil +} + +// GetSessionMcpServers returns the persisted MCP servers for a session, +// ordered by sort_order. Returns an empty (non-nil) slice when none exist. +func (s *Store) GetSessionMcpServers(workspaceID, sessionID string) ([]McpServer, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + rows, err := s.db.Query( + "SELECT url, token, name FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ? ORDER BY sort_order ASC", + workspaceID, sessionID, + ) + if err != nil { + return nil, fmt.Errorf("get session mcp servers: %w", err) + } + defer rows.Close() + + servers := []McpServer{} + for rows.Next() { + var srv McpServer + if err := rows.Scan(&srv.URL, &srv.Token, &srv.Name); err != nil { + return nil, fmt.Errorf("get session mcp servers: scan: %w", err) + } + servers = append(servers, srv) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("get session mcp servers: iterate: %w", err) + } + return servers, nil +} + +// DeleteSessionMcpServers removes all MCP server entries for a specific +// session. It is a no-op when no entries exist. +func (s *Store) DeleteSessionMcpServers(workspaceID, sessionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + _, err := s.db.Exec( + "DELETE FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ?", + workspaceID, sessionID, + ) + if err != nil { + return fmt.Errorf("delete session mcp servers: %w", err) + } + return nil +} + +// DeleteWorkspaceMcpServers removes all MCP server entries for every session +// belonging to the given workspace. Called during workspace cleanup. +func (s *Store) DeleteWorkspaceMcpServers(workspaceID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + _, err := s.db.Exec( + "DELETE FROM session_mcp_servers WHERE workspace_id = ?", + workspaceID, + ) + if err != nil { + return fmt.Errorf("delete workspace mcp servers: %w", err) + } + return nil +} diff --git a/packages/vm-agent/internal/persistence/store.go b/packages/vm-agent/internal/persistence/store.go index 0ec23a2ea..7995c24ad 100644 --- a/packages/vm-agent/internal/persistence/store.go +++ b/packages/vm-agent/internal/persistence/store.go @@ -18,17 +18,6 @@ import ( _ "modernc.org/sqlite" ) -// McpServer represents a persisted MCP server config for an ACP session. -// It mirrors acp.McpServerEntry and is stored independently to avoid an -// import cycle between the persistence and acp packages. -type McpServer struct { - URL string `json:"url"` - Token string `json:"token"` - // Name is the agent-visible server name. Empty for rows written before the - // name column existed; callers fall back to positional naming. - Name string `json:"name,omitempty"` -} - // WorkspaceMetadata represents persisted workspace metadata that survives // agent restarts. This ensures the correct container working directory, // repository name, and other runtime state can be recovered without @@ -225,18 +214,6 @@ func migrateV11(db *sql.DB) error { return err } -// migrateV12 adds the agent-visible name for injected MCP servers. -// -// Additive column with a default so pre-existing rows remain valid: an empty name means -// "unnamed", and acp.ResolveMcpServerNames falls back to the legacy positional scheme for -// those, preserving the behaviour of sessions registered before this column existed. -func migrateV12(db *sql.DB) error { - _, err := db.Exec(` - ALTER TABLE session_mcp_servers ADD COLUMN name TEXT NOT NULL DEFAULT ''; - `) - return err -} - // migrateV13 adds the ProjectData chat session ID to workspace metadata so // VM-agent-local snapshot triggers can survive process restarts. func migrateV13(db *sql.DB) error { @@ -584,23 +561,6 @@ func (s *Store) TabCount(workspaceID string) (int, error) { return count, nil } -// migrateV5 creates the session_mcp_servers table for persisting MCP server -// configs registered per ACP session so they survive VM agent restarts. -func migrateV5(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS session_mcp_servers ( - workspace_id TEXT NOT NULL, - session_id TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0, - url TEXT NOT NULL, - token TEXT NOT NULL DEFAULT '', - PRIMARY KEY (workspace_id, session_id, sort_order) - ); - CREATE INDEX IF NOT EXISTS idx_session_mcp_workspace ON session_mcp_servers(workspace_id); - `) - return err -} - // migrateV6 adds lightweight column to workspace_metadata for persisting // the workspace profile (lightweight vs full) across agent restarts. func migrateV6(db *sql.DB) error { @@ -635,100 +595,3 @@ func migrateV10(db *sql.DB) error { `) return err } - -// UpsertSessionMcpServers replaces all MCP server entries for a session. -// Passing an empty slice removes all servers for the session without error. -// This is intentionally a full replace (delete + insert) so that the -// persisted list always exactly mirrors the in-memory sessionMcpServers map. -func (s *Store) UpsertSessionMcpServers(workspaceID, sessionID string, servers []McpServer) error { - s.mu.Lock() - defer s.mu.Unlock() - - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("upsert session mcp servers: begin tx: %w", err) - } - defer tx.Rollback() //nolint:errcheck - - if _, err := tx.Exec( - "DELETE FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ?", - workspaceID, sessionID, - ); err != nil { - return fmt.Errorf("upsert session mcp servers: delete old rows: %w", err) - } - - for i, srv := range servers { - if _, err := tx.Exec( - "INSERT INTO session_mcp_servers (workspace_id, session_id, sort_order, url, token, name) VALUES (?, ?, ?, ?, ?, ?)", - workspaceID, sessionID, i, srv.URL, srv.Token, srv.Name, - ); err != nil { - return fmt.Errorf("upsert session mcp servers: insert row %d: %w", i, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("upsert session mcp servers: commit: %w", err) - } - return nil -} - -// GetSessionMcpServers returns the persisted MCP servers for a session, -// ordered by sort_order. Returns an empty (non-nil) slice when none exist. -func (s *Store) GetSessionMcpServers(workspaceID, sessionID string) ([]McpServer, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - rows, err := s.db.Query( - "SELECT url, token, name FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ? ORDER BY sort_order ASC", - workspaceID, sessionID, - ) - if err != nil { - return nil, fmt.Errorf("get session mcp servers: %w", err) - } - defer rows.Close() - - servers := []McpServer{} - for rows.Next() { - var srv McpServer - if err := rows.Scan(&srv.URL, &srv.Token, &srv.Name); err != nil { - return nil, fmt.Errorf("get session mcp servers: scan: %w", err) - } - servers = append(servers, srv) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("get session mcp servers: iterate: %w", err) - } - return servers, nil -} - -// DeleteSessionMcpServers removes all MCP server entries for a specific -// session. It is a no-op when no entries exist. -func (s *Store) DeleteSessionMcpServers(workspaceID, sessionID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - _, err := s.db.Exec( - "DELETE FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ?", - workspaceID, sessionID, - ) - if err != nil { - return fmt.Errorf("delete session mcp servers: %w", err) - } - return nil -} - -// DeleteWorkspaceMcpServers removes all MCP server entries for every session -// belonging to the given workspace. Called during workspace cleanup. -func (s *Store) DeleteWorkspaceMcpServers(workspaceID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - _, err := s.db.Exec( - "DELETE FROM session_mcp_servers WHERE workspace_id = ?", - workspaceID, - ) - if err != nil { - return fmt.Errorf("delete workspace mcp servers: %w", err) - } - return nil -} diff --git a/packages/vm-agent/internal/server/mcp_servers.go b/packages/vm-agent/internal/server/mcp_servers.go new file mode 100644 index 000000000..acdded881 --- /dev/null +++ b/packages/vm-agent/internal/server/mcp_servers.go @@ -0,0 +1,63 @@ +package server + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/workspace/vm-agent/internal/acp" + "github.com/workspace/vm-agent/internal/persistence" +) + +func normalizeMcpServers(entries []acp.McpServerEntry) ([]acp.McpServerEntry, error) { + if len(entries) == 0 { + return nil, nil + } + normalized := make([]acp.McpServerEntry, len(entries)) + for i, srv := range entries { + u := strings.TrimSpace(srv.URL) + if u == "" { + return nil, fmt.Errorf("mcpServers[%d].url is required", i) + } + // The URL is a SECRET: providers such as Composio issue pre-signed MCP URLs with the + // credential in the path or query. This error propagates to the control plane, which + // stores it in tasks.error_message / agent_sessions.error_message — plaintext columns + // that any project member with task:read (including viewers) can read. So the message + // must name the index only, never the value. + isLocalhost := strings.HasPrefix(u, "http://localhost:") || strings.HasPrefix(u, "http://127.0.0.1:") + if !strings.HasPrefix(u, "https://") && !isLocalhost { + return nil, fmt.Errorf("mcpServers[%d].url must use HTTPS (or http:// on localhost/127.0.0.1 with an explicit port)", i) + } + // Every field must be copied explicitly: this rebuilds the struct, so a field added + // upstream and forgotten here is silently dropped rather than failing to compile. + normalized[i] = acp.McpServerEntry{URL: u, Token: srv.Token, Name: strings.TrimSpace(srv.Name)} + } + return normalized, nil +} + +func (s *Server) registerSessionMcpServers(workspaceID, sessionID string, entries []acp.McpServerEntry) { + if len(entries) == 0 { + return + } + + hostKey := workspaceID + ":" + sessionID + s.sessionHostMu.Lock() + s.sessionMcpServers[hostKey] = entries + s.sessionHostMu.Unlock() + + // Persist to SQLite so MCP servers survive VM agent restarts and + // are available even if a WebSocket creates the SessionHost first. + if s.store != nil { + persistEntries := make([]persistence.McpServer, len(entries)) + for i, srv := range entries { + persistEntries[i] = persistence.McpServer{URL: srv.URL, Token: srv.Token, Name: srv.Name} + } + if err := s.store.UpsertSessionMcpServers(workspaceID, sessionID, persistEntries); err != nil { + slog.Warn("Failed to persist MCP servers to SQLite", + "workspace", workspaceID, "session", sessionID, "error", err) + } + } + + slog.Info("MCP servers registered for agent session", + "workspace", workspaceID, "session", sessionID, "count", len(entries)) +} diff --git a/packages/vm-agent/internal/server/workspaces.go b/packages/vm-agent/internal/server/workspaces.go index 302aea1eb..3d3e053ce 100644 --- a/packages/vm-agent/internal/server/workspaces.go +++ b/packages/vm-agent/internal/server/workspaces.go @@ -1219,59 +1219,6 @@ func (s *Server) handleCreateAgentSession(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusCreated, session) } -func normalizeMcpServers(entries []acp.McpServerEntry) ([]acp.McpServerEntry, error) { - if len(entries) == 0 { - return nil, nil - } - normalized := make([]acp.McpServerEntry, len(entries)) - for i, srv := range entries { - u := strings.TrimSpace(srv.URL) - if u == "" { - return nil, fmt.Errorf("mcpServers[%d].url is required", i) - } - // The URL is a SECRET: providers such as Composio issue pre-signed MCP URLs with the - // credential in the path or query. This error propagates to the control plane, which - // stores it in tasks.error_message / agent_sessions.error_message — plaintext columns - // that any project member with task:read (including viewers) can read. So the message - // must name the index only, never the value. - isLocalhost := strings.HasPrefix(u, "http://localhost:") || strings.HasPrefix(u, "http://127.0.0.1:") - if !strings.HasPrefix(u, "https://") && !isLocalhost { - return nil, fmt.Errorf("mcpServers[%d].url must use HTTPS (or http:// on localhost/127.0.0.1 with an explicit port)", i) - } - // Every field must be copied explicitly: this rebuilds the struct, so a field added - // upstream and forgotten here is silently dropped rather than failing to compile. - normalized[i] = acp.McpServerEntry{URL: u, Token: srv.Token, Name: strings.TrimSpace(srv.Name)} - } - return normalized, nil -} - -func (s *Server) registerSessionMcpServers(workspaceID, sessionID string, entries []acp.McpServerEntry) { - if len(entries) == 0 { - return - } - - hostKey := workspaceID + ":" + sessionID - s.sessionHostMu.Lock() - s.sessionMcpServers[hostKey] = entries - s.sessionHostMu.Unlock() - - // Persist to SQLite so MCP servers survive VM agent restarts and - // are available even if a WebSocket creates the SessionHost first. - if s.store != nil { - persistEntries := make([]persistence.McpServer, len(entries)) - for i, srv := range entries { - persistEntries[i] = persistence.McpServer{URL: srv.URL, Token: srv.Token, Name: srv.Name} - } - if err := s.store.UpsertSessionMcpServers(workspaceID, sessionID, persistEntries); err != nil { - slog.Warn("Failed to persist MCP servers to SQLite", - "workspace", workspaceID, "session", sessionID, "error", err) - } - } - - slog.Info("MCP servers registered for agent session", - "workspace", workspaceID, "session", sessionID, "count", len(entries)) -} - // handleStartAgentSession starts an agent process and sends an initial prompt // for a previously created agent session. This is the missing link for task-driven // workspaces: the control plane creates the session (handleCreateAgentSession), From d5ac55a91c44d24a448af880009b197ab4497a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 06:20:54 +0000 Subject: [PATCH 03/18] feat(mcp): custom HTTP headers for bring-your-own MCP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP connections can now carry custom HTTP headers alongside (or instead of) a bearer token — Composio requires x-api-key / x-consumer-api-key. Control plane: - D1 0175 adds header_names (display projection) + encrypted_headers/headers_iv (AES-GCM sealed [{name,value}] list). Values are never returned; responses expose headerNames only. - services/mcp-connection-headers.ts owns the rules: [A-Za-z0-9_-]{1,64} names (mcp-remote + TOML bare-key safe), case-insensitive uniqueness, transport headers reserved, Authorization only when authType is none, trimmed values without control characters, configurable MAX_MCP_CONNECTION_HEADERS and MCP_CONNECTION_HEADER_VALUE_MAX_BYTES. - PATCH headers is the full desired set; an entry without a value keeps the stored value, so one header can be rotated without re-sending the others. - Resolution re-validates decrypted headers and skips a bad row (rules 41/50); node-agent sends headers only when present (rule 54). vm-agent: - McpServerEntry.Headers, validated in normalizeMcpServers, persisted (migrateV18) and restored on restart through one pair of converters. - ACP HTTP header list, Codex env_http_headers with SAM_MCP__HEADER__SECRET env vars, Vibe headers table, Amp mcp-remote --header name:${ENV}. Secret values never reach config.toml (Codex) or argv (Amp). Co-Authored-By: Claude Opus 5.5 --- .../0175_mcp_connection_headers.sql | 17 + apps/api/src/db/schema.ts | 16 +- apps/api/src/env.ts | 2 + apps/api/src/lib/utf8.ts | 6 + apps/api/src/routes/mcp-connections.ts | 6 +- apps/api/src/schemas/mcp-connections.ts | 7 +- apps/api/src/services/limits.ts | 14 + .../src/services/mcp-connection-headers.ts | 203 +++++++++++ .../src/services/mcp-connection-resolution.ts | 19 +- apps/api/src/services/mcp-connections.ts | 68 +++- apps/api/src/services/node-agent.ts | 26 +- packages/shared/src/constants/defaults.ts | 6 + packages/shared/src/constants/index.ts | 2 + .../fixtures/mcp-server-name-contract.json | 46 ++- packages/shared/src/types/index.ts | 6 + packages/shared/src/types/mcp-connection.ts | 87 ++++- packages/shared/src/vm-agent-contract.ts | 5 + .../unit/mcp-server-name-contract.test.ts | 30 +- .../vm-agent/internal/acp/codex_config.go | 25 +- .../vm-agent/internal/acp/gateway_test.go | 2 +- .../vm-agent/internal/acp/mcp_headers_test.go | 321 ++++++++++++++++++ .../acp/mcp_server_name_contract_test.go | 27 ++ packages/vm-agent/internal/acp/mcp_servers.go | 126 ++++++- packages/vm-agent/internal/acp/vibe_config.go | 11 +- .../persistence/session_mcp_servers.go | 63 +++- .../persistence/session_mcp_servers_test.go | 121 +++++++ .../vm-agent/internal/persistence/store.go | 1 + packages/vm-agent/internal/server/agent_ws.go | 5 +- .../vm-agent/internal/server/mcp_servers.go | 46 ++- .../server/mcp_servers_headers_test.go | 119 +++++++ 30 files changed, 1340 insertions(+), 93 deletions(-) create mode 100644 apps/api/src/db/migrations/0175_mcp_connection_headers.sql create mode 100644 apps/api/src/lib/utf8.ts create mode 100644 apps/api/src/services/mcp-connection-headers.ts create mode 100644 packages/vm-agent/internal/acp/mcp_headers_test.go create mode 100644 packages/vm-agent/internal/persistence/session_mcp_servers_test.go create mode 100644 packages/vm-agent/internal/server/mcp_servers_headers_test.go diff --git a/apps/api/src/db/migrations/0175_mcp_connection_headers.sql b/apps/api/src/db/migrations/0175_mcp_connection_headers.sql new file mode 100644 index 000000000..27b7ce0a9 --- /dev/null +++ b/apps/api/src/db/migrations/0175_mcp_connection_headers.sql @@ -0,0 +1,17 @@ +-- Custom HTTP headers for bring-your-own MCP servers. +-- +-- Some providers authenticate with an API-key header rather than a bearer token or a +-- pre-signed URL (Composio requires `x-api-key`). Headers are independent of `auth_type`. +-- +-- `encrypted_headers` is the AES-256-GCM ciphertext of the full JSON list of +-- `{ "name", "value" }` pairs, with its own IV. It is the only column injection reads. +-- `header_names` is a plaintext JSON array of the same names, written in the same statement, +-- so the list endpoint can show which headers are set without decrypting anything or ever +-- returning a value. +-- +-- Additive only: three new columns with defaults/NULL, so every existing row keeps its +-- current meaning (no headers). No DROP, no table rebuild. + +ALTER TABLE mcp_connections ADD COLUMN header_names TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE mcp_connections ADD COLUMN encrypted_headers TEXT; +ALTER TABLE mcp_connections ADD COLUMN headers_iv TEXT; diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index f568819aa..124fd48c9 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -2063,11 +2063,11 @@ export type NewSkillRow = typeof skills.$inferInsert; /** * Bring-your-own MCP servers injected into agent sessions alongside SAM's own `sam-mcp`. * - * Both the URL and the token are AES-256-GCM encrypted (`services/encryption.ts`). The URL is - * a secret because providers such as Composio issue pre-signed MCP URLs with the credential - * embedded in the path/query; `urlHost` is the display-only `scheme://host` the API returns - * instead. `projectId` NULL means personal scope; a project row overrides a personal row with - * the same name. + * The URL, the token and the custom headers are AES-256-GCM encrypted + * (`services/encryption.ts`). The URL is a secret because providers issue pre-signed MCP URLs + * with the credential embedded in the path/query; `urlHost` is the display-only + * `scheme://host` the API returns instead, as `headerNames` is for the headers. `projectId` + * NULL means personal scope; a project row overrides a personal row with the same name. */ export const mcpConnections = sqliteTable( 'mcp_connections', @@ -2089,6 +2089,12 @@ export const mcpConnections = sqliteTable( encryptedToken: text('encrypted_token'), /** AES-256-GCM IV (base64). Null when authType is 'none'. */ tokenIv: text('token_iv'), + /** Display-only JSON array of custom header names. Never the values. */ + headerNames: text('header_names').notNull().default('[]'), + /** AES-256-GCM ciphertext (base64) of the JSON `[{name, value}]` list. Null when none. */ + encryptedHeaders: text('encrypted_headers'), + /** AES-256-GCM IV (base64) for `encryptedHeaders`. Null when none. */ + headersIv: text('headers_iv'), enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), createdAt: text('created_at') .notNull() diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 544e686cc..9fb269c9e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -385,6 +385,8 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv { MAX_MCP_CONNECTIONS_PER_SCOPE?: string; MCP_CONNECTION_URL_MAX_BYTES?: string; MCP_CONNECTION_TOKEN_MAX_BYTES?: string; + MAX_MCP_CONNECTION_HEADERS?: string; + MCP_CONNECTION_HEADER_VALUE_MAX_BYTES?: string; TASK_CALLBACK_TIMEOUT_MS?: string; TASK_CALLBACK_RETRY_MAX_ATTEMPTS?: string; NODE_HEARTBEAT_STALE_SECONDS?: string; diff --git a/apps/api/src/lib/utf8.ts b/apps/api/src/lib/utf8.ts new file mode 100644 index 000000000..3d8964d51 --- /dev/null +++ b/apps/api/src/lib/utf8.ts @@ -0,0 +1,6 @@ +const encoder = new TextEncoder(); + +/** Size of `value` in UTF-8 bytes — what byte-denominated limits are measured in. */ +export function utf8ByteLength(value: string): number { + return encoder.encode(value).length; +} diff --git a/apps/api/src/routes/mcp-connections.ts b/apps/api/src/routes/mcp-connections.ts index eb9e206ce..54b6b6a97 100644 --- a/apps/api/src/routes/mcp-connections.ts +++ b/apps/api/src/routes/mcp-connections.ts @@ -9,7 +9,7 @@ * has `secret:read` but not `secret:write`), because a connection stores a credential that * every member's agents will then use. * - * No read path returns the URL or the token; see `toMcpConnectionResponse`. + * No read path returns the URL, the token or a header value; see `toMcpConnectionResponse`. */ import { drizzle } from 'drizzle-orm/d1'; import { type Context, Hono } from 'hono'; @@ -40,6 +40,8 @@ function writeLimits(c: AppContext): McpConnectionWriteLimits { maxPerScope: limits.maxMcpConnectionsPerScope, urlMaxBytes: limits.mcpConnectionUrlMaxBytes, tokenMaxBytes: limits.mcpConnectionTokenMaxBytes, + maxHeaders: limits.maxMcpConnectionHeaders, + headerValueMaxBytes: limits.mcpConnectionHeaderValueMaxBytes, }; } @@ -84,6 +86,7 @@ function buildRoutes(projectScoped: boolean): Hono<{ Bindings: Env }> { url: body.url, authType: body.authType ?? 'bearer', token: body.token ?? null, + headers: body.headers, enabled: body.enabled ?? true, limits: writeLimits(c), encryptionKey: getCredentialEncryptionKey(c.env), @@ -102,6 +105,7 @@ function buildRoutes(projectScoped: boolean): Hono<{ Bindings: Env }> { url: body.url, authType: body.authType, token: body.token, + headers: body.headers, enabled: body.enabled, limits: writeLimits(c), encryptionKey: getCredentialEncryptionKey(c.env), diff --git a/apps/api/src/schemas/mcp-connections.ts b/apps/api/src/schemas/mcp-connections.ts index f3db5ff05..1184b3de2 100644 --- a/apps/api/src/schemas/mcp-connections.ts +++ b/apps/api/src/schemas/mcp-connections.ts @@ -3,8 +3,8 @@ import * as v from 'valibot'; /** * Structural validation only. Semantic rules (name charset, reserved names, URL scheme, - * size limits, token-required-for-bearer) live in `services/mcp-connections.ts` so the - * route and MCP-tool paths cannot drift apart. + * size limits, token-required-for-bearer, header rules) live in `services/mcp-connections.ts` + * and `services/mcp-connection-headers.ts` so the route and MCP-tool paths cannot drift apart. * * Note the values here are echoed back verbatim by `formatIssues` on a 400, so this schema * must never be pointed at anything but the caller's own request body (rule 51). @@ -16,6 +16,7 @@ export const CreateMcpConnectionSchema = v.object({ url: v.string(), authType: v.optional(authTypeSchema), token: v.optional(v.string()), + headers: v.optional(v.array(v.object({ name: v.string(), value: v.string() }))), enabled: v.optional(v.boolean()), }); @@ -24,5 +25,7 @@ export const UpdateMcpConnectionSchema = v.object({ url: v.optional(v.string()), authType: v.optional(authTypeSchema), token: v.optional(v.string()), + // A header without a value keeps the stored value for that name. + headers: v.optional(v.array(v.object({ name: v.string(), value: v.optional(v.string()) }))), enabled: v.optional(v.boolean()), }); diff --git a/apps/api/src/services/limits.ts b/apps/api/src/services/limits.ts index 442d768b1..61d94310b 100644 --- a/apps/api/src/services/limits.ts +++ b/apps/api/src/services/limits.ts @@ -3,6 +3,7 @@ import { DEFAULT_MAX_DEPLOYMENT_ENV_TOTAL_BYTES, DEFAULT_MAX_DEPLOYMENT_ENV_VALUE_BYTES, DEFAULT_MAX_DEPLOYMENT_ENV_VARS_PER_ENVIRONMENT, + DEFAULT_MAX_MCP_CONNECTION_HEADERS, DEFAULT_MAX_MCP_CONNECTIONS_PER_SCOPE, DEFAULT_MAX_NODES_PER_USER, DEFAULT_MAX_PROJECT_GITHUB_REPOS_PER_PROJECT, @@ -14,6 +15,7 @@ import { DEFAULT_MAX_PROJECTS_PER_USER, DEFAULT_MAX_TASK_DEPENDENCIES_PER_TASK, DEFAULT_MAX_TASKS_PER_PROJECT, + DEFAULT_MCP_CONNECTION_HEADER_VALUE_MAX_BYTES, DEFAULT_MCP_CONNECTION_TOKEN_MAX_BYTES, DEFAULT_MCP_CONNECTION_URL_MAX_BYTES, DEFAULT_NODE_HEARTBEAT_STALE_SECONDS, @@ -46,6 +48,8 @@ export interface RuntimeLimits { maxMcpConnectionsPerScope: number; mcpConnectionUrlMaxBytes: number; mcpConnectionTokenMaxBytes: number; + maxMcpConnectionHeaders: number; + mcpConnectionHeaderValueMaxBytes: number; } function parsePositiveInt(value: string | undefined, fallback: number): number { @@ -80,6 +84,8 @@ export function getRuntimeLimits(env: { MAX_MCP_CONNECTIONS_PER_SCOPE?: string; MCP_CONNECTION_URL_MAX_BYTES?: string; MCP_CONNECTION_TOKEN_MAX_BYTES?: string; + MAX_MCP_CONNECTION_HEADERS?: string; + MCP_CONNECTION_HEADER_VALUE_MAX_BYTES?: string; }): RuntimeLimits { return { maxNodesPerUser: parsePositiveInt(env.MAX_NODES_PER_USER, DEFAULT_MAX_NODES_PER_USER), @@ -161,5 +167,13 @@ export function getRuntimeLimits(env: { env.MCP_CONNECTION_TOKEN_MAX_BYTES, DEFAULT_MCP_CONNECTION_TOKEN_MAX_BYTES ), + maxMcpConnectionHeaders: parsePositiveInt( + env.MAX_MCP_CONNECTION_HEADERS, + DEFAULT_MAX_MCP_CONNECTION_HEADERS + ), + mcpConnectionHeaderValueMaxBytes: parsePositiveInt( + env.MCP_CONNECTION_HEADER_VALUE_MAX_BYTES, + DEFAULT_MCP_CONNECTION_HEADER_VALUE_MAX_BYTES + ), }; } diff --git a/apps/api/src/services/mcp-connection-headers.ts b/apps/api/src/services/mcp-connection-headers.ts new file mode 100644 index 000000000..7921664fc --- /dev/null +++ b/apps/api/src/services/mcp-connection-headers.ts @@ -0,0 +1,203 @@ +/** + * Custom HTTP headers for bring-your-own MCP servers. + * + * Rules this module owns, for both the write path (`mcp-connections.ts`) and the injection + * path (`mcp-connection-resolution.ts`): + * - Names use the mcp-remote / TOML-bare-key charset, are unique case-insensitively, and may + * not be headers the MCP transport sets itself. + * - `Authorization` would collide with a bearer token, so it is only accepted when the + * connection's `authType` is `none` (a non-Bearer scheme). + * - Values are secrets. The whole `[{name, value}]` list is sealed as one AES-GCM ciphertext + * and is never returned; `header_names` is the plaintext display projection written beside it. + * + * Error messages name a header by its name only, never by its value. + */ +import { + MCP_CONNECTION_HEADER_NAME_PATTERN, + MCP_CONNECTION_HEADER_NAME_RULE, + MCP_CONNECTION_RESERVED_HEADER_NAMES, + type McpConnectionAuthType, + type McpConnectionHeader, + type McpConnectionHeaderUpdate, +} from '@simple-agent-manager/shared'; +import * as v from 'valibot'; + +import type * as schema from '../db/schema'; +import { log } from '../lib/logger'; +import { utf8ByteLength } from '../lib/utf8'; +import { errors } from '../middleware/error'; +import { decrypt, encrypt } from './encryption'; + +export interface McpConnectionHeaderLimits { + maxHeaders: number; + headerValueMaxBytes: number; +} + +/** The three header columns. They are only ever written together. */ +export interface SealedMcpConnectionHeaders { + headerNames: string; + encryptedHeaders: string | null; + headersIv: string | null; +} + +type HeaderColumns = Pick; + +/** Tab, CR, LF, NUL and the rest: anything that could split a header or a config line. */ +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; + +const StoredHeadersSchema = v.array(v.object({ name: v.string(), value: v.string() })); +const HeaderNamesSchema = v.array(v.string()); + +/** + * Validates the complete header set a connection will store, and trims names and values. + * + * `authType` is the connection's effective auth type after the write, because an + * `Authorization` header is only legal without a bearer token. + */ +export function validateMcpConnectionHeaders( + headers: readonly McpConnectionHeader[], + authType: McpConnectionAuthType, + limits: McpConnectionHeaderLimits +): McpConnectionHeader[] { + if (headers.length > limits.maxHeaders) { + throw errors.badRequest(`Maximum ${limits.maxHeaders} headers per MCP server`); + } + + const seen = new Set(); + return headers.map((header) => { + const name = header.name.trim(); + if (!MCP_CONNECTION_HEADER_NAME_PATTERN.test(name)) { + throw errors.badRequest(`Invalid header name ${JSON.stringify(name)}: ${MCP_CONNECTION_HEADER_NAME_RULE}`); + } + + const key = name.toLowerCase(); + if (MCP_CONNECTION_RESERVED_HEADER_NAMES.includes(key)) { + throw errors.badRequest(`Header "${name}" is set by the MCP transport and cannot be overridden`); + } + if (key === 'authorization' && authType === 'bearer') { + throw errors.badRequest( + 'An Authorization header conflicts with the bearer token; set authentication to "none" to send your own' + ); + } + if (seen.has(key)) { + throw errors.badRequest(`Header "${name}" is set more than once`); + } + seen.add(key); + + const value = header.value.trim(); + if (!value) { + throw errors.badRequest(`Header "${name}" needs a value`); + } + if (utf8ByteLength(value) > limits.headerValueMaxBytes) { + throw errors.badRequest(`Header "${name}" value exceeds max size of ${limits.headerValueMaxBytes} bytes`); + } + if (CONTROL_CHARACTERS.test(value)) { + throw errors.badRequest(`Header "${name}" value must not contain line breaks or control characters`); + } + return { name, value }; + }); +} + +/** + * Resolves an update's desired header set against the stored one. + * + * An entry without a `value` keeps the value stored under that name (case-insensitive), so a + * client can add, remove or rotate a single header without ever holding the others' secrets. + * Naming a header that is not stored, without a value, is a 400 — there is nothing to keep. + */ +export function applyMcpConnectionHeaderUpdate( + stored: readonly McpConnectionHeader[], + desired: readonly McpConnectionHeaderUpdate[] +): McpConnectionHeader[] { + const storedValues = new Map(stored.map((header) => [header.name.toLowerCase(), header.value])); + return desired.map(({ name, value }) => { + if (value !== undefined) { + return { name, value }; + } + const kept = storedValues.get(name.trim().toLowerCase()); + if (kept === undefined) { + throw errors.badRequest(`Header "${name.trim()}" needs a value`); + } + return { name, value: kept }; + }); +} + +/** Seals a validated header set into its columns. An empty set clears all three. */ +export async function sealMcpConnectionHeaders( + headers: readonly McpConnectionHeader[], + encryptionKey: string +): Promise { + if (headers.length === 0) { + return { headerNames: '[]', encryptedHeaders: null, headersIv: null }; + } + const sealed = await encrypt(JSON.stringify(headers), encryptionKey); + return { + headerNames: JSON.stringify(headers.map((header) => header.name)), + encryptedHeaders: sealed.ciphertext, + headersIv: sealed.iv, + }; +} + +/** + * Decrypts a row's header set. + * + * Throws when the ciphertext is unreadable or would not be accepted by the vm-agent, which + * rejects a whole create-agent-session request over one malformed header. The injection path + * catches per row, so a bad row is skipped rather than breaking session start (rules 41/50). + */ +export async function openMcpConnectionHeaders( + row: HeaderColumns, + encryptionKey: string +): Promise { + if (!row.encryptedHeaders && !row.headersIv) { + return []; + } + if (!row.encryptedHeaders || !row.headersIv) { + throw new Error('stored headers are missing their ciphertext or IV'); + } + + // The plaintext holds secret values, so neither a JSON syntax error (V8 quotes the input) nor + // a Valibot issue (it quotes the offending value) may reach a log or response — both are + // replaced with fixed messages (rule 51). + const plaintext = await decrypt(row.encryptedHeaders, row.headersIv, encryptionKey); + let parsed: unknown; + try { + parsed = JSON.parse(plaintext); + } catch { + throw new Error('stored headers are not valid JSON'); + } + const result = v.safeParse(StoredHeadersSchema, parsed); + if (!result.success) { + throw new Error('stored headers are not a list of name/value pairs'); + } + const headers = result.output; + for (const [index, header] of headers.entries()) { + if (!MCP_CONNECTION_HEADER_NAME_PATTERN.test(header.name)) { + throw new Error(`stored header ${index} has an invalid name`); + } + if (!header.value || CONTROL_CHARACTERS.test(header.value)) { + throw new Error(`stored header ${index} has an empty or unsafe value`); + } + } + return headers; +} + +/** + * Display projection for API responses. A malformed column reads as "no names" and is logged, + * rather than failing the whole list the row appears in (rule 50). The injection path never + * reads this column. + */ +export function readMcpConnectionHeaderNames( + row: Pick +): string[] { + try { + return v.parse(HeaderNamesSchema, JSON.parse(row.headerNames)); + } catch (error) { + log.warn('mcp_connections.header_names_unreadable', { + connectionId: row.id, + error: error instanceof Error ? error.message : String(error), + action: 'shown_without_header_names', + }); + return []; + } +} diff --git a/apps/api/src/services/mcp-connection-resolution.ts b/apps/api/src/services/mcp-connection-resolution.ts index 91296557d..8d8035208 100644 --- a/apps/api/src/services/mcp-connection-resolution.ts +++ b/apps/api/src/services/mcp-connection-resolution.ts @@ -32,6 +32,7 @@ import { type drizzle } from 'drizzle-orm/d1'; import * as schema from '../db/schema'; import { log } from '../lib/logger'; import { decrypt } from './encryption'; +import { openMcpConnectionHeaders } from './mcp-connection-headers'; type Db = ReturnType>; @@ -118,16 +119,9 @@ export async function buildSessionMcpServers( options: { baseDomain: string; encryptionKey: string }, scope: McpConnectionResolutionScope, samMcpToken: string -): Promise> { +): Promise { const resolved = await resolveMcpServersForSession(db, scope, options.encryptionKey); - return [ - buildSamMcpEntry(options.baseDomain, samMcpToken), - ...resolved.map((entry) => ({ - url: entry.url, - token: entry.token, - name: entry.name as string, - })), - ]; + return [buildSamMcpEntry(options.baseDomain, samMcpToken), ...resolved]; } /** @@ -168,7 +162,7 @@ export async function decryptAndMerge( ); // Decrypt concurrently, then merge in order. Sequential awaits here would stack up to - // ~100 AES-GCM operations (2 per bearer row, both scopes at cap) directly on the + // ~150 AES-GCM operations (up to 3 per row, both scopes at cap) directly on the // agent-session start path — which the Instant runtime shares, and which has a documented // history of timing out (rule 43). The merge still walks personal-then-project so a project // row wins a name collision. @@ -211,7 +205,10 @@ async function toEntry( } } - return { url, token, name: row.name }; + // Validated on the way out as well as on the way in: the vm-agent rejects the WHOLE + // create-agent-session request over one malformed header, so a bad row must stop here. + const headers = await openMcpConnectionHeaders(row, encryptionKey); + return { url, token, name: row.name, ...(headers.length > 0 ? { headers } : {}) }; } catch (error) { log.warn('mcp_connections.row_skipped', { connectionId: row.id, diff --git a/apps/api/src/services/mcp-connections.ts b/apps/api/src/services/mcp-connections.ts index 2acc2a03b..24b65adfa 100644 --- a/apps/api/src/services/mcp-connections.ts +++ b/apps/api/src/services/mcp-connections.ts @@ -9,6 +9,8 @@ * an environment-variable suffix on the VM. It is validated against a strict charset here * so the vm-agent never has to sanitize a hostile value into config files. * - `sam-mcp` is reserved for SAM's own endpoint and may not be taken by a user connection. + * - Custom header values are secrets too; their rules and storage format live in + * `mcp-connection-headers.ts`. * * Resolution (the read path used at agent-session start) lives in * `mcp-connection-resolution.ts` so a bad row there cannot take this module's limits and @@ -20,6 +22,8 @@ import { MCP_CONNECTION_NAME_RULE, type McpConnection, type McpConnectionAuthType, + type McpConnectionHeader, + type McpConnectionHeaderUpdate, SAM_MCP_SERVER_NAME, } from '@simple-agent-manager/shared'; import { and, count, eq, isNull } from 'drizzle-orm'; @@ -27,8 +31,17 @@ import { type drizzle } from 'drizzle-orm/d1'; import * as schema from '../db/schema'; import { ulid } from '../lib/ulid'; +import { utf8ByteLength } from '../lib/utf8'; import { errors } from '../middleware/error'; import { encrypt } from './encryption'; +import { + applyMcpConnectionHeaderUpdate, + type McpConnectionHeaderLimits, + openMcpConnectionHeaders, + readMcpConnectionHeaderNames, + sealMcpConnectionHeaders, + validateMcpConnectionHeaders, +} from './mcp-connection-headers'; type Db = ReturnType>; @@ -41,7 +54,7 @@ export interface McpConnectionScopeRef { projectId: string | null; } -export interface McpConnectionWriteLimits { +export interface McpConnectionWriteLimits extends McpConnectionHeaderLimits { maxPerScope: number; urlMaxBytes: number; tokenMaxBytes: number; @@ -52,6 +65,7 @@ export interface CreateMcpConnectionInput extends McpConnectionScopeRef { url: string; authType: McpConnectionAuthType; token?: string | null; + headers?: McpConnectionHeader[]; enabled: boolean; limits: McpConnectionWriteLimits; encryptionKey: string; @@ -63,15 +77,13 @@ export interface UpdateMcpConnectionInput extends McpConnectionScopeRef { url?: string; authType?: McpConnectionAuthType; token?: string | null; + /** The complete desired header set; an entry without a value keeps the stored one. */ + headers?: McpConnectionHeaderUpdate[]; enabled?: boolean; limits: McpConnectionWriteLimits; encryptionKey: string; } -function byteLength(value: string): number { - return new TextEncoder().encode(value).length; -} - /** * Validates the agent-visible server name. * @@ -93,7 +105,7 @@ export function validateMcpConnectionName(rawName: string): string { /** * Validates the endpoint URL and derives the display-only host. * - * Mirrors the vm-agent's own check (`normalizeMcpServers` in `internal/server/workspaces.go`) + * Mirrors the vm-agent's own check (`normalizeMcpServers` in `internal/server/mcp_servers.go`) * so a URL that would be rejected on the VM is rejected here, at the point where the user can * still see the error. HTTP is allowed only for loopback, which is what a self-hosted gateway * running on the same box would use. @@ -103,7 +115,7 @@ export function validateMcpConnectionUrl(rawUrl: string, maxBytes: number): { ur if (!url) { throw errors.badRequest('url is required'); } - if (byteLength(url) > maxBytes) { + if (utf8ByteLength(url) > maxBytes) { throw errors.badRequest(`url exceeds max size of ${maxBytes} bytes`); } if (/[\r\n]/.test(url)) { @@ -119,7 +131,7 @@ export function validateMcpConnectionUrl(rawUrl: string, maxBytes: number): { ur // An explicit port is REQUIRED for loopback, because the vm-agent's own check is a string // prefix match on "http://localhost:" / "http://127.0.0.1:" (normalizeMcpServers in - // internal/server/workspaces.go). Without the port requirement here, `http://localhost/mcp` + // internal/server/mcp_servers.go). Without the port requirement here, `http://localhost/mcp` // saves cleanly and then fails normalizeMcpServers on the VM — which rejects the ENTIRE // create-agent-session request, not just that one server, so one bad row would break every // future session for the scope. The two validators are pinned together by @@ -154,7 +166,7 @@ function validateToken( if (!value) { throw errors.badRequest('token is required when authType is "bearer"'); } - if (byteLength(value) > maxBytes) { + if (utf8ByteLength(value) > maxBytes) { throw errors.badRequest(`token exceeds max size of ${maxBytes} bytes`); } if (/[\r\n]/.test(value)) { @@ -190,6 +202,7 @@ export function toMcpConnectionResponse(row: schema.McpConnectionRow): McpConnec urlHost: row.urlHost, authType: assertAuthType(row.authType), hasToken: Boolean(row.encryptedToken), + headerNames: readMcpConnectionHeaderNames(row), enabled: row.enabled, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -248,6 +261,7 @@ export async function createMcpConnection( const authType = assertAuthType(input.authType); const { url, urlHost } = validateMcpConnectionUrl(input.url, input.limits.urlMaxBytes); const token = validateToken(authType, input.token, input.limits.tokenMaxBytes); + const headers = validateMcpConnectionHeaders(input.headers ?? [], authType, input.limits); const scope: McpConnectionScopeRef = { userId: input.userId, projectId: input.projectId }; await assertScopeLimit(db, scope, input.limits.maxPerScope); @@ -255,6 +269,7 @@ export async function createMcpConnection( const encryptedUrl = await encrypt(url, input.encryptionKey); const encryptedToken = token ? await encrypt(token, input.encryptionKey) : null; + const sealedHeaders = await sealMcpConnectionHeaders(headers, input.encryptionKey); const now = new Date().toISOString(); const row: schema.NewMcpConnectionRow = { @@ -268,6 +283,7 @@ export async function createMcpConnection( authType, encryptedToken: encryptedToken?.ciphertext ?? null, tokenIv: encryptedToken?.iv ?? null, + ...sealedHeaders, enabled: input.enabled, createdAt: now, updatedAt: now, @@ -343,6 +359,17 @@ export async function updateMcpConnection( throw errors.badRequest('token is required when authType is "bearer"'); } + // Headers are re-validated whenever they or the auth type change: switching to bearer must + // not leave a stored Authorization header behind. + if (input.headers !== undefined || nextAuthType !== existing.authType) { + const headers = validateMcpConnectionHeaders( + await resolveUpdatedHeaders(existing, input.headers, input.encryptionKey), + nextAuthType, + input.limits + ); + Object.assign(updates, await sealMcpConnectionHeaders(headers, input.encryptionKey)); + } + if (input.enabled !== undefined) { updates.enabled = input.enabled; } @@ -355,6 +382,29 @@ export async function updateMcpConnection( return toMcpConnectionResponse({ ...existing, ...updates } as schema.McpConnectionRow); } +/** + * The header set an update asks for. Stored values are decrypted only when the update keeps + * at least one of them, so a caller replacing every header never depends on the old ciphertext. + */ +async function resolveUpdatedHeaders( + existing: schema.McpConnectionRow, + desired: McpConnectionHeaderUpdate[] | undefined, + encryptionKey: string +): Promise { + const keepsStoredValues = desired === undefined || desired.some((header) => header.value === undefined); + let stored: McpConnectionHeader[] = []; + if (keepsStoredValues) { + try { + stored = await openMcpConnectionHeaders(existing, encryptionKey); + } catch { + throw errors.badRequest( + 'The stored headers for this MCP server cannot be read; send every header with its value to replace them' + ); + } + } + return desired === undefined ? stored : applyMcpConnectionHeaderUpdate(stored, desired); +} + export async function deleteMcpConnection( db: Db, scope: McpConnectionScopeRef, diff --git a/apps/api/src/services/node-agent.ts b/apps/api/src/services/node-agent.ts index 7df1fe2d7..de86001c1 100644 --- a/apps/api/src/services/node-agent.ts +++ b/apps/api/src/services/node-agent.ts @@ -1,4 +1,5 @@ // FILE SIZE EXCEPTION: Cross-boundary node-agent request layer marginally over the limit; interactive/background timeout tiers and cf-container interruption classification are one cohesive concern. Split candidate if it grows further. See .claude/rules/18-file-size-limits.md +import type { McpServerEntry } from '@simple-agent-manager/shared'; import { eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/d1'; @@ -555,19 +556,11 @@ export async function createAgentSessionOnNode( }); } -/** MCP server configuration passed to the VM agent for ACP session injection */ -export interface McpServerConfig { - url: string; - token: string; - /** - * Agent-visible server name. Tools are namespaced by it. - * - * Optional for rollout compatibility (rule 54): a vm-agent built before this field existed - * ignores it and falls back to its legacy positional naming, so a new control plane still - * works against an old agent. - */ - name?: string; -} +/** + * MCP server configuration passed to the VM agent for ACP session injection. The wire contract, + * including why `name` and `headers` are optional (rule 54), is `McpServerEntrySchema`. + */ +export type McpServerConfig = McpServerEntry; /** * Serializes MCP servers for the vm-agent request body. @@ -576,16 +569,17 @@ export interface McpServerConfig { * their own single-element array literal, which is why adding a field here previously meant * remembering two places. */ -function serializeMcpServers( - mcpServers: McpServerConfig[] | undefined -): Array<{ url: string; token: string; name?: string }> | undefined { +function serializeMcpServers(mcpServers: McpServerConfig[] | undefined): McpServerConfig[] | undefined { if (!mcpServers || mcpServers.length === 0) { return undefined; } + // Optional fields are sent only when set, so a server without them serializes byte-for-byte + // as it did before they existed. return mcpServers.map((server) => ({ url: server.url, token: server.token, ...(server.name ? { name: server.name } : {}), + ...(server.headers?.length ? { headers: server.headers } : {}), })); } diff --git a/packages/shared/src/constants/defaults.ts b/packages/shared/src/constants/defaults.ts index 57da73986..5e7e937e8 100644 --- a/packages/shared/src/constants/defaults.ts +++ b/packages/shared/src/constants/defaults.ts @@ -398,3 +398,9 @@ export const DEFAULT_MCP_CONNECTION_URL_MAX_BYTES = 2048; /** Default max MCP bearer token size in bytes. Override via MCP_CONNECTION_TOKEN_MAX_BYTES env var. */ export const DEFAULT_MCP_CONNECTION_TOKEN_MAX_BYTES = 8 * 1024; + +/** Default max custom headers per MCP server. Override via MAX_MCP_CONNECTION_HEADERS env var. */ +export const DEFAULT_MAX_MCP_CONNECTION_HEADERS = 10; + +/** Default max size in bytes of one MCP custom header value. Override via MCP_CONNECTION_HEADER_VALUE_MAX_BYTES env var. */ +export const DEFAULT_MCP_CONNECTION_HEADER_VALUE_MAX_BYTES = 8 * 1024; diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 3c940276a..93642fb60 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -63,6 +63,7 @@ export { DEFAULT_MAX_DEPLOYMENT_ENV_TOTAL_BYTES, DEFAULT_MAX_DEPLOYMENT_ENV_VALUE_BYTES, DEFAULT_MAX_DEPLOYMENT_ENV_VARS_PER_ENVIRONMENT, + DEFAULT_MAX_MCP_CONNECTION_HEADERS, DEFAULT_MAX_MCP_CONNECTIONS_PER_SCOPE, DEFAULT_MAX_NODES_PER_USER, DEFAULT_MAX_PROJECT_GITHUB_REPOS_PER_PROJECT, @@ -74,6 +75,7 @@ export { DEFAULT_MAX_PROJECTS_PER_USER, DEFAULT_MAX_TASK_DEPENDENCIES_PER_TASK, DEFAULT_MAX_TASKS_PER_PROJECT, + DEFAULT_MCP_CONNECTION_HEADER_VALUE_MAX_BYTES, DEFAULT_MCP_CONNECTION_TOKEN_MAX_BYTES, DEFAULT_MCP_CONNECTION_URL_MAX_BYTES, DEFAULT_MCP_TOKEN_MAX_LIFETIME_SECONDS, diff --git a/packages/shared/src/fixtures/mcp-server-name-contract.json b/packages/shared/src/fixtures/mcp-server-name-contract.json index a702b3cbc..3a5c8c689 100644 --- a/packages/shared/src/fixtures/mcp-server-name-contract.json +++ b/packages/shared/src/fixtures/mcp-server-name-contract.json @@ -16,10 +16,17 @@ "", "The `urls` block pins the SECOND rule implemented twice: validateMcpConnectionUrl in", "apps/api/src/services/mcp-connections.ts and normalizeMcpServers in", - "packages/vm-agent/internal/server/workspaces.go. These drifted in review: TS accepted", + "packages/vm-agent/internal/server/mcp_servers.go. These drifted in review: TS accepted", "http://localhost/mcp (no port) while Go required an explicit port, and Go's rejection fails", "the WHOLE create-agent-session request, so one saved row would have broken every session", - "for that scope." + "for that scope.", + "", + "The `headerNames` block pins the THIRD rule implemented twice: MCP_CONNECTION_HEADER_NAME_PATTERN", + "in packages/shared/src/types/mcp-connection.ts (control plane, rejects on write) and", + "ValidMcpHeaderName in packages/vm-agent/internal/acp/mcp_servers.go (vm-agent, rejects the", + "create-agent-session request). Both judge the exact string with no normalization; the control", + "plane trims user input before judging. The charset is what mcp-remote accepts in", + "`--header name:value` and what TOML accepts as a bare key." ], "valid": [ "a", @@ -79,5 +86,40 @@ "https://a.example/mcp\nurl = \"https://evil\"", "https://a.example/mcp\rx" ] + }, + "headerNames": { + "valid": [ + "x-api-key", + "X-API-Key", + "x-consumer-api-key", + "Authorization", + "api_key", + "X_Custom-Header_2", + "a", + "9", + "x-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "invalid": [ + "", + " ", + " x-api-key", + "x-api-key ", + "x api key", + "x:api-key", + "x.api.key", + "x/api", + "x@key", + "x$key", + "x=key", + "x;key", + "x{key}", + "x\"key", + "x\\key", + "x\nkey", + "x\rkey", + "x\tkey", + "caf\u00e9", + "x-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] } } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5e61d8e39..78fa79030 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -853,14 +853,20 @@ export type { CreateMcpConnectionRequest, McpConnection, McpConnectionAuthType, + McpConnectionHeader, + McpConnectionHeaderUpdate, McpConnectionListResponse, McpConnectionScope, UpdateMcpConnectionRequest, } from './mcp-connection'; export { MCP_CONNECTION_AUTH_TYPES, + MCP_CONNECTION_HEADER_NAME_MAX_LENGTH, + MCP_CONNECTION_HEADER_NAME_PATTERN, + MCP_CONNECTION_HEADER_NAME_RULE, MCP_CONNECTION_NAME_PATTERN, MCP_CONNECTION_NAME_RULE, + MCP_CONNECTION_RESERVED_HEADER_NAMES, SAM_MCP_SERVER_NAME, } from './mcp-connection'; export * from './project-event-channels'; diff --git a/packages/shared/src/types/mcp-connection.ts b/packages/shared/src/types/mcp-connection.ts index a0fba29ac..74508b751 100644 --- a/packages/shared/src/types/mcp-connection.ts +++ b/packages/shared/src/types/mcp-connection.ts @@ -4,12 +4,13 @@ * SAM speaks MCP; the endpoint owns the OAuth. A user performs the OAuth dance in their * chosen provider's dashboard (Zapier, executor.sh, Composio/Rube, Klavis, or an official * single-service MCP endpoint) and pastes the resulting endpoint here. SAM stores only - * `url + optional bearer token`, encrypted, and injects it into agent sessions next to - * `sam-mcp`. + * `url + optional bearer token + optional custom headers`, encrypted, and injects it into + * agent sessions next to `sam-mcp`. * - * Both the URL and the token are secrets: several providers issue pre-signed MCP URLs with - * the credential embedded in the path or query, so the URL is encrypted at rest and is never - * returned by a read endpoint. + * The URL, the token and every header value are secrets: several providers issue pre-signed + * MCP URLs with the credential embedded in the path or query, and others (Composio) take an + * API key in a custom header. All three are encrypted at rest and never returned by a read + * endpoint. */ /** Auth modes SAM can express on every harness (ACP HTTP, Codex, Vibe, Amp). */ @@ -49,12 +50,69 @@ export const MCP_CONNECTION_NAME_PATTERN = new RegExp( export const MCP_CONNECTION_NAME_RULE = `name must be 1-${MCP_CONNECTION_NAME_MAX_LENGTH} characters of lowercase letters, digits or hyphens, and may not start or end with a hyphen`; +/** + * Maximum custom header name length. + * + * A safety ceiling like `MCP_CONNECTION_NAME_MAX_LENGTH`, not a business policy: the name + * becomes a TOML key and an mcp-remote `--header` argument on the VM. The vm-agent re-checks + * it (`maxMcpHeaderNameLen` in `packages/vm-agent/internal/acp/mcp_servers.go`); the two are + * pinned together by `packages/shared/src/fixtures/mcp-server-name-contract.json`. + */ +export const MCP_CONNECTION_HEADER_NAME_MAX_LENGTH = 64; + +/** + * Custom header names: letters, digits, hyphens and underscores. + * + * Deliberately narrower than an HTTP token. It is exactly the set the Amp harness's + * mcp-remote bridge accepts in `--header name:value` (it silently drops anything else), and + * the set TOML accepts as a bare key for the Codex and Vibe config files. + */ +export const MCP_CONNECTION_HEADER_NAME_PATTERN = new RegExp( + `^[A-Za-z0-9_-]{1,${MCP_CONNECTION_HEADER_NAME_MAX_LENGTH}}$` +); + +export const MCP_CONNECTION_HEADER_NAME_RULE = + `header names must be 1-${MCP_CONNECTION_HEADER_NAME_MAX_LENGTH} characters of letters, digits, hyphens or underscores`; + +/** + * Headers the MCP transport or the HTTP client sets itself. A stored value would either be + * overwritten or break the connection, so they are rejected when a connection is saved. + * Lowercase; compare case-insensitively. + */ +export const MCP_CONNECTION_RESERVED_HEADER_NAMES: readonly string[] = [ + 'accept', + 'connection', + 'content-length', + 'content-type', + 'host', + 'last-event-id', + 'mcp-protocol-version', + 'mcp-session-id', + 'transfer-encoding', +]; + +/** A custom HTTP header sent with every request to the MCP endpoint. `value` is a secret. */ +export interface McpConnectionHeader { + name: string; + value: string; +} + +/** + * One entry of the desired header set in an update. Omitting `value` keeps the value already + * stored under that name (matched case-insensitively), so a client can add, remove or rotate + * one header without holding the other headers' secrets. + */ +export interface McpConnectionHeaderUpdate { + name: string; + value?: string; +} + /** * A stored MCP server as returned by the API. * - * Deliberately omits `url` and `token`. `urlHost` is a display-only, non-reversible hint - * (scheme + host, never path or query) so the UI can show which provider a row points at - * without echoing a pre-signed credential back to the browser. + * Deliberately omits `url`, `token` and header values. `urlHost` is a display-only, + * non-reversible hint (scheme + host, never path or query) so the UI can show which provider + * a row points at without echoing a pre-signed credential back to the browser. */ export interface McpConnection { id: string; @@ -68,6 +126,8 @@ export interface McpConnection { authType: McpConnectionAuthType; /** True when a bearer token is stored. The token itself is never returned. */ hasToken: boolean; + /** Names of the custom headers sent to the endpoint. Their values are never returned. */ + headerNames: string[]; enabled: boolean; createdAt: string; updatedAt: string; @@ -83,18 +143,25 @@ export interface CreateMcpConnectionRequest { authType?: McpConnectionAuthType; /** Required when authType is 'bearer'. */ token?: string; + /** Custom headers, independent of `authType` (e.g. Composio's `x-api-key` with `none`). */ + headers?: McpConnectionHeader[]; enabled?: boolean; } /** - * All fields optional. `token` is only rewritten when present, so a caller can toggle - * `enabled` without re-sending the secret. + * All fields optional. `url`, `token` and `headers` are only rewritten when present, so a + * caller can toggle `enabled` without re-sending any secret. */ export interface UpdateMcpConnectionRequest { name?: string; url?: string; authType?: McpConnectionAuthType; token?: string; + /** + * The complete desired header set: names left out are removed, `[]` removes them all. + * An entry without a `value` keeps the stored value for that name. + */ + headers?: McpConnectionHeaderUpdate[]; enabled?: boolean; } diff --git a/packages/shared/src/vm-agent-contract.ts b/packages/shared/src/vm-agent-contract.ts index 0bc5c89fc..f7014084f 100644 --- a/packages/shared/src/vm-agent-contract.ts +++ b/packages/shared/src/vm-agent-contract.ts @@ -109,6 +109,11 @@ export const McpServerEntrySchema = z.object({ token: z.string(), /** Agent-visible server name. Tools are namespaced by it. */ name: z.string().optional(), + /** + * Custom HTTP headers sent alongside the bearer token. Additive for the same reason as + * `name`: the control plane sends it only when non-empty, and an older vm-agent ignores it. + */ + headers: z.array(z.object({ name: z.string(), value: z.string() })).optional(), }); export type McpServerEntry = z.infer; diff --git a/packages/shared/tests/unit/mcp-server-name-contract.test.ts b/packages/shared/tests/unit/mcp-server-name-contract.test.ts index 62769848f..dc08fff2a 100644 --- a/packages/shared/tests/unit/mcp-server-name-contract.test.ts +++ b/packages/shared/tests/unit/mcp-server-name-contract.test.ts @@ -12,12 +12,16 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { MCP_CONNECTION_NAME_PATTERN } from '../../src/types/mcp-connection'; +import { + MCP_CONNECTION_HEADER_NAME_PATTERN, + MCP_CONNECTION_NAME_PATTERN, +} from '../../src/types/mcp-connection'; interface Contract { valid: string[]; invalid: string[]; normalized: Record; + headerNames: { valid: string[]; invalid: string[] }; } const contract = JSON.parse( @@ -57,3 +61,27 @@ describe('MCP server name contract (TypeScript side)', () => { expect(contract.invalid.length).toBeGreaterThanOrEqual(15); }); }); + +/** + * Header names are judged as exact strings on both sides — no trimming or case folding — so a + * name the control plane stores is byte-for-byte the name the vm-agent writes into TOML and + * mcp-remote arguments. The Go half is TestMcpHeaderNameContract in mcp_server_name_contract_test.go. + */ +describe('MCP custom header name contract (TypeScript side)', () => { + it('accepts every header name the contract marks valid', () => { + for (const name of contract.headerNames.valid) { + expect(MCP_CONNECTION_HEADER_NAME_PATTERN.test(name), `expected ${JSON.stringify(name)} to be valid`).toBe(true); + } + }); + + it('rejects every header name the contract marks invalid', () => { + for (const name of contract.headerNames.invalid) { + expect(MCP_CONNECTION_HEADER_NAME_PATTERN.test(name), `expected ${JSON.stringify(name)} to be invalid`).toBe(false); + } + }); + + it('has a non-trivial header corpus', () => { + expect(contract.headerNames.valid.length).toBeGreaterThanOrEqual(8); + expect(contract.headerNames.invalid.length).toBeGreaterThanOrEqual(15); + }); +}); diff --git a/packages/vm-agent/internal/acp/codex_config.go b/packages/vm-agent/internal/acp/codex_config.go index 8dc0d66d4..9431a7380 100644 --- a/packages/vm-agent/internal/acp/codex_config.go +++ b/packages/vm-agent/internal/acp/codex_config.go @@ -41,6 +41,16 @@ func codexMcpTokenEnvVar(name string) string { return fmt.Sprintf("SAM_MCP_%s_TOKEN", McpServerEnvVarSuffix(name)) } +// codexMcpHeaderEnvVar derives the env var Codex reads a server's custom header from, via +// env_http_headers, so header values stay out of config.toml just like the bearer token. +// +// "_SECRET" rather than "_TOKEN": it must still classify as a secret for isSecretEnvVar, but a +// "_TOKEN" suffix would let server "x"'s header 0 (SAM_MCP_X_HEADER_0_TOKEN) collide with the +// bearer variable of a server named "x-header-0". +func codexMcpHeaderEnvVar(name string, index int) string { + return fmt.Sprintf("SAM_MCP_%s_HEADER_%d_SECRET", McpServerEnvVarSuffix(name), index) +} + func isAllDigits(s string) bool { if s == "" { return false @@ -209,15 +219,15 @@ func normalizeCodexEffort(effort string) string { // generateCodexMcpConfig produces a managed TOML block for Codex MCP server // configuration plus the environment variables referenced by -// bearer_token_env_var. Codex natively supports streamable HTTP MCP servers -// via ~/.codex/config.toml. +// bearer_token_env_var and env_http_headers. Codex natively supports streamable +// HTTP MCP servers via ~/.codex/config.toml. func generateCodexMcpConfig(mcpServers []McpServerEntry, proxyProvider *codexProxyProviderConfig, effort string) (string, []string) { providerConfig := generateCodexProxyProviderConfig(proxyProvider) codexEffort := normalizeCodexEffort(effort) validServers := make([]McpServerEntry, 0, len(mcpServers)) for i, server := range mcpServers { - if strings.ContainsAny(server.URL, "\n\r") || strings.ContainsAny(server.Token, "\n\r") { - slog.Warn("Skipping Codex MCP server with control characters in URL or token", + if !server.safeForConfigFile() { + slog.Warn("Skipping Codex MCP server with control characters in its URL, token or headers", "index", i, "url_length", len(server.URL)) continue } @@ -248,6 +258,13 @@ func generateCodexMcpConfig(mcpServers []McpServerEntry, proxyProvider *codexPro config.WriteString(fmt.Sprintf("bearer_token_env_var = \"%s\"\n", tokenEnvVar)) envVars = append(envVars, fmt.Sprintf("%s=%s", tokenEnvVar, server.Token)) } + if len(server.Headers) > 0 { + headerEnvVar := func(index int, _ McpHeader) string { return codexMcpHeaderEnvVar(name, index) } + config.WriteString(fmt.Sprintf("env_http_headers = %s\n", mcpHeadersTOMLTable(server.Headers, headerEnvVar))) + for j, header := range server.Headers { + envVars = append(envVars, fmt.Sprintf("%s=%s", codexMcpHeaderEnvVar(name, j), header.Value)) + } + } config.WriteString("\n") } diff --git a/packages/vm-agent/internal/acp/gateway_test.go b/packages/vm-agent/internal/acp/gateway_test.go index 0e9fad3f2..1c6c3a234 100644 --- a/packages/vm-agent/internal/acp/gateway_test.go +++ b/packages/vm-agent/internal/acp/gateway_test.go @@ -1038,7 +1038,7 @@ func TestGenerateVibeConfig_McpServerWithToken(t *testing.T) { if !strings.Contains(config, `url = "https://api.example.com/mcp"`) { t.Error(expectedMcpServerURLMessage) } - if !strings.Contains(config, `headers = { Authorization = "Bearer test-token-123" }`) { + if !strings.Contains(config, `headers = { "Authorization" = "Bearer test-token-123" }`) { t.Error("expected Authorization header with token") } diff --git a/packages/vm-agent/internal/acp/mcp_headers_test.go b/packages/vm-agent/internal/acp/mcp_headers_test.go new file mode 100644 index 000000000..ab3816f5a --- /dev/null +++ b/packages/vm-agent/internal/acp/mcp_headers_test.go @@ -0,0 +1,321 @@ +package acp + +import ( + "encoding/json" + "regexp" + "strings" + "testing" + + "github.com/pelletier/go-toml/v2" +) + +// Custom MCP headers reach four harness formats: ACP HTTP header lists (Claude Code and most +// agents), Codex config.toml, Vibe config.toml, and mcp-remote arguments for Amp. These tests +// pin each format to the one entry below, and prove secret values only ever travel where the +// bearer token already travels. + +const composioAPIKey = "ak_live_composio_secret" + +func composioEntry() McpServerEntry { + return McpServerEntry{ + URL: "https://backend.composio.dev/v3/mcp/server-1", + Name: "composio", + Headers: []McpHeader{ + {Name: "x-api-key", Value: composioAPIKey}, + {Name: "X-Org_Id", Value: "org-42"}, + }, + } +} + +// mcpRemoteHeaderArg is the parser mcp-remote@0.1.38 applies to each `--header` argument +// (dist/chunk-65X3S4HB.js:20713). An argument it cannot parse is dropped with only a log line, +// so the header would silently never be sent. +var mcpRemoteHeaderArg = regexp.MustCompile(`^([A-Za-z0-9_-]+):\s*(.*)$`) + +func TestBuildAcpMcpServers_SendsCustomHeadersAfterAuthorization(t *testing.T) { + t.Parallel() + + entry := composioEntry() + entry.Token = "bearer-token" + + servers := buildAcpMcpServers([]McpServerEntry{entry}, "claude-code") + + if len(servers) != 1 || servers[0].Http == nil { + t.Fatalf("expected one HTTP server, got %#v", servers) + } + got := servers[0].Http.Headers + want := []struct{ name, value string }{ + {"Authorization", "Bearer bearer-token"}, + {"x-api-key", composioAPIKey}, + {"X-Org_Id", "org-42"}, + } + if len(got) != len(want) { + t.Fatalf("headers = %#v, want %d entries", got, len(want)) + } + for i, w := range want { + if got[i].Name != w.name || got[i].Value != w.value { + t.Errorf("header[%d] = %s: %s, want %s: %s", i, got[i].Name, got[i].Value, w.name, w.value) + } + } +} + +func TestBuildAcpMcpServers_CustomHeadersWithoutBearerToken(t *testing.T) { + t.Parallel() + + // Composio's shape: no bearer token, the API key travels in its own header. + servers := buildAcpMcpServers([]McpServerEntry{composioEntry()}, "claude-code") + + wire, err := json.Marshal(servers[0]) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded struct { + Headers []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"headers"` + } + if err := json.Unmarshal(wire, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(decoded.Headers) != 2 { + t.Fatalf("wire headers = %#v, want the two custom headers only", decoded.Headers) + } + for _, header := range decoded.Headers { + if strings.EqualFold(header.Name, "Authorization") { + t.Fatalf("no bearer token was set, but an Authorization header was sent: %#v", decoded.Headers) + } + } + if decoded.Headers[0].Name != "x-api-key" || decoded.Headers[0].Value != composioAPIKey { + t.Errorf("first wire header = %#v, want x-api-key", decoded.Headers[0]) + } +} + +func TestBuildAmpMcpServer_HeaderValuesTravelInEnvNotArgs(t *testing.T) { + t.Parallel() + + entry := composioEntry() + entry.Token = "bearer-token" + server := buildAcpMcpServers([]McpServerEntry{entry}, "amp")[0].Stdio + if server == nil { + t.Fatal("expected the Amp stdio bridge") + } + + env := map[string]string{} + for _, variable := range server.Env { + env[variable.Name] = variable.Value + } + + // Every --header argument must parse the way mcp-remote parses it, and its value must be + // an ${ENV} reference that resolves to the intended secret. + sent := map[string]string{} + for i, arg := range server.Args { + if arg != "--header" { + continue + } + match := mcpRemoteHeaderArg.FindStringSubmatch(server.Args[i+1]) + if match == nil { + t.Fatalf("mcp-remote would drop header argument %q", server.Args[i+1]) + } + reference := strings.TrimSuffix(strings.TrimPrefix(match[2], "${"), "}") + value, ok := env[reference] + if !ok && match[1] != "Authorization" { + t.Fatalf("header %s references %q, which is not in the bridge env", match[1], match[2]) + } + sent[match[1]] = value + } + + if sent["x-api-key"] != composioAPIKey || sent["X-Org_Id"] != "org-42" { + t.Errorf("custom headers resolved to %#v", sent) + } + if env[ampMcpTokenEnvVar] != "bearer-token" { + t.Errorf("bearer token env = %q, want it kept alongside the custom headers", env[ampMcpTokenEnvVar]) + } + for _, arg := range server.Args { + if strings.Contains(arg, composioAPIKey) || strings.Contains(arg, "org-42") { + t.Fatalf("header value leaked into argv (visible in /proc/*/cmdline): %q", arg) + } + } + if last := server.Args[len(server.Args)-1]; last != "--silent" { + t.Errorf("last arg = %q, want --silent after every header", last) + } +} + +func TestGenerateCodexMcpConfig_RoutesCustomHeadersThroughEnv(t *testing.T) { + t.Parallel() + + config, envVars := generateCodexMcpConfig([]McpServerEntry{ + {URL: "https://api.example.com/mcp", Token: "sam-token", Name: SamMcpServerName}, + composioEntry(), + }, nil, "") + + var parsed struct { + McpServers map[string]struct { + URL string `toml:"url"` + BearerTokenEnvVar string `toml:"bearer_token_env_var"` + EnvHTTPHeaders map[string]string `toml:"env_http_headers"` + } `toml:"mcp_servers"` + } + if err := toml.Unmarshal([]byte(config), &parsed); err != nil { + t.Fatalf("managed Codex config is not valid TOML: %v\n%s", err, config) + } + + composio := parsed.McpServers["composio"] + wantHeaders := map[string]string{ + "x-api-key": "SAM_MCP_COMPOSIO_HEADER_0_SECRET", + "X-Org_Id": "SAM_MCP_COMPOSIO_HEADER_1_SECRET", + } + if len(composio.EnvHTTPHeaders) != len(wantHeaders) { + t.Fatalf("env_http_headers = %#v, want %#v", composio.EnvHTTPHeaders, wantHeaders) + } + for name, envVar := range wantHeaders { + if composio.EnvHTTPHeaders[name] != envVar { + t.Errorf("env_http_headers[%q] = %q, want %q", name, composio.EnvHTTPHeaders[name], envVar) + } + } + if composio.BearerTokenEnvVar != "" { + t.Errorf("tokenless server got bearer_token_env_var %q", composio.BearerTokenEnvVar) + } + if len(parsed.McpServers[SamMcpServerName].EnvHTTPHeaders) != 0 { + t.Error("sam-mcp has no custom headers and must not get env_http_headers") + } + + if strings.Contains(config, composioAPIKey) { + t.Fatal("header value was written into config.toml instead of the environment") + } + joined := "\n" + strings.Join(envVars, "\n") + "\n" + for _, want := range []string{ + "\nSAM_MCP_COMPOSIO_HEADER_0_SECRET=" + composioAPIKey + "\n", + "\nSAM_MCP_COMPOSIO_HEADER_1_SECRET=org-42\n", + } { + if !strings.Contains(joined, want) { + t.Errorf("env vars %v missing %q", envVars, strings.TrimSpace(want)) + } + } + for _, envVar := range envVars { + if !isSecretEnvVar(envVar) { + t.Errorf("%s would be passed through docker exec argv", strings.SplitN(envVar, "=", 2)[0]) + } + } +} + +// A "_TOKEN" suffix would make server "x"'s first header variable identical to the bearer +// variable of a server named "x-header-0", and one would overwrite the other. +func TestCodexMcpHeaderEnvVar_CannotCollideWithBearerEnvVar(t *testing.T) { + t.Parallel() + + headerVar := codexMcpHeaderEnvVar("x", 0) + bearerVar := codexMcpTokenEnvVar("x-header-0") + if headerVar == bearerVar { + t.Fatalf("header env var %q collides with bearer env var of server x-header-0", headerVar) + } + if !isSecretEnvVar(headerVar + "=value") { + t.Fatalf("%s is not classified as a secret", headerVar) + } +} + +func TestGenerateVibeConfig_IncludesCustomHeaders(t *testing.T) { + t.Parallel() + + entry := composioEntry() + entry.Token = "bearer-token" + config := generateVibeConfig("mistral-large", []McpServerEntry{entry}) + + var parsed struct { + McpServers []struct { + Name string `toml:"name"` + Headers map[string]string `toml:"headers"` + } `toml:"mcp_servers"` + } + if err := toml.Unmarshal([]byte(config), &parsed); err != nil { + t.Fatalf("Vibe config is not valid TOML: %v\n%s", err, config) + } + if len(parsed.McpServers) != 1 { + t.Fatalf("mcp_servers = %#v, want one server", parsed.McpServers) + } + want := map[string]string{ + "Authorization": "Bearer bearer-token", + "x-api-key": composioAPIKey, + "X-Org_Id": "org-42", + } + got := parsed.McpServers[0].Headers + if len(got) != len(want) { + t.Fatalf("headers = %#v, want %#v", got, want) + } + for name, value := range want { + if got[name] != value { + t.Errorf("headers[%q] = %q, want %q", name, got[name], value) + } + } +} + +// normalizeMcpServers rejects unsafe headers at the control-plane boundary; the config +// generators must still refuse to write one if it arrives by another path. The healthy +// server beside it is the liveness control: skipping must not drop everything. +func TestConfigGenerators_SkipServerWithUnsafeHeader(t *testing.T) { + t.Parallel() + + unsafe := McpServerEntry{ + URL: "https://evil.example/mcp", + Name: "evil", + Headers: []McpHeader{{Name: "x-api-key", Value: "abc\n[mcp_servers.injected]"}}, + } + entries := []McpServerEntry{composioEntry(), unsafe} + + codexConfig, codexEnv := generateCodexMcpConfig(entries, nil, "") + vibeConfig := generateVibeConfig("mistral-large", entries) + + for harness, config := range map[string]string{"codex": codexConfig, "vibe": vibeConfig} { + if strings.Contains(config, "evil.example") || strings.Contains(config, "injected") { + t.Errorf("%s config contains the unsafe server:\n%s", harness, config) + } + if !strings.Contains(config, "backend.composio.dev") { + t.Errorf("%s config dropped the safe server too:\n%s", harness, config) + } + } + for _, envVar := range codexEnv { + if strings.Contains(envVar, "injected") { + t.Errorf("unsafe header value reached the Codex environment: %q", envVar) + } + } +} + +func TestValidateMcpHeaders(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t-value" + cases := []struct { + name string + headers []McpHeader + wantErr bool + }{ + {"none", nil, false}, + {"composio api key", []McpHeader{{Name: "x-api-key", Value: secret}}, false}, + {"value with spaces and symbols", []McpHeader{{Name: "Authorization", Value: "Basic dXNlcjpwYXNz=="}}, false}, + {"name with a colon", []McpHeader{{Name: "x:api", Value: secret}}, true}, + {"name with a dot", []McpHeader{{Name: "x.api", Value: secret}}, true}, + {"name with a space", []McpHeader{{Name: "x api", Value: secret}}, true}, + {"empty name", []McpHeader{{Name: "", Value: secret}}, true}, + {"name over 64 characters", []McpHeader{{Name: strings.Repeat("a", 65), Value: secret}}, true}, + {"empty value", []McpHeader{{Name: "x-api-key", Value: ""}}, true}, + {"value with LF", []McpHeader{{Name: "x-api-key", Value: secret + "\n"}}, true}, + {"value with CR", []McpHeader{{Name: "x-api-key", Value: secret + "\r"}}, true}, + {"value with NUL", []McpHeader{{Name: "x-api-key", Value: secret + "\x00"}}, true}, + {"value with DEL", []McpHeader{{Name: "x-api-key", Value: secret + "\x7f"}}, true}, + {"second header invalid", []McpHeader{{Name: "a", Value: "ok"}, {Name: "b", Value: "bad\n"}}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidateMcpHeaders(tc.headers) + if (err != nil) != tc.wantErr { + t.Fatalf("ValidateMcpHeaders() error = %v, wantErr %v", err, tc.wantErr) + } + // The error travels to the control plane and into rows any project member can + // read, so it must never carry a header value. + if err != nil && strings.Contains(err.Error(), secret) { + t.Fatalf("error leaks the header value: %v", err) + } + }) + } +} diff --git a/packages/vm-agent/internal/acp/mcp_server_name_contract_test.go b/packages/vm-agent/internal/acp/mcp_server_name_contract_test.go index 9ec9de821..8074dfe44 100644 --- a/packages/vm-agent/internal/acp/mcp_server_name_contract_test.go +++ b/packages/vm-agent/internal/acp/mcp_server_name_contract_test.go @@ -26,6 +26,10 @@ type mcpNameContract struct { Valid []string `json:"valid"` Invalid []string `json:"invalid"` } `json:"urls"` + HeaderNames struct { + Valid []string `json:"valid"` + Invalid []string `json:"invalid"` + } `json:"headerNames"` } func loadMcpNameContract(t *testing.T) mcpNameContract { @@ -83,3 +87,26 @@ func TestMcpServerNameContract_Normalizes(t *testing.T) { } } } + +// Header names are judged as exact strings on both sides — no trimming or case folding — so a +// name the control plane stores is byte-for-byte the name written into TOML and mcp-remote +// arguments. The TypeScript half checks MCP_CONNECTION_HEADER_NAME_PATTERN against the same list. +func TestMcpHeaderNameContract(t *testing.T) { + t.Parallel() + contract := loadMcpNameContract(t) + + if len(contract.HeaderNames.Valid) < 8 || len(contract.HeaderNames.Invalid) < 15 { + t.Fatalf("header name corpus looks truncated: %d valid, %d invalid", + len(contract.HeaderNames.Valid), len(contract.HeaderNames.Invalid)) + } + for _, name := range contract.HeaderNames.Valid { + if !ValidMcpHeaderName(name) { + t.Errorf("ValidMcpHeaderName(%q) rejected a name the shared contract marks valid", name) + } + } + for _, name := range contract.HeaderNames.Invalid { + if ValidMcpHeaderName(name) { + t.Errorf("ValidMcpHeaderName(%q) accepted a name the shared contract marks invalid", name) + } + } +} diff --git a/packages/vm-agent/internal/acp/mcp_servers.go b/packages/vm-agent/internal/acp/mcp_servers.go index 2c065e2c8..cf5fb6690 100644 --- a/packages/vm-agent/internal/acp/mcp_servers.go +++ b/packages/vm-agent/internal/acp/mcp_servers.go @@ -1,14 +1,25 @@ package acp import ( + "fmt" + "strings" + acpsdk "github.com/coder/acp-go-sdk" ) const ( ampMcpRemotePackage = "mcp-remote@0.1.38" ampMcpTokenEnvVar = "SAM_MCP_TOKEN" + // ampMcpHeaderEnvVarPrefix + index names the variable that carries a custom header's value + // into the mcp-remote bridge, so values stay out of argv exactly like the token. + ampMcpHeaderEnvVarPrefix = "SAM_MCP_HEADER_" ) +// maxMcpHeaderNameLen bounds a control-plane-supplied header name. It mirrors +// MCP_CONNECTION_HEADER_NAME_MAX_LENGTH in packages/shared/src/types/mcp-connection.ts; the two +// are pinned together by packages/shared/src/fixtures/mcp-server-name-contract.json. +const maxMcpHeaderNameLen = 64 + // McpServerEntry is a lightweight MCP server config passed from the control // plane for injection into ACP sessions. It represents an HTTP MCP server with // optional bearer token authentication. @@ -20,10 +31,100 @@ const ( // Name is the agent-visible server name; tools are namespaced by it. It is // optional because a control plane older than this field does not send one, in // which case ResolveMcpServerNames falls back to the legacy positional scheme. +// +// Headers are custom HTTP headers sent alongside the bearer token, such as +// Composio's x-api-key. Optional for the same rollout reason as Name. type McpServerEntry struct { - URL string `json:"url"` - Token string `json:"token"` - Name string `json:"name,omitempty"` + URL string `json:"url"` + Token string `json:"token"` + Name string `json:"name,omitempty"` + Headers []McpHeader `json:"headers,omitempty"` +} + +// McpHeader is one custom HTTP header for an MCP server. Value is a secret. +type McpHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// httpHeaders returns every header the server receives, in order: the bearer token as +// Authorization, then the custom headers. Harnesses that take a literal header list use it; +// Codex and Amp route values through environment variables and build their own. +func (e McpServerEntry) httpHeaders() []McpHeader { + headers := make([]McpHeader, 0, len(e.Headers)+1) + if e.Token != "" { + headers = append(headers, McpHeader{Name: "Authorization", Value: "Bearer " + e.Token}) + } + return append(headers, e.Headers...) +} + +// safeForConfigFile reports whether the entry can be written into a harness config file +// without corrupting it. normalizeMcpServers rejects unsafe entries at the control-plane +// boundary; this is the last check before a value reaches TOML. +func (e McpServerEntry) safeForConfigFile() bool { + return !strings.ContainsAny(e.URL, "\r\n") && + !strings.ContainsAny(e.Token, "\r\n") && + ValidateMcpHeaders(e.Headers) == nil +} + +// ValidMcpHeaderName reports whether name can be written as a TOML key and passed to +// mcp-remote as `--header name:value`: 1-64 letters, digits, hyphens or underscores. +// mcp-remote silently drops any header whose name falls outside that set. +// +// This mirrors MCP_CONNECTION_HEADER_NAME_PATTERN in packages/shared/src/types/mcp-connection.ts +// — keep the two in sync. Unlike server names, header names are judged exactly as sent. +func ValidMcpHeaderName(name string) bool { + if name == "" || len(name) > maxMcpHeaderNameLen { + return false + } + for _, r := range name { + isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + isDigit := r >= '0' && r <= '9' + if !isLetter && !isDigit && r != '-' && r != '_' { + return false + } + } + return true +} + +// ValidMcpHeaderValue reports whether value is non-empty and free of control characters, any +// of which could split an HTTP header or a config-file line. +func ValidMcpHeaderValue(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +// ValidateMcpHeaders checks every header. The error never carries a value: it propagates to +// the control plane, which stores it where any project member can read it. +func ValidateMcpHeaders(headers []McpHeader) error { + for i, header := range headers { + if !ValidMcpHeaderName(header.Name) { + return fmt.Errorf("header %d has an invalid name", i) + } + if !ValidMcpHeaderValue(header.Value) { + return fmt.Errorf("header %q has an empty value or a control character", header.Name) + } + } + return nil +} + +// mcpHeadersTOMLTable renders headers as a TOML inline table, `{ "name" = "value", ... }`, +// where value decides what each header name maps to: the header value itself for Vibe, an +// environment variable name for Codex. +func mcpHeadersTOMLTable(headers []McpHeader, value func(index int, header McpHeader) string) string { + pairs := make([]string, len(headers)) + for i, header := range headers { + pairs[i] = fmt.Sprintf("\"%s\" = \"%s\"", + tomlEscapeBasicString(header.Name), tomlEscapeBasicString(value(i, header))) + } + return "{ " + strings.Join(pairs, ", ") + " }" } // buildAcpMcpServers converts McpServerEntry configs into acpsdk.McpServer @@ -41,11 +142,8 @@ func buildAcpMcpServers(entries []McpServerEntry, agentType string) []acpsdk.Mcp continue } var headers []acpsdk.HttpHeader - if e.Token != "" { - headers = append(headers, acpsdk.HttpHeader{ - Name: "Authorization", - Value: "Bearer " + e.Token, - }) + for _, header := range e.httpHeaders() { + headers = append(headers, acpsdk.HttpHeader{Name: header.Name, Value: header.Value}) } servers = append(servers, acpsdk.McpServer{ Http: &acpsdk.McpServerHttpInline{ @@ -62,9 +160,9 @@ func buildAcpMcpServers(entries []McpServerEntry, agentType string) []acpsdk.Mcp // KNOWN EXPOSURE (idea 01M0QQ7PTBDPG0DVR10XMKB679): entry.URL is passed as a positional CLI // argument, so it is visible in /proc//cmdline to anything running as the same container // user. That is fine for SAM's own static endpoint but NOT for a bring-your-own connection, -// where the URL can itself be a credential (pre-signed MCP URLs). The token below is already -// kept out of argv for exactly this reason; the URL should get the same treatment once it is -// verified how mcp-remote accepts a URL from the environment. +// where the URL can itself be a credential (pre-signed MCP URLs). The token and header values +// below are already kept out of argv for exactly this reason; the URL should get the same +// treatment once it is verified how mcp-remote accepts a URL from the environment. func buildAmpMcpServer(name string, entry McpServerEntry) acpsdk.McpServer { var env []acpsdk.EnvVariable args := []string{"-y", ampMcpRemotePackage, entry.URL} @@ -77,6 +175,12 @@ func buildAmpMcpServer(name string, entry McpServerEntry) acpsdk.McpServer { // The token is passed via env var (not in CLI args) to avoid /proc visibility. args = append(args, "--header", "Authorization:Bearer ${"+ampMcpTokenEnvVar+"}") } + // Custom headers take the same route: only the header NAME reaches argv. + for i, header := range entry.Headers { + envVar := fmt.Sprintf("%s%d", ampMcpHeaderEnvVarPrefix, i) + env = append(env, acpsdk.EnvVariable{Name: envVar, Value: header.Value}) + args = append(args, "--header", header.Name+":${"+envVar+"}") + } args = append(args, "--silent") return acpsdk.McpServer{ diff --git a/packages/vm-agent/internal/acp/vibe_config.go b/packages/vm-agent/internal/acp/vibe_config.go index 9d49f6287..6e39c105b 100644 --- a/packages/vm-agent/internal/acp/vibe_config.go +++ b/packages/vm-agent/internal/acp/vibe_config.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" "os" - "strings" ) // vibeDefaultActiveModel is the model alias used when no user model override @@ -105,16 +104,16 @@ temperature = 0.2 names := ResolveMcpServerNames(mcpServers) for i, server := range mcpServers { // Skip entries with control characters that would corrupt TOML - if strings.ContainsAny(server.URL, "\n\r") || strings.ContainsAny(server.Token, "\n\r") { - slog.Warn("Skipping MCP server with control characters in URL or token", + if !server.safeForConfigFile() { + slog.Warn("Skipping MCP server with control characters in its URL, token or headers", "index", i, "url_length", len(server.URL)) continue } safeURL := tomlEscapeBasicString(server.URL) config += fmt.Sprintf("\n[[mcp_servers]]\nname = \"%s\"\ntransport = \"http\"\nurl = \"%s\"\n", names[i], safeURL) - if server.Token != "" { - safeToken := tomlEscapeBasicString(server.Token) - config += fmt.Sprintf("headers = { Authorization = \"Bearer %s\" }\n", safeToken) + if headers := server.httpHeaders(); len(headers) > 0 { + headerValue := func(_ int, header McpHeader) string { return header.Value } + config += fmt.Sprintf("headers = %s\n", mcpHeadersTOMLTable(headers, headerValue)) } } diff --git a/packages/vm-agent/internal/persistence/session_mcp_servers.go b/packages/vm-agent/internal/persistence/session_mcp_servers.go index 9e50c67c6..905333506 100644 --- a/packages/vm-agent/internal/persistence/session_mcp_servers.go +++ b/packages/vm-agent/internal/persistence/session_mcp_servers.go @@ -2,6 +2,8 @@ package persistence import ( "database/sql" + "encoding/json" + "errors" "fmt" ) @@ -14,6 +16,16 @@ type McpServer struct { // Name is the agent-visible server name. Empty for rows written before the // name column existed; callers fall back to positional naming. Name string `json:"name,omitempty"` + // Headers are the server's custom HTTP headers, stored as a JSON array in the + // headers column. Empty for rows written before that column existed. + Headers []McpServerHeader `json:"headers,omitempty"` +} + +// McpServerHeader mirrors acp.McpHeader for the same reason McpServer mirrors +// acp.McpServerEntry. +type McpServerHeader struct { + Name string `json:"name"` + Value string `json:"value"` } // migrateV5 creates the session_mcp_servers table for persisting MCP server @@ -45,6 +57,15 @@ func migrateV12(db *sql.DB) error { return err } +// migrateV18 adds custom HTTP headers for injected MCP servers as a JSON array. +// +// Additive with an empty default, so rows written before it read back with no headers — +// which is exactly what they had. +func migrateV18(db *sql.DB) error { + _, err := db.Exec(`ALTER TABLE session_mcp_servers ADD COLUMN headers TEXT NOT NULL DEFAULT ''`) + return err +} + // UpsertSessionMcpServers replaces all MCP server entries for a session. // Passing an empty slice removes all servers for the session without error. // This is intentionally a full replace (delete + insert) so that the @@ -67,9 +88,13 @@ func (s *Store) UpsertSessionMcpServers(workspaceID, sessionID string, servers [ } for i, srv := range servers { + headers, err := encodeMcpServerHeaders(srv.Headers) + if err != nil { + return fmt.Errorf("upsert session mcp servers: encode headers for row %d: %w", i, err) + } if _, err := tx.Exec( - "INSERT INTO session_mcp_servers (workspace_id, session_id, sort_order, url, token, name) VALUES (?, ?, ?, ?, ?, ?)", - workspaceID, sessionID, i, srv.URL, srv.Token, srv.Name, + "INSERT INTO session_mcp_servers (workspace_id, session_id, sort_order, url, token, name, headers) VALUES (?, ?, ?, ?, ?, ?, ?)", + workspaceID, sessionID, i, srv.URL, srv.Token, srv.Name, headers, ); err != nil { return fmt.Errorf("upsert session mcp servers: insert row %d: %w", i, err) } @@ -88,7 +113,7 @@ func (s *Store) GetSessionMcpServers(workspaceID, sessionID string) ([]McpServer defer s.mu.RUnlock() rows, err := s.db.Query( - "SELECT url, token, name FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ? ORDER BY sort_order ASC", + "SELECT url, token, name, headers FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ? ORDER BY sort_order ASC", workspaceID, sessionID, ) if err != nil { @@ -99,9 +124,13 @@ func (s *Store) GetSessionMcpServers(workspaceID, sessionID string) ([]McpServer servers := []McpServer{} for rows.Next() { var srv McpServer - if err := rows.Scan(&srv.URL, &srv.Token, &srv.Name); err != nil { + var headers string + if err := rows.Scan(&srv.URL, &srv.Token, &srv.Name, &headers); err != nil { return nil, fmt.Errorf("get session mcp servers: scan: %w", err) } + if srv.Headers, err = decodeMcpServerHeaders(headers); err != nil { + return nil, fmt.Errorf("get session mcp servers: row %d: %w", len(servers), err) + } servers = append(servers, srv) } if err := rows.Err(); err != nil { @@ -141,3 +170,29 @@ func (s *Store) DeleteWorkspaceMcpServers(workspaceID string) error { } return nil } + +// encodeMcpServerHeaders stores "no headers" as an empty string rather than "[]" or "null", +// matching the column default that rows written before the column existed carry. +func encodeMcpServerHeaders(headers []McpServerHeader) (string, error) { + if len(headers) == 0 { + return "", nil + } + encoded, err := json.Marshal(headers) + if err != nil { + return "", err + } + return string(encoded), nil +} + +// decodeMcpServerHeaders reverses encodeMcpServerHeaders. The error deliberately omits the +// column contents, which hold header values. +func decodeMcpServerHeaders(encoded string) ([]McpServerHeader, error) { + if encoded == "" { + return nil, nil + } + var headers []McpServerHeader + if err := json.Unmarshal([]byte(encoded), &headers); err != nil { + return nil, errors.New("headers column is not a JSON header list") + } + return headers, nil +} diff --git a/packages/vm-agent/internal/persistence/session_mcp_servers_test.go b/packages/vm-agent/internal/persistence/session_mcp_servers_test.go new file mode 100644 index 000000000..375c5e833 --- /dev/null +++ b/packages/vm-agent/internal/persistence/session_mcp_servers_test.go @@ -0,0 +1,121 @@ +package persistence + +import ( + "strings" + "testing" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + store, err := Open(tempDBPath(t)) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +func TestSessionMcpServerHeadersRoundTrip(t *testing.T) { + store := openTestStore(t) + + servers := []McpServer{ + {URL: "https://api.example.com/mcp", Token: "sam-token", Name: "sam-mcp"}, + { + URL: "https://backend.composio.dev/mcp", + Name: "composio", + Headers: []McpServerHeader{ + {Name: "x-api-key", Value: "ak_live_secret"}, + {Name: "X-Org_Id", Value: "org-42"}, + }, + }, + } + if err := store.UpsertSessionMcpServers("ws-1", "sess-1", servers); err != nil { + t.Fatalf("UpsertSessionMcpServers: %v", err) + } + + got, err := store.GetSessionMcpServers("ws-1", "sess-1") + if err != nil { + t.Fatalf("GetSessionMcpServers: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 servers, got %d", len(got)) + } + if len(got[0].Headers) != 0 { + t.Errorf("server without headers read back %#v", got[0].Headers) + } + if len(got[1].Headers) != 2 || got[1].Headers[0] != servers[1].Headers[0] || got[1].Headers[1] != servers[1].Headers[1] { + t.Errorf("headers = %#v, want %#v in order", got[1].Headers, servers[1].Headers) + } + + // "No headers" is stored as the column default, not "[]" or "null", so a server without + // headers is indistinguishable from a row written before the column existed. + var raw string + if err := store.db.QueryRow( + "SELECT headers FROM session_mcp_servers WHERE session_id = ? AND sort_order = 0", "sess-1", + ).Scan(&raw); err != nil { + t.Fatalf("read raw headers column: %v", err) + } + if raw != "" { + t.Errorf("headerless server stored %q, want the empty column default", raw) + } +} + +// A vm-agent upgraded across migrateV18 holds session rows written without a headers column. +// They must read back exactly as they were — same server, no headers — not fail the read, +// because a failed read leaves a restarted session with no MCP servers at all. +func TestMigrationV18KeepsExistingMcpServerRows(t *testing.T) { + dbPath := tempDBPath(t) + store, err := Open(dbPath) + if err != nil { + t.Fatalf("Open: %v", err) + } + // Rewind the database to its pre-V18 shape and write a row the old agent would have. + for _, stmt := range []string{ + "ALTER TABLE session_mcp_servers DROP COLUMN headers", + "DELETE FROM schema_version WHERE version = 18", + "INSERT INTO session_mcp_servers (workspace_id, session_id, sort_order, url, token, name) " + + "VALUES ('ws-1', 'sess-1', 0, 'https://mcp.zapier.com/x', 'zap-token', 'zapier')", + } { + if _, err := store.db.Exec(stmt); err != nil { + t.Fatalf("rewind to V17 (%s): %v", stmt, err) + } + } + store.Close() + + upgraded, err := Open(dbPath) + if err != nil { + t.Fatalf("Open after rewind (runs migrateV18): %v", err) + } + defer upgraded.Close() + + got, err := upgraded.GetSessionMcpServers("ws-1", "sess-1") + if err != nil { + t.Fatalf("GetSessionMcpServers on an upgraded row: %v", err) + } + if len(got) != 1 || got[0].Name != "zapier" || got[0].Token != "zap-token" { + t.Fatalf("upgraded row = %#v, want the original zapier server", got) + } + if len(got[0].Headers) != 0 { + t.Errorf("upgraded row gained headers: %#v", got[0].Headers) + } +} + +func TestGetSessionMcpServers_MalformedHeadersFailWithoutLeakingThem(t *testing.T) { + store := openTestStore(t) + const secret = "ak_live_secret" + if _, err := store.db.Exec( + "INSERT INTO session_mcp_servers (workspace_id, session_id, sort_order, url, token, name, headers) "+ + "VALUES ('ws-1', 'sess-1', 0, 'https://backend.composio.dev/mcp', '', 'composio', ?)", + `[{"name":"x-api-key","value":"`+secret+`"`, // truncated JSON + ); err != nil { + t.Fatalf("insert malformed row: %v", err) + } + + _, err := store.GetSessionMcpServers("ws-1", "sess-1") + if err == nil { + t.Fatal("expected a malformed headers column to fail the read") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaks the stored header value: %v", err) + } +} diff --git a/packages/vm-agent/internal/persistence/store.go b/packages/vm-agent/internal/persistence/store.go index 7995c24ad..0756cae6f 100644 --- a/packages/vm-agent/internal/persistence/store.go +++ b/packages/vm-agent/internal/persistence/store.go @@ -155,6 +155,7 @@ func (s *Store) migrate() error { migrateV15, migrateV16, migrateV17, + migrateV18, } for i := version; i < len(migrations); i++ { diff --git a/packages/vm-agent/internal/server/agent_ws.go b/packages/vm-agent/internal/server/agent_ws.go index 093d131a4..a49f28e67 100644 --- a/packages/vm-agent/internal/server/agent_ws.go +++ b/packages/vm-agent/internal/server/agent_ws.go @@ -243,10 +243,7 @@ func (s *Server) getOrCreateSessionHostForRestore(hostKey, workspaceID, sessionI var prefetchedMcpServers []acp.McpServerEntry if s.store != nil { if persisted, err := s.store.GetSessionMcpServers(workspaceID, sessionID); err == nil && len(persisted) > 0 { - prefetchedMcpServers = make([]acp.McpServerEntry, len(persisted)) - for i, p := range persisted { - prefetchedMcpServers[i] = acp.McpServerEntry{URL: p.URL, Token: p.Token, Name: p.Name} - } + prefetchedMcpServers = fromPersistedMcpServers(persisted) } else if err != nil { slog.Warn("Failed to read MCP servers from SQLite", "workspace", workspaceID, "sessionId", sessionID, "error", err) diff --git a/packages/vm-agent/internal/server/mcp_servers.go b/packages/vm-agent/internal/server/mcp_servers.go index acdded881..56b5a1344 100644 --- a/packages/vm-agent/internal/server/mcp_servers.go +++ b/packages/vm-agent/internal/server/mcp_servers.go @@ -28,9 +28,19 @@ func normalizeMcpServers(entries []acp.McpServerEntry) ([]acp.McpServerEntry, er if !strings.HasPrefix(u, "https://") && !isLocalhost { return nil, fmt.Errorf("mcpServers[%d].url must use HTTPS (or http:// on localhost/127.0.0.1 with an explicit port)", i) } + // Header values reach TOML files and mcp-remote arguments, so a malformed header fails + // the request here, like a malformed URL, rather than being written out. + if err := acp.ValidateMcpHeaders(srv.Headers); err != nil { + return nil, fmt.Errorf("mcpServers[%d]: %w", i, err) + } // Every field must be copied explicitly: this rebuilds the struct, so a field added // upstream and forgotten here is silently dropped rather than failing to compile. - normalized[i] = acp.McpServerEntry{URL: u, Token: srv.Token, Name: strings.TrimSpace(srv.Name)} + normalized[i] = acp.McpServerEntry{ + URL: u, + Token: srv.Token, + Name: strings.TrimSpace(srv.Name), + Headers: srv.Headers, + } } return normalized, nil } @@ -48,11 +58,7 @@ func (s *Server) registerSessionMcpServers(workspaceID, sessionID string, entrie // Persist to SQLite so MCP servers survive VM agent restarts and // are available even if a WebSocket creates the SessionHost first. if s.store != nil { - persistEntries := make([]persistence.McpServer, len(entries)) - for i, srv := range entries { - persistEntries[i] = persistence.McpServer{URL: srv.URL, Token: srv.Token, Name: srv.Name} - } - if err := s.store.UpsertSessionMcpServers(workspaceID, sessionID, persistEntries); err != nil { + if err := s.store.UpsertSessionMcpServers(workspaceID, sessionID, toPersistedMcpServers(entries)); err != nil { slog.Warn("Failed to persist MCP servers to SQLite", "workspace", workspaceID, "session", sessionID, "error", err) } @@ -61,3 +67,31 @@ func (s *Server) registerSessionMcpServers(workspaceID, sessionID string, entrie slog.Info("MCP servers registered for agent session", "workspace", workspaceID, "session", sessionID, "count", len(entries)) } + +// toPersistedMcpServers and fromPersistedMcpServers are the only conversions between the acp +// and persistence shapes. Both rebuild structs field by field, so a field added to one side and +// not copied here is silently dropped. TestMcpServerNameSurvivesFullRoundTrip and +// TestMcpServerHeadersSurviveFullRoundTrip guard that. +func toPersistedMcpServers(entries []acp.McpServerEntry) []persistence.McpServer { + servers := make([]persistence.McpServer, len(entries)) + for i, entry := range entries { + headers := make([]persistence.McpServerHeader, len(entry.Headers)) + for j, header := range entry.Headers { + headers[j] = persistence.McpServerHeader{Name: header.Name, Value: header.Value} + } + servers[i] = persistence.McpServer{URL: entry.URL, Token: entry.Token, Name: entry.Name, Headers: headers} + } + return servers +} + +func fromPersistedMcpServers(servers []persistence.McpServer) []acp.McpServerEntry { + entries := make([]acp.McpServerEntry, len(servers)) + for i, server := range servers { + headers := make([]acp.McpHeader, len(server.Headers)) + for j, header := range server.Headers { + headers[j] = acp.McpHeader{Name: header.Name, Value: header.Value} + } + entries[i] = acp.McpServerEntry{URL: server.URL, Token: server.Token, Name: server.Name, Headers: headers} + } + return entries +} diff --git a/packages/vm-agent/internal/server/mcp_servers_headers_test.go b/packages/vm-agent/internal/server/mcp_servers_headers_test.go new file mode 100644 index 000000000..615e8bc44 --- /dev/null +++ b/packages/vm-agent/internal/server/mcp_servers_headers_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "strings" + "testing" + "time" + + "github.com/workspace/vm-agent/internal/acp" + "github.com/workspace/vm-agent/internal/agentsessions" +) + +// TestMcpServerHeadersSurviveFullRoundTrip drives custom headers through all three field-by- +// field conversions an MCP entry passes through — normalizeMcpServers, acp -> persistence, and +// the restart backfill persistence -> acp — then asserts the SessionHost would receive them. +// A conversion that forgets the field compiles fine and drops the header silently, which for +// Composio means every tool call fails authentication. +func TestMcpServerHeadersSurviveFullRoundTrip(t *testing.T) { + s, store := newMcpTestServer(t) + + wantHeaders := []acp.McpHeader{ + {Name: "x-api-key", Value: "ak_live_secret"}, + {Name: "X-Org_Id", Value: "org-42"}, + } + entries, err := normalizeMcpServers([]acp.McpServerEntry{ + {URL: "https://api.example.com/mcp", Token: "sam-token", Name: acp.SamMcpServerName}, + {URL: "https://backend.composio.dev/v3/mcp/x", Name: "composio", Headers: wantHeaders}, + }) + if err != nil { + t.Fatalf("normalizeMcpServers: %v", err) + } + assertHeaders(t, "normalizeMcpServers", entries[1].Headers, wantHeaders) + if len(entries[0].Headers) != 0 { + t.Errorf("sam-mcp gained headers during normalization: %#v", entries[0].Headers) + } + + s.registerSessionMcpServers("ws-1", "sess-1", entries) + + persisted, err := store.GetSessionMcpServers("ws-1", "sess-1") + if err != nil { + t.Fatalf("GetSessionMcpServers: %v", err) + } + if len(persisted) != 2 { + t.Fatalf("expected 2 persisted servers, got %d", len(persisted)) + } + gotPersisted := make([]acp.McpHeader, len(persisted[1].Headers)) + for i, header := range persisted[1].Headers { + gotPersisted[i] = acp.McpHeader{Name: header.Name, Value: header.Value} + } + assertHeaders(t, "persistence", gotPersisted, wantHeaders) + + // A vm-agent restart empties the in-memory map; the SessionHost is then built from SQLite. + hostKey := "ws-1:sess-1" + delete(s.sessionMcpServers, hostKey) + host := s.getOrCreateSessionHost(hostKey, "ws-1", "sess-1", agentsessions.Session{ + ID: "sess-1", + WorkspaceID: "ws-1", + AgentType: "claude-code", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }, nil, "") + if host == nil { + t.Fatal("expected SessionHost") + } + + backfilled := s.sessionMcpServers[hostKey] + if len(backfilled) != 2 { + t.Fatalf("expected 2 backfilled servers, got %d", len(backfilled)) + } + // Liveness: the fields that already round-tripped still do, so a passing header assertion + // cannot mean the backfill returned something unrelated. + if backfilled[1].Name != "composio" || backfilled[1].URL != "https://backend.composio.dev/v3/mcp/x" { + t.Fatalf("backfilled entry lost its identity: %#v", backfilled[1]) + } + assertHeaders(t, "restart backfill", backfilled[1].Headers, wantHeaders) +} + +// normalizeMcpServers is the trust boundary for control-plane-supplied values. A malformed +// header fails the whole request (like a malformed URL), and the error it returns travels to +// the control plane, so it must identify the server without echoing the value. +func TestNormalizeMcpServersRejectsUnsafeHeaders(t *testing.T) { + const secret = "ak_live_secret" + cases := []struct { + name string + header acp.McpHeader + }{ + {"header name mcp-remote cannot parse", acp.McpHeader{Name: "x:api-key", Value: secret}}, + {"header value with a line break", acp.McpHeader{Name: "x-api-key", Value: secret + "\n[mcp_servers.x]"}}, + {"empty header value", acp.McpHeader{Name: "x-api-key", Value: ""}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := normalizeMcpServers([]acp.McpServerEntry{ + {URL: "https://api.example.com/mcp", Token: "sam-token", Name: acp.SamMcpServerName}, + {URL: "https://backend.composio.dev/mcp", Name: "composio", Headers: []acp.McpHeader{tc.header}}, + }) + if err == nil { + t.Fatal("expected normalizeMcpServers to reject the header") + } + if !strings.Contains(err.Error(), "mcpServers[1]") { + t.Errorf("error %q does not identify the offending server", err) + } + if strings.Contains(err.Error(), secret) { + t.Errorf("error leaks the header value: %q", err) + } + }) + } +} + +func assertHeaders(t *testing.T, stage string, got, want []acp.McpHeader) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: headers = %#v, want %#v", stage, got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("%s: header[%d] = %#v, want %#v", stage, i, got[i], want[i]) + } + } +} From 1bb1639c2ea16da56fc705fe038d1a56b6ada272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 06:25:42 +0000 Subject: [PATCH 04/18] test(mcp): cover custom MCP headers across API, wire contract and vm-agent - API: validation, encryption at rest, never-returned values, PATCH keep/rotate/ remove/clear semantics, bearer vs Authorization conflict, unreadable-ciphertext recovery, rule-50 list tolerance (real SQLite). - Vertical slice: a connection saved through the write path authorizes against a live x-api-key MCP server using only what session resolution injects; corrupt or vm-agent-unsafe rows are skipped without logging secrets. - Shared wire fixture mcp-server-entry-wire.json: node-agent must serialize it exactly; the real vm-agent create-agent-session handler must accept it. - vm-agent: ACP/Amp/Codex/Vibe output (TOML parsed, mcp-remote regex pinned), env var collision + secret classification, boundary validation, full normalize -> SQLite -> restart backfill round trip, migrateV18 upgrade. - Every guard verified discriminating by mutation (15 mutations, all red). Co-Authored-By: Claude Opus 5.5 --- .../tests/unit/node-agent-contract.test.ts | 57 ++++ .../mcp-connection-headers-injection.test.ts | 190 ++++++++++++ .../services/mcp-connection-headers.test.ts | 271 ++++++++++++++++++ .../src/fixtures/mcp-server-entry-wire.json | 39 +++ .../internal/server/mcp_servers_wire_test.go | 74 +++++ .../vm-agent/internal/server/messages-ws.db | Bin 0 -> 4096 bytes .../internal/server/messages-ws.db-shm | Bin 0 -> 32768 bytes .../internal/server/messages-ws.db-wal | Bin 0 -> 24752 bytes 8 files changed, 631 insertions(+) create mode 100644 apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts create mode 100644 apps/api/tests/unit/services/mcp-connection-headers.test.ts create mode 100644 packages/shared/src/fixtures/mcp-server-entry-wire.json create mode 100644 packages/vm-agent/internal/server/mcp_servers_wire_test.go create mode 100644 packages/vm-agent/internal/server/messages-ws.db create mode 100644 packages/vm-agent/internal/server/messages-ws.db-shm create mode 100644 packages/vm-agent/internal/server/messages-ws.db-wal diff --git a/apps/api/tests/unit/node-agent-contract.test.ts b/apps/api/tests/unit/node-agent-contract.test.ts index 9ba1f10e2..f63f24c97 100644 --- a/apps/api/tests/unit/node-agent-contract.test.ts +++ b/apps/api/tests/unit/node-agent-contract.test.ts @@ -6,6 +6,8 @@ * parsing follows the documented contract. */ +import { readFileSync } from 'node:fs'; + import { AgentSessionResponseSchema, CallbackTokenClaimsSchema, @@ -1017,6 +1019,61 @@ describe('Node Agent client functions send correct payloads', () => { ]); }); + it('createAgentSessionOnNode sends custom headers exactly as the shared wire fixture', async () => { + // The same fixture is posted to the real vm-agent handler in + // packages/vm-agent/internal/server/mcp_servers_wire_test.go, so the two sides cannot drift. + const wire = JSON.parse( + readFileSync( + new URL('../../../../packages/shared/src/fixtures/mcp-server-entry-wire.json', import.meta.url), + 'utf8' + ) + ) as { mcpServers: unknown[] }; + fetchWithTimeoutMock.mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sess-headers', + workspaceId: 'ws-test', + status: 'running', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }), + { status: 201, headers: { 'Content-Type': 'application/json' } } + ) + ); + + await createAgentSessionOnNode( + 'node-abc', + 'ws-test', + 'sess-headers', + null, + makeNodeAgentTestEnv(), + 'user-123', + 'chat-123', + 'proj-123', + [ + { url: 'https://api.example.com/mcp', token: 'sam-token', name: 'sam-mcp' }, + { + url: 'https://backend.composio.dev/v3/mcp/server-1', + token: '', + name: 'composio', + headers: [ + { name: 'x-api-key', value: 'ak_fixture_key' }, + { name: 'X-Org_Id', value: 'org-42' }, + ], + }, + // An empty list is omitted, so the entry stays byte-identical to an older control plane's. + { url: 'https://mcp.zapier.com/x', token: 'zap-token', name: 'zapier', headers: [] }, + ] + ); + + const [, capturedInit] = fetchWithTimeoutMock.mock.calls[0] as [string, RequestInit]; + const parsedBody = JSON.parse(capturedInit.body as string); + expect(CreateAgentSessionAgentRequestSchema.safeParse(parsedBody).success).toBe(true); + expect(parsedBody.mcpServers).toEqual(wire.mcpServers); + expect(parsedBody.mcpServers[0]).not.toHaveProperty('headers'); + expect(parsedBody.mcpServers[2]).not.toHaveProperty('headers'); + }); + it('node agent request throws on non-ok response', async () => { fetchWithTimeoutMock.mockResolvedValue( new Response(JSON.stringify({ error: 'workspace not found' }), { diff --git a/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts b/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts new file mode 100644 index 000000000..ce7a7eb46 --- /dev/null +++ b/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts @@ -0,0 +1,190 @@ +/** + * Vertical slice for custom MCP headers: a connection saved through the real write path, + * resolved through the real session-start composition, then used against a real HTTP MCP + * server that — like Composio — authenticates with an `x-api-key` header and no bearer token. + */ +import { createServer, type IncomingHttpHeaders, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import type { McpServerEntry } from '@simple-agent-manager/shared'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/d1'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as schema from '../../../src/db/schema'; +import { log } from '../../../src/lib/logger'; +import { + buildSessionMcpServers, + resolveMcpServersForSession, +} from '../../../src/services/mcp-connection-resolution'; +import { createMcpConnection } from '../../../src/services/mcp-connections'; +import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; + +const ENCRYPTION_KEY = Buffer.alloc(32, 5).toString('base64'); +const LIMITS = { maxPerScope: 25, urlMaxBytes: 2048, tokenMaxBytes: 8192, maxHeaders: 10, headerValueMaxBytes: 8192 }; +const API_KEY = 'ak_live_composio_secret'; + +type Db = ReturnType>; + +interface ApiKeyMcpServer { + url: string; + seen: IncomingHttpHeaders[]; + close: () => Promise; +} + +/** Answers MCP JSON-RPC only when `x-api-key` matches, the way Composio's endpoint does. */ +async function startApiKeyMcpServer(apiKey: string): Promise { + const seen: IncomingHttpHeaders[] = []; + const server: Server = createServer((req, res) => { + seen.push(req.headers); + if (req.headers['x-api-key'] !== apiKey) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'missing or invalid x-api-key' })); + return; + } + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const request = JSON.parse(body || '{}') as { id?: number; method?: string }; + const result = + request.method === 'tools/list' + ? { tools: [{ name: 'GMAIL_SEND_EMAIL', inputSchema: { type: 'object' } }] } + : { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'composio-mock' } }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id ?? 1, result })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/mcp`, + seen, + close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + }; +} + +/** Sends one JSON-RPC call exactly the way a harness would, using only what SAM injected. */ +async function callAsHarness(entry: McpServerEntry, method: string) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (entry.token) { + headers.Authorization = `Bearer ${entry.token}`; + } + for (const header of entry.headers ?? []) { + headers[header.name] = header.value; + } + const response = await fetch(entry.url, { + method: 'POST', + headers, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method }), + }); + return { status: response.status, body: (await response.json()) as { result?: { tools?: Array<{ name: string }> } } }; +} + +let sqlite: Database.Database; +let db: Db; +let mcpServer: ApiKeyMcpServer; + +beforeAll(async () => { + mcpServer = await startApiKeyMcpServer(API_KEY); +}); + +afterAll(async () => { + await mcpServer.close(); +}); + +beforeEach(() => { + sqlite = new Database(':memory:'); + createSchemaTables(sqlite, [schema.mcpConnections]); + db = drizzle(createSqliteD1(sqlite), { schema }); +}); + +function saveComposio(overrides: Record = {}) { + return createMcpConnection(db, { + userId: 'user-1', + projectId: 'proj-1', + name: 'composio', + url: mcpServer.url, + authType: 'none', + token: null, + headers: [{ name: 'x-api-key', value: API_KEY }], + enabled: true, + limits: LIMITS, + encryptionKey: ENCRYPTION_KEY, + ...overrides, + }); +} + +describe('custom headers, end to end', () => { + it('the headers SAM injects authorize against an x-api-key MCP endpoint', async () => { + await saveComposio(); + + const servers = await buildSessionMcpServers( + db, + { baseDomain: 'example.com', encryptionKey: ENCRYPTION_KEY }, + { userId: 'member-2', projectId: 'proj-1' }, + 'sam-session-token' + ); + + expect(servers.map((server) => server.name)).toEqual(['sam-mcp', 'composio']); + const composio = servers[1]; + expect(composio.token).toBe(''); + expect(composio.headers).toEqual([{ name: 'x-api-key', value: API_KEY }]); + // SAM's own entry never picks up another server's headers. + expect(servers[0].headers).toBeUndefined(); + + const initialize = await callAsHarness(composio, 'initialize'); + expect(initialize.status).toBe(200); + const tools = await callAsHarness(composio, 'tools/list'); + expect(tools.body.result?.tools?.map((tool) => tool.name)).toEqual(['GMAIL_SEND_EMAIL']); + expect(mcpServer.seen.at(-1)?.['x-api-key']).toBe(API_KEY); + }); + + it('the endpoint really rejects a request without the header (the check is not a formality)', async () => { + const response = await callAsHarness({ url: mcpServer.url, token: '', name: 'composio' }, 'initialize'); + expect(response.status).toBe(401); + }); + + it('a connection without headers resolves with no headers key, as before this feature', async () => { + await saveComposio({ headers: undefined }); + + const [entry] = await resolveMcpServersForSession(db, { userId: 'user-1', projectId: 'proj-1' }, ENCRYPTION_KEY); + + expect(entry).toEqual({ url: mcpServer.url, token: '', name: 'composio' }); + }); +}); + +describe('header fault isolation on the session-start path', () => { + it('skips a row whose headers cannot be decrypted, keeps the rest, and logs no secret', async () => { + const broken = await saveComposio({ name: 'broken' }); + await saveComposio({ name: 'healthy' }); + sqlite.prepare('UPDATE mcp_connections SET encrypted_headers = ? WHERE id = ?').run('garbage', broken.id); + const warn = vi.spyOn(log, 'warn'); + + const resolved = await resolveMcpServersForSession(db, { userId: 'user-1', projectId: 'proj-1' }, ENCRYPTION_KEY); + + expect(resolved.map((entry) => entry.name)).toEqual(['healthy']); + const skipLog = warn.mock.calls.find(([event]) => event === 'mcp_connections.row_skipped'); + expect(skipLog?.[1]).toMatchObject({ connectionId: broken.id, action: 'skipped' }); + expect(JSON.stringify(warn.mock.calls)).not.toContain(API_KEY); + warn.mockRestore(); + }); + + it('skips a row whose stored header would make the vm-agent reject the whole session', async () => { + // Constructed through the real write path, then corrupted into a shape the write path + // refuses: the resolver must hold the same line, because the vm-agent fails the entire + // create-agent-session request over one malformed header. + const broken = await saveComposio({ name: 'broken' }); + await saveComposio({ name: 'healthy' }); + const { encrypt } = await import('../../../src/services/encryption'); + const sealed = await encrypt(JSON.stringify([{ name: 'x api key', value: API_KEY }]), ENCRYPTION_KEY); + sqlite + .prepare('UPDATE mcp_connections SET encrypted_headers = ?, headers_iv = ? WHERE id = ?') + .run(sealed.ciphertext, sealed.iv, broken.id); + + const resolved = await resolveMcpServersForSession(db, { userId: 'user-1', projectId: 'proj-1' }, ENCRYPTION_KEY); + + expect(resolved.map((entry) => entry.name)).toEqual(['healthy']); + }); +}); diff --git a/apps/api/tests/unit/services/mcp-connection-headers.test.ts b/apps/api/tests/unit/services/mcp-connection-headers.test.ts new file mode 100644 index 000000000..c2497a8da --- /dev/null +++ b/apps/api/tests/unit/services/mcp-connection-headers.test.ts @@ -0,0 +1,271 @@ +/** + * Custom HTTP headers on bring-your-own MCP servers. + * + * Runs against a real in-memory SQLite engine so every read-back is the stored bytes, not a + * mock's echo of what was written (rule 28). Values are secrets: several tests assert they never + * appear in a response, a plaintext column or an error message. + */ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/d1'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import * as schema from '../../../src/db/schema'; +import { openMcpConnectionHeaders } from '../../../src/services/mcp-connection-headers'; +import { + createMcpConnection, + listMcpConnections, + updateMcpConnection, +} from '../../../src/services/mcp-connections'; +import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; + +const ENCRYPTION_KEY = Buffer.alloc(32, 9).toString('base64'); +const LIMITS = { + maxPerScope: 25, + urlMaxBytes: 2048, + tokenMaxBytes: 8192, + maxHeaders: 4, + headerValueMaxBytes: 64, +}; +const API_KEY = 'ak_live_composio_secret'; + +type Db = ReturnType>; + +let sqlite: Database.Database; +let db: Db; + +beforeEach(() => { + sqlite = new Database(':memory:'); + createSchemaTables(sqlite, [schema.mcpConnections]); + db = drizzle(createSqliteD1(sqlite), { schema }); +}); + +function createComposio(overrides: Record = {}) { + return createMcpConnection(db, { + userId: 'user-1', + projectId: null, + name: 'composio', + url: 'https://backend.composio.dev/v3/mcp/server-1', + authType: 'none', + token: null, + headers: [{ name: 'x-api-key', value: API_KEY }], + enabled: true, + limits: LIMITS, + encryptionKey: ENCRYPTION_KEY, + ...overrides, + }); +} + +function update(connectionId: string, changes: Record) { + return updateMcpConnection(db, { + userId: 'user-1', + projectId: null, + connectionId, + limits: LIMITS, + encryptionKey: ENCRYPTION_KEY, + ...changes, + }); +} + +function storedRow(id: string) { + return sqlite.prepare('SELECT * FROM mcp_connections WHERE id = ?').get(id) as Record; +} + +async function storedHeaders(id: string) { + const row = storedRow(id); + return openMcpConnectionHeaders( + { encryptedHeaders: row.encrypted_headers, headersIv: row.headers_iv }, + ENCRYPTION_KEY + ); +} + +describe('creating a connection with custom headers', () => { + it('returns header names but never values, and encrypts the values at rest', async () => { + const created = await createComposio({ + headers: [ + { name: ' x-api-key ', value: ` ${API_KEY} ` }, + { name: 'X-Org_Id', value: 'org-42' }, + ], + }); + + expect(created.headerNames).toEqual(['x-api-key', 'X-Org_Id']); + expect(JSON.stringify(created)).not.toContain(API_KEY); + expect(JSON.stringify(created)).not.toContain('org-42'); + + const row = storedRow(created.id); + expect(row.header_names).toBe('["x-api-key","X-Org_Id"]'); + expect(row.encrypted_headers).not.toContain(API_KEY); + expect(row.headers_iv).toBeTruthy(); + // Names and values are trimmed before they are sealed. + expect(await storedHeaders(created.id)).toEqual([ + { name: 'x-api-key', value: API_KEY }, + { name: 'X-Org_Id', value: 'org-42' }, + ]); + }); + + it('stores nothing when no headers are given, exactly like a pre-headers row', async () => { + const created = await createComposio({ headers: undefined }); + + expect(created.headerNames).toEqual([]); + const row = storedRow(created.id); + expect(row.header_names).toBe('[]'); + expect(row.encrypted_headers).toBeNull(); + expect(row.headers_iv).toBeNull(); + }); + + it('allows a custom Authorization header when authType is none (non-Bearer schemes)', async () => { + const created = await createComposio({ + headers: [{ name: 'Authorization', value: 'Basic dXNlcjpwYXNz' }], + }); + expect(created.headerNames).toEqual(['Authorization']); + }); + + it.each([ + ['a name mcp-remote cannot parse', [{ name: 'x:api-key', value: API_KEY }], /Invalid header name/], + ['a name with a dot', [{ name: 'x.api.key', value: API_KEY }], /Invalid header name/], + ['a name over 64 characters', [{ name: 'x'.repeat(65), value: API_KEY }], /Invalid header name/], + ['a transport-managed header', [{ name: 'Content-Type', value: 'text/plain' }], /set by the MCP transport/], + ['a reserved header in any case', [{ name: 'MCP-SESSION-ID', value: 'abc' }], /set by the MCP transport/], + [ + 'a duplicate name differing only in case', + [ + { name: 'x-api-key', value: API_KEY }, + { name: 'X-API-KEY', value: API_KEY }, + ], + /more than once/, + ], + ['an empty value', [{ name: 'x-api-key', value: ' ' }], /needs a value/], + ['a value with a line break', [{ name: 'x-api-key', value: `${API_KEY}\nX-Evil: 1` }], /control characters/], + ['a value with a tab', [{ name: 'x-api-key', value: `${API_KEY}\t` + 'x' }], /control characters/], + ['a value over the byte limit', [{ name: 'x-api-key', value: 'v'.repeat(65) }], /exceeds max size of 64 bytes/], + [ + 'more headers than the limit', + ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, value: 'v' })), + /Maximum 4 headers/, + ], + ])('rejects %s, without echoing any value', async (_label, headers, message) => { + const attempt = createComposio({ headers }); + await expect(attempt).rejects.toThrow(message); + await expect(attempt).rejects.not.toThrow(new RegExp(API_KEY)); + expect(sqlite.prepare('SELECT COUNT(*) AS n FROM mcp_connections').get()).toEqual({ n: 0 }); + }); + + it('rejects an Authorization header alongside a bearer token', async () => { + await expect( + createComposio({ + authType: 'bearer', + token: 'bearer-token', + headers: [{ name: 'authorization', value: 'Bearer other' }], + }) + ).rejects.toThrow(/conflicts with the bearer token/); + }); +}); + +describe('updating headers', () => { + it('keeps a stored value when an entry omits it, while adding a new header', async () => { + const created = await createComposio(); + + const updated = await update(created.id, { + headers: [{ name: 'x-api-key' }, { name: 'x-org-id', value: 'org-42' }], + }); + + expect(updated.headerNames).toEqual(['x-api-key', 'x-org-id']); + expect(await storedHeaders(created.id)).toEqual([ + { name: 'x-api-key', value: API_KEY }, + { name: 'x-org-id', value: 'org-42' }, + ]); + }); + + it('matches kept headers case-insensitively and adopts the new spelling', async () => { + const created = await createComposio(); + + const updated = await update(created.id, { headers: [{ name: 'X-API-Key' }] }); + + expect(updated.headerNames).toEqual(['X-API-Key']); + expect(await storedHeaders(created.id)).toEqual([{ name: 'X-API-Key', value: API_KEY }]); + }); + + it('rotates a value, removes an omitted header, and clears all with an empty list', async () => { + const created = await createComposio({ + headers: [ + { name: 'x-api-key', value: API_KEY }, + { name: 'x-org-id', value: 'org-42' }, + ], + }); + + await update(created.id, { headers: [{ name: 'x-api-key', value: 'ak_rotated' }] }); + expect(await storedHeaders(created.id)).toEqual([{ name: 'x-api-key', value: 'ak_rotated' }]); + + const cleared = await update(created.id, { headers: [] }); + expect(cleared.headerNames).toEqual([]); + const row = storedRow(created.id); + expect(row.header_names).toBe('[]'); + expect(row.encrypted_headers).toBeNull(); + expect(row.headers_iv).toBeNull(); + }); + + it('refuses to keep a value that was never stored', async () => { + const created = await createComposio(); + + await expect(update(created.id, { headers: [{ name: 'x-new-header' }] })).rejects.toThrow( + /"x-new-header" needs a value/ + ); + expect(await storedHeaders(created.id)).toEqual([{ name: 'x-api-key', value: API_KEY }]); + }); + + it('leaves headers byte-for-byte untouched when an update does not mention them', async () => { + const created = await createComposio(); + const before = storedRow(created.id); + + const updated = await update(created.id, { enabled: false }); + + expect(updated.enabled).toBe(false); + expect(updated.headerNames).toEqual(['x-api-key']); + const after = storedRow(created.id); + expect(after.encrypted_headers).toBe(before.encrypted_headers); + expect(after.headers_iv).toBe(before.headers_iv); + }); + + it('refuses to switch to bearer while an Authorization header is stored', async () => { + const created = await createComposio({ + headers: [{ name: 'Authorization', value: 'Basic dXNlcjpwYXNz' }], + }); + + await expect(update(created.id, { authType: 'bearer', token: 'bearer-token' })).rejects.toThrow( + /conflicts with the bearer token/ + ); + expect(storedRow(created.id).auth_type).toBe('none'); + + // Control: the same switch succeeds once the request drops the conflicting header. + const switched = await update(created.id, { authType: 'bearer', token: 'bearer-token', headers: [] }); + expect(switched.authType).toBe('bearer'); + expect(switched.headerNames).toEqual([]); + }); + + it('asks for every value when the stored headers cannot be decrypted, and accepts a full replacement', async () => { + const created = await createComposio(); + sqlite.prepare('UPDATE mcp_connections SET encrypted_headers = ? WHERE id = ?').run('garbage', created.id); + + await expect(update(created.id, { headers: [{ name: 'x-api-key' }] })).rejects.toThrow( + /cannot be read; send every header with its value/ + ); + + await update(created.id, { headers: [{ name: 'x-api-key', value: 'ak_replacement' }] }); + expect(await storedHeaders(created.id)).toEqual([{ name: 'x-api-key', value: 'ak_replacement' }]); + }); +}); + +describe('listing connections with headers', () => { + // Rule 50: one malformed display column must not take down the whole list. + it('shows a row with an unreadable header_names column as having no names', async () => { + const broken = await createComposio({ name: 'broken' }); + await createComposio({ name: 'healthy' }); + sqlite.prepare('UPDATE mcp_connections SET header_names = ? WHERE id = ?').run('not json', broken.id); + + const listed = await listMcpConnections(db, { userId: 'user-1', projectId: null }); + + expect(listed.map((c) => [c.name, c.headerNames])).toEqual([ + ['broken', []], + ['healthy', ['x-api-key']], + ]); + }); +}); diff --git a/packages/shared/src/fixtures/mcp-server-entry-wire.json b/packages/shared/src/fixtures/mcp-server-entry-wire.json new file mode 100644 index 000000000..8415ca7ce --- /dev/null +++ b/packages/shared/src/fixtures/mcp-server-entry-wire.json @@ -0,0 +1,39 @@ +{ + "$comment": [ + "Cross-language wire fixture for the MCP server list the control plane sends the vm-agent", + "(POST /workspaces/:id/agent-sessions and .../start). Both sides consume THIS file (rule 23):", + "apps/api/tests/unit/node-agent-contract.test.ts asserts serializeMcpServers produces exactly", + "`mcpServers`, and packages/vm-agent/internal/server/mcp_servers_wire_test.go posts it to the real", + "create-agent-session handler and asserts every field arrives.", + "", + "Optional fields appear only when set, so an entry without them is byte-identical to what an", + "older control plane sent: no `headers` key on a server without custom headers." + ], + "mcpServers": [ + { + "url": "https://api.example.com/mcp", + "token": "sam-token", + "name": "sam-mcp" + }, + { + "url": "https://backend.composio.dev/v3/mcp/server-1", + "token": "", + "name": "composio", + "headers": [ + { + "name": "x-api-key", + "value": "ak_fixture_key" + }, + { + "name": "X-Org_Id", + "value": "org-42" + } + ] + }, + { + "url": "https://mcp.zapier.com/x", + "token": "zap-token", + "name": "zapier" + } + ] +} diff --git a/packages/vm-agent/internal/server/mcp_servers_wire_test.go b/packages/vm-agent/internal/server/mcp_servers_wire_test.go new file mode 100644 index 000000000..e637c5296 --- /dev/null +++ b/packages/vm-agent/internal/server/mcp_servers_wire_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/workspace/vm-agent/internal/acp" +) + +// Go half of the MCP server wire contract. apps/api/tests/unit/node-agent-contract.test.ts +// asserts the control plane serializes exactly this fixture; here it is posted to the real +// create-agent-session handler, so a renamed JSON tag on either side fails one of the two. +func TestCreateAgentSessionAcceptsWireFixtureHeaders(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join( + "..", "..", "..", "..", "packages", "shared", "src", "fixtures", "mcp-server-entry-wire.json", + )) + if err != nil { + t.Fatalf("read shared wire fixture: %v", err) + } + var wire struct { + McpServers json.RawMessage `json:"mcpServers"` + } + if err := json.Unmarshal(fixture, &wire); err != nil || len(wire.McpServers) == 0 { + t.Fatalf("parse shared wire fixture: %v", err) + } + + s, store := newMcpTestServer(t) + s.workspaces["ws"] = &WorkspaceRuntime{ID: "ws", ProjectID: "project", Status: "running", CallbackToken: "cb"} + validator, key := newWorkspaceCreateJWTValidator(t, "node-test") + s.jwtValidator = validator + + body := `{"sessionId":"sess-wire","label":"Wire","chatSessionId":"chat","projectId":"project","mcpServers":` + + string(wire.McpServers) + `}` + req := httptest.NewRequest(http.MethodPost, "/workspaces/ws/agent-sessions", strings.NewReader(body)) + req.SetPathValue("workspaceId", "ws") + req.Header.Set("Authorization", "Bearer "+signWorkspaceCreateNodeToken(t, key, "node-test", "ws")) + req.Header.Set("X-SAM-Workspace-Id", "ws") + rec := httptest.NewRecorder() + s.handleCreateAgentSession(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create agent session status = %d: %s", rec.Code, rec.Body.String()) + } + + wantComposioHeaders := []acp.McpHeader{ + {Name: "x-api-key", Value: "ak_fixture_key"}, + {Name: "X-Org_Id", Value: "org-42"}, + } + registered := s.sessionMcpServers["ws:sess-wire"] + if len(registered) != 3 { + t.Fatalf("registered %d MCP servers, want 3: %#v", len(registered), registered) + } + if registered[1].Name != "composio" || registered[1].Token != "" { + t.Fatalf("composio entry arrived as %#v", registered[1]) + } + assertHeaders(t, "handler", registered[1].Headers, wantComposioHeaders) + for _, i := range []int{0, 2} { + if len(registered[i].Headers) != 0 { + t.Errorf("%s gained headers it was never sent: %#v", registered[i].Name, registered[i].Headers) + } + } + + persisted, err := store.GetSessionMcpServers("ws", "sess-wire") + if err != nil || len(persisted) != 3 { + t.Fatalf("persisted servers = %#v, err %v", persisted, err) + } + if len(persisted[1].Headers) != len(wantComposioHeaders) || persisted[1].Headers[0].Value != "ak_fixture_key" { + t.Errorf("persisted composio headers = %#v", persisted[1].Headers) + } +} diff --git a/packages/vm-agent/internal/server/messages-ws.db b/packages/vm-agent/internal/server/messages-ws.db new file mode 100644 index 0000000000000000000000000000000000000000..cc04c103cdf5416b37dcbcd114b399f735eadcdf GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYC*>h8Lt=fNV2HHI9bB nXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP#6LLQSS$G literal 0 HcmV?d00001 diff --git a/packages/vm-agent/internal/server/messages-ws.db-shm b/packages/vm-agent/internal/server/messages-ws.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..5a8ae48652fd87307801ff3717f5e5a8fb2ba6ed GIT binary patch literal 32768 zcmeI)tqsCJ7zW@ge;Qdwf=3Vp5*A=15(I)Bm;nI-7GMHaKrsQq!5yJV4FXB;d6O^c zHO*b`8Q?9iqfn&?q2G&(Zk*+KH#{$nhr#@^zD>59+2bBo)6>5E@%w7;wC}T)`up@8 z&kn6P?Kqv!I{k+bAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&&S)h1#T{QWI?ndC7KQqu8cfO<`4}Wb4}qw25n8p`f61 zFt76Cde=0!vhJnS$};>DYYco1QNH%}gYuS>F~bSQe-_&+GkOTO=G`b#L}(f7{Z z>c1nM`Fc!}j!2@NocOTzeeB`g`PY-955|p6zDw@hzn%GJ%zW8=d3i2pGfI}+M%`j$ zjAtu~G(iZDRro6|a{MOpyZgbhz1|h+?EUx2pqT{CrJY8P`l>55G|R-KOU zOm*$FJdjdVd0lSTtW(dkmTARpvIVyl?J1Fp(PJc?7__S-(z1rB&*()mTg>LQVwqgh z%S0=gg{;8~<#odxNel*6f?QLdH$@SnU=pL0%aM|ioh#`)(&J}lyG~`VNFLH~YqawW zx{L9UMb}}}VPRB{5aznIN}V#RYB7rkiV%_6pcbRm3V(ENw;q`mW3+yqc|r96Ap9~@ z`m|QcndBU=SF=3Ei?uUEQU7DyhP`Y%L4}=9PpB7^{!~GgCEKaemVYg+CUe_;ezKhx zt&)lMWBgV-kIUS z*}c~r=s4VSGhAOFo}BjA7vLLc5P$##AOHafKmY;|fB*#ct-y`=KuS~P)#L5C-FCIr zJ-yqXh#sBf-RIBE&l*$uJlQ)dF<3B2G&Q|Dvt7md0$5+*f7TZe&-i`8{{>Dx&)j_R jD!+w!1pD^K2qz8!2tWV=5P$##AOHafKmY;|_-ldRZgk3Z literal 0 HcmV?d00001 From 29472e5f8334b9b87c8c23d8d81333088d8ee159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 06:43:30 +0000 Subject: [PATCH 05/18] feat(web): manage MCP server custom headers, with inline edit - McpServerForm (add + inline Edit) and McpServerHeadersField replace the inline create form in McpServersManager, which keeps list/toggle/delete. - Headers are name + masked value rows. A saved header shows its name and a blank value that keeps the saved one (the API never returns values); editing likewise keeps the URL and token unless new ones are typed. - Rows list their header names; one form is open at a time. - Payloads are built in mcp-server-form-state.ts: create omits empty headers, update always sends the full desired header set (value-less = keep). - Unit tests drive the real form: Composio-shape create, unsaved-row removal, edit keep/rotate/remove/add, bearer token requirement, rejected save keeps the editor. Playwright audit adds header data, add/edit form scenarios and a measured-coordinate check of the responsive header row. - Docs: MCP servers guide (headers, Composio, editing, Amp note), limits in configuration reference and .env.example, changelog skill entry. - Formatting: prettier on the new files and on the MCP-owned files touched. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/changelog/SKILL.md | 3 +- apps/api/.env.example | 2 + .../src/services/mcp-connection-headers.ts | 17 +- .../src/services/mcp-connection-resolution.ts | 6 +- apps/api/src/services/mcp-connections.ts | 12 +- .../tests/unit/node-agent-contract.test.ts | 5 +- .../mcp-connection-headers-injection.test.ts | 56 +++- .../services/mcp-connection-headers.test.ts | 65 +++- .../components/mcp-servers/McpServerForm.tsx | 148 ++++++++++ .../mcp-servers/McpServerHeadersField.tsx | 98 ++++++ .../mcp-servers/McpServersManager.tsx | 279 +++++++----------- .../mcp-servers/mcp-server-form-state.ts | 86 ++++++ .../playwright/mcp-servers-audit.spec.ts | 81 ++++- .../web/tests/unit/McpServersManager.test.tsx | 188 +++++++++++- .../content/docs/docs/guides/mcp-servers.md | 20 +- .../docs/docs/reference/configuration.md | 2 + packages/shared/src/types/mcp-connection.ts | 6 +- .../unit/mcp-server-name-contract.test.ts | 20 +- ...026-09-29-mcp-connection-custom-headers.md | 8 +- 19 files changed, 865 insertions(+), 237 deletions(-) create mode 100644 apps/web/src/components/mcp-servers/McpServerForm.tsx create mode 100644 apps/web/src/components/mcp-servers/McpServerHeadersField.tsx create mode 100644 apps/web/src/components/mcp-servers/mcp-server-form-state.ts diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md index 3081045fb..257529238 100644 --- a/.claude/skills/changelog/SKILL.md +++ b/.claude/skills/changelog/SKILL.md @@ -14,6 +14,7 @@ These entries were removed from root `CLAUDE.md` so startup instructions stay co Use the `/changelog` skill for structured queries. +- mcp-connection-custom-headers: Bring-your-own MCP servers can carry custom HTTP headers (Composio requires `x-api-key` / `x-consumer-api-key`). Headers are independent of `authType`; `Authorization` is accepted only with `none`. Names `^[A-Za-z0-9_-]{1,64}$` — exactly what the Amp bridge `mcp-remote@0.1.38` parses and a TOML bare key — pinned TS↔Go by the `headerNames` block of `mcp-server-name-contract.json`; transport headers are reserved. D1 `0175` adds `header_names` (plaintext display projection) + `encrypted_headers`/`headers_iv` (one AES-GCM ciphertext of the `[{name,value}]` list, the only column injection reads). All rules and the storage format live in `services/mcp-connection-headers.ts`. PATCH `headers` is the full desired set and an entry without `value` keeps the stored value, so the UI edits without ever holding secrets. Resolution re-validates decrypted headers and skips a bad row. vm-agent: `McpServerEntry.Headers` validated in `normalizeMcpServers`, persisted (`migrateV18`), converted by `toPersistedMcpServers`/`fromPersistedMcpServers`; ACP header list, Codex `env_http_headers` with `SAM_MCP__HEADER__SECRET` (the `_SECRET` suffix both classifies it for `isSecretEnvVar` and cannot collide with a bearer `_TOKEN` var), Vibe `headers` table, Amp `--header name:${SAM_MCP_HEADER_}`. Wire shape pinned by `packages/shared/src/fixtures/mcp-server-entry-wire.json` (node-agent serializer + real Go handler). UI: `McpServerForm` (add + inline Edit), `McpServerHeadersField`. Limits `MAX_MCP_CONNECTION_HEADERS` (10), `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (8192). Refactor in the same PR: MCP/Codex/Vibe code moved out of `gateway.go`/`session_host.go`/`workspaces.go`/`store.go` into `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go`. - project-chat-instant-switching: Switching between project chats renders the target chat at once. Transcripts (`sessions/messages`) now keep a 24 h `gcTime` (`CHAT_TRANSCRIPT_CACHE_TTL_MS`, from shared `DEFAULT_CHAT_TRANSCRIPT_CACHE_TTL_MS`, override `VITE_CHAT_TRANSCRIPT_CACHE_TTL_MS`); previously the TanStack 5-minute default dropped an unobserved transcript from memory and, on the next write, from IndexedDB. Restored queries get `RESTORED_QUERY_GC_TIME_MS` via `hydrateOptions`, the dehydrate filter stops writing a transcript older than the TTL, and opening a chat evicts the least recently updated unobserved transcripts beyond `CHAT_TRANSCRIPT_CACHE_MAX_SESSIONS` (20; `evictStaleTranscripts` in `lib/query-options/chats.ts`); on disk each transcript keeps only its newest `CHAT_TRANSCRIPT_PERSIST_MAX_ROWS` rows (default: the 500-row page; `persistedQueryForDisk`). `ProjectMessageView` is keyed per session (`SessionMessageView`), and the transcript is read straight from the query cache (`useSessionTranscript`), so a cached chat paints in the switching commit and an uncached one shows a spinner, never the previous chat. The cold load requests the newest page (`fetchNewestPage`, `CHAT_SESSION_MESSAGE_LIMIT` = 500) instead of the 50,000-row `CHAT_SESSION_MESSAGE_MAX` ceiling (now only the server clamp); older history pages in through Virtuoso `startReached` once the reader scrolls up — wheel, swipe, or ArrowUp/PageUp/Home, not the list position (a page of tool calls can fold into a few rows that fit on screen, and a chat can open away from the bottom when its newest message is taller than the screen; an ungated `startReached` then paged the whole history in on open) and through the existing "Load earlier" button. The #2159 forward-delta refresh moved into the query function unchanged. Jumps to a specific message page back until that message id is loaded (`HistoryTarget` in `lib/message-paging.ts`) and then confirm the row is on screen, re-scrolling past Virtuoso's prepend compensation (`useConversationJump`), and an unloaded comment anchor reads "on a message". Composer drafts are per chat (`session-drafts.tsx`), report-issue config is a cached query, and server `session`/`state` snapshots hydrate only when the server reports new ones, which fixes a streamed row resetting a working agent to idle. Auth gating is unchanged: no persisted transcript renders before the session check resolves. - archive-sweep-affordability-ceiling-and-fallthrough: The production ProjectData archive sweep stopped reclaiming anything on 2026-09-08 and reported `succeeded` for 226 hourly runs while the root object climbed from 94% to 96.7% of its 10 GB ceiling. Two independently configured ceilings had to agree and drifted: a GitHub `production` Environment override lowered `PROJECT_DATA_ARCHIVE_DAILY_WRITE_BUDGET` to 100000 (affordability ceiling `floor((100000-1000)/32)` = 3093 write units) while `PROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET` stayed at the checked-in 5000, and `selectCandidates` orders `message_count DESC ... LIMIT sweepProjects * sweepSessions` (deployed 1x1). Every tick therefore picked the same 4994-message session, estimated ~160,808 writes, was refused by `reserveArchiveWrites` BEFORE it touched D1 (so the UTC budget window also froze at `2026-09-08T00:00:00Z`), and `continue`d out of a one-element list — no journal row, no location change, no error. The selection ceiling is now DERIVED from the allowance (`archiveAffordableWriteUnits` / `archiveAffordableMessageCeiling` in `project-data-archive/write-budget.ts`, used as the `message_count <= ?` bind), so the two can no longer disagree at any configuration; an explicit session-scoped operator canary still bypasses it. `selectCandidates` over-reads `PROJECT_DATA_ARCHIVE_SWEEP_FALLTHROUGH_DEPTH` (4) spare candidates and the journaling loop descends past a refusal instead of ending the tick, with both per-tick bounds (session slots and the cumulative message budget) moved INTO that loop so a refused candidate — which opens no `migrating` fence and moves no rows — consumes neither. `reserveArchiveWrites` returns a discriminated outcome separating `exceeds_allowance` (waiting cannot help) from `window_exhausted` (normal end-of-day backpressure), and `PROJECT_DATA_ARCHIVE_BUDGET_STALL_ALERT_SWEEPS` (3) consecutive ticks that migrate nothing and see only the former flip the cadence row to `partial` with an actionable `last_error` — counted and escalated inside one atomic `UPDATE ... RETURNING` (migration `0156` adds `consecutive_budget_stalls`) so a failed read cannot silently restart a streak. `wrangler.toml` now ships `DAILY_WRITE_BUDGET=100000` (matching the production override, so staging and self-hosts derive the same ceiling), `SWEEP_MESSAGE_BUDGET=2000`, `SWEEP_SESSIONS=2`. Owner stubs are memoised per tick so the fall-through does not multiply `ensureProjectId` DO round trips. NOTE: within a 100000/day allowance the restored sweep reclaims on the order of 1-1.5 MB/day against ~66 MB/day of growth — it ends the deadlock but cannot reverse the storage curve; raising the budget is a spend decision. - codex-astra-runtime-selection: Codex ACP is upgraded 1.8.0→1.10.0 and its Codex companion 0.153.2→0.153.4 across the canonical install manifest, VM-agent installer, and cf-container runtime image; the sandbox image's CLI-only pin is aligned to 0.153.4. VM-agent now validates both exact executable versions and supplies `CODEX_PATH=codex`, ensuring the adapter launches the explicitly pinned companion rather than a nested dependency resolved relative to itself. An explicit Codex profile model is applied through ACP `session/set_config_option`; rejection now fails session establishment with the requested model in the diagnostic instead of silently retaining the adapter default. A wire-level ACP regression test pins both the successful `gpt-6-astra` request and the fail-closed case. Process fix: `.claude/rules/23-cross-boundary-contract-tests.md` now treats adapter/companion resolution as one runtime contract. @@ -23,7 +24,7 @@ Use the `/changelog` skill for structured queries. - knowledge-injection-relevance-ranking: Session-start knowledge injection (`get_instructions`) is ranked instead of alphabetical. `getAllHighConfidenceKnowledge` previously used `ORDER BY e.name ... LIMIT 50`, so the cap filtered on entity _spelling_: in production all 50 slots went to `AccountMap`..`AgentReliability`, 46 of them to the one `AgentBehavior` grab-bag, while `ContentStyle`/`CodeQuality`/`User`/`Architecture`/`BusinessStrategy` — entities the same payload tells agents to consult — had never been injected once, with no hint they existed. Now scored by the EXISTING formula (`computeRelevanceScore`: confidence × 1/(1 + age/30d) on `last_confirmed_at`, so `confirm_knowledge` restores rank), mirrored into SQL and pinned by a parity test; per-entity cap via `ROW_NUMBER() OVER (PARTITION BY entity_id)`; `now` bound as a parameter and ties broken to a total order (`score DESC, last_confirmed_at DESC, id ASC`) so output is reproducible. New `getKnowledgeEntityIndex` appends a compact `Name (type, N)` index to `knowledgeDirectives` disclosing what was NOT injected and naming `search_knowledge`/`get_relevant_knowledge` as the retrieval path; it returns `{entries, totalEntities}` (total via `COUNT(*) OVER ()`) so a truncated index can never be labelled "full". The three DO reads run as an isolated `Promise.allSettled` fan-out — a failed ranked read still leaves the index. Limits are clamped at the DO boundary (`clampRowLimit`): a negative `perEntityLimit` would otherwise make `entity_rank <= ?` unsatisfiable and inject nothing project-wide, and `LIMIT -1` means _unbounded_ in SQLite. New env vars `KNOWLEDGE_AUTO_RETRIEVE_PER_ENTITY_LIMIT` (8), `KNOWLEDGE_ENTITY_INDEX_LIMIT` (200). Process fix: `.claude/rules/65-capped-selection-must-rank-and-disclose.md`. -- byo-mcp-servers: Bring-your-own MCP endpoints. Users store `{name, url, authType, token, enabled}` at personal (`/api/mcp-connections`) or project (`/api/projects/:projectId/mcp-connections`) scope; SAM injects them into every agent session alongside `sam-mcp`. Both URL and token are AES-256-GCM encrypted and never returned by a read path (several providers issue pre-signed URLs with the credential in the URL, so the URL is a secret; `url_host` is the display value). Migration `0120_mcp_connections`. `buildSessionMcpServers` is the single composition point, called by `agent-session-bootstrap.ts` (covers VM + cf-container, rule 61) and the manual workspace agent-session route; the anonymous trial path is deliberately pinned to `sam-mcp` only. Resolution skips-and-warns per row so one bad connection cannot brick session start (rules 41/50). vm-agent: `McpServerEntry.Name` (additive, rule 54) plus `ResolveMcpServerNames` as the single naming source of truth — it replaced three drifted copies, one of which named a lone server `sam-mcp-0` for Vibe and `sam-mcp` everywhere else; persistence `migrateV12`. Codex's startup precondition, which required a bearer token for EVERY injected server, is now scoped to the reserved `sam-mcp` entry so a no-auth connection cannot break all Codex sessions. UI: Settings → MCP Servers (personal) and Project Settings → Runtime (project), one shared `McpServersManager`. v1 is bearer/none auth and personal/project scope only; custom headers and profile/skill attachment are tracked in idea `01M0QDASJCK3YWVX1GETZTSFWZ`. Limits: `MAX_MCP_CONNECTIONS_PER_SCOPE`, `MCP_CONNECTION_URL_MAX_BYTES`, `MCP_CONNECTION_TOKEN_MAX_BYTES`. +- byo-mcp-servers: Bring-your-own MCP endpoints. Users store `{name, url, authType, token, enabled}` at personal (`/api/mcp-connections`) or project (`/api/projects/:projectId/mcp-connections`) scope; SAM injects them into every agent session alongside `sam-mcp`. Both URL and token are AES-256-GCM encrypted and never returned by a read path (several providers issue pre-signed URLs with the credential in the URL, so the URL is a secret; `url_host` is the display value). Migration `0120_mcp_connections`. `buildSessionMcpServers` is the single composition point, called by `agent-session-bootstrap.ts` (covers VM + cf-container, rule 61) and the manual workspace agent-session route; the anonymous trial path is deliberately pinned to `sam-mcp` only. Resolution skips-and-warns per row so one bad connection cannot brick session start (rules 41/50). vm-agent: `McpServerEntry.Name` (additive, rule 54) plus `ResolveMcpServerNames` as the single naming source of truth — it replaced three drifted copies, one of which named a lone server `sam-mcp-0` for Vibe and `sam-mcp` everywhere else; persistence `migrateV12`. Codex's startup precondition, which required a bearer token for EVERY injected server, is now scoped to the reserved `sam-mcp` entry so a no-auth connection cannot break all Codex sessions. UI: Settings → MCP Servers (personal) and Project Settings → Runtime (project), one shared `McpServersManager`. v1 was bearer/none auth and personal/project scope only; custom headers shipped later (see `mcp-connection-custom-headers`), profile/skill attachment is tracked in idea `01M0QDASJCK3YWVX1GETZTSFWZ`. Limits: `MAX_MCP_CONNECTIONS_PER_SCOPE`, `MCP_CONNECTION_URL_MAX_BYTES`, `MCP_CONNECTION_TOKEN_MAX_BYTES`. - policy-lifecycle-controls: Project policies gain a shelf life so one-shot workflow policies stop being injected into every session forever. Additive DO migration `034-policy-lifecycle-controls` adds `expires_at INTEGER` (nullable — `NULL` means never expires, preserving every existing policy's behaviour) and `scope TEXT NOT NULL DEFAULT 'always'` (`'always' | 'task'`). `getActivePolicies` filters at READ time (`active = 1 AND (expires_at IS NULL OR expires_at > ?)`) — no sweep, no cron; the row is retained and stays `active` so `get_policy` / `list_policies` / the Policies tab still show a human why a policy stopped applying, and the per-project cap COUNT excludes expired rows. A `scope: 'task'` policy MUST carry an `expiresAt`, enforced by one shared `validatePolicyLifecycle` at all three write boundaries (MCP `policy-tools.ts`, REST `routes/policies.ts`, sam-session `tools/add-policy.ts`) plus a DO-level choke point. `add_policy` / `update_policy` accept `scope` + `expiresAt`; `expiresAt: null` on update clears an expiry. Expiring policies render an inline `(task-scoped, expires YYYY-MM-DD)` annotation in `policyDirectives`, and the capture instruction now tells agents to scope dated work. New limit `POLICY_MAX_EXPIRY_MS` (default 365 days). Also hardened `scripts/quality/check-do-migration-safety.ts`, which extracted only backtick and single-quoted SQL — a `sql.exec("DROP TABLE ...")` in double quotes was invisible to the gate and reported PASS. - claude-fable-51-model-catalog: Claude Fable 5.1 (`claude-fable-5-1`, released 2026-09-01, $10/$50 per MTok, native 1M context) added to both canonical model lists — `CLAUDE_MODELS` dropdown catalog and `PLATFORM_AI_MODELS` proxy allowlist/pricing. Claude Code CLI pinned to `@anthropic-ai/claude-code@2.1.258` because Fable 5.1 requires Claude Code 2.1.251 or newer; the VM-agent installer now validates the underlying `claude` binary instead of treating `claude-agent-acp` alone as sufficient. Focused tests pin dropdown presence, platform metadata, and the no-`[1m]` selector rule for native 1M Claude 5-family models. - report-issue-idea-flow: Hosted "Report an Issue" flow that groups reports into private feedback incidents and creates/updates a linked draft Idea in the effective private feedback project (Admin → Integrations runtime setting, falling back to `PLATFORM_FEEDBACK_PROJECT_ID`). Two entry points: the session tool rail's Report action and ErrorBoundary crash screen. Users explicitly consent before technical refs (sessionId, taskId, nodeId) are attached. Server-side cross-tenant ref authorization validates project membership before storing references. User text sanitized with secret/PII redaction and fenced with provenance markers. Feature auto-hidden when no effective feedback project exists or it does not reference an existing project in the current deployment database. Configurable limits: `REPORT_ISSUE_TITLE_MAX_LENGTH`, `REPORT_ISSUE_DESCRIPTION_MAX_LENGTH`, `REPORT_ISSUE_CONTENT_MAX_LENGTH`. diff --git a/apps/api/.env.example b/apps/api/.env.example index a71e46773..f7eda3cad 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -352,6 +352,8 @@ BASE_DOMAIN=workspaces.example.com # MAX_MCP_CONNECTIONS_PER_SCOPE=25 # MCP_CONNECTION_URL_MAX_BYTES=2048 # MCP_CONNECTION_TOKEN_MAX_BYTES=8192 +# MAX_MCP_CONNECTION_HEADERS=10 +# MCP_CONNECTION_HEADER_VALUE_MAX_BYTES=8192 # Missions (Phase 2: Orchestration Primitives) # MISSION_MAX_PER_PROJECT=50 diff --git a/apps/api/src/services/mcp-connection-headers.ts b/apps/api/src/services/mcp-connection-headers.ts index 7921664fc..b79850f67 100644 --- a/apps/api/src/services/mcp-connection-headers.ts +++ b/apps/api/src/services/mcp-connection-headers.ts @@ -43,6 +43,7 @@ export interface SealedMcpConnectionHeaders { type HeaderColumns = Pick; /** Tab, CR, LF, NUL and the rest: anything that could split a header or a config line. */ +// eslint-disable-next-line no-control-regex -- matching control characters is the purpose of this pattern const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; const StoredHeadersSchema = v.array(v.object({ name: v.string(), value: v.string() })); @@ -67,12 +68,16 @@ export function validateMcpConnectionHeaders( return headers.map((header) => { const name = header.name.trim(); if (!MCP_CONNECTION_HEADER_NAME_PATTERN.test(name)) { - throw errors.badRequest(`Invalid header name ${JSON.stringify(name)}: ${MCP_CONNECTION_HEADER_NAME_RULE}`); + throw errors.badRequest( + `Invalid header name ${JSON.stringify(name)}: ${MCP_CONNECTION_HEADER_NAME_RULE}` + ); } const key = name.toLowerCase(); if (MCP_CONNECTION_RESERVED_HEADER_NAMES.includes(key)) { - throw errors.badRequest(`Header "${name}" is set by the MCP transport and cannot be overridden`); + throw errors.badRequest( + `Header "${name}" is set by the MCP transport and cannot be overridden` + ); } if (key === 'authorization' && authType === 'bearer') { throw errors.badRequest( @@ -89,10 +94,14 @@ export function validateMcpConnectionHeaders( throw errors.badRequest(`Header "${name}" needs a value`); } if (utf8ByteLength(value) > limits.headerValueMaxBytes) { - throw errors.badRequest(`Header "${name}" value exceeds max size of ${limits.headerValueMaxBytes} bytes`); + throw errors.badRequest( + `Header "${name}" value exceeds max size of ${limits.headerValueMaxBytes} bytes` + ); } if (CONTROL_CHARACTERS.test(value)) { - throw errors.badRequest(`Header "${name}" value must not contain line breaks or control characters`); + throw errors.badRequest( + `Header "${name}" value must not contain line breaks or control characters` + ); } return { name, value }; }); diff --git a/apps/api/src/services/mcp-connection-resolution.ts b/apps/api/src/services/mcp-connection-resolution.ts index 8d8035208..c0c765d77 100644 --- a/apps/api/src/services/mcp-connection-resolution.ts +++ b/apps/api/src/services/mcp-connection-resolution.ts @@ -75,11 +75,7 @@ export async function resolveMcpServersForSession( // is not atomic, and lowering MAX_MCP_CONNECTIONS_PER_SCOPE does not retroactively delete // rows — so the write-side cap is not a guarantee the read side can rely on. Two scopes // are visible at once, hence twice the cap. - const rows: unknown = await db - .select() - .from(schema.mcpConnections) - .where(where) - .limit(maxRows); + const rows: unknown = await db.select().from(schema.mcpConnections).where(where).limit(maxRows); // The result shape is validated rather than assumed. This function runs on the // agent-session start path, so anything that throws here takes session start down for // the whole tenant — including a driver or binding that returns a non-array. diff --git a/apps/api/src/services/mcp-connections.ts b/apps/api/src/services/mcp-connections.ts index 24b65adfa..a525d528b 100644 --- a/apps/api/src/services/mcp-connections.ts +++ b/apps/api/src/services/mcp-connections.ts @@ -110,7 +110,10 @@ export function validateMcpConnectionName(rawName: string): string { * still see the error. HTTP is allowed only for loopback, which is what a self-hosted gateway * running on the same box would use. */ -export function validateMcpConnectionUrl(rawUrl: string, maxBytes: number): { url: string; urlHost: string } { +export function validateMcpConnectionUrl( + rawUrl: string, + maxBytes: number +): { url: string; urlHost: string } { const url = rawUrl.trim(); if (!url) { throw errors.badRequest('url is required'); @@ -340,7 +343,9 @@ export async function updateMcpConnection( updates.urlHost = urlHost; } - const nextAuthType = input.authType ? assertAuthType(input.authType) : assertAuthType(existing.authType); + const nextAuthType = input.authType + ? assertAuthType(input.authType) + : assertAuthType(existing.authType); if (input.authType !== undefined) { updates.authType = nextAuthType; } @@ -391,7 +396,8 @@ async function resolveUpdatedHeaders( desired: McpConnectionHeaderUpdate[] | undefined, encryptionKey: string ): Promise { - const keepsStoredValues = desired === undefined || desired.some((header) => header.value === undefined); + const keepsStoredValues = + desired === undefined || desired.some((header) => header.value === undefined); let stored: McpConnectionHeader[] = []; if (keepsStoredValues) { try { diff --git a/apps/api/tests/unit/node-agent-contract.test.ts b/apps/api/tests/unit/node-agent-contract.test.ts index f63f24c97..72003e6dc 100644 --- a/apps/api/tests/unit/node-agent-contract.test.ts +++ b/apps/api/tests/unit/node-agent-contract.test.ts @@ -1024,7 +1024,10 @@ describe('Node Agent client functions send correct payloads', () => { // packages/vm-agent/internal/server/mcp_servers_wire_test.go, so the two sides cannot drift. const wire = JSON.parse( readFileSync( - new URL('../../../../packages/shared/src/fixtures/mcp-server-entry-wire.json', import.meta.url), + new URL( + '../../../../packages/shared/src/fixtures/mcp-server-entry-wire.json', + import.meta.url + ), 'utf8' ) ) as { mcpServers: unknown[] }; diff --git a/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts b/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts index ce7a7eb46..6a3382f2a 100644 --- a/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts +++ b/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts @@ -21,7 +21,13 @@ import { createMcpConnection } from '../../../src/services/mcp-connections'; import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; const ENCRYPTION_KEY = Buffer.alloc(32, 5).toString('base64'); -const LIMITS = { maxPerScope: 25, urlMaxBytes: 2048, tokenMaxBytes: 8192, maxHeaders: 10, headerValueMaxBytes: 8192 }; +const LIMITS = { + maxPerScope: 25, + urlMaxBytes: 2048, + tokenMaxBytes: 8192, + maxHeaders: 10, + headerValueMaxBytes: 8192, +}; const API_KEY = 'ak_live_composio_secret'; type Db = ReturnType>; @@ -51,7 +57,11 @@ async function startApiKeyMcpServer(apiKey: string): Promise { const result = request.method === 'tools/list' ? { tools: [{ name: 'GMAIL_SEND_EMAIL', inputSchema: { type: 'object' } }] } - : { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'composio-mock' } }; + : { + protocolVersion: '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'composio-mock' }, + }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id ?? 1, result })); }); @@ -61,7 +71,10 @@ async function startApiKeyMcpServer(apiKey: string): Promise { return { url: `http://127.0.0.1:${port}/mcp`, seen, - close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ), }; } @@ -79,7 +92,10 @@ async function callAsHarness(entry: McpServerEntry, method: string) { headers, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method }), }); - return { status: response.status, body: (await response.json()) as { result?: { tools?: Array<{ name: string }> } } }; + return { + status: response.status, + body: (await response.json()) as { result?: { tools?: Array<{ name: string }> } }, + }; } let sqlite: Database.Database; @@ -142,14 +158,21 @@ describe('custom headers, end to end', () => { }); it('the endpoint really rejects a request without the header (the check is not a formality)', async () => { - const response = await callAsHarness({ url: mcpServer.url, token: '', name: 'composio' }, 'initialize'); + const response = await callAsHarness( + { url: mcpServer.url, token: '', name: 'composio' }, + 'initialize' + ); expect(response.status).toBe(401); }); it('a connection without headers resolves with no headers key, as before this feature', async () => { await saveComposio({ headers: undefined }); - const [entry] = await resolveMcpServersForSession(db, { userId: 'user-1', projectId: 'proj-1' }, ENCRYPTION_KEY); + const [entry] = await resolveMcpServersForSession( + db, + { userId: 'user-1', projectId: 'proj-1' }, + ENCRYPTION_KEY + ); expect(entry).toEqual({ url: mcpServer.url, token: '', name: 'composio' }); }); @@ -159,10 +182,16 @@ describe('header fault isolation on the session-start path', () => { it('skips a row whose headers cannot be decrypted, keeps the rest, and logs no secret', async () => { const broken = await saveComposio({ name: 'broken' }); await saveComposio({ name: 'healthy' }); - sqlite.prepare('UPDATE mcp_connections SET encrypted_headers = ? WHERE id = ?').run('garbage', broken.id); + sqlite + .prepare('UPDATE mcp_connections SET encrypted_headers = ? WHERE id = ?') + .run('garbage', broken.id); const warn = vi.spyOn(log, 'warn'); - const resolved = await resolveMcpServersForSession(db, { userId: 'user-1', projectId: 'proj-1' }, ENCRYPTION_KEY); + const resolved = await resolveMcpServersForSession( + db, + { userId: 'user-1', projectId: 'proj-1' }, + ENCRYPTION_KEY + ); expect(resolved.map((entry) => entry.name)).toEqual(['healthy']); const skipLog = warn.mock.calls.find(([event]) => event === 'mcp_connections.row_skipped'); @@ -178,12 +207,19 @@ describe('header fault isolation on the session-start path', () => { const broken = await saveComposio({ name: 'broken' }); await saveComposio({ name: 'healthy' }); const { encrypt } = await import('../../../src/services/encryption'); - const sealed = await encrypt(JSON.stringify([{ name: 'x api key', value: API_KEY }]), ENCRYPTION_KEY); + const sealed = await encrypt( + JSON.stringify([{ name: 'x api key', value: API_KEY }]), + ENCRYPTION_KEY + ); sqlite .prepare('UPDATE mcp_connections SET encrypted_headers = ?, headers_iv = ? WHERE id = ?') .run(sealed.ciphertext, sealed.iv, broken.id); - const resolved = await resolveMcpServersForSession(db, { userId: 'user-1', projectId: 'proj-1' }, ENCRYPTION_KEY); + const resolved = await resolveMcpServersForSession( + db, + { userId: 'user-1', projectId: 'proj-1' }, + ENCRYPTION_KEY + ); expect(resolved.map((entry) => entry.name)).toEqual(['healthy']); }); diff --git a/apps/api/tests/unit/services/mcp-connection-headers.test.ts b/apps/api/tests/unit/services/mcp-connection-headers.test.ts index c2497a8da..6a3ffe956 100644 --- a/apps/api/tests/unit/services/mcp-connection-headers.test.ts +++ b/apps/api/tests/unit/services/mcp-connection-headers.test.ts @@ -67,7 +67,10 @@ function update(connectionId: string, changes: Record) { } function storedRow(id: string) { - return sqlite.prepare('SELECT * FROM mcp_connections WHERE id = ?').get(id) as Record; + return sqlite.prepare('SELECT * FROM mcp_connections WHERE id = ?').get(id) as Record< + string, + string | null + >; } async function storedHeaders(id: string) { @@ -120,11 +123,27 @@ describe('creating a connection with custom headers', () => { }); it.each([ - ['a name mcp-remote cannot parse', [{ name: 'x:api-key', value: API_KEY }], /Invalid header name/], + [ + 'a name mcp-remote cannot parse', + [{ name: 'x:api-key', value: API_KEY }], + /Invalid header name/, + ], ['a name with a dot', [{ name: 'x.api.key', value: API_KEY }], /Invalid header name/], - ['a name over 64 characters', [{ name: 'x'.repeat(65), value: API_KEY }], /Invalid header name/], - ['a transport-managed header', [{ name: 'Content-Type', value: 'text/plain' }], /set by the MCP transport/], - ['a reserved header in any case', [{ name: 'MCP-SESSION-ID', value: 'abc' }], /set by the MCP transport/], + [ + 'a name over 64 characters', + [{ name: 'x'.repeat(65), value: API_KEY }], + /Invalid header name/, + ], + [ + 'a transport-managed header', + [{ name: 'Content-Type', value: 'text/plain' }], + /set by the MCP transport/, + ], + [ + 'a reserved header in any case', + [{ name: 'MCP-SESSION-ID', value: 'abc' }], + /set by the MCP transport/, + ], [ 'a duplicate name differing only in case', [ @@ -134,9 +153,21 @@ describe('creating a connection with custom headers', () => { /more than once/, ], ['an empty value', [{ name: 'x-api-key', value: ' ' }], /needs a value/], - ['a value with a line break', [{ name: 'x-api-key', value: `${API_KEY}\nX-Evil: 1` }], /control characters/], - ['a value with a tab', [{ name: 'x-api-key', value: `${API_KEY}\t` + 'x' }], /control characters/], - ['a value over the byte limit', [{ name: 'x-api-key', value: 'v'.repeat(65) }], /exceeds max size of 64 bytes/], + [ + 'a value with a line break', + [{ name: 'x-api-key', value: `${API_KEY}\nX-Evil: 1` }], + /control characters/, + ], + [ + 'a value with a tab', + [{ name: 'x-api-key', value: `${API_KEY}\t` + 'x' }], + /control characters/, + ], + [ + 'a value over the byte limit', + [{ name: 'x-api-key', value: 'v'.repeat(65) }], + /exceeds max size of 64 bytes/, + ], [ 'more headers than the limit', ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, value: 'v' })), @@ -236,21 +267,29 @@ describe('updating headers', () => { expect(storedRow(created.id).auth_type).toBe('none'); // Control: the same switch succeeds once the request drops the conflicting header. - const switched = await update(created.id, { authType: 'bearer', token: 'bearer-token', headers: [] }); + const switched = await update(created.id, { + authType: 'bearer', + token: 'bearer-token', + headers: [], + }); expect(switched.authType).toBe('bearer'); expect(switched.headerNames).toEqual([]); }); it('asks for every value when the stored headers cannot be decrypted, and accepts a full replacement', async () => { const created = await createComposio(); - sqlite.prepare('UPDATE mcp_connections SET encrypted_headers = ? WHERE id = ?').run('garbage', created.id); + sqlite + .prepare('UPDATE mcp_connections SET encrypted_headers = ? WHERE id = ?') + .run('garbage', created.id); await expect(update(created.id, { headers: [{ name: 'x-api-key' }] })).rejects.toThrow( /cannot be read; send every header with its value/ ); await update(created.id, { headers: [{ name: 'x-api-key', value: 'ak_replacement' }] }); - expect(await storedHeaders(created.id)).toEqual([{ name: 'x-api-key', value: 'ak_replacement' }]); + expect(await storedHeaders(created.id)).toEqual([ + { name: 'x-api-key', value: 'ak_replacement' }, + ]); }); }); @@ -259,7 +298,9 @@ describe('listing connections with headers', () => { it('shows a row with an unreadable header_names column as having no names', async () => { const broken = await createComposio({ name: 'broken' }); await createComposio({ name: 'healthy' }); - sqlite.prepare('UPDATE mcp_connections SET header_names = ? WHERE id = ?').run('not json', broken.id); + sqlite + .prepare('UPDATE mcp_connections SET header_names = ? WHERE id = ?') + .run('not json', broken.id); const listed = await listMcpConnections(db, { userId: 'user-1', projectId: null }); diff --git a/apps/web/src/components/mcp-servers/McpServerForm.tsx b/apps/web/src/components/mcp-servers/McpServerForm.tsx new file mode 100644 index 000000000..302d0b024 --- /dev/null +++ b/apps/web/src/components/mcp-servers/McpServerForm.tsx @@ -0,0 +1,148 @@ +import { + MCP_CONNECTION_NAME_RULE, + type McpConnection, + type McpConnectionAuthType, +} from '@simple-agent-manager/shared'; +import { Button, Input, Select } from '@simple-agent-manager/ui'; +import { type FC, type FormEvent, useId, useState } from 'react'; + +import { + emptyMcpServerForm, + mcpServerFormFor, + type McpServerFormState, +} from './mcp-server-form-state'; +import { McpServerHeadersField } from './McpServerHeadersField'; + +interface McpServerFormProps { + /** The server being edited, or null to add a new one. */ + connection: McpConnection | null; + saving: boolean; + onSubmit: (form: McpServerFormState) => void; + onCancel: () => void; +} + +/** + * Add or edit one MCP server. + * + * Editing never shows a stored secret — the API does not return them — so the URL, the token + * and each saved header value start blank and are only replaced when the user types a new one. + */ +export const McpServerForm: FC = ({ + connection, + saving, + onSubmit, + onCancel, +}) => { + const editing = connection !== null; + const [form, setForm] = useState(() => + connection ? mcpServerFormFor(connection) : emptyMcpServerForm() + ); + const id = useId(); + + // A bearer token must be typed unless one is already saved for this server. + const tokenRequired = !(editing && connection.hasToken); + const [submitLabel, savingLabel] = editing + ? ['Save changes', 'Saving…'] + : ['Add server', 'Adding…']; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!saving) onSubmit(form); + }; + + return ( +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="zapier" + autoCapitalize="none" + required + className="mt-1" + /> +

+ Agents see tools namespaced by this name — {MCP_CONNECTION_NAME_RULE}. +

+
+ +
+ + setForm({ ...form, url: e.target.value })} + placeholder={ + editing ? 'Leave blank to keep the saved URL' : 'https://mcp.zapier.com/api/mcp/s/...' + } + required={!editing} + className="mt-1" + /> +

+ {editing + ? `Saved URL: ${connection.urlHost}/… — stored encrypted and never shown in full.` + : 'Stored encrypted and never shown again — some providers put the credential in the URL itself.'} +

+
+ +
+ + +
+ + {form.authType === 'bearer' && ( +
+ + setForm({ ...form, token: e.target.value })} + placeholder={tokenRequired ? undefined : 'Leave blank to keep the saved token'} + required={tokenRequired} + className="mt-1" + /> +
+ )} + + setForm({ ...form, headers })} + /> + +
+ + +
+ + ); +}; diff --git a/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx b/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx new file mode 100644 index 000000000..222c871df --- /dev/null +++ b/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx @@ -0,0 +1,98 @@ +import { MCP_CONNECTION_HEADER_NAME_MAX_LENGTH } from '@simple-agent-manager/shared'; +import { Button, Input } from '@simple-agent-manager/ui'; +import { Plus, X } from 'lucide-react'; +import type { FC } from 'react'; + +import { type McpHeaderRow, newHeaderRow } from './mcp-server-form-state'; + +interface McpServerHeadersFieldProps { + headers: McpHeaderRow[]; + onChange: (headers: McpHeaderRow[]) => void; +} + +/** + * Custom HTTP headers for an MCP server, as name/value rows. + * + * A saved header shows its name as fixed text and a blank value that keeps the saved one: + * the API never returns values, so there is nothing to prefill. Renaming a saved header is + * remove-then-add, which keeps "blank means keep" unambiguous. + * + * Layout: on a phone the value takes its own full-width line beneath the name and the remove + * button; from `sm` up the three share one line. DOM order stays name, value, remove. + */ +export const McpServerHeadersField: FC = ({ headers, onChange }) => { + const update = (key: string, patch: Partial) => + onChange(headers.map((row) => (row.key === key ? { ...row, ...patch } : row))); + + return ( +
+ Headers +

+ Sent with every request, for example x-api-key for + Composio. Values are stored encrypted and never shown again. +

+ + {headers.length > 0 && ( +
    + {headers.map((row, index) => { + const label = row.name.trim() || `header ${index + 1}`; + return ( +
  • + {row.stored ? ( + + {row.name} + + ) : ( + update(row.key, { name: e.target.value })} + placeholder="x-api-key" + maxLength={MCP_CONNECTION_HEADER_NAME_MAX_LENGTH} + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + required + className="col-start-1 row-start-1 font-mono" + /> + )} + update(row.key, { value: e.target.value })} + placeholder={row.stored ? 'Leave blank to keep' : 'Value'} + required={!row.stored} + className="col-span-2 row-start-2 sm:col-span-1 sm:col-start-2 sm:row-start-1" + /> + +
  • + ); + })} +
+ )} + + +
+ ); +}; diff --git a/apps/web/src/components/mcp-servers/McpServersManager.tsx b/apps/web/src/components/mcp-servers/McpServersManager.tsx index 9a494f66c..7b8c58715 100644 --- a/apps/web/src/components/mcp-servers/McpServersManager.tsx +++ b/apps/web/src/components/mcp-servers/McpServersManager.tsx @@ -1,22 +1,15 @@ -import { - type CreateMcpConnectionRequest, - MCP_CONNECTION_NAME_RULE, - type McpConnection, - type McpConnectionAuthType, -} from '@simple-agent-manager/shared'; -import { Alert, Button, Input, Select, Spinner, StatusBadge } from '@simple-agent-manager/ui'; +import type { McpConnection } from '@simple-agent-manager/shared'; +import { Alert, Button, Spinner, StatusBadge } from '@simple-agent-manager/ui'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2 } from 'lucide-react'; +import { Pencil, Plus, Trash2 } from 'lucide-react'; import { type FC, useCallback, useState } from 'react'; import { useToast } from '../../hooks/useToast'; -import { - createMcpConnection, - deleteMcpConnection, - updateMcpConnection, -} from '../../lib/api'; +import { createMcpConnection, deleteMcpConnection, updateMcpConnection } from '../../lib/api'; import { mcpConnectionQueryKeys, mcpConnectionsQueryOptions } from '../../lib/query-options'; import { ConfirmDialog } from '../ConfirmDialog'; +import { type McpServerFormState, toCreateRequest, toUpdateRequest } from './mcp-server-form-state'; +import { McpServerForm } from './McpServerForm'; interface McpServersManagerProps { /** null = the caller's personal scope; a project id = that project's shared scope. */ @@ -32,12 +25,8 @@ interface McpServersManagerProps { title?: string | null; } -const EMPTY_FORM = { - name: '', - url: '', - authType: 'bearer' as McpConnectionAuthType, - token: '', -}; +/** At most one form is open: adding a server, or editing the one with this id. */ +type Editor = { mode: 'create' } | { mode: 'edit'; connectionId: string } | null; /** * One implementation for both the personal and project MCP-server scopes. @@ -53,8 +42,7 @@ export const McpServersManager: FC = ({ }) => { const toast = useToast(); const queryClient = useQueryClient(); - const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState(EMPTY_FORM); + const [editor, setEditor] = useState(null); const [saving, setSaving] = useState(false); const [busyId, setBusyId] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); @@ -66,24 +54,22 @@ export const McpServersManager: FC = ({ await queryClient.invalidateQueries({ queryKey: mcpConnectionQueryKeys.all(queryScope) }); }, [queryClient, queryScope]); - const handleCreate = async (event: React.FormEvent) => { - event.preventDefault(); - if (saving) return; + const handleSubmit = async (form: McpServerFormState) => { + if (!editor) return; + const editing = editor.mode === 'edit'; setSaving(true); try { - const payload: CreateMcpConnectionRequest = { - name: form.name.trim(), - url: form.url.trim(), - authType: form.authType, - ...(form.authType === 'bearer' ? { token: form.token } : {}), - }; - await createMcpConnection(projectId, payload); + if (editing) { + await updateMcpConnection(projectId, editor.connectionId, toUpdateRequest(form)); + } else { + await createMcpConnection(projectId, toCreateRequest(form)); + } await invalidate(); - setForm(EMPTY_FORM); - setShowForm(false); - toast.success('MCP server added'); + setEditor(null); + toast.success(editing ? 'MCP server updated' : 'MCP server added'); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to add MCP server'); + const fallback = editing ? 'Failed to update MCP server' : 'Failed to add MCP server'; + toast.error(error instanceof Error ? error.message : fallback); } finally { setSaving(false); } @@ -135,9 +121,7 @@ export const McpServersManager: FC = ({ Failed to load MCP servers - {query.error instanceof Error && query.error.message - ? `: ${query.error.message}` - : '.'} + {query.error instanceof Error && query.error.message ? `: ${query.error.message}` : '.'} ); @@ -163,103 +147,20 @@ export const McpServersManager: FC = ({ )} - {canWrite && !showForm && ( - )} - {showForm && canWrite && ( -
-
- - setForm({ ...form, name: e.target.value })} - placeholder="zapier" - required - className="mt-1" - /> -

- Agents see tools namespaced by this name — {MCP_CONNECTION_NAME_RULE}. -

-
- -
- - setForm({ ...form, url: e.target.value })} - placeholder="https://mcp.zapier.com/api/mcp/s/..." - required - className="mt-1" - /> -

- Stored encrypted and never shown again — some providers put the credential in - the URL itself. -

-
- -
- - -
- - {form.authType === 'bearer' && ( -
- - setForm({ ...form, token: e.target.value })} - required - className="mt-1" - /> -
- )} - -
- - -
-
+ {editor?.mode === 'create' && canWrite && ( + void handleSubmit(form)} + onCancel={() => setEditor(null)} + /> )} {connections.length === 0 ? ( @@ -268,53 +169,79 @@ export const McpServersManager: FC = ({

) : (
    - {connections.map((connection) => ( -
  • -
    -
    - - {connection.name} - - {!connection.enabled && } + {connections.map((connection) => + editor?.mode === 'edit' && editor.connectionId === connection.id && canWrite ? ( +
  • + void handleSubmit(form)} + onCancel={() => setEditor(null)} + /> +
  • + ) : ( +
  • +
    +
    + + {connection.name} + + {!connection.enabled && } +
    + {/* + The host needs `break-all` because a pre-signed gateway subdomain has no + break opportunities, but the auth label must not inherit it — otherwise it + wraps as "bea rer token". + */} +

    + {connection.urlHost} + + {connection.hasToken ? ' · bearer token' : ' · no auth'} + +

    + {connection.headerNames.length > 0 && ( +

    + Headers:{' '} + {connection.headerNames.join(', ')} +

    + )}
    - {/* - The host needs `break-all` because a pre-signed gateway subdomain has no - break opportunities, but the auth label must not inherit it — otherwise it - wraps as "bea rer token". - */} -

    - {connection.urlHost} - - {connection.hasToken ? ' · bearer token' : ' · no auth'} - -

    - - {canWrite && ( -
    - - -
    - )} -
  • - ))} + {canWrite && ( +
    + + + +
    + )} + + ) + )}
)} diff --git a/apps/web/src/components/mcp-servers/mcp-server-form-state.ts b/apps/web/src/components/mcp-servers/mcp-server-form-state.ts new file mode 100644 index 000000000..0390bcbde --- /dev/null +++ b/apps/web/src/components/mcp-servers/mcp-server-form-state.ts @@ -0,0 +1,86 @@ +import type { + CreateMcpConnectionRequest, + McpConnection, + McpConnectionAuthType, + McpConnectionHeaderUpdate, + UpdateMcpConnectionRequest, +} from '@simple-agent-manager/shared'; + +/** + * One custom-header row in the MCP server form. + * + * `stored` rows already exist on the server. The API returns their names but never their + * values, so a stored row's blank value means "keep the saved value" rather than "empty". + */ +export interface McpHeaderRow { + /** React key only; never sent. */ + key: string; + name: string; + value: string; + stored: boolean; +} + +export interface McpServerFormState { + name: string; + /** Blank while editing means "keep the saved URL". */ + url: string; + authType: McpConnectionAuthType; + /** Blank while editing a bearer server means "keep the saved token". */ + token: string; + headers: McpHeaderRow[]; +} + +export function newHeaderRow(): McpHeaderRow { + return { key: crypto.randomUUID(), name: '', value: '', stored: false }; +} + +export function emptyMcpServerForm(): McpServerFormState { + return { name: '', url: '', authType: 'bearer', token: '', headers: [] }; +} + +/** An edit starts from everything the API can tell us: names, never secrets. */ +export function mcpServerFormFor(connection: McpConnection): McpServerFormState { + return { + name: connection.name, + url: '', + authType: connection.authType, + token: '', + headers: connection.headerNames.map((name) => ({ + key: crypto.randomUUID(), + name, + value: '', + stored: true, + })), + }; +} + +export function toCreateRequest(form: McpServerFormState): CreateMcpConnectionRequest { + const headers = form.headers.map((row) => ({ name: row.name.trim(), value: row.value })); + return { + name: form.name.trim(), + url: form.url.trim(), + authType: form.authType, + ...(form.authType === 'bearer' ? { token: form.token } : {}), + ...(headers.length > 0 ? { headers } : {}), + }; +} + +/** + * Only what the user typed replaces a secret. The header list is always the complete desired + * set: a stored row left blank keeps its saved value, and a removed row is simply absent. + */ +export function toUpdateRequest(form: McpServerFormState): UpdateMcpConnectionRequest { + const url = form.url.trim(); + const headers: McpConnectionHeaderUpdate[] = form.headers.map((row) => + row.stored && row.value === '' + ? { name: row.name } + : { name: row.name.trim(), value: row.value } + ); + return { + name: form.name.trim(), + authType: form.authType, + ...(url ? { url } : {}), + ...(form.authType === 'bearer' && form.token ? { token: form.token } : {}), + headers, + }; +} diff --git a/apps/web/tests/playwright/mcp-servers-audit.spec.ts b/apps/web/tests/playwright/mcp-servers-audit.spec.ts index d5f0a5d3d..404587b4a 100644 --- a/apps/web/tests/playwright/mcp-servers-audit.spec.ts +++ b/apps/web/tests/playwright/mcp-servers-audit.spec.ts @@ -21,6 +21,7 @@ interface ConnectionOverrides { urlHost?: string; authType?: 'none' | 'bearer'; hasToken?: boolean; + headerNames?: string[]; enabled?: boolean; projectId?: string | null; } @@ -32,6 +33,7 @@ function makeConnection(overrides: ConnectionOverrides) { urlHost: 'https://mcp.zapier.com', authType: 'bearer', hasToken: true, + headerNames: [], enabled: true, createdAt: '2026-08-23T00:00:00Z', updatedAt: '2026-08-23T00:00:00Z', @@ -52,6 +54,7 @@ const NORMAL = [ urlHost: 'https://backend.composio.dev', authType: 'none', hasToken: false, + headerNames: ['x-api-key'], }), makeConnection({ id: 'c4', name: 'notion', urlHost: 'https://mcp.notion.com', enabled: false }), ]; @@ -70,6 +73,21 @@ const LONG_TEXT = [ name: 'x', urlHost: 'https://a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.example.com', }), + // Header names are bounded at 64 characters of [A-Za-z0-9_-], so the widest realistic row is + // several maximum-length names with no break opportunity between hyphens. + makeConnection({ + id: 'l3', + name: 'many-headers', + authType: 'none', + hasToken: false, + headerNames: [ + `x-${'a'.repeat(62)}`, + 'X-Composio-Consumer-Api-Key', + 'x_org_id', + 'x-team', + `X_${'Z'.repeat(62)}`, + ], + }), ]; const MANY = Array.from({ length: 30 }, (_, i) => @@ -80,19 +98,21 @@ const MANY = Array.from({ length: 30 }, (_, i) => enabled: i % 3 !== 0, authType: i % 4 === 0 ? 'none' : 'bearer', hasToken: i % 4 !== 0, + headerNames: i % 5 === 0 ? ['x-api-key', 'x-org-id'] : [], }) ); const SPECIAL = [ makeConnection({ id: 's1', name: 'emoji-host', urlHost: 'https://xn--ls8h.example.com' }), - makeConnection({ id: 's2', name: 'script-tag', urlHost: 'https://.com' }), + makeConnection({ + id: 's2', + name: 'script-tag', + urlHost: 'https://.com', + }), makeConnection({ id: 's3', name: 'unicode', urlHost: 'https://日本語ドメイン.example.com' }), ]; -async function setupMocks( - page: Page, - options: { connections?: unknown[]; error?: boolean } = {} -) { +async function setupMocks(page: Page, options: { connections?: unknown[]; error?: boolean } = {}) { // Without this the first-run onboarding wizard covers the page. Playwright would still // report the settings content "visible" (it is in the DOM), so every screenshot would // capture the modal and every overflow check would measure the modal's layout — the exact @@ -171,6 +191,57 @@ function runScenarios(label: string) { await audit(page, `mcp-servers-error-${label}`); }); + test('add form with custom headers keeps every row inside the viewport', async ({ page }) => { + await setupMocks(page, { connections: NORMAL }); + await gotoMcpServers(page); + + await page.getByRole('button', { name: /^add$/i }).click(); + await page.getByLabel(/Authentication/i).selectOption('none'); + await page.getByRole('button', { name: /add header/i }).click(); + await page.getByLabel('Header 1 name').fill('x-api-key'); + await page.getByLabel('x-api-key value').fill('ak_live_1234567890'); + await page.getByRole('button', { name: /add header/i }).click(); + await page.getByLabel('Header 2 name').fill(`x-${'a'.repeat(62)}`); + + // The row's layout claim, measured (rule 17): on a phone the value takes its own line + // under the name; from `sm` up the name, value and remove button share one line. + const name = await page.getByLabel('Header 1 name').boundingBox(); + const value = await page.getByLabel('x-api-key value').boundingBox(); + const remove = await page.getByRole('button', { name: 'Remove x-api-key' }).boundingBox(); + expect(name && value && remove).toBeTruthy(); + const viewportWidth = page.viewportSize()!.width; + if (viewportWidth < 640) { + expect(value!.y).toBeGreaterThanOrEqual(name!.y + name!.height - 1); + expect(remove!.y).toBeLessThan(value!.y); + } else { + expect(Math.abs(value!.y - name!.y)).toBeLessThanOrEqual(2); + expect(value!.x).toBeGreaterThanOrEqual(name!.x + name!.width); + expect(remove!.x).toBeGreaterThanOrEqual(value!.x + value!.width); + } + expect(remove!.x + remove!.width).toBeLessThanOrEqual(viewportWidth); + await audit(page, `mcp-servers-add-form-headers-${label}`); + }); + + test('edit form shows saved header names with blank, keep-by-default values', async ({ + page, + }) => { + await setupMocks(page, { connections: LONG_TEXT }); + await gotoMcpServers(page); + + await page.getByRole('button', { name: 'Edit many-headers' }).click(); + const form = page.getByRole('form', { name: 'Edit many-headers' }); + await expect(form).toBeVisible(); + await expect(form.getByText('X-Composio-Consumer-Api-Key', { exact: true })).toBeVisible(); + await expect(form.getByLabel('X-Composio-Consumer-Api-Key value')).toHaveValue(''); + await expect(form.getByLabel('X-Composio-Consumer-Api-Key value')).toHaveAttribute( + 'placeholder', + 'Leave blank to keep' + ); + // Only one form at a time: the header Add button is withdrawn while editing. + await expect(page.getByRole('button', { name: /^add$/i })).toHaveCount(0); + await audit(page, `mcp-servers-edit-form-${label}`); + }); + test('add form is usable', async ({ page }) => { await setupMocks(page, { connections: NORMAL }); await gotoMcpServers(page); diff --git a/apps/web/tests/unit/McpServersManager.test.tsx b/apps/web/tests/unit/McpServersManager.test.tsx index 163b0ebc4..a105bbc76 100644 --- a/apps/web/tests/unit/McpServersManager.test.tsx +++ b/apps/web/tests/unit/McpServersManager.test.tsx @@ -33,6 +33,7 @@ function makeConnection(overrides: Partial = {}): McpConnection { urlHost: 'https://mcp.zapier.com', authType: 'bearer', hasToken: true, + headerNames: [], enabled: true, createdAt: '2026-08-23T00:00:00Z', updatedAt: '2026-08-23T00:00:00Z', @@ -204,9 +205,7 @@ describe('McpServersManager', () => { it('hides write controls when the caller cannot write, but still lists servers', async () => { listMcpConnections.mockResolvedValue([makeConnection()]); - renderWithQuery( - - ); + renderWithQuery(); // Positive liveness assertion beside the absence assertions (rule 62): a crashed render // would also satisfy "no buttons". @@ -216,6 +215,189 @@ describe('McpServersManager', () => { expect(screen.queryByRole('button', { name: /delete zapier/i })).toBeNull(); }); + it('shows which custom headers a server sends, never their values', async () => { + listMcpConnections.mockResolvedValue([ + makeConnection({ + name: 'composio', + authType: 'none', + hasToken: false, + headerNames: ['x-api-key', 'X-Org_Id'], + }), + ]); + const { container } = renderWithQuery( + + ); + + expect(await screen.findByText('x-api-key, X-Org_Id')).toBeInTheDocument(); + expect(container.querySelector('input[type="password"]')).toBeNull(); + }); + + it('adds a server authenticated only by a custom header (the Composio shape)', async () => { + const user = userEvent.setup(); + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: /^add$/i })); + await user.type(screen.getByLabelText(/^Name$/i), 'composio'); + await user.type( + screen.getByLabelText(/MCP endpoint URL/i), + 'https://backend.composio.dev/v3/mcp/x' + ); + await user.selectOptions(screen.getByLabelText(/Authentication/i), 'none'); + await user.click(screen.getByRole('button', { name: /add header/i })); + await user.type(screen.getByLabelText('Header 1 name'), 'x-api-key'); + await user.type(screen.getByLabelText('x-api-key value'), 'ak_live_secret'); + + createMcpConnection.mockResolvedValue(makeConnection({ name: 'composio' })); + await user.click(screen.getByRole('button', { name: /add server/i })); + + await waitFor(() => { + expect(createMcpConnection).toHaveBeenCalledWith('proj-1', { + name: 'composio', + url: 'https://backend.composio.dev/v3/mcp/x', + authType: 'none', + headers: [{ name: 'x-api-key', value: 'ak_live_secret' }], + }); + }); + }); + + it('removes an unsaved header row before it is ever sent', async () => { + const user = userEvent.setup(); + renderWithQuery(); + + await user.click(await screen.findByRole('button', { name: /^add$/i })); + await user.click(screen.getByRole('button', { name: /add header/i })); + await user.type(screen.getByLabelText('Header 1 name'), 'x-debug'); + await user.click(screen.getByRole('button', { name: /remove x-debug/i })); + + expect(screen.queryByLabelText('x-debug value')).toBeNull(); + await user.type(screen.getByLabelText(/^Name$/i), 'zapier'); + await user.type(screen.getByLabelText(/MCP endpoint URL/i), 'https://mcp.zapier.com/s/abc'); + await user.type(screen.getByLabelText(/Bearer token/i), 'secret-token'); + createMcpConnection.mockResolvedValue(makeConnection()); + await user.click(screen.getByRole('button', { name: /add server/i })); + + await waitFor(() => { + expect(createMcpConnection).toHaveBeenCalledWith(null, { + name: 'zapier', + url: 'https://mcp.zapier.com/s/abc', + authType: 'bearer', + token: 'secret-token', + }); + }); + }); + + describe('editing a saved server', () => { + const saved = makeConnection({ + name: 'composio', + urlHost: 'https://backend.composio.dev', + authType: 'none', + hasToken: false, + headerNames: ['x-api-key', 'x-org-id'], + }); + + async function openEditor() { + const user = userEvent.setup(); + listMcpConnections.mockResolvedValue([saved]); + renderWithQuery(); + await user.click(await screen.findByRole('button', { name: /edit composio/i })); + return user; + } + + it('opens prefilled with names only, and keeps every secret that is not retyped', async () => { + const user = await openEditor(); + + const form = screen.getByRole('form', { name: /edit composio/i }); + expect(within(form).getByLabelText(/^Name$/i)).toHaveValue('composio'); + expect(within(form).getByLabelText(/MCP endpoint URL/i)).toHaveValue(''); + expect(within(form).getByLabelText('x-api-key value')).toHaveValue(''); + // No other form is offered while one is open. + expect(screen.queryByRole('button', { name: /^add$/i })).toBeNull(); + + updateMcpConnection.mockResolvedValue(saved); + await user.click(within(form).getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(updateMcpConnection).toHaveBeenCalledWith('proj-1', 'conn-1', { + name: 'composio', + authType: 'none', + headers: [{ name: 'x-api-key' }, { name: 'x-org-id' }], + }); + }); + expect(toastSuccess).toHaveBeenCalledWith('MCP server updated'); + }); + + it('rotates one header, removes another and adds a third in a single save', async () => { + const user = await openEditor(); + const form = screen.getByRole('form', { name: /edit composio/i }); + + await user.type(within(form).getByLabelText('x-api-key value'), 'ak_rotated'); + await user.click(within(form).getByRole('button', { name: /remove x-org-id/i })); + await user.click(within(form).getByRole('button', { name: /add header/i })); + await user.type(within(form).getByLabelText('Header 2 name'), 'x-team'); + await user.type(within(form).getByLabelText('x-team value'), 'platform'); + + updateMcpConnection.mockResolvedValue(saved); + await user.click(within(form).getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(updateMcpConnection).toHaveBeenCalledWith('proj-1', 'conn-1', { + name: 'composio', + authType: 'none', + headers: [ + { name: 'x-api-key', value: 'ak_rotated' }, + { name: 'x-team', value: 'platform' }, + ], + }); + }); + }); + + it('requires a token when switching a tokenless server to bearer', async () => { + const user = await openEditor(); + const form = screen.getByRole('form', { name: /edit composio/i }); + + await user.selectOptions(within(form).getByLabelText(/Authentication/i), 'bearer'); + + expect(within(form).getByLabelText(/Bearer token/i)).toBeRequired(); + }); + + it('does not require retyping the saved token of a bearer server', async () => { + const user = userEvent.setup(); + listMcpConnections.mockResolvedValue([makeConnection()]); + renderWithQuery(); + await user.click(await screen.findByRole('button', { name: /edit zapier/i })); + + const form = screen.getByRole('form', { name: /edit zapier/i }); + expect(within(form).getByLabelText(/Bearer token/i)).not.toBeRequired(); + + updateMcpConnection.mockResolvedValue(makeConnection()); + await user.click(within(form).getByRole('button', { name: /save changes/i })); + await waitFor(() => { + expect(updateMcpConnection).toHaveBeenCalledWith(null, 'conn-1', { + name: 'zapier', + authType: 'bearer', + headers: [], + }); + }); + }); + + it('keeps the editor open with the typed values when the server rejects the save', async () => { + const user = await openEditor(); + const form = screen.getByRole('form', { name: /edit composio/i }); + await user.type(within(form).getByLabelText('x-api-key value'), 'ak_rotated'); + + updateMcpConnection.mockRejectedValue( + new Error('Header "x-api-key" value must not contain line breaks') + ); + await user.click(within(form).getByRole('button', { name: /save changes/i })); + + await waitFor(() => + expect(toastError).toHaveBeenCalledWith(expect.stringMatching(/x-api-key/)) + ); + expect(screen.getByRole('form', { name: /edit composio/i })).toBeInTheDocument(); + expect(screen.getByLabelText('x-api-key value')).toHaveValue('ak_rotated'); + }); + }); + it('reads the project endpoint when given a project id', async () => { renderWithQuery(); await waitFor(() => { diff --git a/apps/www/src/content/docs/docs/guides/mcp-servers.md b/apps/www/src/content/docs/docs/guides/mcp-servers.md index 497a9ab66..7be20e2f2 100644 --- a/apps/www/src/content/docs/docs/guides/mcp-servers.md +++ b/apps/www/src/content/docs/docs/guides/mcp-servers.md @@ -10,13 +10,13 @@ SAM does not build per-service connectors. It speaks MCP, and the endpoint owns ## How it works 1. Pick a provider (see below) and connect the services you want **in that provider's dashboard**. That is where the OAuth happens, in your browser. -2. The provider gives you an MCP endpoint URL, usually with a bearer token. -3. Paste both into SAM under **Settings → MCP Servers** (yours alone) or **Project Settings → Runtime** (shared with the project). +2. The provider gives you an MCP endpoint URL, usually with a bearer token or an API key to send in a header. +3. Paste them into SAM under **Settings → MCP Servers** (yours alone) or **Project Settings → Runtime** (shared with the project). 4. Start a chat or task. The agent sees the new tools immediately, namespaced by the name you chose. This works on both runtimes — VM workspaces and Instant (container) sessions. -How the endpoint reaches the agent depends on the agent. Claude Code receives it in the session handshake, Codex and Vibe get it written into their own config files, and Amp reaches it through a bridge. Agents that do not implement remote MCP servers will not see the tools. +How the endpoint reaches the agent depends on the agent. Claude Code receives it in the session handshake, Codex and Vibe get it written into their own config files (Codex reads the token and header values from environment variables, so they never land in its config file), and Amp reaches it through a bridge. Agents that do not implement remote MCP servers will not see the tools. ## Choosing a provider @@ -24,7 +24,7 @@ How the endpoint reaches the agent depends on the agent. Claude Code receives it | --- | --- | --- | | [Zapier MCP](https://zapier.com/mcp) | Breadth — around 9,000 apps, including LinkedIn and Google Docs | Bearer token | | [executor.sh](https://executor.sh/) | Open source (MIT). Run it yourself via CLI, Docker or a Cloudflare Worker, or use their hosted endpoint | Bearer token | -| [Composio / Rube](https://composio.dev/) | Managed OAuth with a large toolkit catalog | Pre-signed URL — choose **None** | +| [Composio / Rube](https://composio.dev/) | Managed OAuth with a large toolkit catalog | API key header — choose **None** and add an `x-api-key` header (`x-consumer-api-key` for Composio Connect). Older pre-signed URLs need no header. | | [Klavis / Strata](https://www.klavis.ai/) | Self-hosting everything (Apache-2.0) | Bearer token | | Official service endpoints | A single service you already pay for — GitHub, Notion, Linear, Sentry, Stripe | Personal access token as bearer | @@ -36,9 +36,14 @@ Prefer gateway-style providers that expose a small number of tools over servers | --- | --- | | **Name** | How the agent sees the server; its tools are namespaced by it. 1–32 characters, lowercase letters, digits and hyphens; it may not start or end with a hyphen. `sam-mcp` is reserved. | | **MCP endpoint URL** | Must be HTTPS. `http://localhost:` and `http://127.0.0.1:` are allowed for a gateway running on the same machine — an explicit port is required. | -| **Authentication** | **Bearer token** for most providers. **None** when the credential is embedded in the URL itself, as with Composio's pre-signed URLs. | +| **Authentication** | **Bearer token** for most providers. **None** when the credential travels in the URL itself (pre-signed URLs) or in a custom header. | +| **Headers** | Optional HTTP headers sent with every request, for providers that take an API key in a header — Composio's `x-api-key`, for example. Names are 1–64 letters, digits, hyphens or underscores. Headers the MCP transport sets itself (`Accept`, `Content-Type`, `Host`, `Connection`, `Content-Length`, `Transfer-Encoding`, `Mcp-Session-Id`, `Mcp-Protocol-Version`, `Last-Event-ID`) cannot be overridden. `Authorization` is accepted only when Authentication is **None**, so you can use a scheme other than Bearer. By default a server can have up to 10 headers. | -Both the URL and the token are encrypted at rest and are never returned by the API or shown again after you save them — several providers put the credential directly in the URL, so the URL is treated as a secret too. SAM shows only the host. +The URL, the token and every header value are encrypted at rest and are never returned by the API or shown again after you save them — several providers put the credential directly in the URL, so the URL is treated as a secret too. SAM shows only the host and the header names. + +## Editing a server + +Use **Edit** to rename a server, switch its authentication, or add, replace and remove headers. Saved secrets are never shown, so the URL, the token and each saved header value start blank: leave them blank to keep what is saved, or type a new value to replace it. To rename a header, remove it and add it again under the new name. ## Scopes @@ -71,6 +76,5 @@ Tools from a connected MCP server run inside your agent's session, which already ## Limitations - **Remote HTTP servers only.** `stdio` servers are not supported: configuring an arbitrary command from a web UI is an unnecessary attack surface, and every major provider is remote-first. -- **Bearer or no authentication.** Custom auth headers (for example `X-API-Key`) are not yet supported. - **Personal and project scope only.** Attaching a server to a specific agent profile or skill is not yet supported. -- **Amp exposes the endpoint URL locally.** The Amp harness reaches remote MCP servers through a bridge process that receives the URL as a command-line argument, so anything running inside that same workspace can read it. The bearer token is not exposed this way. If your endpoint's URL is itself the credential (a pre-signed URL), prefer a different agent for now. +- **Amp exposes the endpoint URL locally.** The Amp harness reaches remote MCP servers through a bridge process that receives the URL and the header names as command-line arguments, so anything running inside that same workspace can read them. The bearer token and header values are not exposed this way. If your endpoint's URL is itself the credential (a pre-signed URL), prefer a different agent for now. diff --git a/apps/www/src/content/docs/docs/reference/configuration.md b/apps/www/src/content/docs/docs/reference/configuration.md index 75955ca02..5760422ce 100644 --- a/apps/www/src/content/docs/docs/reference/configuration.md +++ b/apps/www/src/content/docs/docs/reference/configuration.md @@ -1371,6 +1371,8 @@ lifecycle bookkeeping. | `MAX_MCP_CONNECTIONS_PER_SCOPE` | `25` | Max bring-your-own MCP servers per scope | | `MCP_CONNECTION_URL_MAX_BYTES` | `2048` | Max MCP endpoint URL size | | `MCP_CONNECTION_TOKEN_MAX_BYTES` | `8192` | Max MCP bearer token size | +| `MAX_MCP_CONNECTION_HEADERS` | `10` | Max custom headers per MCP server | +| `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` | `8192` | Max bytes per MCP custom header value | ## External API Timeouts diff --git a/packages/shared/src/types/mcp-connection.ts b/packages/shared/src/types/mcp-connection.ts index 74508b751..fc9d171e9 100644 --- a/packages/shared/src/types/mcp-connection.ts +++ b/packages/shared/src/types/mcp-connection.ts @@ -47,8 +47,7 @@ export const MCP_CONNECTION_NAME_PATTERN = new RegExp( `^[a-z0-9][a-z0-9-]{0,${MCP_CONNECTION_NAME_MAX_LENGTH - 2}}[a-z0-9]$|^[a-z0-9]$` ); -export const MCP_CONNECTION_NAME_RULE = - `name must be 1-${MCP_CONNECTION_NAME_MAX_LENGTH} characters of lowercase letters, digits or hyphens, and may not start or end with a hyphen`; +export const MCP_CONNECTION_NAME_RULE = `name must be 1-${MCP_CONNECTION_NAME_MAX_LENGTH} characters of lowercase letters, digits or hyphens, and may not start or end with a hyphen`; /** * Maximum custom header name length. @@ -71,8 +70,7 @@ export const MCP_CONNECTION_HEADER_NAME_PATTERN = new RegExp( `^[A-Za-z0-9_-]{1,${MCP_CONNECTION_HEADER_NAME_MAX_LENGTH}}$` ); -export const MCP_CONNECTION_HEADER_NAME_RULE = - `header names must be 1-${MCP_CONNECTION_HEADER_NAME_MAX_LENGTH} characters of letters, digits, hyphens or underscores`; +export const MCP_CONNECTION_HEADER_NAME_RULE = `header names must be 1-${MCP_CONNECTION_HEADER_NAME_MAX_LENGTH} characters of letters, digits, hyphens or underscores`; /** * Headers the MCP transport or the HTTP client sets itself. A stored value would either be diff --git a/packages/shared/tests/unit/mcp-server-name-contract.test.ts b/packages/shared/tests/unit/mcp-server-name-contract.test.ts index dc08fff2a..b8e59f25c 100644 --- a/packages/shared/tests/unit/mcp-server-name-contract.test.ts +++ b/packages/shared/tests/unit/mcp-server-name-contract.test.ts @@ -39,13 +39,19 @@ function normalize(raw: string): string { describe('MCP server name contract (TypeScript side)', () => { it('accepts every name the contract marks valid', () => { for (const name of contract.valid) { - expect(MCP_CONNECTION_NAME_PATTERN.test(normalize(name)), `expected ${JSON.stringify(name)} to be valid`).toBe(true); + expect( + MCP_CONNECTION_NAME_PATTERN.test(normalize(name)), + `expected ${JSON.stringify(name)} to be valid` + ).toBe(true); } }); it('rejects every name the contract marks invalid', () => { for (const name of contract.invalid) { - expect(MCP_CONNECTION_NAME_PATTERN.test(normalize(name)), `expected ${JSON.stringify(name)} to be invalid`).toBe(false); + expect( + MCP_CONNECTION_NAME_PATTERN.test(normalize(name)), + `expected ${JSON.stringify(name)} to be invalid` + ).toBe(false); } }); @@ -70,13 +76,19 @@ describe('MCP server name contract (TypeScript side)', () => { describe('MCP custom header name contract (TypeScript side)', () => { it('accepts every header name the contract marks valid', () => { for (const name of contract.headerNames.valid) { - expect(MCP_CONNECTION_HEADER_NAME_PATTERN.test(name), `expected ${JSON.stringify(name)} to be valid`).toBe(true); + expect( + MCP_CONNECTION_HEADER_NAME_PATTERN.test(name), + `expected ${JSON.stringify(name)} to be valid` + ).toBe(true); } }); it('rejects every header name the contract marks invalid', () => { for (const name of contract.headerNames.invalid) { - expect(MCP_CONNECTION_HEADER_NAME_PATTERN.test(name), `expected ${JSON.stringify(name)} to be invalid`).toBe(false); + expect( + MCP_CONNECTION_HEADER_NAME_PATTERN.test(name), + `expected ${JSON.stringify(name)} to be invalid` + ).toBe(false); } }); diff --git a/tasks/active/2026-09-29-mcp-connection-custom-headers.md b/tasks/active/2026-09-29-mcp-connection-custom-headers.md index e2ad1db02..ec26be9dc 100644 --- a/tasks/active/2026-09-29-mcp-connection-custom-headers.md +++ b/tasks/active/2026-09-29-mcp-connection-custom-headers.md @@ -46,7 +46,7 @@ headers were deferred there). (`internal/acp/session_host.go:76`). It already emits `[]acpsdk.HttpHeader`, but only `Authorization`. - Amp: `buildAmpMcpServer` bridges through `npx mcp-remote@0.1.38 --header - Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env rather than argv. +Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env rather than argv. - Codex: `generateCodexMcpConfig` (`gateway.go:1393`) writes `[mcp_servers.] url` + `bearer_token_env_var`, and exports env vars for docker exec. - Vibe: `generateVibeConfig` (`gateway.go:1481`) writes `headers = { Authorization = "Bearer …" }`. @@ -112,6 +112,7 @@ headers were deferred there). ## Implementation Checklist ### Shared + - [ ] `mcp-connection.ts`: `McpConnectionHeader`, `McpConnectionHeaderUpdate`, `headerNames` on `McpConnection`, `headers` on create/update requests, header name pattern/rule/max length, reserved header names @@ -121,6 +122,7 @@ headers were deferred there). consumed by the TS test and the Go test ### API + - [ ] Migration `0175_mcp_connection_headers.sql` (ADD COLUMN ×3, additive) + `schema.ts` columns - [ ] `services/mcp-connection-headers.ts`: validate, merge-for-update, seal/open, display names - [ ] `services/mcp-connections.ts`: create/update/response use the header module @@ -131,6 +133,7 @@ headers were deferred there). - [ ] `services/node-agent.ts`: `McpServerConfig.headers`, `serializeMcpServers` sends only when non-empty ### vm-agent + - [ ] Refactor commit: extract `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go` (pure moves) - [ ] `McpHeader` + `McpServerEntry.Headers`; header name/value validators in acp @@ -143,12 +146,14 @@ headers were deferred there). - [ ] Vibe: custom headers in the `headers` inline table ### Web + - [ ] Split `McpServersManager.tsx` into list + `McpServerForm` + `McpServerHeadersField` - [ ] Headers editor in the create form; Edit action with keep-semantics payload; header names in the row - [ ] Unit tests (create payload, edit payload keep/replace/remove, rendering) - [ ] Playwright audit: headers form + edit form + rows with many/long headers, 375 and 1280 ### Tests + - [ ] API: header validation, encryption at rest, never-returned values, PATCH keep/replace/remove, authType/Authorization conflict, malformed `header_names` tolerated on list - [ ] API vertical slice: mock MCP server requiring `x-api-key` authorizes the resolved entry, @@ -160,6 +165,7 @@ headers were deferred there). - [ ] Contract fixture consumed on both sides ### Docs + - [ ] `apps/www/.../guides/mcp-servers.md`: headers field, Composio row, editing, remove limitation, Amp note - [ ] `apps/www/.../reference/configuration.md` + `apps/api/.env.example`: new limits - [ ] `.claude/skills/changelog/SKILL.md` entry; env-reference skill if it lists MCP limits From b63461d3ff507939ab5fb36943c90c1729106f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 06:57:16 +0000 Subject: [PATCH 06/18] fix(web): make MCP header rows and server actions readable on phones Visual audit findings, fixed: - header rows are bordered cards below sm so each value visibly belongs to its name (they read as one undifferentiated stack before) - server row actions wrap under the text on phones instead of squeezing long hosts into a ~24-character column now that there are three of them - the None auth option no longer truncates at 375px - Settings help card: Composio takes an x-api-key header, not only a pre-signed URL Audit: long header-name row and Project Settings -> Runtime scenarios added. Co-Authored-By: Claude Opus 5.5 --- .../components/mcp-servers/McpServerForm.tsx | 2 +- .../mcp-servers/McpServerHeadersField.tsx | 7 ++- .../mcp-servers/McpServersManager.tsx | 6 +- apps/web/src/pages/SettingsMcpServers.tsx | 18 +++--- .../playwright/mcp-servers-audit.spec.ts | 55 +++++++++++++++++++ 5 files changed, 75 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/mcp-servers/McpServerForm.tsx b/apps/web/src/components/mcp-servers/McpServerForm.tsx index 302d0b024..719d5f5d6 100644 --- a/apps/web/src/components/mcp-servers/McpServerForm.tsx +++ b/apps/web/src/components/mcp-servers/McpServerForm.tsx @@ -108,7 +108,7 @@ export const McpServerForm: FC = ({ className="mt-1" > - + diff --git a/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx b/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx index 222c871df..3c54d87d3 100644 --- a/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx +++ b/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx @@ -17,8 +17,9 @@ interface McpServerHeadersFieldProps { * the API never returns values, so there is nothing to prefill. Renaming a saved header is * remove-then-add, which keeps "blank means keep" unambiguous. * - * Layout: on a phone the value takes its own full-width line beneath the name and the remove - * button; from `sm` up the three share one line. DOM order stays name, value, remove. + * Layout: on a phone each header is a bordered card whose value takes its own full-width line + * beneath the name and the remove button, so it is clear which value belongs to which name; + * from `sm` up the three share one borderless line. DOM order stays name, value, remove. */ export const McpServerHeadersField: FC = ({ headers, onChange }) => { const update = (key: string, patch: Partial) => @@ -39,7 +40,7 @@ export const McpServerHeadersField: FC = ({ headers, return (
  • {row.stored ? ( diff --git a/apps/web/src/components/mcp-servers/McpServersManager.tsx b/apps/web/src/components/mcp-servers/McpServersManager.tsx index 7b8c58715..c80713794 100644 --- a/apps/web/src/components/mcp-servers/McpServersManager.tsx +++ b/apps/web/src/components/mcp-servers/McpServersManager.tsx @@ -184,7 +184,11 @@ export const McpServersManager: FC = ({ key={connection.id} className="flex flex-wrap items-center gap-2 rounded-md border border-border-default p-3" > -
    + {/* + `basis-48` lets the three actions wrap under the text on a phone instead of + squeezing a long host into a column a few characters wide. + */} +
    {connection.name} diff --git a/apps/web/src/pages/SettingsMcpServers.tsx b/apps/web/src/pages/SettingsMcpServers.tsx index 54573188b..7c35d8564 100644 --- a/apps/web/src/pages/SettingsMcpServers.tsx +++ b/apps/web/src/pages/SettingsMcpServers.tsx @@ -28,26 +28,28 @@ export function SettingsMcpServers() {

    Where do I get an MCP endpoint?

    • - Zapier MCP — broadest catalog (~9,000 apps). - Do the OAuth in Zapier, copy the endpoint and its bearer token. + Zapier MCP — broadest catalog (~9,000 + apps). Do the OAuth in Zapier, copy the endpoint and its bearer token.
    • executor.sh — open source (MIT); run it yourself or use their hosted endpoint.
    • - Composio / Rube — issues a pre-signed URL, so - choose “None” for authentication. + Composio / Rube — choose “None” + for authentication and add your API key as an{' '} + x-api-key header (older pre-signed URLs need no + header).
    • - Official service endpoints — GitHub, Notion, - Linear, Sentry and Stripe all publish remote MCP servers that take a personal + Official service endpoints — GitHub, + Notion, Linear, Sentry and Stripe all publish remote MCP servers that take a personal access token as the bearer.

    - Tools from an MCP server run with your agent's full repository and shell access, - and their descriptions enter the agent's context. Only add endpoints you trust. + Tools from an MCP server run with your agent's full repository and shell access, and + their descriptions enter the agent's context. Only add endpoints you trust.

    diff --git a/apps/web/tests/playwright/mcp-servers-audit.spec.ts b/apps/web/tests/playwright/mcp-servers-audit.spec.ts index 404587b4a..22a995833 100644 --- a/apps/web/tests/playwright/mcp-servers-audit.spec.ts +++ b/apps/web/tests/playwright/mcp-servers-audit.spec.ts @@ -147,6 +147,42 @@ async function audit(page: Page, name: string) { await assertNoClippedOverflow(page); } +const PROJECT = { + id: 'proj-mcp-1', + name: 'Composio Project', + repository: 'acme/app', + repoProvider: 'github', + defaultBranch: 'main', + userId: 'user-test-1', + createdAt: '2026-08-23T00:00:00Z', + updatedAt: '2026-08-23T00:00:00Z', +}; + +/** The same manager, rendered in its project scope under Project Settings → Runtime. */ +async function gotoProjectRuntime(page: Page, connections: unknown[]) { + await page.addInitScript((userId) => { + window.localStorage.setItem(`sam-onboarding-wizard-dismissed-${userId}`, 'true'); + }, MOCK_USER.user.id); + await setupAuditRoutes(page, (path, respond) => { + if (path.includes('/api/auth/get-session')) return respond(200, MOCK_USER); + if (path === `/api/projects/${PROJECT.id}/mcp-connections`) { + return respond(200, { items: connections }); + } + if (path === `/api/projects/${PROJECT.id}/runtime-config`) { + return respond(200, { envVars: [], files: [] }); + } + if (path === `/api/projects/${PROJECT.id}`) return respond(200, PROJECT); + if (path === '/api/projects') return respond(200, { projects: [PROJECT], nextCursor: null }); + if (path.includes('/sessions')) return respond(200, { sessions: [], total: 0 }); + if (path.includes('/api/credentials')) return respond(200, []); + return undefined; + }); + await page.goto(`/projects/${PROJECT.id}/settings/runtime`); + await page.waitForLoadState('networkidle'); + await expect(page.locator('[data-testid="onboarding-wizard"]')).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'MCP servers' })).toBeVisible(); +} + function runScenarios(label: string) { test('normal data', async ({ page }) => { await setupMocks(page, { connections: NORMAL }); @@ -160,6 +196,14 @@ function runScenarios(label: string) { await gotoMcpServers(page); await expect(page.getByText('a-very-long-server-name-here', { exact: true })).toBeVisible(); await audit(page, `mcp-servers-long-text-${label}`); + + // Five header names, two of them 64 unbroken characters, on one row. + const headersLine = page.getByText(/^Headers:/); + await headersLine.scrollIntoViewIfNeeded(); + await expect(headersLine).toBeVisible(); + const box = await headersLine.boundingBox(); + expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width); + await audit(page, `mcp-servers-long-headers-row-${label}`); }); test('empty state', async ({ page }) => { @@ -242,6 +286,17 @@ function runScenarios(label: string) { await audit(page, `mcp-servers-edit-form-${label}`); }); + test('project runtime settings list header names for a shared server', async ({ page }) => { + const shared = NORMAL.map((connection) => ({ ...connection, projectId: PROJECT.id })); + await gotoProjectRuntime(page, shared); + + const headerNames = page.getByText('x-api-key', { exact: true }); + await headerNames.scrollIntoViewIfNeeded(); + await expect(headerNames).toBeVisible(); + await expect(page.getByRole('button', { name: 'Edit composio' })).toBeVisible(); + await audit(page, `mcp-servers-project-runtime-${label}`); + }); + test('add form is usable', async ({ page }) => { await setupMocks(page, { connections: NORMAL }); await gotoMcpServers(page); From 1daf06264f2431d79a3875ac09c01443838bd7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:04:07 +0000 Subject: [PATCH 07/18] fix(mcp): drop test SQLite artifacts; tolerate pre-headers API during deploy - The wire-contract test drove the real create-agent-session handler, whose message reporter wrote messages-ws.db* into the package directory, and those files were committed. Point the test's persistence path at a temp dir and remove them (found by the constitution-validator review). - The deploy publishes the web UI before the API Worker, so listMcpConnections defaults a missing headerNames to [] for that window instead of crashing the MCP settings page. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/api/mcp-connections.ts | 11 ++- .../internal/server/mcp_servers_wire_test.go | 3 + .../vm-agent/internal/server/messages-ws.db | Bin 4096 -> 0 bytes .../internal/server/messages-ws.db-shm | Bin 32768 -> 0 bytes .../internal/server/messages-ws.db-wal | Bin 24752 -> 0 bytes ...026-09-29-mcp-connection-custom-headers.md | 75 ++++++++++-------- 6 files changed, 53 insertions(+), 36 deletions(-) delete mode 100644 packages/vm-agent/internal/server/messages-ws.db delete mode 100644 packages/vm-agent/internal/server/messages-ws.db-shm delete mode 100644 packages/vm-agent/internal/server/messages-ws.db-wal diff --git a/apps/web/src/lib/api/mcp-connections.ts b/apps/web/src/lib/api/mcp-connections.ts index fe6ef5789..c90757fa1 100644 --- a/apps/web/src/lib/api/mcp-connections.ts +++ b/apps/web/src/lib/api/mcp-connections.ts @@ -16,14 +16,17 @@ import { request } from './client'; * both scopes from a single component. */ function basePath(projectId: string | null): string { - return projectId === null - ? '/api/mcp-connections' - : `/api/projects/${projectId}/mcp-connections`; + return projectId === null ? '/api/mcp-connections' : `/api/projects/${projectId}/mcp-connections`; } export async function listMcpConnections(projectId: string | null): Promise { const response = await request(basePath(projectId)); - return response.items; + // The deploy publishes the web UI before the API Worker, so for a minute this UI can talk + // to an API that predates custom headers and omits `headerNames`. + return response.items.map((connection) => ({ + ...connection, + headerNames: connection.headerNames ?? [], + })); } export async function createMcpConnection( diff --git a/packages/vm-agent/internal/server/mcp_servers_wire_test.go b/packages/vm-agent/internal/server/mcp_servers_wire_test.go index e637c5296..48e8e1082 100644 --- a/packages/vm-agent/internal/server/mcp_servers_wire_test.go +++ b/packages/vm-agent/internal/server/mcp_servers_wire_test.go @@ -30,6 +30,9 @@ func TestCreateAgentSessionAcceptsWireFixtureHeaders(t *testing.T) { } s, store := newMcpTestServer(t) + // The handler late-inits a message reporter whose database sits beside this path; without + // it the reporter writes messages-ws.db into the package directory. + s.config.PersistenceDBPath = filepath.Join(t.TempDir(), "vm-agent.db") s.workspaces["ws"] = &WorkspaceRuntime{ID: "ws", ProjectID: "project", Status: "running", CallbackToken: "cb"} validator, key := newWorkspaceCreateJWTValidator(t, "node-test") s.jwtValidator = validator diff --git a/packages/vm-agent/internal/server/messages-ws.db b/packages/vm-agent/internal/server/messages-ws.db deleted file mode 100644 index cc04c103cdf5416b37dcbcd114b399f735eadcdf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYC*>h8Lt=fNV2HHI9bB nXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP#6LLQSS$G diff --git a/packages/vm-agent/internal/server/messages-ws.db-shm b/packages/vm-agent/internal/server/messages-ws.db-shm deleted file mode 100644 index 5a8ae48652fd87307801ff3717f5e5a8fb2ba6ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)tqsCJ7zW@ge;Qdwf=3Vp5*A=15(I)Bm;nI-7GMHaKrsQq!5yJV4FXB;d6O^c zHO*b`8Q?9iqfn&?q2G&(Zk*+KH#{$nhr#@^zD>59+2bBo)6>5E@%w7;wC}T)`up@8 z&kn6P?Kqv!I{k+bAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&&S)h1#T{QWI?ndC7KQqu8cfO<`4}Wb4}qw25n8p`f61 zFt76Cde=0!vhJnS$};>DYYco1QNH%}gYuS>F~bSQe-_&+GkOTO=G`b#L}(f7{Z z>c1nM`Fc!}j!2@NocOTzeeB`g`PY-955|p6zDw@hzn%GJ%zW8=d3i2pGfI}+M%`j$ zjAtu~G(iZDRro6|a{MOpyZgbhz1|h+?EUx2pqT{CrJY8P`l>55G|R-KOU zOm*$FJdjdVd0lSTtW(dkmTARpvIVyl?J1Fp(PJc?7__S-(z1rB&*()mTg>LQVwqgh z%S0=gg{;8~<#odxNel*6f?QLdH$@SnU=pL0%aM|ioh#`)(&J}lyG~`VNFLH~YqawW zx{L9UMb}}}VPRB{5aznIN}V#RYB7rkiV%_6pcbRm3V(ENw;q`mW3+yqc|r96Ap9~@ z`m|QcndBU=SF=3Ei?uUEQU7DyhP`Y%L4}=9PpB7^{!~GgCEKaemVYg+CUe_;ezKhx zt&)lMWBgV-kIUS z*}c~r=s4VSGhAOFo}BjA7vLLc5P$##AOHafKmY;|fB*#ct-y`=KuS~P)#L5C-FCIr zJ-yqXh#sBf-RIBE&l*$uJlQ)dF<3B2G&Q|Dvt7md0$5+*f7TZe&-i`8{{>Dx&)j_R jD!+w!1pD^K2qz8!2tWV=5P$##AOHafKmY;|_-ldRZgk3Z diff --git a/tasks/active/2026-09-29-mcp-connection-custom-headers.md b/tasks/active/2026-09-29-mcp-connection-custom-headers.md index ec26be9dc..3063ec2d8 100644 --- a/tasks/active/2026-09-29-mcp-connection-custom-headers.md +++ b/tasks/active/2026-09-29-mcp-connection-custom-headers.md @@ -113,62 +113,62 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r ### Shared -- [ ] `mcp-connection.ts`: `McpConnectionHeader`, `McpConnectionHeaderUpdate`, `headerNames` on +- [x] `mcp-connection.ts`: `McpConnectionHeader`, `McpConnectionHeaderUpdate`, `headerNames` on `McpConnection`, `headers` on create/update requests, header name pattern/rule/max length, reserved header names -- [ ] `defaults.ts`: `DEFAULT_MAX_MCP_CONNECTION_HEADERS`, `DEFAULT_MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` -- [ ] `vm-agent-contract.ts`: optional `headers` on `McpServerEntrySchema` -- [ ] Contract fixture `mcp-server-name-contract.json`: `headerNames` valid/invalid block, +- [x] `defaults.ts`: `DEFAULT_MAX_MCP_CONNECTION_HEADERS`, `DEFAULT_MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` +- [x] `vm-agent-contract.ts`: optional `headers` on `McpServerEntrySchema` +- [x] Contract fixture `mcp-server-name-contract.json`: `headerNames` valid/invalid block, consumed by the TS test and the Go test ### API -- [ ] Migration `0175_mcp_connection_headers.sql` (ADD COLUMN ×3, additive) + `schema.ts` columns -- [ ] `services/mcp-connection-headers.ts`: validate, merge-for-update, seal/open, display names -- [ ] `services/mcp-connections.ts`: create/update/response use the header module -- [ ] `schemas/mcp-connections.ts`: structural `headers` for create/update -- [ ] `routes/mcp-connections.ts`: pass headers + new limits -- [ ] `services/limits.ts` + `env.ts`: two new limits -- [ ] `services/mcp-connection-resolution.ts`: decrypt + validate headers per row (skip on failure) -- [ ] `services/node-agent.ts`: `McpServerConfig.headers`, `serializeMcpServers` sends only when non-empty +- [x] Migration `0175_mcp_connection_headers.sql` (ADD COLUMN ×3, additive) + `schema.ts` columns +- [x] `services/mcp-connection-headers.ts`: validate, merge-for-update, seal/open, display names +- [x] `services/mcp-connections.ts`: create/update/response use the header module +- [x] `schemas/mcp-connections.ts`: structural `headers` for create/update +- [x] `routes/mcp-connections.ts`: pass headers + new limits +- [x] `services/limits.ts` + `env.ts`: two new limits +- [x] `services/mcp-connection-resolution.ts`: decrypt + validate headers per row (skip on failure) +- [x] `services/node-agent.ts`: `McpServerConfig.headers`, `serializeMcpServers` sends only when non-empty ### vm-agent -- [ ] Refactor commit: extract `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, +- [x] Refactor commit: extract `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go` (pure moves) -- [ ] `McpHeader` + `McpServerEntry.Headers`; header name/value validators in acp -- [ ] `normalizeMcpServers` validates + copies headers; persistence conversion helpers used by +- [x] `McpHeader` + `McpServerEntry.Headers`; header name/value validators in acp +- [x] `normalizeMcpServers` validates + copies headers; persistence conversion helpers used by register + agent_ws prefetch -- [ ] Persistence `migrateV18` (`headers` JSON column) + upsert/get -- [ ] ACP: custom headers after Authorization -- [ ] Amp: `--header name:${SAM_MCP_HEADER_}` with values in the server env, not argv -- [ ] Codex: `env_http_headers` + `SAM_MCP__HEADER__SECRET` env vars -- [ ] Vibe: custom headers in the `headers` inline table +- [x] Persistence `migrateV18` (`headers` JSON column) + upsert/get +- [x] ACP: custom headers after Authorization +- [x] Amp: `--header name:${SAM_MCP_HEADER_}` with values in the server env, not argv +- [x] Codex: `env_http_headers` + `SAM_MCP__HEADER__SECRET` env vars +- [x] Vibe: custom headers in the `headers` inline table ### Web -- [ ] Split `McpServersManager.tsx` into list + `McpServerForm` + `McpServerHeadersField` -- [ ] Headers editor in the create form; Edit action with keep-semantics payload; header names in the row -- [ ] Unit tests (create payload, edit payload keep/replace/remove, rendering) +- [x] Split `McpServersManager.tsx` into list + `McpServerForm` + `McpServerHeadersField` +- [x] Headers editor in the create form; Edit action with keep-semantics payload; header names in the row +- [x] Unit tests (create payload, edit payload keep/replace/remove, rendering) - [ ] Playwright audit: headers form + edit form + rows with many/long headers, 375 and 1280 ### Tests -- [ ] API: header validation, encryption at rest, never-returned values, PATCH keep/replace/remove, +- [x] API: header validation, encryption at rest, never-returned values, PATCH keep/replace/remove, authType/Authorization conflict, malformed `header_names` tolerated on list -- [ ] API vertical slice: mock MCP server requiring `x-api-key` authorizes the resolved entry, +- [x] API vertical slice: mock MCP server requiring `x-api-key` authorizes the resolved entry, and rejects without it -- [ ] API: resolution skips a row with undecryptable headers, others still resolve -- [ ] API: node-agent contract serializes headers only when present -- [ ] Go: ACP/Amp/Codex/Vibe header output; normalize rejects bad header without leaking the value; +- [x] API: resolution skips a row with undecryptable headers, others still resolve +- [x] API: node-agent contract serializes headers only when present +- [x] Go: ACP/Amp/Codex/Vibe header output; normalize rejects bad header without leaking the value; full round trip incl. restart backfill; migrateV18 upgrade of existing rows -- [ ] Contract fixture consumed on both sides +- [x] Contract fixture consumed on both sides ### Docs -- [ ] `apps/www/.../guides/mcp-servers.md`: headers field, Composio row, editing, remove limitation, Amp note -- [ ] `apps/www/.../reference/configuration.md` + `apps/api/.env.example`: new limits -- [ ] `.claude/skills/changelog/SKILL.md` entry; env-reference skill if it lists MCP limits +- [x] `apps/www/.../guides/mcp-servers.md`: headers field, Composio row, editing, remove limitation, Amp note +- [x] `apps/www/.../reference/configuration.md` + `apps/api/.env.example`: new limits +- [x] `.claude/skills/changelog/SKILL.md` entry (env-reference skill never listed MCP limits; the public configuration reference is canonical) ## Acceptance Criteria @@ -190,3 +190,14 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r - `.claude/rules/54` (vm-agent rollout), `41`/`50` (per-row isolation), `28` (real SQL for scoping), `62`/`73` (field copies must not drop), `18` (file size), `23` (cross-boundary contract) - `tasks/archive/2026-08-23-byo-mcp-servers.md` + +## Implementation Notes + +- Main is protected ("push declined due to repository rule violations"), so this task file + ships in the PR instead of being committed to main first. +- Commits: `03c2ce2bb` pure-move refactor; `3417926b1` shared/API/vm-agent feature; + `582936857` tests; `3234c96a7` web UI + docs. +- Discrimination: 8 Go mutations + 7 API mutations + 1 wire-tag mutation each turned the + intended tests red (see PR body). +- Codex `env_http_headers` was verified against the published Codex config reference, and + mcp-remote's header parser against the `mcp-remote@0.1.38` tarball source. From 6f2bbec460042e5bb5aaa7178a182b5ea0a299f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:07:19 +0000 Subject: [PATCH 08/18] chore(mcp): ignore vm-agent test SQLite files; document header check scope - gitignore packages/vm-agent/**/*.db{,-shm,-wal} so a test run cannot commit message-reporter databases again (task-completion + security review). - ValidateMcpHeaders: say that policy rules (reserved names, duplicates, Authorization vs bearer, limits) live in the control plane, the only writer. - Task file: acceptance criteria 1-6 and the Playwright audit ticked with the tests that prove them; staging stays open. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 +++++ packages/vm-agent/internal/acp/mcp_servers.go | 5 +++++ ...026-09-29-mcp-connection-custom-headers.md | 20 ++++++++++++------- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 73ab06881..1ecd23011 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,11 @@ packages/vm-agent/bin/ packages/vm-agent/vm-agent apps/api/container-artifacts/ +# SQLite files a vm-agent test run can leave beside the package (message reporter databases) +packages/vm-agent/**/*.db +packages/vm-agent/**/*.db-shm +packages/vm-agent/**/*.db-wal + # Environment files .env .env.local diff --git a/packages/vm-agent/internal/acp/mcp_servers.go b/packages/vm-agent/internal/acp/mcp_servers.go index cf5fb6690..a9ae085fd 100644 --- a/packages/vm-agent/internal/acp/mcp_servers.go +++ b/packages/vm-agent/internal/acp/mcp_servers.go @@ -103,6 +103,11 @@ func ValidMcpHeaderValue(value string) bool { // ValidateMcpHeaders checks every header. The error never carries a value: it propagates to // the control plane, which stores it where any project member can read it. +// +// Only what protects the config files and argv this package writes is checked here. The +// write-time policy rules — reserved transport headers, case-insensitive duplicates, the +// Authorization/bearer conflict, size limits — belong to the control plane +// (apps/api/src/services/mcp-connection-headers.ts), the only writer of stored headers. func ValidateMcpHeaders(headers []McpHeader) error { for i, header := range headers { if !ValidMcpHeaderName(header.Name) { diff --git a/tasks/active/2026-09-29-mcp-connection-custom-headers.md b/tasks/active/2026-09-29-mcp-connection-custom-headers.md index 3063ec2d8..b6e4b1de2 100644 --- a/tasks/active/2026-09-29-mcp-connection-custom-headers.md +++ b/tasks/active/2026-09-29-mcp-connection-custom-headers.md @@ -150,7 +150,7 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r - [x] Split `McpServersManager.tsx` into list + `McpServerForm` + `McpServerHeadersField` - [x] Headers editor in the create form; Edit action with keep-semantics payload; header names in the row - [x] Unit tests (create payload, edit payload keep/replace/remove, rendering) -- [ ] Playwright audit: headers form + edit form + rows with many/long headers, 375 and 1280 +- [x] Playwright audit: headers form + edit form + rows with many/long headers, 375 and 1280 (20 scenarios incl. Project Settings → Runtime; screenshots reviewed) ### Tests @@ -172,16 +172,22 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r ## Acceptance Criteria -- [ ] A user can add an MCP server with one or more custom headers (e.g. `x-api-key`) in +- [x] A user can add an MCP server with one or more custom headers (e.g. `x-api-key`) in Settings → MCP Servers and in Project Settings → Runtime -- [ ] A user can edit an existing server to add, rotate, or remove headers without re-entering + (McpServersManager.test.tsx "adds a server authenticated only by a custom header"; Playwright add-form-headers + project-runtime) +- [x] A user can edit an existing server to add, rotate, or remove headers without re-entering the URL, the token, or other header values -- [ ] Header values are encrypted at rest and never returned by any API response; names are shown -- [ ] Every harness receives the headers: ACP HTTP (Claude Code etc.), Codex + (McpServersManager.test.tsx editing suite; mcp-connection-headers.test.ts "updating headers") +- [x] Header values are encrypted at rest and never returned by any API response; names are shown + (mcp-connection-headers.test.ts "returns header names but never values...") +- [x] Every harness receives the headers: ACP HTTP (Claude Code etc.), Codex (`env_http_headers`), Vibe, and Amp (mcp-remote) -- [ ] Invalid header names/values are rejected at write time with a clear message; a bad stored + (mcp_headers_test.go, one test per harness; wire fixture through the real Go handler) +- [x] Invalid header names/values are rejected at write time with a clear message; a bad stored row cannot break session start for the scope -- [ ] Existing connections without headers behave exactly as before + (mcp-connection-headers.test.ts rejection table; mcp-connection-headers-injection.test.ts fault isolation) +- [x] Existing connections without headers behave exactly as before + (no-header resolution test; node-agent wire fixture; TestMigrationV18KeepsExistingMcpServerRows) - [ ] Staging: an agent session reaches a real MCP server that requires a custom header, and successfully calls a tool From bbacb6cffae2776d00f5633a331050caa40e24f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:33:40 +0000 Subject: [PATCH 09/18] fix(mcp): guard header edits against races and repeated or reserved names - updateMcpConnection writes only if updated_at still matches the row it read (409 otherwise); nextUpdatedAt keeps the column strictly increasing, so a header-only edit racing a switch to bearer can no longer persist an Authorization header beside a bearer token. Resolution refuses that pair as a backstop. - vm-agent McpServerEntry.ValidateHeaders rejects reserved transport names, case-insensitive duplicates and Authorization beside a bearer token: one repeated key made the whole Codex/Vibe TOML unparseable, sam-mcp included. The reserved list is pinned TS<->Go by headerNames.reserved. - GetSessionMcpServers skips a row whose headers cannot be decoded instead of failing the read, which the restore path treated as no MCP servers. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/changelog/SKILL.md | 2 +- .../src/services/mcp-connection-resolution.ts | 5 + apps/api/src/services/mcp-connections.ts | 33 ++++- .../mcp-connection-headers-injection.test.ts | 25 ++++ .../services/mcp-connection-headers.test.ts | 92 +++++++++++++- .../fixtures/mcp-server-name-contract.json | 17 ++- .../unit/mcp-server-name-contract.test.ts | 9 +- .../vm-agent/internal/acp/mcp_headers_test.go | 117 +++++++++++++++--- .../acp/mcp_server_name_contract_test.go | 30 ++++- packages/vm-agent/internal/acp/mcp_servers.go | 49 ++++++-- packages/vm-agent/internal/acp/vibe_config.go | 3 + .../persistence/session_mcp_servers.go | 15 ++- .../persistence/session_mcp_servers_test.go | 46 +++++-- .../vm-agent/internal/server/mcp_servers.go | 2 +- .../server/session_snapshot_archive.go | 4 +- ...026-09-29-mcp-connection-custom-headers.md | 33 +++++ 16 files changed, 432 insertions(+), 50 deletions(-) diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md index 257529238..049727580 100644 --- a/.claude/skills/changelog/SKILL.md +++ b/.claude/skills/changelog/SKILL.md @@ -14,7 +14,7 @@ These entries were removed from root `CLAUDE.md` so startup instructions stay co Use the `/changelog` skill for structured queries. -- mcp-connection-custom-headers: Bring-your-own MCP servers can carry custom HTTP headers (Composio requires `x-api-key` / `x-consumer-api-key`). Headers are independent of `authType`; `Authorization` is accepted only with `none`. Names `^[A-Za-z0-9_-]{1,64}$` — exactly what the Amp bridge `mcp-remote@0.1.38` parses and a TOML bare key — pinned TS↔Go by the `headerNames` block of `mcp-server-name-contract.json`; transport headers are reserved. D1 `0175` adds `header_names` (plaintext display projection) + `encrypted_headers`/`headers_iv` (one AES-GCM ciphertext of the `[{name,value}]` list, the only column injection reads). All rules and the storage format live in `services/mcp-connection-headers.ts`. PATCH `headers` is the full desired set and an entry without `value` keeps the stored value, so the UI edits without ever holding secrets. Resolution re-validates decrypted headers and skips a bad row. vm-agent: `McpServerEntry.Headers` validated in `normalizeMcpServers`, persisted (`migrateV18`), converted by `toPersistedMcpServers`/`fromPersistedMcpServers`; ACP header list, Codex `env_http_headers` with `SAM_MCP__HEADER__SECRET` (the `_SECRET` suffix both classifies it for `isSecretEnvVar` and cannot collide with a bearer `_TOKEN` var), Vibe `headers` table, Amp `--header name:${SAM_MCP_HEADER_}`. Wire shape pinned by `packages/shared/src/fixtures/mcp-server-entry-wire.json` (node-agent serializer + real Go handler). UI: `McpServerForm` (add + inline Edit), `McpServerHeadersField`. Limits `MAX_MCP_CONNECTION_HEADERS` (10), `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (8192). Refactor in the same PR: MCP/Codex/Vibe code moved out of `gateway.go`/`session_host.go`/`workspaces.go`/`store.go` into `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go`. +- mcp-connection-custom-headers: Bring-your-own MCP servers can carry custom HTTP headers (Composio requires `x-api-key` / `x-consumer-api-key`). Headers are independent of `authType`; `Authorization` is accepted only with `none`. Names `^[A-Za-z0-9_-]{1,64}$` — exactly what the Amp bridge `mcp-remote@0.1.38` parses and a TOML bare key — pinned TS↔Go by the `headerNames` block of `mcp-server-name-contract.json`; transport headers are reserved (`headerNames.reserved` pins that list on both sides). D1 `0175` adds `header_names` (plaintext display projection) + `encrypted_headers`/`headers_iv` (one AES-GCM ciphertext of the `[{name,value}]` list, the only column injection reads). All rules and the storage format live in `services/mcp-connection-headers.ts`. PATCH `headers` is the full desired set and an entry without `value` keeps the stored value, so the UI edits without ever holding secrets. PATCH writes only if `updated_at` still matches the row it read (`nextUpdatedAt` keeps the column strictly increasing; a lost race is a 409): every derived field comes from that read, so without the guard a header-only edit racing a switch to bearer persisted an `Authorization` header beside a bearer token. Resolution re-validates decrypted headers, refuses that pair as a backstop, and skips a bad row. vm-agent: `McpServerEntry.ValidateHeaders` (charset, reserved names, case-insensitive duplicates including `Authorization` beside the bearer token — one repeated key makes the whole Codex/Vibe TOML unparseable, `sam-mcp` included) runs in `normalizeMcpServers` and again before any config file is written; headers are persisted (`migrateV18`; a row whose headers cannot be decoded is skipped on restore instead of dropping every server), converted by `toPersistedMcpServers`/`fromPersistedMcpServers`; ACP header list, Codex `env_http_headers` with `SAM_MCP__HEADER__SECRET` (the `_SECRET` suffix both classifies it for `isSecretEnvVar` and cannot collide with a bearer `_TOKEN` var), Vibe `headers` table, Amp `--header name:${SAM_MCP_HEADER_}`. Wire shape pinned by `packages/shared/src/fixtures/mcp-server-entry-wire.json` (node-agent serializer + real Go handler). UI: `McpServerForm` (add + inline Edit), `McpServerHeadersField`. Limits `MAX_MCP_CONNECTION_HEADERS` (10), `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (8192). Refactor in the same PR: MCP/Codex/Vibe code moved out of `gateway.go`/`session_host.go`/`workspaces.go`/`store.go` into `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go`. - project-chat-instant-switching: Switching between project chats renders the target chat at once. Transcripts (`sessions/messages`) now keep a 24 h `gcTime` (`CHAT_TRANSCRIPT_CACHE_TTL_MS`, from shared `DEFAULT_CHAT_TRANSCRIPT_CACHE_TTL_MS`, override `VITE_CHAT_TRANSCRIPT_CACHE_TTL_MS`); previously the TanStack 5-minute default dropped an unobserved transcript from memory and, on the next write, from IndexedDB. Restored queries get `RESTORED_QUERY_GC_TIME_MS` via `hydrateOptions`, the dehydrate filter stops writing a transcript older than the TTL, and opening a chat evicts the least recently updated unobserved transcripts beyond `CHAT_TRANSCRIPT_CACHE_MAX_SESSIONS` (20; `evictStaleTranscripts` in `lib/query-options/chats.ts`); on disk each transcript keeps only its newest `CHAT_TRANSCRIPT_PERSIST_MAX_ROWS` rows (default: the 500-row page; `persistedQueryForDisk`). `ProjectMessageView` is keyed per session (`SessionMessageView`), and the transcript is read straight from the query cache (`useSessionTranscript`), so a cached chat paints in the switching commit and an uncached one shows a spinner, never the previous chat. The cold load requests the newest page (`fetchNewestPage`, `CHAT_SESSION_MESSAGE_LIMIT` = 500) instead of the 50,000-row `CHAT_SESSION_MESSAGE_MAX` ceiling (now only the server clamp); older history pages in through Virtuoso `startReached` once the reader scrolls up — wheel, swipe, or ArrowUp/PageUp/Home, not the list position (a page of tool calls can fold into a few rows that fit on screen, and a chat can open away from the bottom when its newest message is taller than the screen; an ungated `startReached` then paged the whole history in on open) and through the existing "Load earlier" button. The #2159 forward-delta refresh moved into the query function unchanged. Jumps to a specific message page back until that message id is loaded (`HistoryTarget` in `lib/message-paging.ts`) and then confirm the row is on screen, re-scrolling past Virtuoso's prepend compensation (`useConversationJump`), and an unloaded comment anchor reads "on a message". Composer drafts are per chat (`session-drafts.tsx`), report-issue config is a cached query, and server `session`/`state` snapshots hydrate only when the server reports new ones, which fixes a streamed row resetting a working agent to idle. Auth gating is unchanged: no persisted transcript renders before the session check resolves. - archive-sweep-affordability-ceiling-and-fallthrough: The production ProjectData archive sweep stopped reclaiming anything on 2026-09-08 and reported `succeeded` for 226 hourly runs while the root object climbed from 94% to 96.7% of its 10 GB ceiling. Two independently configured ceilings had to agree and drifted: a GitHub `production` Environment override lowered `PROJECT_DATA_ARCHIVE_DAILY_WRITE_BUDGET` to 100000 (affordability ceiling `floor((100000-1000)/32)` = 3093 write units) while `PROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET` stayed at the checked-in 5000, and `selectCandidates` orders `message_count DESC ... LIMIT sweepProjects * sweepSessions` (deployed 1x1). Every tick therefore picked the same 4994-message session, estimated ~160,808 writes, was refused by `reserveArchiveWrites` BEFORE it touched D1 (so the UTC budget window also froze at `2026-09-08T00:00:00Z`), and `continue`d out of a one-element list — no journal row, no location change, no error. The selection ceiling is now DERIVED from the allowance (`archiveAffordableWriteUnits` / `archiveAffordableMessageCeiling` in `project-data-archive/write-budget.ts`, used as the `message_count <= ?` bind), so the two can no longer disagree at any configuration; an explicit session-scoped operator canary still bypasses it. `selectCandidates` over-reads `PROJECT_DATA_ARCHIVE_SWEEP_FALLTHROUGH_DEPTH` (4) spare candidates and the journaling loop descends past a refusal instead of ending the tick, with both per-tick bounds (session slots and the cumulative message budget) moved INTO that loop so a refused candidate — which opens no `migrating` fence and moves no rows — consumes neither. `reserveArchiveWrites` returns a discriminated outcome separating `exceeds_allowance` (waiting cannot help) from `window_exhausted` (normal end-of-day backpressure), and `PROJECT_DATA_ARCHIVE_BUDGET_STALL_ALERT_SWEEPS` (3) consecutive ticks that migrate nothing and see only the former flip the cadence row to `partial` with an actionable `last_error` — counted and escalated inside one atomic `UPDATE ... RETURNING` (migration `0156` adds `consecutive_budget_stalls`) so a failed read cannot silently restart a streak. `wrangler.toml` now ships `DAILY_WRITE_BUDGET=100000` (matching the production override, so staging and self-hosts derive the same ceiling), `SWEEP_MESSAGE_BUDGET=2000`, `SWEEP_SESSIONS=2`. Owner stubs are memoised per tick so the fall-through does not multiply `ensureProjectId` DO round trips. NOTE: within a 100000/day allowance the restored sweep reclaims on the order of 1-1.5 MB/day against ~66 MB/day of growth — it ends the deadlock but cannot reverse the storage curve; raising the budget is a spend decision. - codex-astra-runtime-selection: Codex ACP is upgraded 1.8.0→1.10.0 and its Codex companion 0.153.2→0.153.4 across the canonical install manifest, VM-agent installer, and cf-container runtime image; the sandbox image's CLI-only pin is aligned to 0.153.4. VM-agent now validates both exact executable versions and supplies `CODEX_PATH=codex`, ensuring the adapter launches the explicitly pinned companion rather than a nested dependency resolved relative to itself. An explicit Codex profile model is applied through ACP `session/set_config_option`; rejection now fails session establishment with the requested model in the diagnostic instead of silently retaining the adapter default. A wire-level ACP regression test pins both the successful `gpt-6-astra` request and the fail-closed case. Process fix: `.claude/rules/23-cross-boundary-contract-tests.md` now treats adapter/companion resolution as one runtime contract. diff --git a/apps/api/src/services/mcp-connection-resolution.ts b/apps/api/src/services/mcp-connection-resolution.ts index c0c765d77..ab9a3d765 100644 --- a/apps/api/src/services/mcp-connection-resolution.ts +++ b/apps/api/src/services/mcp-connection-resolution.ts @@ -204,6 +204,11 @@ async function toEntry( // Validated on the way out as well as on the way in: the vm-agent rejects the WHOLE // create-agent-session request over one malformed header, so a bad row must stop here. const headers = await openMcpConnectionHeaders(row, encryptionKey); + if (token && headers.some((header) => header.name.toLowerCase() === 'authorization')) { + // Writes forbid this pair; a row that holds it anyway would reach the harness with two + // Authorization headers and harness-dependent precedence, so it is not injected. + throw new Error('a custom Authorization header conflicts with the bearer token'); + } return { url, token, name: row.name, ...(headers.length > 0 ? { headers } : {}) }; } catch (error) { log.warn('mcp_connections.row_skipped', { diff --git a/apps/api/src/services/mcp-connections.ts b/apps/api/src/services/mcp-connections.ts index a525d528b..1978b9bc1 100644 --- a/apps/api/src/services/mcp-connections.ts +++ b/apps/api/src/services/mcp-connections.ts @@ -325,7 +325,9 @@ export async function updateMcpConnection( const scope: McpConnectionScopeRef = { userId: input.userId, projectId: input.projectId }; const existing = await requireScopedConnection(db, scope, input.connectionId); - const updates: Partial = { updatedAt: new Date().toISOString() }; + const updates: Partial = { + updatedAt: nextUpdatedAt(existing.updatedAt), + }; if (input.name !== undefined) { const name = validateMcpConnectionName(input.name); @@ -379,14 +381,39 @@ export async function updateMcpConnection( updates.enabled = input.enabled; } - await db + // Every value above was derived from `existing`, so the write only lands if the row is still + // the one that was read. Without this, a request replacing only the headers could commit after + // a concurrent switch to bearer and persist a custom Authorization header beside a bearer + // token — a pair validation forbids but a stale snapshot cannot see. + const written = await db .update(schema.mcpConnections) .set(updates) - .where(and(scopeWhere(scope), eq(schema.mcpConnections.id, existing.id))); + .where( + and( + scopeWhere(scope), + eq(schema.mcpConnections.id, existing.id), + eq(schema.mcpConnections.updatedAt, existing.updatedAt) + ) + ) + .returning({ id: schema.mcpConnections.id }); + if (written.length === 0) { + throw errors.conflict('This MCP server was changed by another request; reload and try again'); + } return toMcpConnectionResponse({ ...existing, ...updates } as schema.McpConnectionRow); } +/** + * A write timestamp strictly after `previous`, so every update changes the `updated_at` the + * concurrency guard in `updateMcpConnection` compares against — even two writes in one + * millisecond. + */ +function nextUpdatedAt(previous: string): string { + const previousMs = Date.parse(previous); + const now = Date.now(); + return new Date(Number.isNaN(previousMs) ? now : Math.max(now, previousMs + 1)).toISOString(); +} + /** * The header set an update asks for. Stored values are decrypted only when the update keeps * at least one of them, so a caller replacing every header never depends on the old ciphertext. diff --git a/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts b/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts index 6a3382f2a..1ad1d07ec 100644 --- a/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts +++ b/apps/api/tests/unit/services/mcp-connection-headers-injection.test.ts @@ -200,6 +200,31 @@ describe('header fault isolation on the session-start path', () => { warn.mockRestore(); }); + it('skips a row that pairs a bearer token with a custom Authorization header', async () => { + // Writes forbid this pair, so it is built by hand: a stored Authorization header, then the + // row flipped to bearer underneath it. + const broken = await saveComposio({ + name: 'broken', + headers: [{ name: 'Authorization', value: 'Basic dXNlcjpwYXNz' }], + }); + await saveComposio({ name: 'healthy' }); + const { encrypt } = await import('../../../src/services/encryption'); + const token = await encrypt('bearer-token', ENCRYPTION_KEY); + sqlite + .prepare( + "UPDATE mcp_connections SET auth_type = 'bearer', encrypted_token = ?, token_iv = ? WHERE id = ?" + ) + .run(token.ciphertext, token.iv, broken.id); + + const resolved = await resolveMcpServersForSession( + db, + { userId: 'user-1', projectId: 'proj-1' }, + ENCRYPTION_KEY + ); + + expect(resolved.map((entry) => entry.name)).toEqual(['healthy']); + }); + it('skips a row whose stored header would make the vm-agent reject the whole session', async () => { // Constructed through the real write path, then corrupted into a shape the write path // refuses: the resolver must hold the same line, because the vm-agent fails the entire diff --git a/apps/api/tests/unit/services/mcp-connection-headers.test.ts b/apps/api/tests/unit/services/mcp-connection-headers.test.ts index 6a3ffe956..3f60ba31f 100644 --- a/apps/api/tests/unit/services/mcp-connection-headers.test.ts +++ b/apps/api/tests/unit/services/mcp-connection-headers.test.ts @@ -7,7 +7,7 @@ */ import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/d1'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as schema from '../../../src/db/schema'; import { openMcpConnectionHeaders } from '../../../src/services/mcp-connection-headers'; @@ -180,6 +180,25 @@ describe('creating a connection with custom headers', () => { expect(sqlite.prepare('SELECT COUNT(*) AS n FROM mcp_connections').get()).toEqual({ n: 0 }); }); + it('lets a bearer token and a non-Authorization header coexist', async () => { + const created = await createComposio({ authType: 'bearer', token: 'bearer-token' }); + + expect(created.authType).toBe('bearer'); + expect(created.hasToken).toBe(true); + expect(created.headerNames).toEqual(['x-api-key']); + }); + + it('measures the value limit in UTF-8 bytes, not characters', async () => { + // 40 characters, 80 bytes: under a character-count check, over the 64-byte limit. + await expect( + createComposio({ headers: [{ name: 'x-api-key', value: 'é'.repeat(40) }] }) + ).rejects.toThrow(/exceeds max size of 64 bytes/); + // Control: 32 two-byte characters are exactly 64 bytes and fit. + await expect( + createComposio({ headers: [{ name: 'x-api-key', value: 'é'.repeat(32) }] }) + ).resolves.toMatchObject({ headerNames: ['x-api-key'] }); + }); + it('rejects an Authorization header alongside a bearer token', async () => { await expect( createComposio({ @@ -276,6 +295,77 @@ describe('updating headers', () => { expect(switched.headerNames).toEqual([]); }); + it('keeps a non-conflicting header when the auth type changes and headers are not mentioned', async () => { + const created = await createComposio(); + + const switched = await update(created.id, { authType: 'bearer', token: 'bearer-token' }); + + expect(switched.authType).toBe('bearer'); + expect(switched.headerNames).toEqual(['x-api-key']); + expect(await storedHeaders(created.id)).toEqual([{ name: 'x-api-key', value: API_KEY }]); + }); + + it('enforces the header limit on update as well as create', async () => { + const created = await createComposio(); + + await expect( + update(created.id, { + headers: [ + { name: 'x-api-key' }, + ...['b', 'c', 'd', 'e'].map((name) => ({ name, value: 'v' })), + ], + }) + ).rejects.toThrow(/Maximum 4 headers/); + expect(await storedHeaders(created.id)).toEqual([{ name: 'x-api-key', value: API_KEY }]); + }); + + describe('with the clock frozen, so every write lands in the same millisecond', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-09-29T08:00:00.000Z')); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('lets only one of two concurrent updates land, so auth type and headers never mix', async () => { + // Both updates start in the same tick, so both read the row before either writes — the + // interleaving a double submit or two collaborators produce. Without the updated_at guard + // both would commit, and whichever landed last would leave its own stale view of the + // other column behind. The frozen clock makes the create and both updates share one + // millisecond, so the guard only holds if every write still moves updated_at forward. + const created = await createComposio({ headers: [] }); + + const results = await Promise.allSettled([ + update(created.id, { headers: [{ name: 'Authorization', value: 'Basic dXNlcjpwYXNz' }] }), + update(created.id, { authType: 'bearer', token: 'bearer-token' }), + ]); + + const rejected = results.filter((result) => result.status === 'rejected'); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(String((rejected[0] as PromiseRejectedResult).reason)).toMatch( + /changed by another request/ + ); + + const row = storedRow(created.id); + const headers = await storedHeaders(created.id); + const hasAuthorizationHeader = headers.some((header) => header.name === 'Authorization'); + expect(row.auth_type === 'bearer' && hasAuthorizationHeader).toBe(false); + }); + + it('still accepts sequential updates, each against the row the previous one wrote', async () => { + const created = await createComposio(); + + await update(created.id, { enabled: false }); + await update(created.id, { enabled: true }); + const renamed = await update(created.id, { name: 'composio-2' }); + + expect(renamed.name).toBe('composio-2'); + expect(renamed.enabled).toBe(true); + }); + }); + it('asks for every value when the stored headers cannot be decrypted, and accepts a full replacement', async () => { const created = await createComposio(); sqlite diff --git a/packages/shared/src/fixtures/mcp-server-name-contract.json b/packages/shared/src/fixtures/mcp-server-name-contract.json index 3a5c8c689..886211639 100644 --- a/packages/shared/src/fixtures/mcp-server-name-contract.json +++ b/packages/shared/src/fixtures/mcp-server-name-contract.json @@ -26,7 +26,11 @@ "ValidMcpHeaderName in packages/vm-agent/internal/acp/mcp_servers.go (vm-agent, rejects the", "create-agent-session request). Both judge the exact string with no normalization; the control", "plane trims user input before judging. The charset is what mcp-remote accepts in", - "`--header name:value` and what TOML accepts as a bare key." + "`--header name:value` and what TOML accepts as a bare key.", + "", + "`headerNames.reserved` pins MCP_CONNECTION_RESERVED_HEADER_NAMES (TS) to reservedMcpHeaderNames", + "in packages/vm-agent/internal/acp/mcp_servers.go: names the HTTP client or MCP transport sets", + "itself. Both sides compare case-insensitively and must hold exactly this list." ], "valid": [ "a", @@ -120,6 +124,17 @@ "x\tkey", "caf\u00e9", "x-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "reserved": [ + "accept", + "connection", + "content-length", + "content-type", + "host", + "last-event-id", + "mcp-protocol-version", + "mcp-session-id", + "transfer-encoding" ] } } diff --git a/packages/shared/tests/unit/mcp-server-name-contract.test.ts b/packages/shared/tests/unit/mcp-server-name-contract.test.ts index b8e59f25c..2a311ec7d 100644 --- a/packages/shared/tests/unit/mcp-server-name-contract.test.ts +++ b/packages/shared/tests/unit/mcp-server-name-contract.test.ts @@ -15,13 +15,14 @@ import { describe, expect, it } from 'vitest'; import { MCP_CONNECTION_HEADER_NAME_PATTERN, MCP_CONNECTION_NAME_PATTERN, + MCP_CONNECTION_RESERVED_HEADER_NAMES, } from '../../src/types/mcp-connection'; interface Contract { valid: string[]; invalid: string[]; normalized: Record; - headerNames: { valid: string[]; invalid: string[] }; + headerNames: { valid: string[]; invalid: string[]; reserved: string[] }; } const contract = JSON.parse( @@ -96,4 +97,10 @@ describe('MCP custom header name contract (TypeScript side)', () => { expect(contract.headerNames.valid.length).toBeGreaterThanOrEqual(8); expect(contract.headerNames.invalid.length).toBeGreaterThanOrEqual(15); }); + + it('reserves exactly the transport-managed names the vm-agent reserves', () => { + expect([...MCP_CONNECTION_RESERVED_HEADER_NAMES].sort()).toEqual( + [...contract.headerNames.reserved].sort() + ); + }); }); diff --git a/packages/vm-agent/internal/acp/mcp_headers_test.go b/packages/vm-agent/internal/acp/mcp_headers_test.go index ab3816f5a..a662bb9cd 100644 --- a/packages/vm-agent/internal/acp/mcp_headers_test.go +++ b/packages/vm-agent/internal/acp/mcp_headers_test.go @@ -280,36 +280,44 @@ func TestConfigGenerators_SkipServerWithUnsafeHeader(t *testing.T) { } } -func TestValidateMcpHeaders(t *testing.T) { +func TestMcpServerEntryValidateHeaders(t *testing.T) { t.Parallel() const secret = "s3cr3t-value" cases := []struct { name string + token string headers []McpHeader wantErr bool }{ - {"none", nil, false}, - {"composio api key", []McpHeader{{Name: "x-api-key", Value: secret}}, false}, - {"value with spaces and symbols", []McpHeader{{Name: "Authorization", Value: "Basic dXNlcjpwYXNz=="}}, false}, - {"name with a colon", []McpHeader{{Name: "x:api", Value: secret}}, true}, - {"name with a dot", []McpHeader{{Name: "x.api", Value: secret}}, true}, - {"name with a space", []McpHeader{{Name: "x api", Value: secret}}, true}, - {"empty name", []McpHeader{{Name: "", Value: secret}}, true}, - {"name over 64 characters", []McpHeader{{Name: strings.Repeat("a", 65), Value: secret}}, true}, - {"empty value", []McpHeader{{Name: "x-api-key", Value: ""}}, true}, - {"value with LF", []McpHeader{{Name: "x-api-key", Value: secret + "\n"}}, true}, - {"value with CR", []McpHeader{{Name: "x-api-key", Value: secret + "\r"}}, true}, - {"value with NUL", []McpHeader{{Name: "x-api-key", Value: secret + "\x00"}}, true}, - {"value with DEL", []McpHeader{{Name: "x-api-key", Value: secret + "\x7f"}}, true}, - {"second header invalid", []McpHeader{{Name: "a", Value: "ok"}, {Name: "b", Value: "bad\n"}}, true}, + {"none", "", nil, false}, + {"composio api key", "", []McpHeader{{Name: "x-api-key", Value: secret}}, false}, + {"api key beside a bearer token", "tok", []McpHeader{{Name: "x-api-key", Value: secret}}, false}, + {"custom Authorization without a bearer token", "", []McpHeader{{Name: "Authorization", Value: "Basic dXNlcjpwYXNz=="}}, false}, + {"name with a colon", "", []McpHeader{{Name: "x:api", Value: secret}}, true}, + {"name with a dot", "", []McpHeader{{Name: "x.api", Value: secret}}, true}, + {"name with a space", "", []McpHeader{{Name: "x api", Value: secret}}, true}, + {"empty name", "", []McpHeader{{Name: "", Value: secret}}, true}, + {"name over 64 characters", "", []McpHeader{{Name: strings.Repeat("a", 65), Value: secret}}, true}, + {"empty value", "", []McpHeader{{Name: "x-api-key", Value: ""}}, true}, + {"value with LF", "", []McpHeader{{Name: "x-api-key", Value: secret + "\n"}}, true}, + {"value with CR", "", []McpHeader{{Name: "x-api-key", Value: secret + "\r"}}, true}, + {"value with NUL", "", []McpHeader{{Name: "x-api-key", Value: secret + "\x00"}}, true}, + {"value with DEL", "", []McpHeader{{Name: "x-api-key", Value: secret + "\x7f"}}, true}, + {"second header invalid", "", []McpHeader{{Name: "a", Value: "ok"}, {Name: "b", Value: "bad\n"}}, true}, + {"repeated name", "", []McpHeader{{Name: "x-api-key", Value: "a"}, {Name: "x-api-key", Value: secret}}, true}, + {"repeated name in another case", "", []McpHeader{{Name: "x-api-key", Value: "a"}, {Name: "X-API-Key", Value: secret}}, true}, + {"custom Authorization beside a bearer token", "tok", []McpHeader{{Name: "authorization", Value: secret}}, true}, + {"transport-managed name", "", []McpHeader{{Name: "content-length", Value: "0"}}, true}, + {"transport-managed name in another case", "", []McpHeader{{Name: "Host", Value: "evil.example"}}, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := ValidateMcpHeaders(tc.headers) + entry := McpServerEntry{URL: "https://api.example.com/mcp", Token: tc.token, Headers: tc.headers} + err := entry.ValidateHeaders() if (err != nil) != tc.wantErr { - t.Fatalf("ValidateMcpHeaders() error = %v, wantErr %v", err, tc.wantErr) + t.Fatalf("ValidateHeaders() error = %v, wantErr %v", err, tc.wantErr) } // The error travels to the control plane and into rows any project member can // read, so it must never carry a header value. @@ -319,3 +327,78 @@ func TestValidateMcpHeaders(t *testing.T) { }) } } + +// A repeated key makes a TOML file unparseable, which would take every MCP server in it down, +// sam-mcp included. Each generator must drop only the offending server and still emit a file +// its harness can parse. +func TestConfigGenerators_SkipServerWhoseHeadersRepeatAKey(t *testing.T) { + t.Parallel() + + entries := []McpServerEntry{ + {URL: "https://api.example.com/mcp", Token: "sam-token", Name: SamMcpServerName}, + composioEntry(), + { + URL: "https://repeat.example/mcp", + Name: "repeat", + Headers: []McpHeader{{Name: "x-api-key", Value: "a"}, {Name: "X-API-KEY", Value: "b"}}, + }, + { + URL: "https://clash.example/mcp", + Name: "clash", + Token: "bearer", + Headers: []McpHeader{{Name: "Authorization", Value: "Basic dXNlcjpwYXNz"}}, + }, + } + + codexConfig, _ := generateCodexMcpConfig(entries, nil, "") + var codex struct { + McpServers map[string]any `toml:"mcp_servers"` + } + if err := toml.Unmarshal([]byte(codexConfig), &codex); err != nil { + t.Fatalf("Codex config is not valid TOML: %v\n%s", err, codexConfig) + } + if len(codex.McpServers) != 2 || codex.McpServers[SamMcpServerName] == nil || codex.McpServers["composio"] == nil { + t.Fatalf("Codex mcp_servers = %v, want only sam-mcp and composio", codex.McpServers) + } + + vibeConfig := generateVibeConfig("mistral-large", entries) + var vibe struct { + McpServers []struct { + Name string `toml:"name"` + } `toml:"mcp_servers"` + } + if err := toml.Unmarshal([]byte(vibeConfig), &vibe); err != nil { + t.Fatalf("Vibe config is not valid TOML: %v\n%s", err, vibeConfig) + } + if len(vibe.McpServers) != 2 || vibe.McpServers[0].Name != SamMcpServerName || vibe.McpServers[1].Name != "composio" { + t.Fatalf("Vibe mcp_servers = %#v, want only sam-mcp and composio", vibe.McpServers) + } +} + +// Vibe is the one harness that writes header values into its config file, so a value holding +// TOML's own delimiters must round-trip exactly rather than end the string early. +func TestGenerateVibeConfig_EscapesHeaderValues(t *testing.T) { + t.Parallel() + + const value = `ak"live\x = "injected"` + config := generateVibeConfig("mistral-large", []McpServerEntry{{ + URL: "https://backend.composio.dev/mcp", + Name: "composio", + Headers: []McpHeader{{Name: "x-api-key", Value: value}}, + }}) + + var parsed struct { + McpServers []struct { + Headers map[string]string `toml:"headers"` + } `toml:"mcp_servers"` + } + if err := toml.Unmarshal([]byte(config), &parsed); err != nil { + t.Fatalf("Vibe config is not valid TOML: %v\n%s", err, config) + } + if len(parsed.McpServers) != 1 || len(parsed.McpServers[0].Headers) != 1 { + t.Fatalf("mcp_servers = %#v, want one server with one header", parsed.McpServers) + } + if got := parsed.McpServers[0].Headers["x-api-key"]; got != value { + t.Fatalf("x-api-key = %q, want %q", got, value) + } +} diff --git a/packages/vm-agent/internal/acp/mcp_server_name_contract_test.go b/packages/vm-agent/internal/acp/mcp_server_name_contract_test.go index 8074dfe44..8a415aa38 100644 --- a/packages/vm-agent/internal/acp/mcp_server_name_contract_test.go +++ b/packages/vm-agent/internal/acp/mcp_server_name_contract_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -27,8 +28,9 @@ type mcpNameContract struct { Invalid []string `json:"invalid"` } `json:"urls"` HeaderNames struct { - Valid []string `json:"valid"` - Invalid []string `json:"invalid"` + Valid []string `json:"valid"` + Invalid []string `json:"invalid"` + Reserved []string `json:"reserved"` } `json:"headerNames"` } @@ -110,3 +112,27 @@ func TestMcpHeaderNameContract(t *testing.T) { } } } + +// The control plane refuses to save a transport-managed header, and the vm-agent refuses one +// that arrives anyway. Both lists come from the fixture, so neither side can reserve a name the +// other lets through — in either case. +func TestMcpReservedHeaderNameContract(t *testing.T) { + t.Parallel() + contract := loadMcpNameContract(t) + + if len(contract.HeaderNames.Reserved) == 0 { + t.Fatal("reserved header name list is empty") + } + if len(reservedMcpHeaderNames) != len(contract.HeaderNames.Reserved) { + t.Fatalf("vm-agent reserves %d header names, the shared contract %d", + len(reservedMcpHeaderNames), len(contract.HeaderNames.Reserved)) + } + for _, name := range contract.HeaderNames.Reserved { + for _, variant := range []string{name, strings.ToUpper(name)} { + entry := McpServerEntry{URL: "https://api.example.com/mcp", Headers: []McpHeader{{Name: variant, Value: "v"}}} + if entry.ValidateHeaders() == nil { + t.Errorf("ValidateHeaders accepted reserved header %q", variant) + } + } + } +} diff --git a/packages/vm-agent/internal/acp/mcp_servers.go b/packages/vm-agent/internal/acp/mcp_servers.go index a9ae085fd..8b84473ec 100644 --- a/packages/vm-agent/internal/acp/mcp_servers.go +++ b/packages/vm-agent/internal/acp/mcp_servers.go @@ -20,6 +20,22 @@ const ( // are pinned together by packages/shared/src/fixtures/mcp-server-name-contract.json. const maxMcpHeaderNameLen = 64 +// reservedMcpHeaderNames are set by the HTTP client or the MCP transport itself, so a custom +// value would be overwritten or would break the connection (a wrong Content-Length or Host +// corrupts request framing). Lowercase. It mirrors MCP_CONNECTION_RESERVED_HEADER_NAMES in +// packages/shared/src/types/mcp-connection.ts; mcp-server-name-contract.json pins the two. +var reservedMcpHeaderNames = map[string]bool{ + "accept": true, + "connection": true, + "content-length": true, + "content-type": true, + "host": true, + "last-event-id": true, + "mcp-protocol-version": true, + "mcp-session-id": true, + "transfer-encoding": true, +} + // McpServerEntry is a lightweight MCP server config passed from the control // plane for injection into ACP sessions. It represents an HTTP MCP server with // optional bearer token authentication. @@ -64,7 +80,7 @@ func (e McpServerEntry) httpHeaders() []McpHeader { func (e McpServerEntry) safeForConfigFile() bool { return !strings.ContainsAny(e.URL, "\r\n") && !strings.ContainsAny(e.Token, "\r\n") && - ValidateMcpHeaders(e.Headers) == nil + e.ValidateHeaders() == nil } // ValidMcpHeaderName reports whether name can be written as a TOML key and passed to @@ -101,18 +117,33 @@ func ValidMcpHeaderValue(value string) bool { return true } -// ValidateMcpHeaders checks every header. The error never carries a value: it propagates to -// the control plane, which stores it where any project member can read it. +// ValidateHeaders checks the entry's custom headers against everything that would corrupt +// what this package writes: an unusable name or value, a transport-managed name, or a name the +// server already receives — a case-insensitive duplicate, or Authorization beside a bearer +// token. A repeated key makes the whole Codex or Vibe TOML file unparseable, taking every other +// MCP server, including sam-mcp, down with it. The error never carries a value: it propagates +// to the control plane, which stores it where any project member can read it. // -// Only what protects the config files and argv this package writes is checked here. The -// write-time policy rules — reserved transport headers, case-insensitive duplicates, the -// Authorization/bearer conflict, size limits — belong to the control plane -// (apps/api/src/services/mcp-connection-headers.ts), the only writer of stored headers. -func ValidateMcpHeaders(headers []McpHeader) error { - for i, header := range headers { +// The control plane (apps/api/src/services/mcp-connection-headers.ts) enforces the same rules +// when a connection is saved, plus the configurable count and size limits, which stay there +// alone so an operator raising them never has to redeploy the vm-agent. +func (e McpServerEntry) ValidateHeaders() error { + seen := make(map[string]bool, len(e.Headers)+1) + if e.Token != "" { + seen["authorization"] = true + } + for i, header := range e.Headers { if !ValidMcpHeaderName(header.Name) { return fmt.Errorf("header %d has an invalid name", i) } + key := strings.ToLower(header.Name) + if reservedMcpHeaderNames[key] { + return fmt.Errorf("header %q is set by the MCP transport and cannot be overridden", header.Name) + } + if seen[key] { + return fmt.Errorf("header %q is sent more than once (repeated, or Authorization beside a bearer token)", header.Name) + } + seen[key] = true if !ValidMcpHeaderValue(header.Value) { return fmt.Errorf("header %q has an empty value or a control character", header.Name) } diff --git a/packages/vm-agent/internal/acp/vibe_config.go b/packages/vm-agent/internal/acp/vibe_config.go index 6e39c105b..4a29fc555 100644 --- a/packages/vm-agent/internal/acp/vibe_config.go +++ b/packages/vm-agent/internal/acp/vibe_config.go @@ -112,6 +112,9 @@ temperature = 0.2 safeURL := tomlEscapeBasicString(server.URL) config += fmt.Sprintf("\n[[mcp_servers]]\nname = \"%s\"\ntransport = \"http\"\nurl = \"%s\"\n", names[i], safeURL) if headers := server.httpHeaders(); len(headers) > 0 { + // Vibe has no environment-variable indirection for arbitrary headers (its + // api_key_env covers one header), so values are written into the file, exactly as + // the bearer token always has been. The file is private to the container user. headerValue := func(_ int, header McpHeader) string { return header.Value } config += fmt.Sprintf("headers = %s\n", mcpHeadersTOMLTable(headers, headerValue)) } diff --git a/packages/vm-agent/internal/persistence/session_mcp_servers.go b/packages/vm-agent/internal/persistence/session_mcp_servers.go index 905333506..cefb8122f 100644 --- a/packages/vm-agent/internal/persistence/session_mcp_servers.go +++ b/packages/vm-agent/internal/persistence/session_mcp_servers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" ) // McpServer represents a persisted MCP server config for an ACP session. @@ -108,12 +109,17 @@ func (s *Store) UpsertSessionMcpServers(workspaceID, sessionID string, servers [ // GetSessionMcpServers returns the persisted MCP servers for a session, // ordered by sort_order. Returns an empty (non-nil) slice when none exist. +// +// A row whose headers cannot be decoded is skipped with a warning rather than failing the +// read: the caller treats any error as "no MCP servers", so one bad row would otherwise cost +// the restored session every server, sam-mcp included. A server without its headers would +// only fail authentication, so it is dropped rather than returned half-configured. func (s *Store) GetSessionMcpServers(workspaceID, sessionID string) ([]McpServer, error) { s.mu.RLock() defer s.mu.RUnlock() rows, err := s.db.Query( - "SELECT url, token, name, headers FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ? ORDER BY sort_order ASC", + "SELECT sort_order, url, token, name, headers FROM session_mcp_servers WHERE workspace_id = ? AND session_id = ? ORDER BY sort_order ASC", workspaceID, sessionID, ) if err != nil { @@ -124,12 +130,15 @@ func (s *Store) GetSessionMcpServers(workspaceID, sessionID string) ([]McpServer servers := []McpServer{} for rows.Next() { var srv McpServer + var sortOrder int var headers string - if err := rows.Scan(&srv.URL, &srv.Token, &srv.Name, &headers); err != nil { + if err := rows.Scan(&sortOrder, &srv.URL, &srv.Token, &srv.Name, &headers); err != nil { return nil, fmt.Errorf("get session mcp servers: scan: %w", err) } if srv.Headers, err = decodeMcpServerHeaders(headers); err != nil { - return nil, fmt.Errorf("get session mcp servers: row %d: %w", len(servers), err) + slog.Warn("Skipping persisted MCP server whose headers cannot be read", + "workspace", workspaceID, "session", sessionID, "sortOrder", sortOrder, "error", err) + continue } servers = append(servers, srv) } diff --git a/packages/vm-agent/internal/persistence/session_mcp_servers_test.go b/packages/vm-agent/internal/persistence/session_mcp_servers_test.go index 375c5e833..578437ec9 100644 --- a/packages/vm-agent/internal/persistence/session_mcp_servers_test.go +++ b/packages/vm-agent/internal/persistence/session_mcp_servers_test.go @@ -1,6 +1,8 @@ package persistence import ( + "bytes" + "log/slog" "strings" "testing" ) @@ -100,22 +102,48 @@ func TestMigrationV18KeepsExistingMcpServerRows(t *testing.T) { } } -func TestGetSessionMcpServers_MalformedHeadersFailWithoutLeakingThem(t *testing.T) { +// One unreadable row must not cost the restored session its other servers: the caller treats +// any error as "no MCP servers", which would silently drop sam-mcp as well. +func TestGetSessionMcpServers_SkipsARowWithMalformedHeadersWithoutLeakingThem(t *testing.T) { store := openTestStore(t) + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + if err := store.UpsertSessionMcpServers("ws-1", "sess-1", []McpServer{ + {URL: "https://api.example.com/mcp", Token: "sam-token", Name: "sam-mcp"}, + {URL: "https://backend.composio.dev/mcp", Name: "composio"}, + { + URL: "https://mcp.zapier.com/api/mcp", + Name: "zapier", + Headers: []McpServerHeader{{Name: "x-api-key", Value: "zap-key"}}, + }, + }); err != nil { + t.Fatalf("UpsertSessionMcpServers: %v", err) + } const secret = "ak_live_secret" if _, err := store.db.Exec( - "INSERT INTO session_mcp_servers (workspace_id, session_id, sort_order, url, token, name, headers) "+ - "VALUES ('ws-1', 'sess-1', 0, 'https://backend.composio.dev/mcp', '', 'composio', ?)", + "UPDATE session_mcp_servers SET headers = ? WHERE workspace_id = 'ws-1' AND session_id = 'sess-1' AND sort_order = 1", `[{"name":"x-api-key","value":"`+secret+`"`, // truncated JSON ); err != nil { - t.Fatalf("insert malformed row: %v", err) + t.Fatalf("corrupt row: %v", err) } - _, err := store.GetSessionMcpServers("ws-1", "sess-1") - if err == nil { - t.Fatal("expected a malformed headers column to fail the read") + got, err := store.GetSessionMcpServers("ws-1", "sess-1") + if err != nil { + t.Fatalf("GetSessionMcpServers: %v", err) + } + if len(got) != 2 || got[0].Name != "sam-mcp" || got[1].Name != "zapier" { + t.Fatalf("got %#v, want sam-mcp and zapier with the corrupted composio row skipped", got) + } + if len(got[1].Headers) != 1 || got[1].Headers[0].Value != "zap-key" { + t.Fatalf("zapier headers = %#v, want its own header intact", got[1].Headers) + } + if !strings.Contains(logs.String(), "sortOrder=1") { + t.Fatalf("expected a warning naming the skipped row, got logs:\n%s", logs.String()) } - if strings.Contains(err.Error(), secret) { - t.Fatalf("error leaks the stored header value: %v", err) + if strings.Contains(logs.String(), secret) { + t.Fatalf("warning leaks the stored header value:\n%s", logs.String()) } } diff --git a/packages/vm-agent/internal/server/mcp_servers.go b/packages/vm-agent/internal/server/mcp_servers.go index 56b5a1344..40b36e6c6 100644 --- a/packages/vm-agent/internal/server/mcp_servers.go +++ b/packages/vm-agent/internal/server/mcp_servers.go @@ -30,7 +30,7 @@ func normalizeMcpServers(entries []acp.McpServerEntry) ([]acp.McpServerEntry, er } // Header values reach TOML files and mcp-remote arguments, so a malformed header fails // the request here, like a malformed URL, rather than being written out. - if err := acp.ValidateMcpHeaders(srv.Headers); err != nil { + if err := srv.ValidateHeaders(); err != nil { return nil, fmt.Errorf("mcpServers[%d]: %w", i, err) } // Every field must be copied explicitly: this rebuilds the struct, so a field added diff --git a/packages/vm-agent/internal/server/session_snapshot_archive.go b/packages/vm-agent/internal/server/session_snapshot_archive.go index c5d5ecfeb..a48241ba9 100644 --- a/packages/vm-agent/internal/server/session_snapshot_archive.go +++ b/packages/vm-agent/internal/server/session_snapshot_archive.go @@ -633,8 +633,8 @@ var homeExcludeFiles = map[string]bool{ ".claude/.credentials.json": true, ".codex/auth.json": true, // Generated runtime configuration can contain callback-token URLs (Codex) - // or literal MCP bearer tokens (Vibe). It is regenerated from fresh control- - // plane credentials before the restored harness is loaded. + // or literal MCP bearer tokens and header values (Vibe). It is regenerated + // from fresh control-plane credentials before the restored harness is loaded. ".codex/config.toml": true, ".vibe/config.toml": true, ".git-credentials": true, diff --git a/tasks/active/2026-09-29-mcp-connection-custom-headers.md b/tasks/active/2026-09-29-mcp-connection-custom-headers.md index b6e4b1de2..52cee7866 100644 --- a/tasks/active/2026-09-29-mcp-connection-custom-headers.md +++ b/tasks/active/2026-09-29-mcp-connection-custom-headers.md @@ -207,3 +207,36 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r intended tests red (see PR body). - Codex `env_http_headers` was verified against the published Codex config reference, and mcp-remote's header parser against the `mcp-remote@0.1.38` tarball source. + +### Phase 5 review fixes + +- cloudflare-specialist (HIGH, TOCTOU): `updateMcpConnection` derives every field from the row + it read, so a header-only PATCH racing a switch to bearer could persist `Authorization` beside + a bearer token. The write is now conditional on `updated_at` still matching (409 otherwise), + with `nextUpdatedAt` keeping the column strictly increasing inside one millisecond, plus a + resolution-time backstop that refuses that pair. Tests freeze the clock so both halves are + load-bearing; dropping the predicate, the strict step, or the backstop each turned a test red. +- go-specialist (HIGH): a repeated header name made the Codex/Vibe TOML unparseable. The + vm-agent now validates through `McpServerEntry.ValidateHeaders` — charset, reserved transport + names (pinned TS↔Go by `headerNames.reserved` in the contract fixture), case-insensitive + duplicates, and `Authorization` beside a bearer token. Five Go mutations proven red. +- go-specialist + test-engineer (MEDIUM): `GetSessionMcpServers` failed the whole read over one + undecodable `headers` column, which the restore path treats as "no MCP servers" (sam-mcp + included). It now skips that row with a warning that names the row, never the value. +- go-specialist (MEDIUM, Vibe writes header values into `~/.vibe/config.toml`): accepted and + documented. Vibe has env indirection for one header only (`api_key_env`); the bearer token has + always been written the same way, the directory is `chmod 700`, and the file is excluded from + session snapshots (`homeExcludeFiles`). The public guide already says only Codex keeps values + out of its config file. +- go-specialist (LOW, count/size limits not mirrored in Go): declined on purpose. They are + operator-configurable in the control plane; a Go constant would silently diverge from an + operator who raises them. +- test-engineer: route-level POST/PATCH tests with real SQLite, env-driven + `MAX_MCP_CONNECTION_HEADERS` / `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (hardcoding either in + the route turned its test red), Vibe TOML escaping of `"` and `\`, authType switch keeping a + non-conflicting header, bearer + `x-api-key` on create, update-time header limit, UTF-8 byte + (not character) limit. +- ui-ux-specialist: the header name rule is shown up front, the edit form says a saved header + keeps its value unless retyped, and server names use the card-title type style. +- Full API suite under load (load average ~18 on 8 cores) showed 8 cold-import timeouts in + unrelated files; all 40 affected tests pass when rerun with longer timeouts. From 9ae91f73545762ad7e5cdab73087da1d92f0cf29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:33:40 +0000 Subject: [PATCH 10/18] test(mcp): cover custom headers and their env limits through the HTTP routes Co-Authored-By: Claude Opus 5.5 --- .../tests/unit/routes/mcp-connections.test.ts | 159 ++++++++++++++++-- .../mcp-connection-bootstrap-wiring.test.ts | 8 +- .../services/mcp-connection-injection.test.ts | 8 +- .../unit/services/mcp-connections.test.ts | 42 +++-- 4 files changed, 187 insertions(+), 30 deletions(-) diff --git a/apps/api/tests/unit/routes/mcp-connections.test.ts b/apps/api/tests/unit/routes/mcp-connections.test.ts index 466001793..7a5b16902 100644 --- a/apps/api/tests/unit/routes/mcp-connections.test.ts +++ b/apps/api/tests/unit/routes/mcp-connections.test.ts @@ -16,6 +16,7 @@ import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as schema from '../../../src/db/schema'; +import { openMcpConnectionHeaders } from '../../../src/services/mcp-connection-headers'; import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; let currentUserId = 'owner-user'; @@ -26,9 +27,8 @@ vi.mock('../../../src/middleware/auth', () => ({ getUserId: () => currentUserId, })); -const { projectMcpConnectionRoutes, userMcpConnectionRoutes } = await import( - '../../../src/routes/mcp-connections' -); +const { projectMcpConnectionRoutes, userMcpConnectionRoutes } = + await import('../../../src/routes/mcp-connections'); const { handleAppError } = await import('../../../src/middleware/app-error-handler'); const ENCRYPTION_KEY = Buffer.alloc(32, 9).toString('base64'); @@ -43,8 +43,8 @@ function makeApp() { return app; } -function env() { - return { DATABASE: createSqliteD1(sqlite), ENCRYPTION_KEY } as unknown as Record< +function env(overrides: Record = {}) { + return { DATABASE: createSqliteD1(sqlite), ENCRYPTION_KEY, ...overrides } as unknown as Record< string, unknown >; @@ -59,9 +59,7 @@ function seedProject(projectId: string, ownerId: string) { function seedMember(projectId: string, userId: string, role: string, status = 'active') { // Composite (project_id, user_id) primary key — no surrogate id column. sqlite - .prepare( - 'INSERT INTO project_members (project_id, user_id, role, status) VALUES (?, ?, ?, ?)' - ) + .prepare('INSERT INTO project_members (project_id, user_id, role, status) VALUES (?, ?, ?, ?)') .run(projectId, userId, role, status); } @@ -74,7 +72,7 @@ const VALID_BODY = { async function request( path: string, - init?: { method?: string; body?: unknown } + init?: { method?: string; body?: unknown; env?: Record } ): Promise { return makeApp().request( path, @@ -84,7 +82,7 @@ async function request( ? { body: JSON.stringify(init.body), headers: { 'Content-Type': 'application/json' } } : {}), }, - env() + env(init?.env) ); } @@ -273,3 +271,144 @@ describe('personal-scope routes', () => { expect(res.status).toBe(400); }); }); + +describe('custom headers over HTTP', () => { + const API_KEY = 'ak_route_secret'; + const COMPOSIO_BODY = { + name: 'composio', + url: 'https://backend.composio.dev/v3/mcp/server-1', + authType: 'none', + headers: [{ name: 'x-api-key', value: API_KEY }], + }; + + async function storedHeaders(id: string) { + const row = sqlite + .prepare( + 'SELECT encrypted_headers AS encryptedHeaders, headers_iv AS headersIv FROM mcp_connections WHERE id = ?' + ) + .get(id) as { encryptedHeaders: string | null; headersIv: string | null }; + return openMcpConnectionHeaders(row, ENCRYPTION_KEY); + } + + it('stores header values encrypted and returns only their names', async () => { + currentUserId = 'user-a'; + const created = await request('/api/mcp-connections', { method: 'POST', body: COMPOSIO_BODY }); + const raw = await created.text(); + + expect(created.status).toBe(201); + expect(raw).not.toContain(API_KEY); + const { id, headerNames } = JSON.parse(raw) as { id: string; headerNames: string[] }; + expect(headerNames).toEqual(['x-api-key']); + + const listed = await (await request('/api/mcp-connections')).text(); + expect(listed).not.toContain(API_KEY); + expect(JSON.parse(listed)).toMatchObject({ items: [{ id, headerNames: ['x-api-key'] }] }); + + expect(await storedHeaders(id)).toEqual([{ name: 'x-api-key', value: API_KEY }]); + const ciphertext = sqlite + .prepare('SELECT encrypted_headers FROM mcp_connections') + .pluck() + .get(); + expect(String(ciphertext)).not.toContain(API_KEY); + }); + + it('PATCH keeps a value sent without one and adds a new header', async () => { + currentUserId = 'owner-user'; + const created = await request('/api/projects/proj-1/mcp-connections', { + method: 'POST', + body: COMPOSIO_BODY, + }); + const { id } = (await created.json()) as { id: string }; + + const patched = await request(`/api/projects/proj-1/mcp-connections/${id}`, { + method: 'PATCH', + body: { headers: [{ name: 'x-api-key' }, { name: 'x-org-id', value: 'org-42' }] }, + }); + + expect(patched.status).toBe(200); + expect(((await patched.json()) as { headerNames: string[] }).headerNames).toEqual([ + 'x-api-key', + 'x-org-id', + ]); + expect(await storedHeaders(id)).toEqual([ + { name: 'x-api-key', value: API_KEY }, + { name: 'x-org-id', value: 'org-42' }, + ]); + }); + + it('requires a value for every header on create', async () => { + currentUserId = 'user-a'; + const res = await request('/api/mcp-connections', { + method: 'POST', + body: { ...COMPOSIO_BODY, headers: [{ name: 'x-api-key' }] }, + }); + expect(res.status).toBe(400); + }); + + it('rejects an Authorization header on a bearer server with 400', async () => { + currentUserId = 'user-a'; + const res = await request('/api/mcp-connections', { + method: 'POST', + body: { ...VALID_BODY, headers: [{ name: 'Authorization', value: 'Basic dXNlcjpwYXNz' }] }, + }); + expect(res.status).toBe(400); + expect(await res.text()).toMatch(/conflicts with the bearer token/); + }); + + it('applies MAX_MCP_CONNECTION_HEADERS from the environment on create and update', async () => { + currentUserId = 'user-a'; + const limitEnv = { MAX_MCP_CONNECTION_HEADERS: '1' }; + const twoHeaders = [ + { name: 'x-api-key', value: API_KEY }, + { name: 'x-org-id', value: 'org-42' }, + ]; + + const tooMany = await request('/api/mcp-connections', { + method: 'POST', + body: { ...COMPOSIO_BODY, headers: twoHeaders }, + env: limitEnv, + }); + expect(tooMany.status).toBe(400); + expect(await tooMany.text()).toMatch(/Maximum 1 headers/); + + // Control: one header fits the same limit, so the 400 above is the limit and not breakage. + const fits = await request('/api/mcp-connections', { + method: 'POST', + body: COMPOSIO_BODY, + env: limitEnv, + }); + expect(fits.status).toBe(201); + const { id } = (await fits.json()) as { id: string }; + + const grow = await request(`/api/mcp-connections/${id}`, { + method: 'PATCH', + body: { headers: [{ name: 'x-api-key' }, { name: 'x-org-id', value: 'org-42' }] }, + env: limitEnv, + }); + expect(grow.status).toBe(400); + expect(await storedHeaders(id)).toEqual([{ name: 'x-api-key', value: API_KEY }]); + }); + + it('applies MCP_CONNECTION_HEADER_VALUE_MAX_BYTES from the environment, in bytes', async () => { + currentUserId = 'user-a'; + const limitEnv = { MCP_CONNECTION_HEADER_VALUE_MAX_BYTES: '8' }; + + // Five characters, ten bytes. + const tooBig = await request('/api/mcp-connections', { + method: 'POST', + body: { ...COMPOSIO_BODY, headers: [{ name: 'x-api-key', value: 'ééééé' }] }, + env: limitEnv, + }); + expect(tooBig.status).toBe(400); + const message = await tooBig.text(); + expect(message).toMatch(/exceeds max size of 8 bytes/); + expect(message).not.toContain('ééééé'); + + const fits = await request('/api/mcp-connections', { + method: 'POST', + body: { ...COMPOSIO_BODY, headers: [{ name: 'x-api-key', value: 'abcdefgh' }] }, + env: limitEnv, + }); + expect(fits.status).toBe(201); + }); +}); diff --git a/apps/api/tests/unit/services/mcp-connection-bootstrap-wiring.test.ts b/apps/api/tests/unit/services/mcp-connection-bootstrap-wiring.test.ts index 451b3bb3f..4fad0c06e 100644 --- a/apps/api/tests/unit/services/mcp-connection-bootstrap-wiring.test.ts +++ b/apps/api/tests/unit/services/mcp-connection-bootstrap-wiring.test.ts @@ -56,7 +56,13 @@ const { createMcpConnection } = await import('../../../src/services/mcp-connecti const projectDataService = await import('../../../src/services/project-data'); const ENCRYPTION_KEY = Buffer.alloc(32, 5).toString('base64'); -const LIMITS = { maxPerScope: 25, urlMaxBytes: 2048, tokenMaxBytes: 8192 }; +const LIMITS = { + maxPerScope: 25, + urlMaxBytes: 2048, + tokenMaxBytes: 8192, + maxHeaders: 10, + headerValueMaxBytes: 8192, +}; let sqlite: Database.Database; let db: ReturnType>; diff --git a/apps/api/tests/unit/services/mcp-connection-injection.test.ts b/apps/api/tests/unit/services/mcp-connection-injection.test.ts index fe7dac7ae..6e43246d0 100644 --- a/apps/api/tests/unit/services/mcp-connection-injection.test.ts +++ b/apps/api/tests/unit/services/mcp-connection-injection.test.ts @@ -24,7 +24,13 @@ import { createMcpConnection } from '../../../src/services/mcp-connections'; import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; const ENCRYPTION_KEY = Buffer.alloc(32, 3).toString('base64'); -const LIMITS = { maxPerScope: 25, urlMaxBytes: 2048, tokenMaxBytes: 8192 }; +const LIMITS = { + maxPerScope: 25, + urlMaxBytes: 2048, + tokenMaxBytes: 8192, + maxHeaders: 10, + headerValueMaxBytes: 8192, +}; const BASE_DOMAIN = 'example.com'; type Db = ReturnType>; diff --git a/apps/api/tests/unit/services/mcp-connections.test.ts b/apps/api/tests/unit/services/mcp-connections.test.ts index 13479a88b..d1da078c3 100644 --- a/apps/api/tests/unit/services/mcp-connections.test.ts +++ b/apps/api/tests/unit/services/mcp-connections.test.ts @@ -21,7 +21,13 @@ import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; // 32 random bytes, base64 — the shape getCredentialEncryptionKey returns. const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64'); -const LIMITS = { maxPerScope: 25, urlMaxBytes: 2048, tokenMaxBytes: 8192 }; +const LIMITS = { + maxPerScope: 25, + urlMaxBytes: 2048, + tokenMaxBytes: 8192, + maxHeaders: 10, + headerValueMaxBytes: 8192, +}; type Db = ReturnType>; @@ -90,17 +96,12 @@ describe('createMcpConnection', () => { expect(created.name).toBe(expected); }); - it.each([ - 'has space', - 'has.dot', - 'has/slash', - '-leading', - 'trailing-', - 'a'.repeat(33), - '', - ])('rejects unsafe name %j', async (name) => { - await expect(createMcpConnection(db, baseInput({ name }))).rejects.toThrow(); - }); + it.each(['has space', 'has.dot', 'has/slash', '-leading', 'trailing-', 'a'.repeat(33), ''])( + 'rejects unsafe name %j', + async (name) => { + await expect(createMcpConnection(db, baseInput({ name }))).rejects.toThrow(); + } + ); it.each([ ['plain http on a public host', 'http://evil.example.com/mcp'], @@ -155,9 +156,7 @@ describe('createMcpConnection', () => { createMcpConnection(db, baseInput({ projectId: 'proj-2' })) ).resolves.toBeDefined(); // A different user's personal scope is also independent. - await expect( - createMcpConnection(db, baseInput({ userId: 'user-2' })) - ).resolves.toBeDefined(); + await expect(createMcpConnection(db, baseInput({ userId: 'user-2' }))).resolves.toBeDefined(); }); }); @@ -209,7 +208,9 @@ describe('scope isolation', () => { deleteMcpConnection(db, { userId: 'attacker', projectId: 'proj-attacker' }, victim.id) ).rejects.toThrow(); - const count = sqlite.prepare('SELECT COUNT(*) AS n FROM mcp_connections').get() as { n: number }; + const count = sqlite.prepare('SELECT COUNT(*) AS n FROM mcp_connections').get() as { + n: number; + }; expect(count.n).toBe(1); }); @@ -240,14 +241,19 @@ describe('scope isolation', () => { expect(updated.enabled).toBe(false); await deleteMcpConnection(db, { userId: 'user-1', projectId: 'proj-1' }, mine.id); - const count = sqlite.prepare('SELECT COUNT(*) AS n FROM mcp_connections').get() as { n: number }; + const count = sqlite.prepare('SELECT COUNT(*) AS n FROM mcp_connections').get() as { + n: number; + }; expect(count.n).toBe(0); }); // Project rows are shared project resources: any member's session must see them, not only // the member who created them. it('a project row created by one member is visible to another member', async () => { - await createMcpConnection(db, baseInput({ userId: 'creator', projectId: 'proj-1', name: 'shared' })); + await createMcpConnection( + db, + baseInput({ userId: 'creator', projectId: 'proj-1', name: 'shared' }) + ); const asOtherMember = await listMcpConnections(db, { userId: 'other-member', From fd5c1b18d789104ba526a8cbc0837f80135f94f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:33:40 +0000 Subject: [PATCH 11/18] fix(web): state the header name rule and keep-by-default behaviour up front Co-Authored-By: Claude Opus 5.5 --- .../mcp-servers/McpServerHeadersField.tsx | 13 +++++++++++-- .../components/mcp-servers/McpServersManager.tsx | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx b/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx index 3c54d87d3..7bc6e1668 100644 --- a/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx +++ b/apps/web/src/components/mcp-servers/McpServerHeadersField.tsx @@ -1,4 +1,7 @@ -import { MCP_CONNECTION_HEADER_NAME_MAX_LENGTH } from '@simple-agent-manager/shared'; +import { + MCP_CONNECTION_HEADER_NAME_MAX_LENGTH, + MCP_CONNECTION_HEADER_NAME_RULE, +} from '@simple-agent-manager/shared'; import { Button, Input } from '@simple-agent-manager/ui'; import { Plus, X } from 'lucide-react'; import type { FC } from 'react'; @@ -30,8 +33,14 @@ export const McpServerHeadersField: FC = ({ headers, Headers

    Sent with every request, for example x-api-key for - Composio. Values are stored encrypted and never shown again. + Composio. Values are stored encrypted and never shown again;{' '} + {MCP_CONNECTION_HEADER_NAME_RULE}.

    + {headers.some((row) => row.stored) && ( +

    + A saved header keeps its value unless you type a new one. +

    + )} {headers.length > 0 && (
      diff --git a/apps/web/src/components/mcp-servers/McpServersManager.tsx b/apps/web/src/components/mcp-servers/McpServersManager.tsx index c80713794..bb85764da 100644 --- a/apps/web/src/components/mcp-servers/McpServersManager.tsx +++ b/apps/web/src/components/mcp-servers/McpServersManager.tsx @@ -138,7 +138,7 @@ export const McpServersManager: FC = ({ */} {title !== null && ( <> -

      {title}

      +

      {title}

      {projectId === null ? 'Available to every session you start, in any project.' From e3dade0517b680733e233a5242a3df3d8f7c24ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 07:34:03 +0000 Subject: [PATCH 12/18] style(mcp): format the injection test fixture Co-Authored-By: Claude Opus 5.5 --- apps/api/tests/unit/services/mcp-connection-injection.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/api/tests/unit/services/mcp-connection-injection.test.ts b/apps/api/tests/unit/services/mcp-connection-injection.test.ts index 6e43246d0..cdf151bf9 100644 --- a/apps/api/tests/unit/services/mcp-connection-injection.test.ts +++ b/apps/api/tests/unit/services/mcp-connection-injection.test.ts @@ -299,9 +299,7 @@ describe('resolveMcpServersForSession', () => { { userId: 'user-1', projectId: null }, ENCRYPTION_KEY ); - expect(resolved).toEqual([ - expect.objectContaining({ name: 'composio', token: '' }), - ]); + expect(resolved).toEqual([expect.objectContaining({ name: 'composio', token: '' })]); }); }); From 71bdf30a94cf727201300b54213297fac8487815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 09:52:53 +0000 Subject: [PATCH 13/18] fix(mcp): renumber the headers migration to 0177 Main claimed 0175 in #2181, and a sibling branch's 0176 is already in staging's D1 ledger. This file had never been applied anywhere, so it moves to the next free prefix before its first staging deploy. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/changelog/SKILL.md | 2 +- ...nection_headers.sql => 0177_mcp_connection_headers.sql} | 0 tasks/active/2026-09-29-mcp-connection-custom-headers.md | 7 ++++++- 3 files changed, 7 insertions(+), 2 deletions(-) rename apps/api/src/db/migrations/{0175_mcp_connection_headers.sql => 0177_mcp_connection_headers.sql} (100%) diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md index 049727580..fd715fd07 100644 --- a/.claude/skills/changelog/SKILL.md +++ b/.claude/skills/changelog/SKILL.md @@ -14,7 +14,7 @@ These entries were removed from root `CLAUDE.md` so startup instructions stay co Use the `/changelog` skill for structured queries. -- mcp-connection-custom-headers: Bring-your-own MCP servers can carry custom HTTP headers (Composio requires `x-api-key` / `x-consumer-api-key`). Headers are independent of `authType`; `Authorization` is accepted only with `none`. Names `^[A-Za-z0-9_-]{1,64}$` — exactly what the Amp bridge `mcp-remote@0.1.38` parses and a TOML bare key — pinned TS↔Go by the `headerNames` block of `mcp-server-name-contract.json`; transport headers are reserved (`headerNames.reserved` pins that list on both sides). D1 `0175` adds `header_names` (plaintext display projection) + `encrypted_headers`/`headers_iv` (one AES-GCM ciphertext of the `[{name,value}]` list, the only column injection reads). All rules and the storage format live in `services/mcp-connection-headers.ts`. PATCH `headers` is the full desired set and an entry without `value` keeps the stored value, so the UI edits without ever holding secrets. PATCH writes only if `updated_at` still matches the row it read (`nextUpdatedAt` keeps the column strictly increasing; a lost race is a 409): every derived field comes from that read, so without the guard a header-only edit racing a switch to bearer persisted an `Authorization` header beside a bearer token. Resolution re-validates decrypted headers, refuses that pair as a backstop, and skips a bad row. vm-agent: `McpServerEntry.ValidateHeaders` (charset, reserved names, case-insensitive duplicates including `Authorization` beside the bearer token — one repeated key makes the whole Codex/Vibe TOML unparseable, `sam-mcp` included) runs in `normalizeMcpServers` and again before any config file is written; headers are persisted (`migrateV18`; a row whose headers cannot be decoded is skipped on restore instead of dropping every server), converted by `toPersistedMcpServers`/`fromPersistedMcpServers`; ACP header list, Codex `env_http_headers` with `SAM_MCP__HEADER__SECRET` (the `_SECRET` suffix both classifies it for `isSecretEnvVar` and cannot collide with a bearer `_TOKEN` var), Vibe `headers` table, Amp `--header name:${SAM_MCP_HEADER_}`. Wire shape pinned by `packages/shared/src/fixtures/mcp-server-entry-wire.json` (node-agent serializer + real Go handler). UI: `McpServerForm` (add + inline Edit), `McpServerHeadersField`. Limits `MAX_MCP_CONNECTION_HEADERS` (10), `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (8192). Refactor in the same PR: MCP/Codex/Vibe code moved out of `gateway.go`/`session_host.go`/`workspaces.go`/`store.go` into `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go`. +- mcp-connection-custom-headers: Bring-your-own MCP servers can carry custom HTTP headers (Composio requires `x-api-key` / `x-consumer-api-key`). Headers are independent of `authType`; `Authorization` is accepted only with `none`. Names `^[A-Za-z0-9_-]{1,64}$` — exactly what the Amp bridge `mcp-remote@0.1.38` parses and a TOML bare key — pinned TS↔Go by the `headerNames` block of `mcp-server-name-contract.json`; transport headers are reserved (`headerNames.reserved` pins that list on both sides). D1 `0177` adds `header_names` (plaintext display projection) + `encrypted_headers`/`headers_iv` (one AES-GCM ciphertext of the `[{name,value}]` list, the only column injection reads). All rules and the storage format live in `services/mcp-connection-headers.ts`. PATCH `headers` is the full desired set and an entry without `value` keeps the stored value, so the UI edits without ever holding secrets. PATCH writes only if `updated_at` still matches the row it read (`nextUpdatedAt` keeps the column strictly increasing; a lost race is a 409): every derived field comes from that read, so without the guard a header-only edit racing a switch to bearer persisted an `Authorization` header beside a bearer token. Resolution re-validates decrypted headers, refuses that pair as a backstop, and skips a bad row. vm-agent: `McpServerEntry.ValidateHeaders` (charset, reserved names, case-insensitive duplicates including `Authorization` beside the bearer token — one repeated key makes the whole Codex/Vibe TOML unparseable, `sam-mcp` included) runs in `normalizeMcpServers` and again before any config file is written; headers are persisted (`migrateV18`; a row whose headers cannot be decoded is skipped on restore instead of dropping every server), converted by `toPersistedMcpServers`/`fromPersistedMcpServers`; ACP header list, Codex `env_http_headers` with `SAM_MCP__HEADER__SECRET` (the `_SECRET` suffix both classifies it for `isSecretEnvVar` and cannot collide with a bearer `_TOKEN` var), Vibe `headers` table, Amp `--header name:${SAM_MCP_HEADER_}`. Wire shape pinned by `packages/shared/src/fixtures/mcp-server-entry-wire.json` (node-agent serializer + real Go handler). UI: `McpServerForm` (add + inline Edit), `McpServerHeadersField`. Limits `MAX_MCP_CONNECTION_HEADERS` (10), `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (8192). Refactor in the same PR: MCP/Codex/Vibe code moved out of `gateway.go`/`session_host.go`/`workspaces.go`/`store.go` into `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go`. - project-chat-instant-switching: Switching between project chats renders the target chat at once. Transcripts (`sessions/messages`) now keep a 24 h `gcTime` (`CHAT_TRANSCRIPT_CACHE_TTL_MS`, from shared `DEFAULT_CHAT_TRANSCRIPT_CACHE_TTL_MS`, override `VITE_CHAT_TRANSCRIPT_CACHE_TTL_MS`); previously the TanStack 5-minute default dropped an unobserved transcript from memory and, on the next write, from IndexedDB. Restored queries get `RESTORED_QUERY_GC_TIME_MS` via `hydrateOptions`, the dehydrate filter stops writing a transcript older than the TTL, and opening a chat evicts the least recently updated unobserved transcripts beyond `CHAT_TRANSCRIPT_CACHE_MAX_SESSIONS` (20; `evictStaleTranscripts` in `lib/query-options/chats.ts`); on disk each transcript keeps only its newest `CHAT_TRANSCRIPT_PERSIST_MAX_ROWS` rows (default: the 500-row page; `persistedQueryForDisk`). `ProjectMessageView` is keyed per session (`SessionMessageView`), and the transcript is read straight from the query cache (`useSessionTranscript`), so a cached chat paints in the switching commit and an uncached one shows a spinner, never the previous chat. The cold load requests the newest page (`fetchNewestPage`, `CHAT_SESSION_MESSAGE_LIMIT` = 500) instead of the 50,000-row `CHAT_SESSION_MESSAGE_MAX` ceiling (now only the server clamp); older history pages in through Virtuoso `startReached` once the reader scrolls up — wheel, swipe, or ArrowUp/PageUp/Home, not the list position (a page of tool calls can fold into a few rows that fit on screen, and a chat can open away from the bottom when its newest message is taller than the screen; an ungated `startReached` then paged the whole history in on open) and through the existing "Load earlier" button. The #2159 forward-delta refresh moved into the query function unchanged. Jumps to a specific message page back until that message id is loaded (`HistoryTarget` in `lib/message-paging.ts`) and then confirm the row is on screen, re-scrolling past Virtuoso's prepend compensation (`useConversationJump`), and an unloaded comment anchor reads "on a message". Composer drafts are per chat (`session-drafts.tsx`), report-issue config is a cached query, and server `session`/`state` snapshots hydrate only when the server reports new ones, which fixes a streamed row resetting a working agent to idle. Auth gating is unchanged: no persisted transcript renders before the session check resolves. - archive-sweep-affordability-ceiling-and-fallthrough: The production ProjectData archive sweep stopped reclaiming anything on 2026-09-08 and reported `succeeded` for 226 hourly runs while the root object climbed from 94% to 96.7% of its 10 GB ceiling. Two independently configured ceilings had to agree and drifted: a GitHub `production` Environment override lowered `PROJECT_DATA_ARCHIVE_DAILY_WRITE_BUDGET` to 100000 (affordability ceiling `floor((100000-1000)/32)` = 3093 write units) while `PROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET` stayed at the checked-in 5000, and `selectCandidates` orders `message_count DESC ... LIMIT sweepProjects * sweepSessions` (deployed 1x1). Every tick therefore picked the same 4994-message session, estimated ~160,808 writes, was refused by `reserveArchiveWrites` BEFORE it touched D1 (so the UTC budget window also froze at `2026-09-08T00:00:00Z`), and `continue`d out of a one-element list — no journal row, no location change, no error. The selection ceiling is now DERIVED from the allowance (`archiveAffordableWriteUnits` / `archiveAffordableMessageCeiling` in `project-data-archive/write-budget.ts`, used as the `message_count <= ?` bind), so the two can no longer disagree at any configuration; an explicit session-scoped operator canary still bypasses it. `selectCandidates` over-reads `PROJECT_DATA_ARCHIVE_SWEEP_FALLTHROUGH_DEPTH` (4) spare candidates and the journaling loop descends past a refusal instead of ending the tick, with both per-tick bounds (session slots and the cumulative message budget) moved INTO that loop so a refused candidate — which opens no `migrating` fence and moves no rows — consumes neither. `reserveArchiveWrites` returns a discriminated outcome separating `exceeds_allowance` (waiting cannot help) from `window_exhausted` (normal end-of-day backpressure), and `PROJECT_DATA_ARCHIVE_BUDGET_STALL_ALERT_SWEEPS` (3) consecutive ticks that migrate nothing and see only the former flip the cadence row to `partial` with an actionable `last_error` — counted and escalated inside one atomic `UPDATE ... RETURNING` (migration `0156` adds `consecutive_budget_stalls`) so a failed read cannot silently restart a streak. `wrangler.toml` now ships `DAILY_WRITE_BUDGET=100000` (matching the production override, so staging and self-hosts derive the same ceiling), `SWEEP_MESSAGE_BUDGET=2000`, `SWEEP_SESSIONS=2`. Owner stubs are memoised per tick so the fall-through does not multiply `ensureProjectId` DO round trips. NOTE: within a 100000/day allowance the restored sweep reclaims on the order of 1-1.5 MB/day against ~66 MB/day of growth — it ends the deadlock but cannot reverse the storage curve; raising the budget is a spend decision. - codex-astra-runtime-selection: Codex ACP is upgraded 1.8.0→1.10.0 and its Codex companion 0.153.2→0.153.4 across the canonical install manifest, VM-agent installer, and cf-container runtime image; the sandbox image's CLI-only pin is aligned to 0.153.4. VM-agent now validates both exact executable versions and supplies `CODEX_PATH=codex`, ensuring the adapter launches the explicitly pinned companion rather than a nested dependency resolved relative to itself. An explicit Codex profile model is applied through ACP `session/set_config_option`; rejection now fails session establishment with the requested model in the diagnostic instead of silently retaining the adapter default. A wire-level ACP regression test pins both the successful `gpt-6-astra` request and the fail-closed case. Process fix: `.claude/rules/23-cross-boundary-contract-tests.md` now treats adapter/companion resolution as one runtime contract. diff --git a/apps/api/src/db/migrations/0175_mcp_connection_headers.sql b/apps/api/src/db/migrations/0177_mcp_connection_headers.sql similarity index 100% rename from apps/api/src/db/migrations/0175_mcp_connection_headers.sql rename to apps/api/src/db/migrations/0177_mcp_connection_headers.sql diff --git a/tasks/active/2026-09-29-mcp-connection-custom-headers.md b/tasks/active/2026-09-29-mcp-connection-custom-headers.md index 52cee7866..9150fb825 100644 --- a/tasks/active/2026-09-29-mcp-connection-custom-headers.md +++ b/tasks/active/2026-09-29-mcp-connection-custom-headers.md @@ -123,7 +123,7 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r ### API -- [x] Migration `0175_mcp_connection_headers.sql` (ADD COLUMN ×3, additive) + `schema.ts` columns +- [x] Migration `0177_mcp_connection_headers.sql` (ADD COLUMN ×3, additive) + `schema.ts` columns - [x] `services/mcp-connection-headers.ts`: validate, merge-for-update, seal/open, display names - [x] `services/mcp-connections.ts`: create/update/response use the header module - [x] `schemas/mcp-connections.ts`: structural `headers` for create/update @@ -240,3 +240,8 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r keeps its value unless retyped, and server names use the card-title type style. - Full API suite under load (load average ~18 on 8 cores) showed 8 cold-import timeouts in unrelated files; all 40 affected tests pass when rerun with longer timeouts. +- Migration renumbered `0175` → `0177` before its first staging deploy. Main claimed `0175` in #2181 + (`0175_backfill_workspace_resource_attribution.sql`), and a sibling branch's + `0176_workspace_resource_working_set.sql` is already recorded in staging's D1 ledger. + `check-migration-ordering.ts` rejects duplicate prefixes but allows gaps, and renaming a file + staging has already applied would replay it. This file had never been applied anywhere. From 2f5e68f62a46cf70b2f0b8d13f79ba2d794a815c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 10:28:29 +0000 Subject: [PATCH 14/18] fix(mcp): renumber the headers migration to 0178 Open sibling branches have already applied 0176 and 0177 to staging, so this migration takes the next prefix no open branch uses, before its first staging deploy. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/changelog/SKILL.md | 2 +- ...headers.sql => 0178_mcp_connection_headers.sql} | 0 .../2026-09-29-mcp-connection-custom-headers.md | 14 ++++++++------ 3 files changed, 9 insertions(+), 7 deletions(-) rename apps/api/src/db/migrations/{0177_mcp_connection_headers.sql => 0178_mcp_connection_headers.sql} (100%) diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md index fd715fd07..8e526d877 100644 --- a/.claude/skills/changelog/SKILL.md +++ b/.claude/skills/changelog/SKILL.md @@ -14,7 +14,7 @@ These entries were removed from root `CLAUDE.md` so startup instructions stay co Use the `/changelog` skill for structured queries. -- mcp-connection-custom-headers: Bring-your-own MCP servers can carry custom HTTP headers (Composio requires `x-api-key` / `x-consumer-api-key`). Headers are independent of `authType`; `Authorization` is accepted only with `none`. Names `^[A-Za-z0-9_-]{1,64}$` — exactly what the Amp bridge `mcp-remote@0.1.38` parses and a TOML bare key — pinned TS↔Go by the `headerNames` block of `mcp-server-name-contract.json`; transport headers are reserved (`headerNames.reserved` pins that list on both sides). D1 `0177` adds `header_names` (plaintext display projection) + `encrypted_headers`/`headers_iv` (one AES-GCM ciphertext of the `[{name,value}]` list, the only column injection reads). All rules and the storage format live in `services/mcp-connection-headers.ts`. PATCH `headers` is the full desired set and an entry without `value` keeps the stored value, so the UI edits without ever holding secrets. PATCH writes only if `updated_at` still matches the row it read (`nextUpdatedAt` keeps the column strictly increasing; a lost race is a 409): every derived field comes from that read, so without the guard a header-only edit racing a switch to bearer persisted an `Authorization` header beside a bearer token. Resolution re-validates decrypted headers, refuses that pair as a backstop, and skips a bad row. vm-agent: `McpServerEntry.ValidateHeaders` (charset, reserved names, case-insensitive duplicates including `Authorization` beside the bearer token — one repeated key makes the whole Codex/Vibe TOML unparseable, `sam-mcp` included) runs in `normalizeMcpServers` and again before any config file is written; headers are persisted (`migrateV18`; a row whose headers cannot be decoded is skipped on restore instead of dropping every server), converted by `toPersistedMcpServers`/`fromPersistedMcpServers`; ACP header list, Codex `env_http_headers` with `SAM_MCP__HEADER__SECRET` (the `_SECRET` suffix both classifies it for `isSecretEnvVar` and cannot collide with a bearer `_TOKEN` var), Vibe `headers` table, Amp `--header name:${SAM_MCP_HEADER_}`. Wire shape pinned by `packages/shared/src/fixtures/mcp-server-entry-wire.json` (node-agent serializer + real Go handler). UI: `McpServerForm` (add + inline Edit), `McpServerHeadersField`. Limits `MAX_MCP_CONNECTION_HEADERS` (10), `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (8192). Refactor in the same PR: MCP/Codex/Vibe code moved out of `gateway.go`/`session_host.go`/`workspaces.go`/`store.go` into `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go`. +- mcp-connection-custom-headers: Bring-your-own MCP servers can carry custom HTTP headers (Composio requires `x-api-key` / `x-consumer-api-key`). Headers are independent of `authType`; `Authorization` is accepted only with `none`. Names `^[A-Za-z0-9_-]{1,64}$` — exactly what the Amp bridge `mcp-remote@0.1.38` parses and a TOML bare key — pinned TS↔Go by the `headerNames` block of `mcp-server-name-contract.json`; transport headers are reserved (`headerNames.reserved` pins that list on both sides). D1 `0178` adds `header_names` (plaintext display projection) + `encrypted_headers`/`headers_iv` (one AES-GCM ciphertext of the `[{name,value}]` list, the only column injection reads). All rules and the storage format live in `services/mcp-connection-headers.ts`. PATCH `headers` is the full desired set and an entry without `value` keeps the stored value, so the UI edits without ever holding secrets. PATCH writes only if `updated_at` still matches the row it read (`nextUpdatedAt` keeps the column strictly increasing; a lost race is a 409): every derived field comes from that read, so without the guard a header-only edit racing a switch to bearer persisted an `Authorization` header beside a bearer token. Resolution re-validates decrypted headers, refuses that pair as a backstop, and skips a bad row. vm-agent: `McpServerEntry.ValidateHeaders` (charset, reserved names, case-insensitive duplicates including `Authorization` beside the bearer token — one repeated key makes the whole Codex/Vibe TOML unparseable, `sam-mcp` included) runs in `normalizeMcpServers` and again before any config file is written; headers are persisted (`migrateV18`; a row whose headers cannot be decoded is skipped on restore instead of dropping every server), converted by `toPersistedMcpServers`/`fromPersistedMcpServers`; ACP header list, Codex `env_http_headers` with `SAM_MCP__HEADER__SECRET` (the `_SECRET` suffix both classifies it for `isSecretEnvVar` and cannot collide with a bearer `_TOKEN` var), Vibe `headers` table, Amp `--header name:${SAM_MCP_HEADER_}`. Wire shape pinned by `packages/shared/src/fixtures/mcp-server-entry-wire.json` (node-agent serializer + real Go handler). UI: `McpServerForm` (add + inline Edit), `McpServerHeadersField`. Limits `MAX_MCP_CONNECTION_HEADERS` (10), `MCP_CONNECTION_HEADER_VALUE_MAX_BYTES` (8192). Refactor in the same PR: MCP/Codex/Vibe code moved out of `gateway.go`/`session_host.go`/`workspaces.go`/`store.go` into `acp/mcp_servers.go`, `acp/codex_config.go`, `acp/vibe_config.go`, `server/mcp_servers.go`, `persistence/session_mcp_servers.go`. - project-chat-instant-switching: Switching between project chats renders the target chat at once. Transcripts (`sessions/messages`) now keep a 24 h `gcTime` (`CHAT_TRANSCRIPT_CACHE_TTL_MS`, from shared `DEFAULT_CHAT_TRANSCRIPT_CACHE_TTL_MS`, override `VITE_CHAT_TRANSCRIPT_CACHE_TTL_MS`); previously the TanStack 5-minute default dropped an unobserved transcript from memory and, on the next write, from IndexedDB. Restored queries get `RESTORED_QUERY_GC_TIME_MS` via `hydrateOptions`, the dehydrate filter stops writing a transcript older than the TTL, and opening a chat evicts the least recently updated unobserved transcripts beyond `CHAT_TRANSCRIPT_CACHE_MAX_SESSIONS` (20; `evictStaleTranscripts` in `lib/query-options/chats.ts`); on disk each transcript keeps only its newest `CHAT_TRANSCRIPT_PERSIST_MAX_ROWS` rows (default: the 500-row page; `persistedQueryForDisk`). `ProjectMessageView` is keyed per session (`SessionMessageView`), and the transcript is read straight from the query cache (`useSessionTranscript`), so a cached chat paints in the switching commit and an uncached one shows a spinner, never the previous chat. The cold load requests the newest page (`fetchNewestPage`, `CHAT_SESSION_MESSAGE_LIMIT` = 500) instead of the 50,000-row `CHAT_SESSION_MESSAGE_MAX` ceiling (now only the server clamp); older history pages in through Virtuoso `startReached` once the reader scrolls up — wheel, swipe, or ArrowUp/PageUp/Home, not the list position (a page of tool calls can fold into a few rows that fit on screen, and a chat can open away from the bottom when its newest message is taller than the screen; an ungated `startReached` then paged the whole history in on open) and through the existing "Load earlier" button. The #2159 forward-delta refresh moved into the query function unchanged. Jumps to a specific message page back until that message id is loaded (`HistoryTarget` in `lib/message-paging.ts`) and then confirm the row is on screen, re-scrolling past Virtuoso's prepend compensation (`useConversationJump`), and an unloaded comment anchor reads "on a message". Composer drafts are per chat (`session-drafts.tsx`), report-issue config is a cached query, and server `session`/`state` snapshots hydrate only when the server reports new ones, which fixes a streamed row resetting a working agent to idle. Auth gating is unchanged: no persisted transcript renders before the session check resolves. - archive-sweep-affordability-ceiling-and-fallthrough: The production ProjectData archive sweep stopped reclaiming anything on 2026-09-08 and reported `succeeded` for 226 hourly runs while the root object climbed from 94% to 96.7% of its 10 GB ceiling. Two independently configured ceilings had to agree and drifted: a GitHub `production` Environment override lowered `PROJECT_DATA_ARCHIVE_DAILY_WRITE_BUDGET` to 100000 (affordability ceiling `floor((100000-1000)/32)` = 3093 write units) while `PROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET` stayed at the checked-in 5000, and `selectCandidates` orders `message_count DESC ... LIMIT sweepProjects * sweepSessions` (deployed 1x1). Every tick therefore picked the same 4994-message session, estimated ~160,808 writes, was refused by `reserveArchiveWrites` BEFORE it touched D1 (so the UTC budget window also froze at `2026-09-08T00:00:00Z`), and `continue`d out of a one-element list — no journal row, no location change, no error. The selection ceiling is now DERIVED from the allowance (`archiveAffordableWriteUnits` / `archiveAffordableMessageCeiling` in `project-data-archive/write-budget.ts`, used as the `message_count <= ?` bind), so the two can no longer disagree at any configuration; an explicit session-scoped operator canary still bypasses it. `selectCandidates` over-reads `PROJECT_DATA_ARCHIVE_SWEEP_FALLTHROUGH_DEPTH` (4) spare candidates and the journaling loop descends past a refusal instead of ending the tick, with both per-tick bounds (session slots and the cumulative message budget) moved INTO that loop so a refused candidate — which opens no `migrating` fence and moves no rows — consumes neither. `reserveArchiveWrites` returns a discriminated outcome separating `exceeds_allowance` (waiting cannot help) from `window_exhausted` (normal end-of-day backpressure), and `PROJECT_DATA_ARCHIVE_BUDGET_STALL_ALERT_SWEEPS` (3) consecutive ticks that migrate nothing and see only the former flip the cadence row to `partial` with an actionable `last_error` — counted and escalated inside one atomic `UPDATE ... RETURNING` (migration `0156` adds `consecutive_budget_stalls`) so a failed read cannot silently restart a streak. `wrangler.toml` now ships `DAILY_WRITE_BUDGET=100000` (matching the production override, so staging and self-hosts derive the same ceiling), `SWEEP_MESSAGE_BUDGET=2000`, `SWEEP_SESSIONS=2`. Owner stubs are memoised per tick so the fall-through does not multiply `ensureProjectId` DO round trips. NOTE: within a 100000/day allowance the restored sweep reclaims on the order of 1-1.5 MB/day against ~66 MB/day of growth — it ends the deadlock but cannot reverse the storage curve; raising the budget is a spend decision. - codex-astra-runtime-selection: Codex ACP is upgraded 1.8.0→1.10.0 and its Codex companion 0.153.2→0.153.4 across the canonical install manifest, VM-agent installer, and cf-container runtime image; the sandbox image's CLI-only pin is aligned to 0.153.4. VM-agent now validates both exact executable versions and supplies `CODEX_PATH=codex`, ensuring the adapter launches the explicitly pinned companion rather than a nested dependency resolved relative to itself. An explicit Codex profile model is applied through ACP `session/set_config_option`; rejection now fails session establishment with the requested model in the diagnostic instead of silently retaining the adapter default. A wire-level ACP regression test pins both the successful `gpt-6-astra` request and the fail-closed case. Process fix: `.claude/rules/23-cross-boundary-contract-tests.md` now treats adapter/companion resolution as one runtime contract. diff --git a/apps/api/src/db/migrations/0177_mcp_connection_headers.sql b/apps/api/src/db/migrations/0178_mcp_connection_headers.sql similarity index 100% rename from apps/api/src/db/migrations/0177_mcp_connection_headers.sql rename to apps/api/src/db/migrations/0178_mcp_connection_headers.sql diff --git a/tasks/active/2026-09-29-mcp-connection-custom-headers.md b/tasks/active/2026-09-29-mcp-connection-custom-headers.md index 9150fb825..4795cd035 100644 --- a/tasks/active/2026-09-29-mcp-connection-custom-headers.md +++ b/tasks/active/2026-09-29-mcp-connection-custom-headers.md @@ -123,7 +123,7 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r ### API -- [x] Migration `0177_mcp_connection_headers.sql` (ADD COLUMN ×3, additive) + `schema.ts` columns +- [x] Migration `0178_mcp_connection_headers.sql` (ADD COLUMN ×3, additive) + `schema.ts` columns - [x] `services/mcp-connection-headers.ts`: validate, merge-for-update, seal/open, display names - [x] `services/mcp-connections.ts`: create/update/response use the header module - [x] `schemas/mcp-connections.ts`: structural `headers` for create/update @@ -240,8 +240,10 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r keeps its value unless retyped, and server names use the card-title type style. - Full API suite under load (load average ~18 on 8 cores) showed 8 cold-import timeouts in unrelated files; all 40 affected tests pass when rerun with longer timeouts. -- Migration renumbered `0175` → `0177` before its first staging deploy. Main claimed `0175` in #2181 - (`0175_backfill_workspace_resource_attribution.sql`), and a sibling branch's - `0176_workspace_resource_working_set.sql` is already recorded in staging's D1 ledger. - `check-migration-ordering.ts` rejects duplicate prefixes but allows gaps, and renaming a file - staging has already applied would replay it. This file had never been applied anywhere. +- Migration renumbered `0175` → `0178` before its first staging deploy. Main claimed `0175` in #2181 + (`0175_backfill_workspace_resource_attribution.sql`), and two open sibling branches already have + staged migrations: `0176_workspace_resource_working_set.sql` (jpj3nt) and + `0177_workspace_resource_chunk_rollups.sql` (51112h). Both are in staging's D1 ledger, so neither + can be renamed without being replayed. `check-migration-ordering.ts` rejects duplicate prefixes but + allows gaps. Before this file was first deployed, every open PR and active agent branch was scanned + for prefixes `0176+`; `0178` was free. From 51034bac747d1bd077fde1655dd5529a3854c549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 11:34:42 +0000 Subject: [PATCH 15/18] task: record staging evidence and archive mcp connection custom headers Co-Authored-By: Claude Opus 5.5 --- .../2026-09-29-mcp-connection-custom-headers.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) rename tasks/{active => archive}/2026-09-29-mcp-connection-custom-headers.md (97%) diff --git a/tasks/active/2026-09-29-mcp-connection-custom-headers.md b/tasks/archive/2026-09-29-mcp-connection-custom-headers.md similarity index 97% rename from tasks/active/2026-09-29-mcp-connection-custom-headers.md rename to tasks/archive/2026-09-29-mcp-connection-custom-headers.md index 4795cd035..bfbf12f40 100644 --- a/tasks/active/2026-09-29-mcp-connection-custom-headers.md +++ b/tasks/archive/2026-09-29-mcp-connection-custom-headers.md @@ -188,8 +188,12 @@ Authorization:Bearer ${SAM_MCP_TOKEN}`, with the token in the stdio server env r (mcp-connection-headers.test.ts rejection table; mcp-connection-headers-injection.test.ts fault isolation) - [x] Existing connections without headers behave exactly as before (no-header resolution test; node-agent wire fixture; TestMigrationV18KeepsExistingMcpServerRows) -- [ ] Staging: an agent session reaches a real MCP server that requires a custom header, and +- [x] Staging: an agent session reaches a real MCP server that requires a custom header, and successfully calls a tool + (2026-09-29, deploy run 36558820009 at c1c9084a4: a Claude Code VM session and an Instant + Codex session each called a header-gated probe server, which answered only with the right + `x-api-key`, and got its nonce back. The Codex call came after an Edit that added + `x-probe-mode` and kept the stored key. See the PR's Staging Verification Evidence) ## References From 22ef04876a9e39aabdb689de1667cf339b285abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 11:46:02 +0000 Subject: [PATCH 16/18] style(api): format node-agent.ts Co-Authored-By: Claude Opus 5.5 --- apps/api/src/services/node-agent.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/node-agent.ts b/apps/api/src/services/node-agent.ts index de86001c1..764b3d629 100644 --- a/apps/api/src/services/node-agent.ts +++ b/apps/api/src/services/node-agent.ts @@ -441,9 +441,11 @@ export async function stopWorkspaceOnNode( // Snapshot before network dispatch for older internal callers. This guards // in-flight transport delay; callers with an earlier lifecycle claim must // pass that claim's generation explicitly, as the Stop route does. - const workspace = await env.DATABASE.prepare(`SELECT w.eviction_generation, n.runtime + const workspace = await env.DATABASE.prepare( + `SELECT w.eviction_generation, n.runtime FROM workspaces w JOIN nodes n ON n.id = w.node_id - WHERE w.id = ? AND w.node_id = ? AND w.user_id = ? AND n.user_id = ?`) + WHERE w.id = ? AND w.node_id = ? AND w.user_id = ? AND n.user_id = ?` + ) .bind(workspaceId, nodeId, userId, userId) .first<{ eviction_generation: string | null; runtime: string | null }>(); if (!workspace) throw new AppError(409, 'CONFLICT', 'Workspace stop identity changed'); @@ -569,7 +571,9 @@ export type McpServerConfig = McpServerEntry; * their own single-element array literal, which is why adding a field here previously meant * remembering two places. */ -function serializeMcpServers(mcpServers: McpServerConfig[] | undefined): McpServerConfig[] | undefined { +function serializeMcpServers( + mcpServers: McpServerConfig[] | undefined +): McpServerConfig[] | undefined { if (!mcpServers || mcpServers.length === 0) { return undefined; } From 82674ca69bf5b58f3fa52d6b47681889070cbb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 12:18:08 +0000 Subject: [PATCH 17/18] test(vm-agent): build expected Codex header env names in a loop A literal "x-api-key": "SAM_MCP_..._SECRET" pair reads as an API key assignment to Gitleaks; the values are environment variable names. Co-Authored-By: Claude Opus 5.5 --- packages/vm-agent/internal/acp/mcp_headers_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/vm-agent/internal/acp/mcp_headers_test.go b/packages/vm-agent/internal/acp/mcp_headers_test.go index a662bb9cd..3421f1cbe 100644 --- a/packages/vm-agent/internal/acp/mcp_headers_test.go +++ b/packages/vm-agent/internal/acp/mcp_headers_test.go @@ -2,6 +2,7 @@ package acp import ( "encoding/json" + "fmt" "regexp" "strings" "testing" @@ -161,9 +162,11 @@ func TestGenerateCodexMcpConfig_RoutesCustomHeadersThroughEnv(t *testing.T) { } composio := parsed.McpServers["composio"] - wantHeaders := map[string]string{ - "x-api-key": "SAM_MCP_COMPOSIO_HEADER_0_SECRET", - "X-Org_Id": "SAM_MCP_COMPOSIO_HEADER_1_SECRET", + // Built in a loop: a literal `"x-api-key": "SAM_…"` pair reads as an API key assignment + // to secret scanners, but the values are environment variable NAMES. + wantHeaders := map[string]string{} + for i, name := range []string{"x-api-key", "X-Org_Id"} { + wantHeaders[name] = fmt.Sprintf("SAM_MCP_COMPOSIO_HEADER_%d_SECRET", i) } if len(composio.EnvHTTPHeaders) != len(wantHeaders) { t.Fatalf("env_http_headers = %#v, want %#v", composio.EnvHTTPHeaders, wantHeaders) From 6550382102c6a791ae892facd5dcf8a601e6ffc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 29 Sep 2026 12:31:48 +0000 Subject: [PATCH 18/18] fix(ci): review historical MCP header fixture --- packages/vm-agent/internal/acp/mcp_headers_test.go | 4 ++-- scripts/quality/gitleaks-reviewed-baseline.json | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/vm-agent/internal/acp/mcp_headers_test.go b/packages/vm-agent/internal/acp/mcp_headers_test.go index 3421f1cbe..ebd3ea52d 100644 --- a/packages/vm-agent/internal/acp/mcp_headers_test.go +++ b/packages/vm-agent/internal/acp/mcp_headers_test.go @@ -162,8 +162,8 @@ func TestGenerateCodexMcpConfig_RoutesCustomHeadersThroughEnv(t *testing.T) { } composio := parsed.McpServers["composio"] - // Built in a loop: a literal `"x-api-key": "SAM_…"` pair reads as an API key assignment - // to secret scanners, but the values are environment variable NAMES. + // Build this in a loop because a literal header map entry can look like an API + // credential assignment even when its values only name environment variables. wantHeaders := map[string]string{} for i, name := range []string{"x-api-key", "X-Org_Id"} { wantHeaders[name] = fmt.Sprintf("SAM_MCP_COMPOSIO_HEADER_%d_SECRET", i) diff --git a/scripts/quality/gitleaks-reviewed-baseline.json b/scripts/quality/gitleaks-reviewed-baseline.json index 5ccc342a1..b49af1069 100644 --- a/scripts/quality/gitleaks-reviewed-baseline.json +++ b/scripts/quality/gitleaks-reviewed-baseline.json @@ -218,6 +218,16 @@ "cebc591fa06ac4eb357e4355bd9685d7f9b717e68c0ca19f8a2d601b50f989c8", "d1825d5702b57ea0ab87a15624a93992cd2ad7a00aecaf179bb0d1be452fe8fb" ] + }, + { + "classification": "synthetic-test-fixture", + "reason": "Intermediate PR-range bytes only: a unit-test map associated a public HTTP header name with an environment-variable identifier, which the generic API-key detector interpreted as a credential assignment. The literal map entry is absent from the current tree and contained no credential material.", + "owner": "security", + "reviewedAt": "2026-09-29T00:00:00.000Z", + "expiresAt": "2026-12-28T00:00:00.000Z", + "digests": [ + "662cc0d541ef21f3ab25c0d51d96e5959d225d317f9930c4b151d449dce64376" + ] } ] }