Feature F — MCP Connections (manual servers + bindings + tool funnel)
Branch: session/feat-mcp-connections. Implements the MCP slice of the merged
docs/specs/features/agent-plugins/ spec (plan §2.4/§2.5, tasks T23–T27
adapted) plus the founder-added manual connection source that works without
the package system (T1–T22 remain out of scope).
What shipped
- Manual MCP connection registry (
mcp_server_connections): user-global rows withname(slug-safe, becomes themcp__<name>__<tool>prefix), url, transport (streamable-http|sse; NO stdio per ADR-018), encryptedauthHeaders(_secret-json-columnenvelope),enabled,source('manual'now,'package'reserved),lastConnectedAt/lastErrorstatus stamps. - Per-agent bindings (
agent_mcp_server_bindings, per plan §2.5 naming):'tenant'rows inherit a connection to ALL of the user's agents (created enabled=true alongside the connection),'agent'rows override per agent (narrow-only semantics like tool grants; delete the row to revert to inheritance). Unique(connectionId, targetType, targetId). packages/agent/src/mcp/(new module, exported as@ever-works/agent/mcp):McpClientService— official@modelcontextprotocol/sdk(^1.27.1, same as apps/mcp) behind theMCP_CLIENT_FACTORYseam (structural interfaces, lazyimport(); tests inject fakes — no SDK, no network). listTools with a 60s TTL cache per connection; callTool with 30s timeout + 100KB serialized-result cap; errors classified into short header-free messages and stamped on the row. Auth header values never reach logs or errors.McpConnectionsService— CRUD + masking (responses carryauthHeaderNamesonly), name/URL/header validation (lexical SSRF guardisSafeWebhookUrlon URLs), test endpoint, per-agent binding state + effective-connection resolution. Cross-user access → 404.McpToolSource— bound connections →AgentToolDescriptors namedmcp__<server>__<tool>(schema passthrough, description prefixed[<server>], name/description sanitized, length caps). Dead server → zero tools + WARN, never a failed run. Gated onpermissions.canCallExternalTools(outbound-call risk class).
- Tool funnel integration: new
AGENT_MCP_TOOL_SOURCEtoken (packages/agent/src/agents/agent-mcp-tool-source.ts), injected@Optional()APPENDED LAST intoAgentToolService; consumed insideresolveGrantedTools(async) BEFORE the grant partition, somcp__*names flow throughpartitionToolsByGrantand the run-level funnel for free. Built-in name collisions are dropped with a WARN, and each accepted descriptor is wrapped in the samewithCredentialInterpolationthe built-ins get — MCP servers are the outbound calls{{cred.key}}exists for, so an unwrapped descriptor would forward the literal template text to a third party and scrub nothing on the way back. Bound in the api-side @GlobalAgentsModule(useExisting: McpToolSource) + pin-spec updated. - Activity: additive
ActivityActionTypemembersmcp_connection_created/updated/deleted/tested,mcp_binding_updated(varchar storage — no migration). Emitted fromMcpConnectionsService. - Run-log: no change needed — MCP invocations ride the existing tool-loop
logging;
toolNamecarries themcp__prefix end-to-end.
Data model / migration
apps/api/src/migrations/1786840000000-CreateMcpServerConnections.ts —
portable Table API (postgres + better-sqlite3), idempotent guards, both
tables + unique indexes, FKs to users (+ bindings → connections CASCADE).
Entities registered in entities/index.ts, _entities-inventory.ts, and
_entity-names.ts (both halves of the two-step registration).
Endpoints
GET/POST /api/mcp-connections,GET/PATCH/DELETE /api/mcp-connections/:id,POST /api/mcp-connections/:id/test(returns{ok, toolCount, tools[]}).GET /api/agents/:agentId/mcp-servers(connections + effective state incl.inheritedFromTenant),PUT /api/agents/:agentId/mcp-servers/:connectionId{enabled},DELETE …/:connectionId(revert to inherit).- Module
apps/api/src/mcp-connections/, registered inapi.module.ts.
UI routes
- Settings → Connections (
/settings/connections): list + add form (name/url/transport/one auth header), enable/disable switch, Test (shows tool count + first names), delete, last-status line. New tab insettings-layout-client.tsx(Plug icon). - Agent detail → MCP Servers tab (
/agents/[id]/mcp-servers): all connections with effective toggles, Inherited/Override/Connection-disabled badges, Revert-to-inherit. Tab added inAgentDetailTabs.tsx; route constantDASHBOARD_AGENT_MCP_SERVERS. - i18n keys added to ALL 21
apps/web/messages/*.json(English values copied per convention).
Tests
packages/agent(Jest):cd packages/agent && npx jest --testPathPattern='(src/mcp/|agent-tool-mcp-funnel)'— 37 tests. Client cache TTL/timeout/size-cap/error-classification/secret masking; binding resolution matrix (tenant-inherit / agent-disable / agent-only / disabled-connection); descriptor naming + sanitization + executor proxy + dead-server isolation; connections CRUD + masking + SSRF + cross-user 404 + test endpoint + per-agent state.agents/__tests__/agent-tool-mcp-funnel.spec.tsis the FUNNEL half: no-source parity, descriptors appended, built-in collision dropped, grant-matrix deny of anmcp__*name (proves the append happens beforepartitionToolsByGrant), a throwing source never failing run assembly, and{{cred.key}}interpolation reaching MCP args. Updated pin spec:entities/__tests__/activity-log.types.spec.ts(+5 MCP action types).apps/api(Jest):cd apps/api && npx jest --testPathPattern='(agents.module|mcp-connections|CreateMcpServerConnections)'— pin spec asserts McpModule import + AGENT_MCP_TOOL_SOURCE binding/export; controller delegation/auth-scoping pins; the migration spec runs both tables'up/downon better-sqlite3 (columns, indexes, idempotency, both unique constraints).
Verification run on this branch
cd packages/agent && npx tsc -p tsconfig.types.json --noEmit— clean.npx turbo build --filter=@ever-works/agent— passes.cd packages/agent && npx jest --testPathPattern='(src/mcp/|activity-log.types|portable-date-columns|agent-tool)'— 12 suites / 219 tests green (regression sweep over everyagent-tool*suite, since the funnel changed).cd apps/api && npx jest— 254 suites / 4030 tests green.cd apps/api && npx tsc --noEmit— 4 PRE-EXISTING errors unrelated to this branch (@ever-works/k8s-pluginmissing indeploy.e2e.spec.ts; three@src/*alias misses inpackages/agentsources reached through a relative path). None involve MCP files; not fixed here.cd apps/web && npx tsc --noEmit— clean.cd apps/web && npx eslint <the six new web files>— clean. (packages/agentandapps/apihave nolintscript and no eslint config —pnpm lintonly reachesapps/web.)npx prettier --checkover every touched file — clean (root config resolves to.prettierrc: 4-space, 100 cols, trailing commas).- i18n audit: all 10 new key paths present in all 21
apps/web/messages/*.jsonfiles, English values copied.
Decisions & divergences from the brief
- Bindings are consumed in
resolveGrantedTools(async), not the syncresolveAllowedTools: descriptor assembly needs I/O and the run path (agent-run.service.ts:1480-1488) already prefers the async method. Sync-only callers (no grant enforcer path) see no MCP tools — acceptable: production runs go throughresolveGrantedTools. - Creating a connection auto-creates the enabled tenant binding so a manual connection is usable immediately (the brief's "global to the workspace"), narrowable per agent. No separate tenant-binding management UI in v1.
- MCP tools are gated on
canCallExternalTools— same outbound-network risk class as searchWeb/screenshot/extractContent/sendEmail. Not in the brief, consistent with the funnel's posture. skill-binding-style@ManyToOneto the connection/user rows (the no-@ManyToOnerule applies to SCOPE entities only; tenant/org stay raw uuid columns).lastConnectedAtis@PortableDateColumn(NOT rawtype: 'timestamp'): the e2e/CI driver is better-sqlite3, which has notimestamptype — a raw timestamp fails TypeORM metadata validation at boot. The repo-wideentities/__tests__/portable-date-columns.spec.tsguard SCANS entity sources, so it needed no edit; it simply had to pass, which is what forced the column type. The MIGRATION still usestimestamp(house pattern — migrations run on Postgres only); TypeORM mapstype: Datetotimestampthere, so entity and schema agree.
Known follow-ups
- Account-transfer whitelist entries for the two new tables (export/import of connections as references, masked secrets) — plan §2.5's 4-place whitelist work was out of budget here; new tables simply do not transfer.
- Playwright e2e for the two new pages (binding CRUD, authz matrix).
- No tenant-binding management UI:
POST /api/mcp-connectionscreates the enabled'tenant'row and nothing exposes it afterwards, so "disable everywhere" is done by disabling the CONNECTION. Fine for v1 (they are equivalent whilemanualis the only source), but the tenant row needs its own control once'package'connections exist. - Domain chat tools for connections/bindings (the in-code DoD at
agent-tool.service.ts:317-319) — new entities have no chat-tool source yet. - Redirect policy (AP-15 cross-origin header rules) relies on the SDK's fetch defaults; the explicit no-forward implementation is deferred to the package slice (T25).
- Per-run connection pooling (connect-per-operation today; fine at v1 scale).