Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ Tasks tracked as markdown in `tasks/` (backlog -> active -> archive). See `tasks

Use the `/changelog` skill for structured queries.

- 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`.
- 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: SessionHeader action row (expanded) 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`.
- claude-opus-5-model-catalog: Claude Opus 5 (`claude-opus-5`, released 2026-07-24, $5/$25 per MTok, native 1M context) added to both canonical model lists — `CLAUDE_MODELS` dropdown catalog and `PLATFORM_AI_MODELS` proxy allowlist/pricing. Retired `claude-sonnet-4-20250514` pruned; stale defaults bumped to `claude-sonnet-5` (`DEFAULT_SAM_MODEL` — was pointing at the retired model, `DEFAULT_AI_PROXY_ANTHROPIC_MODEL`, `DEFAULT_TRIAL_MODEL_PRODUCTION`). New regression tests pin retired-Anthropic-model exclusion and default↔catalog registration; process rule `.claude/rules/52-model-catalog-lifecycle.md`.
- dispatch-task-runtime-routing: MCP `dispatch_task` now honors explicit and skill/profile `runtime` selection. Explicit `cf-container` decisions launch task-mode Instant sessions asynchronously without VM credential/quota gates or duplicate chat persistence; the Instant branch re-verifies repository owner access (`requireRepositoryOwnerAccess`) before launching, matching the VM branch; async Instant launch failures mark the task failed via the shared queued-guarded transition (`markQueuedTaskFailed`) instead of stalling `queued` until the stuck-task cron; VM-only arguments conflict with container runtime instead of silently downgrading; responses expose the effective runtime and decision reason, and `get_task_details` returns the task's chat `sessionId` once the async session exists.
Expand Down
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ BASE_DOMAIN=workspaces.example.com
# TASK_CALLBACK_RETRY_MAX_ATTEMPTS=3
# NODE_HEARTBEAT_STALE_SECONDS=180

# Bring-your-own MCP servers (see docs/guides/mcp-servers)
# MAX_MCP_CONNECTIONS_PER_SCOPE=25
# MCP_CONNECTION_URL_MAX_BYTES=2048
# MCP_CONNECTION_TOKEN_MAX_BYTES=8192

# Missions (Phase 2: Orchestration Primitives)
# MISSION_MAX_PER_PROJECT=50
# MISSION_MAX_STATE_ENTRIES=200
Expand Down
49 changes: 49 additions & 0 deletions apps/api/src/db/migrations/0120_mcp_connections.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- Bring-your-own MCP servers.
--
-- Users paste an MCP endpoint (URL + optional bearer token) obtained from a connector
-- platform (Zapier MCP, executor.sh, Composio/Rube, Klavis) or an official single-service
-- MCP server. SAM injects it into agent sessions alongside its own `sam-mcp` endpoint.
--
-- Both `encrypted_url` and `encrypted_token` are AES-256-GCM ciphertext with their own IV.
-- The URL is encrypted because several providers issue pre-signed MCP URLs that embed the
-- credential in the path or query — the URL itself is a secret. `url_host` holds a
-- display-only `scheme://host` (never path or query) so the UI can show which provider a row
-- points at without the API ever returning a usable credential.
--
-- Additive only: creates one new table. No DROP, no ALTER of an existing table.

CREATE TABLE IF NOT EXISTS mcp_connections (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- NULL = personal scope (applies to every session this user starts).
-- Non-NULL = project scope (shared with project members, overrides personal by name).
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
encrypted_url TEXT NOT NULL,
url_iv TEXT NOT NULL,
url_host TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'bearer' CHECK (auth_type IN ('none', 'bearer')),
encrypted_token TEXT,
token_iv TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Name uniqueness is per-scope. Two partial indexes rather than one composite so that
-- personal rows (project_id IS NULL) collide only with the same user's personal rows.
CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_connections_project_name
ON mcp_connections(project_id, name)
WHERE project_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_connections_user_name
ON mcp_connections(user_id, name)
WHERE project_id IS NULL;

-- Resolution reads personal rows by user and project rows by project on the
-- agent-session start path; both need to be cheap.
CREATE INDEX IF NOT EXISTS idx_mcp_connections_user_id
ON mcp_connections(user_id);

CREATE INDEX IF NOT EXISTS idx_mcp_connections_project_id
ON mcp_connections(project_id);
56 changes: 56 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1533,6 +1533,62 @@ export const skills = sqliteTable(
export type SkillRow = typeof skills.$inferSelect;
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.
*/
export const mcpConnections = sqliteTable(
'mcp_connections',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
/** AES-256-GCM ciphertext (base64) of the full MCP endpoint URL. */
encryptedUrl: text('encrypted_url').notNull(),
/** AES-256-GCM IV (base64) for `encryptedUrl`. */
urlIv: text('url_iv').notNull(),
/** Display-only `scheme://host`. Never the path or query. */
urlHost: text('url_host').notNull(),
authType: text('auth_type').notNull().default('bearer'),
/** AES-256-GCM ciphertext (base64). Null when authType is 'none'. */
encryptedToken: text('encrypted_token'),
/** AES-256-GCM IV (base64). Null when authType is 'none'. */
tokenIv: text('token_iv'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
createdAt: text('created_at')
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(datetime('now'))`),
},
(table) => ({
// Partial, mirroring 0120_mcp_connections.sql. Without the WHERE clauses these would be
// FULL unique indexes, which is different semantics: a non-partial UNIQUE(user_id, name)
// also covers project rows (every row has a non-null user_id) and would wrongly stop a
// user from having a personal and a project connection with the same name.
projectNameUnique: uniqueIndex('idx_mcp_connections_project_name')
.on(table.projectId, table.name)
.where(sql`project_id IS NOT NULL`),
userNameUnique: uniqueIndex('idx_mcp_connections_user_name')
.on(table.userId, table.name)
.where(sql`project_id IS NULL`),
userIdIdx: index('idx_mcp_connections_user_id').on(table.userId),
projectIdIdx: index('idx_mcp_connections_project_id').on(table.projectId),
})
);

export type McpConnectionRow = typeof mcpConnections.$inferSelect;
export type NewMcpConnectionRow = typeof mcpConnections.$inferInsert;

const profileRuntimeBaseColumns = () => ({
id: text('id').primaryKey(),
profileId: text('profile_id')
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/durable-objects/trial-orchestrator/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ulid } from '../../lib/ulid';
import { createOwnerProjectMembership } from '../../middleware/project-auth';
import { signCallbackToken } from '../../services/jwt';
import { getRuntimeLimits } from '../../services/limits';
import { buildSamMcpEntry } from '../../services/mcp-connection-resolution';
import { generateMcpToken, storeMcpToken } from '../../services/mcp-token';
import {
createAgentSessionOnNode,
Expand Down Expand Up @@ -751,7 +752,11 @@ export async function handleDiscoveryAgentStart(
throw new Error('discovery_agent_start: mcpToken missing after step 3');
}
const initialPrompt = buildDiscoveryInitialPrompt(state.repoOwner, state.repoName);
const mcpServerUrl = `https://api.${rc.env.BASE_DOMAIN}/mcp`;

// Only sam-mcp. Bring-your-own MCP connections are deliberately NOT resolved here: this
// session runs as the anonymous trial sentinel user (resolveAnonymousUserId), which owns
// no connections, and resolving by that identity could only ever surface rows that do not
// belong to the visitor. See tests/unit/services/mcp-connection-injection.test.ts.
await startAgentSessionOnNode(
nodeId,
workspaceId,
Expand All @@ -760,7 +765,7 @@ export async function handleDiscoveryAgentStart(
initialPrompt,
rc.env,
userId,
{ url: mcpServerUrl, token: state.mcpToken },
[buildSamMcpEntry(rc.env.BASE_DOMAIN, state.mcpToken)],
);
state.agentStartedOnVm = true;
await rc.ctx.storage.put('state', state);
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv {
MAX_DEPLOYMENT_ENV_VALUE_BYTES?: string;
MAX_DEPLOYMENT_ENV_TOTAL_BYTES?: string;
AGENT_SETTINGS_VALIDATION_LIMITS?: string;
// Bring-your-own MCP servers
MAX_MCP_CONNECTIONS_PER_SCOPE?: string;
MCP_CONNECTION_URL_MAX_BYTES?: string;
MCP_CONNECTION_TOKEN_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/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ import { libraryRoutes } from './routes/library';
import { libraryCommentRoutes } from './routes/library-comments';
import { mailboxRoutes } from './routes/mailbox';
import { mcpRoutes } from './routes/mcp';
import {
projectMcpConnectionRoutes,
userMcpConnectionRoutes,
} from './routes/mcp-connections';
import { missionRoutes } from './routes/missions';
import { modelCatalogRoutes } from './routes/model-catalog';
import { nodeLifecycleRoutes } from './routes/node-lifecycle';
Expand Down Expand Up @@ -683,6 +687,7 @@ app.route('/api/setup', setupRoutes);
app.route('/api/credentials', resolutionStatusRoute);
app.route('/api/credentials', credentialsRoutes);
app.route('/api/agent-credential-setup-sessions', agentCredentialSetupSessionsRoutes);
app.route('/api/mcp-connections', userMcpConnectionRoutes);
app.route('/api/cc', ccRoutes);
app.route('/api/providers', providersRoutes);
app.route('/api/github', githubRoutes);
Expand Down Expand Up @@ -733,6 +738,7 @@ app.route('/api/projects/:projectId/agent-profiles/:profileId/runtime', profileR
app.route('/api/projects/:projectId/agent-profiles', agentProfileRoutes);
app.route('/api/projects/:projectId/skills/:skillId/runtime', skillRuntimeRoutes);
app.route('/api/projects/:projectId/skills', skillRoutes);
app.route('/api/projects/:projectId/mcp-connections', projectMcpConnectionRoutes);
app.route('/api/projects/:projectId/triggers', triggersRoutes);
app.route('/api/projects/:projectId/knowledge', knowledgeRoutes);
app.route('/api/projects/:projectId/mailbox', mailboxRoutes);
Expand Down
130 changes: 130 additions & 0 deletions apps/api/src/routes/mcp-connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Bring-your-own MCP server routes.
*
* Two mount points share one handler set so the personal and project scopes cannot drift:
* /api/mcp-connections -> personal (projectId null)
* /api/projects/:projectId/mcp-connections -> project-scoped
*
* Project-scope writes require `secret:write` (owner/admin only — `maintainer` deliberately
* 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`.
*/
import { drizzle } from 'drizzle-orm/d1';
import { type Context, Hono } from 'hono';

import * as schema from '../db/schema';
import type { Env } from '../env';
import { requireRouteParam } from '../lib/route-helpers';
import { getCredentialEncryptionKey } from '../lib/secrets';
import { getUserId, requireApproved, requireAuth } from '../middleware/auth';
import type { ProjectCapability } from '../middleware/project-auth';
import { CreateMcpConnectionSchema, jsonValidator, UpdateMcpConnectionSchema } from '../schemas';
import { getRuntimeLimits } from '../services/limits';
import {
createMcpConnection,
deleteMcpConnection,
listMcpConnections,
type McpConnectionScopeRef,
type McpConnectionWriteLimits,
updateMcpConnection,
} from '../services/mcp-connections';
import { requireProjectRuntimeAuthorization } from './runtime-project-auth';

type AppContext = Context<{ Bindings: Env }>;

function writeLimits(c: AppContext): McpConnectionWriteLimits {
const limits = getRuntimeLimits(c.env);
return {
maxPerScope: limits.maxMcpConnectionsPerScope,
urlMaxBytes: limits.mcpConnectionUrlMaxBytes,
tokenMaxBytes: limits.mcpConnectionTokenMaxBytes,
};
}

/**
* Resolves the scope for a request and authorizes the caller for it.
*
* Personal scope needs no project check — the scope predicate itself is `userId` bound.
* Project scope goes through the shared project authorization so membership and capability
* are enforced before any row is touched.
*/
async function requireScope(
c: AppContext,
capability: ProjectCapability,
projectScoped: boolean
): Promise<{ db: ReturnType<typeof drizzle<typeof schema>>; scope: McpConnectionScopeRef }> {
const userId = getUserId(c);
const db = drizzle(c.env.DATABASE, { schema });

if (!projectScoped) {
return { db, scope: { userId, projectId: null } };
}

const projectId = requireRouteParam(c, 'projectId');
await requireProjectRuntimeAuthorization(db, projectId, userId, capability);
return { db, scope: { userId, projectId } };
}

function buildRoutes(projectScoped: boolean): Hono<{ Bindings: Env }> {
const routes = new Hono<{ Bindings: Env }>();

routes.get('/', async (c) => {
const { db, scope } = await requireScope(c, 'project:read', projectScoped);
return c.json({ items: await listMcpConnections(db, scope) });
});

routes.post('/', jsonValidator(CreateMcpConnectionSchema), async (c) => {
const body = c.req.valid('json');
const { db, scope } = await requireScope(c, 'secret:write', projectScoped);
const connection = await createMcpConnection(db, {
...scope,
name: body.name,
url: body.url,
authType: body.authType ?? 'bearer',
token: body.token ?? null,
enabled: body.enabled ?? true,
limits: writeLimits(c),
encryptionKey: getCredentialEncryptionKey(c.env),
});
return c.json(connection, 201);
});

routes.patch('/:connectionId', jsonValidator(UpdateMcpConnectionSchema), async (c) => {
const body = c.req.valid('json');
const connectionId = requireRouteParam(c, 'connectionId');
const { db, scope } = await requireScope(c, 'secret:write', projectScoped);
const connection = await updateMcpConnection(db, {
...scope,
connectionId,
name: body.name,
url: body.url,
authType: body.authType,
token: body.token,
enabled: body.enabled,
limits: writeLimits(c),
encryptionKey: getCredentialEncryptionKey(c.env),
});
return c.json(connection);
});

routes.delete('/:connectionId', async (c) => {
const connectionId = requireRouteParam(c, 'connectionId');
const { db, scope } = await requireScope(c, 'secret:write', projectScoped);
await deleteMcpConnection(db, scope, connectionId);
return c.json({ success: true });
});

return routes;
}

/** Personal scope: /api/mcp-connections */
export const userMcpConnectionRoutes = new Hono<{ Bindings: Env }>();
userMcpConnectionRoutes.use('/*', requireAuth(), requireApproved());
userMcpConnectionRoutes.route('/', buildRoutes(false));

/** Project scope: /api/projects/:projectId/mcp-connections */
export const projectMcpConnectionRoutes = new Hono<{ Bindings: Env }>();
projectMcpConnectionRoutes.use('/*', requireAuth(), requireApproved());
projectMcpConnectionRoutes.route('/', buildRoutes(true));
Loading
Loading