Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fe3430f
task: add mcp connection custom headers
raphaeltm Sep 29, 2026
2f95c25
refactor(vm-agent): extract MCP server, Codex and Vibe config code in…
raphaeltm Sep 29, 2026
d5ac55a
feat(mcp): custom HTTP headers for bring-your-own MCP servers
raphaeltm Sep 29, 2026
1bb1639
test(mcp): cover custom MCP headers across API, wire contract and vm-…
raphaeltm Sep 29, 2026
29472e5
feat(web): manage MCP server custom headers, with inline edit
raphaeltm Sep 29, 2026
b63461d
fix(web): make MCP header rows and server actions readable on phones
raphaeltm Sep 29, 2026
1daf062
fix(mcp): drop test SQLite artifacts; tolerate pre-headers API during…
raphaeltm Sep 29, 2026
6f2bbec
chore(mcp): ignore vm-agent test SQLite files; document header check …
raphaeltm Sep 29, 2026
bbacb6c
fix(mcp): guard header edits against races and repeated or reserved n…
raphaeltm Sep 29, 2026
9ae91f7
test(mcp): cover custom headers and their env limits through the HTTP…
raphaeltm Sep 29, 2026
fd5c1b1
fix(web): state the header name rule and keep-by-default behaviour up…
raphaeltm Sep 29, 2026
e3dade0
style(mcp): format the injection test fixture
raphaeltm Sep 29, 2026
71bdf30
fix(mcp): renumber the headers migration to 0177
raphaeltm Sep 29, 2026
2f5e68f
fix(mcp): renumber the headers migration to 0178
raphaeltm Sep 29, 2026
51034ba
task: record staging evidence and archive mcp connection custom headers
raphaeltm Sep 29, 2026
22ef048
style(api): format node-agent.ts
raphaeltm Sep 29, 2026
82674ca
test(vm-agent): build expected Codex header env names in a loop
raphaeltm Sep 29, 2026
6550382
fix(ci): review historical MCP header fixture
raphaeltm Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/skills/changelog/SKILL.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/db/migrations/0178_mcp_connection_headers.sql
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 11 additions & 5 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/lib/utf8.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 5 additions & 1 deletion apps/api/src/routes/mcp-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +40,8 @@ function writeLimits(c: AppContext): McpConnectionWriteLimits {
maxPerScope: limits.maxMcpConnectionsPerScope,
urlMaxBytes: limits.mcpConnectionUrlMaxBytes,
tokenMaxBytes: limits.mcpConnectionTokenMaxBytes,
maxHeaders: limits.maxMcpConnectionHeaders,
headerValueMaxBytes: limits.mcpConnectionHeaderValueMaxBytes,
};
}

Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/schemas/mcp-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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()),
});

Expand All @@ -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()),
});
14 changes: 14 additions & 0 deletions apps/api/src/services/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
),
};
}
Loading
Loading