From 4bcd1bfeea892063e0281fdf29d7e699d8c6ef76 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Wed, 21 Oct 2026 02:07:32 +0530 Subject: [PATCH 01/20] pass mcp server description for deferred mcp servers --- .../src/agent-session/TurnResourceResolver.ts | 15 +++++++-------- packages/trueforge-core/src/core/index.ts | 2 +- packages/trueforge-core/src/core/mcp/RemoteMCP.ts | 9 ++++++++- .../tests/agent-session/turnStream.test.ts | 6 +++--- packages/trueforge/src/apis/mcpServers.ts | 2 +- packages/trueforge/src/apis/turns.ts | 5 +---- .../trueforge/src/runtime/sessionResources.ts | 12 ++++-------- .../tests/unit/runtime/getMcpConnection.test.ts | 3 +++ 8 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts index 0c19b32ba..f094afb9d 100644 --- a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts +++ b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts @@ -2,7 +2,7 @@ import type { Logger } from 'winston'; import type { ILLM } from '../core/llm/ILLM'; import type { ModelParams } from '../core/llm/model'; import type { ToolSource } from '../core/mcp/IMCPServer'; -import { RemoteMCP, type RemoteMcpHeaders } from '../core/mcp/RemoteMCP'; +import { RemoteMCP, type ResolvedMcpServer } from '../core/mcp/RemoteMCP'; import type { ToolSelectorConfig } from '../core/mcp/ToolSelectorPolicy'; import { ToolSet } from '../core/mcp/ToolSet'; import type { AgentDefinition } from '../core/runtime/AgentDefinition'; @@ -69,12 +69,10 @@ export class TurnResourceResolver< /** Model name → client and defaults. Called once per resolved definition; may load provider config. */ llm: (model: string) => Promise; /** - * MCP server name → connection details. Required to use spec.mcp_servers: - * the AgentSpec carries names only (no url/headers on the wire) — the - * runtime supplies the catalog. Called once per distinct server name per - * turn. Throw for unknown names. `headers` supports the async resolver form. + * MCP server name → resolved server. The AgentSpec carries names only; the runtime supplies the catalog. + * Called once per distinct server name per turn. Throw for unknown names. */ - mcp: (name: string) => Promise<{ url: string; headers?: RemoteMcpHeaders }>; + mcp: (name: string) => Promise; mcpRequestTimeoutMs: number; mcpConnectTimeoutMs: number; mcpMaxResponseBytes?: number | undefined; @@ -166,7 +164,7 @@ export class TurnResourceResolver< /** * Default: maps the spec directly to a definition — spec.mcp_servers carry - * names only; deps.mcp resolves each name to url/headers, and the result + * names only; deps.mcp resolves each name to a {@link ResolvedMcpServer}, and the result * becomes a RemoteMCP source (deduped per turn via getOrCreateToolSource) * wrapped in a per-agent ToolSet view. Passes the previous turn's persisted * MCP session_id into RemoteMCP for cross-turn stateful resume. @@ -184,12 +182,13 @@ export class TurnResourceResolver< const source = await this.getOrCreateToolSource({ id: entry.name, create: async () => { - const { url, headers } = await this.deps.mcp(entry.name); + const { url, description, headers } = await this.deps.mcp(entry.name); return new RemoteMCP({ id: entry.name, name: entry.name, url, headers: headers ?? {}, + description, sessionId: previousTurn?.snapshot.mcp_servers?.[entry.name]?.session_id, requestTimeoutMs: this.deps.mcpRequestTimeoutMs, connectTimeoutMs: this.deps.mcpConnectTimeoutMs, diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index 6ff8d866a..ea60be1ba 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -77,7 +77,7 @@ export type { ToolDefinition } from './mcp/LocalToolMCP'; // wrapper (`ToolSet`). `RemoteMCP` connects itself from a `url` + `headers`; the networking helpers // live in the harness-internal `remoteMcpClient` module. export { RemoteMCP } from './mcp/RemoteMCP'; -export type { RemoteMcpHeaders, ResolveHeadersResult } from './mcp/RemoteMCP'; +export type { RemoteMcpHeaders, ResolveHeadersResult, ResolvedMcpServer } from './mcp/RemoteMCP'; export { DEFAULT_MAX_MCP_RESPONSE_BYTES } from './mcp/remoteMcpClient'; export type { RemoteMcpConnection, RemoteMcpTransportType } from './mcp/remoteMcpClient'; export type { ToolSelectorConfig } from './mcp/ToolSelectorPolicy'; diff --git a/packages/trueforge-core/src/core/mcp/RemoteMCP.ts b/packages/trueforge-core/src/core/mcp/RemoteMCP.ts index a825b739d..924bcc1c0 100644 --- a/packages/trueforge-core/src/core/mcp/RemoteMCP.ts +++ b/packages/trueforge-core/src/core/mcp/RemoteMCP.ts @@ -39,6 +39,13 @@ export type ResolveHeadersResult = { headers: Record } | { authR /** Static headers, or a resolver (invoked at connect) returning headers or signalling auth-required. */ export type RemoteMcpHeaders = Record | (() => Promise); +/** A configured MCP server resolved for a turn */ +export interface ResolvedMcpServer { + url: string; + description: string; + headers?: RemoteMcpHeaders; +} + type ExecuteResult = { result: T; wasInitialized: MCPServerInitInfo | undefined } | AuthRequiredResponse; /** @@ -75,7 +82,7 @@ export class RemoteMCP implements ToolSource { constructor(params: { name: string; id: string; - description?: string | undefined; + description: string | undefined; url: string; headers: RemoteMcpHeaders; logger: Logger; diff --git a/packages/trueforge-core/tests/agent-session/turnStream.test.ts b/packages/trueforge-core/tests/agent-session/turnStream.test.ts index 15b857110..8f5c788bd 100644 --- a/packages/trueforge-core/tests/agent-session/turnStream.test.ts +++ b/packages/trueforge-core/tests/agent-session/turnStream.test.ts @@ -410,7 +410,7 @@ describe('TurnHandle.stream()', () => { modelClient: makeMockILLM({ create: jest.fn().mockImplementation(() => emptyLlmStream()) }), defaultModelParams: {}, }), - mcp: () => Promise.resolve({ url: 'http://localhost' }), + mcp: () => Promise.resolve({ url: 'http://localhost', description: 'Local MCP server.' }), mcpRequestTimeoutMs: 60_000, mcpConnectTimeoutMs: 5_000, sandboxProvider: () => Promise.resolve(sandbox), @@ -465,7 +465,7 @@ describe('TurnResourceResolver caches', () => { } })({ llm: () => Promise.resolve({ modelClient: makeMockILLM(), defaultModelParams: {} }), - mcp: () => Promise.resolve({ url: 'http://example.invalid' }), + mcp: () => Promise.resolve({ url: 'http://example.invalid', description: 'Example MCP server.' }), mcpRequestTimeoutMs: 60_000, mcpConnectTimeoutMs: 5_000, logger, @@ -509,7 +509,7 @@ describe('TurnResourceResolver caches', () => { modelClient: makeMockILLM({ create: jest.fn().mockImplementation(() => emptyLlmStream()) }), defaultModelParams: {}, }), - mcp: () => Promise.resolve({ url: 'http://localhost' }), + mcp: () => Promise.resolve({ url: 'http://localhost', description: 'Local MCP server.' }), mcpRequestTimeoutMs: 60_000, mcpConnectTimeoutMs: 5_000, sandboxProvider: () => { diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index 347ba6860..a7e2b18d1 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -480,7 +480,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< id: name, name, url: connection.url, - headers: connection.headers, + headers: connection.headers ?? {}, requestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, connectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, maxResponseBytes: configuration.MCP_TOOL_CALL_MAX_RESPONSE_BYTES, diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index af77bd6e1..26995c087 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -226,10 +226,7 @@ function createTurnResolver(deps: { message: `Unknown MCP server "${name}" — not configured`, }); } - return { - url: connection.url, - headers: connection.headers, - }; + return connection; }, mcpRequestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, mcpConnectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 4d33def07..56f9be024 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -6,7 +6,7 @@ import { type AgentTracing, type DaytonaSandboxEnvironment, type ModelParams, - type RemoteMcpHeaders, + type ResolvedMcpServer, type SandboxProvider, type Skill, type VercelAIProviderConfig, @@ -33,11 +33,6 @@ import type { ReasoningEffort } from '../schemas/modelProvider'; import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { hasConfiguredWebSearchProvider } from '../websearch/providers'; -export interface McpConnection { - url: string; - headers: RemoteMcpHeaders; -} - /** Split `provider/model` FQN. Returns undefined when the shape is not exactly one slash. */ export function parseModelFqn(name: string): { providerName: string; modelName: string } | undefined { const slash = name.indexOf('/'); @@ -112,7 +107,7 @@ export async function getModelDetails({ } /** - * Load MCP url + headers for a configured server. Returns undefined when unregistered. + * Load MCP for a configured server. Returns undefined when unregistered. * * TODO: OAuth header resolvers re-run on every RemoteMCP listTools/callTool; cache or gate later. */ @@ -128,13 +123,14 @@ export async function getMcpConnection({ store: IMcpServerWithAuthStore; userRef: string; turnMetadata?: TurnMetadata; -}): Promise { +}): Promise { const record = await store.getServer({ tenant_id, name }); if (record === undefined) { return undefined; } return { url: record.manifest.url, + description: record.manifest.description, headers: store.resolveInvokeHeaders({ record, userRef, diff --git a/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts b/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts index 3ebac3a5d..3756b127c 100644 --- a/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts +++ b/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts @@ -108,6 +108,7 @@ describe('getMcpConnection', () => { } expect(connection.url).toBe(mcpUrl); + expect(connection.description).toBe('OAuth MCP server.'); expect(typeof connection.headers).toBe('function'); if (typeof connection.headers !== 'function') { throw new Error('expected async headers resolver'); @@ -193,6 +194,7 @@ describe('getMcpConnection', () => { } expect(connection.url).toBe('https://mcp.open.example/mcp'); + expect(connection.description).toBe('Open MCP server.'); expect(connection.headers).toEqual({}); }); @@ -221,6 +223,7 @@ describe('getMcpConnection', () => { } expect(connection.url).toBe('https://mcp.header.example/mcp'); + expect(connection.description).toBe('Header MCP server.'); expect(connection.headers).toEqual({ Authorization: 'Bearer static-token' }); }); From 06eb6b855182ced2f6d14eee869041a98cff56be Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Wed, 21 Oct 2026 02:12:11 +0530 Subject: [PATCH 02/20] rename --- .../tests/agent-session/turnStream.test.ts | 1 + .../tests/core/mcp/remoteMcpServer.test.ts | 1 + packages/trueforge/src/apis/mcpServers.ts | 5 +++-- packages/trueforge/src/apis/turns.ts | 4 ++-- .../trueforge/src/runtime/sessionResources.ts | 4 ++-- ...nnection.test.ts => resolveMcpServer.test.ts} | 16 ++++++++-------- 6 files changed, 17 insertions(+), 14 deletions(-) rename packages/trueforge/tests/unit/runtime/{getMcpConnection.test.ts => resolveMcpServer.test.ts} (96%) diff --git a/packages/trueforge-core/tests/agent-session/turnStream.test.ts b/packages/trueforge-core/tests/agent-session/turnStream.test.ts index 8f5c788bd..c1044fcac 100644 --- a/packages/trueforge-core/tests/agent-session/turnStream.test.ts +++ b/packages/trueforge-core/tests/agent-session/turnStream.test.ts @@ -448,6 +448,7 @@ describe('TurnResourceResolver caches', () => { return new RemoteMCP({ id: 'svc', name: 'svc', + description: 'Example MCP server.', url: 'http://example.invalid', headers: {}, logger, diff --git a/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts b/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts index 9cfaee087..267f85bd6 100644 --- a/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts +++ b/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts @@ -131,6 +131,7 @@ function makeServer(params: { const remote = new RemoteMCP({ name: 'my-inline', id: 'inline-id-123', + description: 'my-inline MCP server', url: 'https://mcp.example.com/mcp', headers: params.headers ?? {}, logger: noopLogger, diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index a7e2b18d1..d8ad768d9 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -36,7 +36,7 @@ import { putMcpServerRoute, } from '../routes/mcpServerRoutes'; import { findCatalogUsageConflict } from '../runtime/catalogUsage'; -import { getMcpConnection } from '../runtime/sessionResources'; +import { resolveMcpServer } from '../runtime/sessionResources'; import type { AvailableMcpServer, ConfiguredMcpServer, @@ -467,7 +467,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const requestContext = deps.resolveRequestContext(c); const userRef = requestContext.subject.id; // Same url + header resolution as turn execution (store Bearer, DCR, or static headers). - const connection = await getMcpConnection({ + const connection = await resolveMcpServer({ tenant_id: requestContext.tenant_id, name, store: deps.resolveMcpServerStore(c), @@ -479,6 +479,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const remote = new RemoteMCP({ id: name, name, + description: connection.description, url: connection.url, headers: connection.headers ?? {}, requestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 26995c087..c3402b361 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -61,8 +61,8 @@ import { StreamGoneError, type EventSubscription, type EventSubscriptionRegistry import { validateSandboxFilePath } from '../runtime/sandboxFilePath'; import { buildTurnSandbox, - getMcpConnection, getModelDetails, + resolveMcpServer, resolveSandboxEnvironment, resolveSandboxProvider, } from '../runtime/sessionResources'; @@ -214,7 +214,7 @@ function createTurnResolver(deps: { }; }, mcp: async name => { - const connection = await getMcpConnection({ + const connection = await resolveMcpServer({ tenant_id, name, store: mcpServerStore, diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 56f9be024..d2bd82962 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -107,11 +107,11 @@ export async function getModelDetails({ } /** - * Load MCP for a configured server. Returns undefined when unregistered. + * Resolve a configured MCP server for a turn. * * TODO: OAuth header resolvers re-run on every RemoteMCP listTools/callTool; cache or gate later. */ -export async function getMcpConnection({ +export async function resolveMcpServer({ tenant_id, name, store, diff --git a/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts similarity index 96% rename from packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts rename to packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts index 3756b127c..74a4f3f8f 100644 --- a/packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts +++ b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts @@ -7,7 +7,7 @@ import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; -import { getMcpConnection } from '../../../src/runtime/sessionResources'; +import { resolveMcpServer } from '../../../src/runtime/sessionResources'; jest.mock('undici', () => { const actual = jest.requireActual('undici'); @@ -20,7 +20,7 @@ jest.mock('undici', () => { }; }); -describe('getMcpConnection', () => { +describe('resolveMcpServer', () => { let db: ReturnType; let mcpServerStore: IMcpServerWithAuthStore; let tokenStore: SqliteOAuthTokenStore; @@ -96,7 +96,7 @@ describe('getMcpConnection', () => { }, }); - const connection = await getMcpConnection({ + const connection = await resolveMcpServer({ tenant_id: 'default', name: 'oauth-mcp', store: mcpServerStore, @@ -155,7 +155,7 @@ describe('getMcpConnection', () => { }, }); - const connection = await getMcpConnection({ + const connection = await resolveMcpServer({ tenant_id: 'default', name: 'tokened-mcp', store: mcpServerStore, @@ -182,7 +182,7 @@ describe('getMcpConnection', () => { }, }); - const connection = await getMcpConnection({ + const connection = await resolveMcpServer({ tenant_id: 'default', name: 'open-mcp', store: mcpServerStore, @@ -211,7 +211,7 @@ describe('getMcpConnection', () => { }, }); - const connection = await getMcpConnection({ + const connection = await resolveMcpServer({ tenant_id: 'default', name: 'header-mcp', store: mcpServerStore, @@ -251,7 +251,7 @@ describe('getMcpConnection', () => { }, }); - const connection = await getMcpConnection({ + const connection = await resolveMcpServer({ tenant_id: 'default', name: 'tfy-mcp', store, @@ -270,7 +270,7 @@ describe('getMcpConnection', () => { it('returns undefined when the server is not registered', async () => { await expect( - getMcpConnection({ + resolveMcpServer({ tenant_id: 'default', name: 'missing-mcp', store: mcpServerStore, From b387015c21b2717287a63133788e5cf460daa568 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Wed, 7 Oct 2026 22:46:18 +0530 Subject: [PATCH 03/20] add mcp for trueforge --- packages/trueforge/src/app.ts | 14 +++ .../src/mcp/server/trueforge/client.ts | 16 +++ .../src/mcp/server/trueforge/errors.ts | 38 ++++++ .../src/mcp/server/trueforge/result.ts | 28 +++++ .../src/mcp/server/trueforge/route.ts | 100 +++++++++++++++ .../src/mcp/server/trueforge/server.ts | 25 ++++ .../src/mcp/server/trueforge/tools/agents.ts | 87 +++++++++++++ .../src/mcp/server/trueforge/tools/index.ts | 18 +++ .../mcp/server/trueforge/tools/mcpServers.ts | 30 +++++ .../src/mcp/server/trueforge/tools/models.ts | 17 +++ .../trueforge/tools/sandboxEnvironments.ts | 109 +++++++++++++++++ .../mcp/server/trueforge/tools/schedules.ts | 115 ++++++++++++++++++ .../src/mcp/server/trueforge/tools/skills.ts | 29 +++++ packages/trueforge/src/schemas/agent.ts | 6 +- packages/trueforge/src/schemas/common.ts | 11 ++ packages/trueforge/src/schemas/mcpServer.ts | 12 +- .../src/schemas/sandboxEnvironment.ts | 8 +- .../tests/unit/apis/mcpServers.test.ts | 8 ++ .../unit/mcp/server/trueforge/route.test.ts | 79 ++++++++++++ 19 files changed, 739 insertions(+), 11 deletions(-) create mode 100644 packages/trueforge/src/mcp/server/trueforge/client.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/errors.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/result.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/route.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/server.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/tools/agents.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/tools/index.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/tools/mcpServers.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/tools/models.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/tools/skills.ts create mode 100644 packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index b66bbab55..777767932 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -55,6 +55,7 @@ import { createHttpMetrics } from './http/prometheus'; import { createClientCertificateMiddleware } from './http/tls'; import { runWithLogContext, updateLogContext, type LogContext } from './logger'; import type { IOAuthTokenStore } from './mcp/auth/types'; +import { createTrueForgeMcpRouter } from './mcp/server/trueforge/route'; import { PACKAGE_VERSION } from './packageVersion'; import { OPENAPI_DOCUMENT_TAGS } from './routes/openapiTags'; import type { ActiveTurnRegistry } from './runtime/activeTurns'; @@ -612,6 +613,19 @@ export function createServerApp(deps: ServerDeps) { ), ); + // First-party TrueForge MCP (Streamable HTTP). Protocol endpoint. + app.use('/mcp', authMiddleware); + app.route( + '/mcp', + createTrueForgeMcpRouter({ + sessionStore: deps.sessionStore, + resolveRequestContext, + serverUrl: configuration.SERVER_URL, + version: PACKAGE_VERSION, + logger: deps.logger, + }), + ); + const uiBasePath = getPublicUiBasePath(); if (configuration.SWAGGER_ENABLED) { const openApiSpecPath = `${uiBasePath}api/v1/openapi.json`; diff --git a/packages/trueforge/src/mcp/server/trueforge/client.ts b/packages/trueforge/src/mcp/server/trueforge/client.ts new file mode 100644 index 000000000..e2a151936 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/client.ts @@ -0,0 +1,16 @@ +/** + * Request-scoped TrueForge SDK client for the TrueForge MCP. + * + * Base URL comes from trusted server config (never a tool arg or Host header). The draft caller's + * bearer is forwarded as-is; a standalone request with no credential uses no auth. The service + * `TRUEFORGE_API_KEY` is intentionally never used here. + */ +import { TrueForge } from '@truefoundry/trueforge-sdk'; + +export function createTrueForgeClient(input: { baseUrl: string; userCredential: string | null }): TrueForge { + return new TrueForge({ + baseUrl: input.baseUrl, + timeoutInSeconds: 60, + ...(input.userCredential === null ? { auth: false } : { token: input.userCredential }), + }); +} diff --git a/packages/trueforge/src/mcp/server/trueforge/errors.ts b/packages/trueforge/src/mcp/server/trueforge/errors.ts new file mode 100644 index 000000000..0458a1f54 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/errors.ts @@ -0,0 +1,38 @@ +/** Convert SDK/API failures into a compact, secret-free shape for MCP tool results. */ +import { TrueForgeError } from '@truefoundry/trueforge-sdk'; + +export interface SafeToolError { + status: number | null; + message: string; +} + +const FALLBACK_MESSAGE = 'The TrueForge API request failed.'; + +/** Best-effort read of the TrueForge `{ error: { message } }` envelope. */ +function messageFromBody(body: unknown): string | undefined { + if (typeof body === 'object' && body !== null && 'error' in body) { + const { error } = body; + if (typeof error === 'object' && error !== null && 'message' in error) { + const { message } = error; + if (typeof message === 'string') { + return message; + } + } + } + return undefined; +} + +/** + * Surface only the HTTP status and the API's own error-envelope message. Anything else (raw body, + * response headers, cause chain, stack, internal URLs in `Error.message`) collapses to a static + * message so transport/network failures can't leak infra detail into the model's context. + */ +export function toSafeToolError(error: unknown): SafeToolError { + if (error instanceof TrueForgeError) { + return { + status: error.statusCode ?? null, + message: messageFromBody(error.body) ?? FALLBACK_MESSAGE, + }; + } + return { status: null, message: FALLBACK_MESSAGE }; +} diff --git a/packages/trueforge/src/mcp/server/trueforge/result.ts b/packages/trueforge/src/mcp/server/trueforge/result.ts new file mode 100644 index 000000000..6b2f60f81 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/result.ts @@ -0,0 +1,28 @@ +/** Shared success/error shaping for TrueForge tool results. */ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { toSafeToolError } from './errors'; + +/** Mirror the API envelope as both text and structuredContent; no second prose summary. */ +export function toolOk(result: unknown): CallToolResult { + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + structuredContent: result as Record, + }; +} + +export function toolError(error: unknown): CallToolResult { + const safe = toSafeToolError(error); + return { + isError: true, + content: [{ type: 'text', text: JSON.stringify(safe) }], + }; +} + +/** Run an SDK call and map success/failure into a tool result. No mutation is retried here. */ +export async function runTool(fn: () => Promise): Promise { + try { + return toolOk(await fn()); + } catch (error) { + return toolError(error); + } +} diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts new file mode 100644 index 000000000..ff7c4e987 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -0,0 +1,100 @@ +/** + * Authenticated, stateless Streamable HTTP endpoint for the TrueForge MCP (mounted at /mcp). + * + * Each request: authenticate → parse server-generated session/turn metadata → revalidate draft + * state → build a request-scoped SDK client + stateless MCP server → hand the Web Request to the + * transport. Nothing is retained across requests. + */ +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import type { ISessionStore } from '@truefoundry/trueforge-core/agent-session'; +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import type { Logger } from 'winston'; +import type { RequestContext, ResolveRequestContext } from '../../../auth/identity'; +import { parseGatewayMetadataHeader, TFG_METADATA_PREFIX, X_TFY_METADATA } from '../../../truefoundry/gatewayMetadata'; +import { createTrueForgeClient } from './client'; +import { createTrueForgeMcpServer, TRUEFORGE_CONNECTOR_NAME } from './server'; + +export interface TrueForgeMcpRouterDeps { + sessionStore: ISessionStore; + resolveRequestContext: ResolveRequestContext; + /** Trusted self-origin base URL */ + serverUrl: string; + version: string; + logger: Logger; +} + +function requireMetadata(raw: string | undefined): { sessionId: string; turnId: string } { + if (raw === undefined) { + throw new HTTPException(400, { message: `Missing ${X_TFY_METADATA} turn metadata` }); + } + const metadata = parseGatewayMetadataHeader(raw); + const sessionId = metadata[`${TFG_METADATA_PREFIX}.session_id`]; + const turnId = metadata[`${TFG_METADATA_PREFIX}.turn_id`]; + if (sessionId === undefined || turnId === undefined) { + throw new HTTPException(400, { message: 'Turn metadata must include session_id and turn_id' }); + } + return { sessionId, turnId }; +} + +/** + * Server-side draft-only enforcement. Routing metadata is revalidated against persisted session/turn + * state — a Bearer token alone is insufficient. See docs-internal/trueforge-mcp-server.md §3.6. + */ +export async function validateDraftInvocation(input: { + sessionStore: ISessionStore; + requestContext: RequestContext; + sessionId: string; + turnId: string; +}): Promise { + const { sessionStore, requestContext, sessionId, turnId } = input; + + // Tenant scoping + existence (getSession is tenant-scoped, so a cross-tenant id reads as not found). + const session = await sessionStore.getSession({ tenant_id: requestContext.tenant_id, session_id: sessionId }); + if (session === undefined) { + throw new HTTPException(404, { message: 'Session not found' }); + } + // Ownership: the authenticated subject must own the session. + if (session.created_by_subject.subject_id !== requestContext.subject.id) { + throw new HTTPException(403, { message: 'Session not owned by caller' }); + } + // Draft sessions only: inline agent binding. + if (session.agent.type !== 'inline') { + throw new HTTPException(403, { message: 'The trueforge MCP is available only for draft (inline) sessions' }); + } + // The inline spec must currently include the connector. + const mcpServers = session.agent.spec.mcp_servers ?? []; + if (!mcpServers.some(entry => entry.name === TRUEFORGE_CONNECTOR_NAME)) { + throw new HTTPException(403, { message: 'Draft spec does not include the trueforge connector' }); + } + // The turn must belong to the supplied session. + const turn = await sessionStore.getTurn({ session_id: sessionId, turn_id: turnId }); + if (turn === undefined) { + throw new HTTPException(404, { message: 'Turn not found for session' }); + } +} + +export function createTrueForgeMcpRouter(deps: TrueForgeMcpRouterDeps): Hono { + const router = new Hono(); + router.all('/', async c => { + const requestContext = deps.resolveRequestContext(c); + const { sessionId, turnId } = requireMetadata(c.req.header(X_TFY_METADATA)); + await validateDraftInvocation({ sessionStore: deps.sessionStore, requestContext, sessionId, turnId }); + + // Built per request: the server's tools close over this caller's credential, and stateless + // Streamable HTTP wants a fresh server+transport per request to isolate concurrent callers. + const client = createTrueForgeClient({ + baseUrl: deps.serverUrl, + userCredential: requestContext.user_credential, + }); + const server = createTrueForgeMcpServer({ client, version: deps.version }); + const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true }); + await server.connect(transport); + try { + return await transport.handleRequest(c.req.raw); + } finally { + void server.close(); + } + }); + return router; +} diff --git a/packages/trueforge/src/mcp/server/trueforge/server.ts b/packages/trueforge/src/mcp/server/trueforge/server.ts new file mode 100644 index 000000000..18184cdf8 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/server.ts @@ -0,0 +1,25 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +import { registerTrueForgeTools } from './tools'; + +/** The active built-in TrueForge connector name. See docs-internal/trueforge-mcp-server.md §3.5/§3.8. */ +export const TRUEFORGE_CONNECTOR_NAME = 'trueforge'; + +/** Server-level framing surfaced in the MCP `initialize` response. */ +const TRUEFORGE_SERVER_TITLE = 'TrueForge server'; + +const TRUEFORGE_INSTRUCTIONS = + 'Build and configure agents and their supporting entities on the same TrueForge harness you are ' + + 'running on: create, update, and read agents, schedules, and sandbox environments, and read the ' + + 'configured models, MCP servers, and skills they can reference. You cannot run agents or execute ' + + 'turns through this server; it only defines and configures them.'; + +/** Build a stateless TrueForge MCP server bound to a request-scoped SDK client. */ +export function createTrueForgeMcpServer(input: { client: TrueForge; version: string }): McpServer { + const server = new McpServer( + { name: TRUEFORGE_CONNECTOR_NAME, title: TRUEFORGE_SERVER_TITLE, version: input.version }, + { instructions: TRUEFORGE_INSTRUCTIONS }, + ); + registerTrueForgeTools(server, input.client); + return server; +} diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts b/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts new file mode 100644 index 000000000..cdcb9ecd3 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts @@ -0,0 +1,87 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +import { z } from 'zod'; +import { runTool } from '../result'; + +/** Agent manifest is passed through to the API, which validates all references. */ +const manifestSchema = z.record(z.string(), z.unknown()); + +export function registerAgentTools(server: McpServer, client: TrueForge): void { + server.registerTool( + 'list_agents', + { + description: + 'Discover existing agents — for example to find one to inspect or update, or to check whether a ' + + 'name is already taken before creating a new agent. Supports optional filters and returns one paginated page.', + inputSchema: { + limit: z.number().int().positive().optional(), + page_token: z.string().optional(), + agent_name: z.string().optional(), + }, + annotations: { readOnlyHint: true }, + }, + async ({ limit, page_token, agent_name }) => + runTool(async () => { + const page = await client.agents.list({ + limit, + pageToken: page_token, + agentName: agent_name, + } as Parameters[0]); + return page.response; + }), + ); + + server.registerTool( + 'get_agent', + { + description: + "Fetch one agent's full definition by its immutable ID, including the complete manifest. Use it to " + + 'review an agent or to get a working template before updating it.', + inputSchema: { agent_id: z.string() }, + annotations: { readOnlyHint: true }, + }, + async ({ agent_id }) => runTool(() => client.agents.get(agent_id)), + ); + + server.registerTool( + 'create_agent', + { + description: + 'Create a named agent from a complete manifest. The name is immutable and all referenced models, ' + + 'MCP servers, skills, and sandbox environments must exist. Call get_agent on an existing agent to ' + + 'obtain a complete, valid manifest to use as a template.', + inputSchema: { + name: z.string(), + description: z.string(), + manifest: manifestSchema, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + async ({ name, description, manifest }) => + runTool(() => + client.agents.create({ name, description, manifest } as unknown as Parameters[0]), + ), + ); + + server.registerTool( + 'update_agent', + { + description: + "Replace an agent's description and complete manifest by immutable ID. This is a full replacement: " + + 'omitted manifest fields are removed. Call get_agent first to retrieve the current complete manifest ' + + 'and edit it as a template rather than authoring one from scratch.', + inputSchema: { + agent_id: z.string(), + description: z.string().optional(), + manifest: manifestSchema, + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + async ({ agent_id, description, manifest }) => + runTool(() => + client.agents.update(agent_id, { description, manifest } as unknown as Parameters< + typeof client.agents.update + >[1]), + ), + ); +} diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/index.ts b/packages/trueforge/src/mcp/server/trueforge/tools/index.ts new file mode 100644 index 000000000..b399ae99d --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/index.ts @@ -0,0 +1,18 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +import { registerAgentTools } from './agents'; +import { registerMcpServerTools } from './mcpServers'; +import { registerModelTools } from './models'; +import { registerSandboxEnvironmentTools } from './sandboxEnvironments'; +import { registerScheduleTools } from './schedules'; +import { registerSkillTools } from './skills'; + +/** Register the fixed TrueForge tool catalog against a request-scoped SDK client. */ +export function registerTrueForgeTools(server: McpServer, client: TrueForge): void { + registerAgentTools(server, client); + registerScheduleTools(server, client); + registerSandboxEnvironmentTools(server, client); + registerModelTools(server, client); + registerMcpServerTools(server, client); + registerSkillTools(server, client); +} diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/mcpServers.ts b/packages/trueforge/src/mcp/server/trueforge/tools/mcpServers.ts new file mode 100644 index 000000000..41d111e6d --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/mcpServers.ts @@ -0,0 +1,30 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +import { z } from 'zod'; +import { runTool } from '../result'; + +export function registerMcpServerTools(server: McpServer, client: TrueForge): void { + server.registerTool( + 'list_mcp_servers', + { + description: + 'List the MCP servers you can attach to an agent, each with its current authentication status so you ' + + 'know whether it is ready to use. Check this before referencing an MCP server in a manifest.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => runTool(() => client.mcpServers.list()), + ); + + server.registerTool( + 'get_mcp_server', + { + description: + 'Fetch one MCP server by name — its public connection details and current authentication status — to ' + + 'confirm it exists and is authenticated before referencing it in an agent.', + inputSchema: { name: z.string() }, + annotations: { readOnlyHint: true }, + }, + async ({ name }) => runTool(() => client.mcpServers.get(name)), + ); +} diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/models.ts b/packages/trueforge/src/mcp/server/trueforge/tools/models.ts new file mode 100644 index 000000000..d8939ad43 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/models.ts @@ -0,0 +1,17 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +import { runTool } from '../result'; + +export function registerModelTools(server: McpServer, client: TrueForge): void { + server.registerTool( + 'list_models', + { + description: + 'List the models you can reference in an agent or draft manifest. Use it to find a valid model ' + + 'identifier before setting one on an agent.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => runTool(() => client.models.list()), + ); +} diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts new file mode 100644 index 000000000..d628fcf95 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts @@ -0,0 +1,109 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { TrueForgeError, type TrueForge } from '@truefoundry/trueforge-sdk'; +import { z } from 'zod'; +import { runTool } from '../result'; + +/** + * The HTTP API exposes a single create-or-update PUT. We split create and update over it and add a + * preflight GET so intent is explicit (create fails if present; update fails if absent). Best-effort: + * two concurrent creates can still race (no conditional-create at the API layer). + */ +const manifestSchema = z.looseObject({ name: z.string() }); + +async function environmentExists(client: TrueForge, name: string): Promise { + try { + await client.sandboxEnvironments.get(name); + return true; + } catch (error) { + if (error instanceof TrueForgeError && error.statusCode === 404) { + return false; + } + throw error; + } +} + +export function registerSandboxEnvironmentTools(server: McpServer, client: TrueForge): void { + server.registerTool( + 'create_sandbox_environment', + { + description: + 'Create a reusable sandbox environment that agents run in — defining its resources, environment ' + + 'variables, networking, and build configuration — from a complete manifest. Fails if an environment ' + + 'with the same name already exists; the reserved `default` environment cannot be created.', + inputSchema: { manifest: manifestSchema }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + async ({ manifest }) => + runTool(async () => { + if (await environmentExists(client, manifest.name)) { + throw new TrueForgeError({ + statusCode: 409, + body: { error: { message: `Sandbox environment already exists: ${manifest.name}` } }, + }); + } + return client.sandboxEnvironments.createOrUpdate({ manifest }); + }), + ); + + server.registerTool( + 'list_sandbox_environments', + { + description: + 'Discover existing sandbox environments you can reference from an agent, or find one to update. ' + + "Returns one paginated page; environment values and secrets follow the HTTP API's redaction behavior.", + inputSchema: { + page_size: z.number().int().positive().optional(), + page_token: z.string().optional(), + }, + annotations: { readOnlyHint: true }, + }, + async ({ page_size, page_token }) => + runTool(async () => { + const page = await client.sandboxEnvironments.list({ + limit: page_size, + pageToken: page_token, + } as Parameters[0]); + return page.response; + }), + ); + + server.registerTool( + 'get_sandbox_environment', + { + description: + 'Fetch one sandbox environment by name — its manifest, build status, resources, environment ' + + 'variables, and networking configuration. Use it to review an environment or get a template before updating it.', + inputSchema: { name: z.string() }, + annotations: { readOnlyHint: true }, + }, + async ({ name }) => runTool(() => client.sandboxEnvironments.get(name)), + ); + + server.registerTool( + 'update_sandbox_environment', + { + description: + 'Change an existing sandbox environment — the resources, environment variables, networking, and build ' + + 'configuration agents run in — by replacing it with a complete manifest. The input name and manifest ' + + 'name must match, and this tool never creates a missing environment (use create_sandbox_environment for that).', + inputSchema: { name: z.string(), manifest: manifestSchema }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + async ({ name, manifest }) => + runTool(async () => { + if (name !== manifest.name) { + throw new TrueForgeError({ + statusCode: 400, + body: { error: { message: 'input name and manifest.name must match' } }, + }); + } + if (!(await environmentExists(client, name))) { + throw new TrueForgeError({ + statusCode: 404, + body: { error: { message: `Sandbox environment not found: ${name}` } }, + }); + } + return client.sandboxEnvironments.createOrUpdate({ manifest }); + }), + ); +} diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts b/packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts new file mode 100644 index 000000000..365002380 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts @@ -0,0 +1,115 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +import { z } from 'zod'; +import { runTool } from '../result'; + +const manifestSchema = z.object({ + task: z.string(), + cron: z.string(), + timezone: z.string().optional(), + status: z.enum(['active', 'paused']).optional(), +}); + +export function registerScheduleTools(server: McpServer, client: TrueForge): void { + server.registerTool( + 'create_schedule', + { + description: + 'Make an existing named agent run automatically on a recurring basis — for example a daily summary ' + + '— by binding it to a task and a cron schedule. Timezone defaults to UTC and status to active.', + inputSchema: { + agent_name: z.string(), + name: z.string(), + manifest: manifestSchema, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + async ({ agent_name, name, manifest }) => + runTool(() => + client.schedules.create({ agentName: agent_name, name, manifest } as Parameters< + typeof client.schedules.create + >[0]), + ), + ); + + server.registerTool( + 'list_schedules', + { + description: + 'Discover existing schedules — for example to see what an agent already runs on a timer, or to find ' + + 'one to update. Supports optional filters and returns one paginated page.', + inputSchema: { + agent_names: z.array(z.string()).optional(), + created_by_me: z.boolean().optional(), + page_size: z.number().int().positive().optional(), + page_token: z.string().optional(), + }, + annotations: { readOnlyHint: true }, + }, + async ({ agent_names, created_by_me, page_size, page_token }) => + runTool(async () => { + const page = await client.schedules.list({ + limit: page_size, + pageToken: page_token, + agentNames: agent_names?.join(','), + createdByMe: created_by_me, + } as Parameters[0]); + return page.response; + }), + ); + + server.registerTool( + 'get_schedule', + { + description: + "Fetch one schedule's full configuration by its immutable ID — its bound agent, task, cron " + + 'expression, timezone, and status. Use it to review a schedule or get a template before updating it.', + inputSchema: { schedule_id: z.string() }, + annotations: { readOnlyHint: true }, + }, + async ({ schedule_id }) => runTool(() => client.schedules.get(schedule_id)), + ); + + server.registerTool( + 'list_schedule_runs', + { + description: + "Check a schedule's execution history to see whether its runs are succeeding or why they failed. " + + 'Each run includes its status, scheduled and triggered times, and any failure reason. Returns one paginated page.', + inputSchema: { + schedule_id: z.string(), + page_size: z.number().int().positive().optional(), + page_token: z.string().optional(), + }, + annotations: { readOnlyHint: true }, + }, + async ({ schedule_id, page_size, page_token }) => + runTool(async () => { + const page = await client.schedules.listRuns(schedule_id, { + limit: page_size, + pageToken: page_token, + } as Parameters[1]); + return page.response; + }), + ); + + server.registerTool( + 'update_schedule', + { + description: + 'Change an existing schedule — for example its task, cadence, or active/paused state — by replacing ' + + 'its name and complete manifest by immutable ID. The bound agent cannot change, and because this is a ' + + 'full replacement, omitted optional fields return to their defaults.', + inputSchema: { + schedule_id: z.string(), + name: z.string(), + manifest: manifestSchema, + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + async ({ schedule_id, name, manifest }) => + runTool(() => + client.schedules.update(schedule_id, { name, manifest } as Parameters[1]), + ), + ); +} diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/skills.ts b/packages/trueforge/src/mcp/server/trueforge/tools/skills.ts new file mode 100644 index 000000000..8989f3be5 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/skills.ts @@ -0,0 +1,29 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +import { z } from 'zod'; +import { runTool } from '../result'; + +export function registerSkillTools(server: McpServer, client: TrueForge): void { + server.registerTool( + 'list_skills', + { + description: + 'List the skills you can add to an agent, each with its name and description. Use it to find a skill ' + + 'to reference in a manifest.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => runTool(() => client.skills.list()), + ); + + server.registerTool( + 'list_skill_versions', + { + description: + 'List the available versions of a skill so you can pin a specific version when referencing it in an agent manifest.', + inputSchema: { name: z.string() }, + annotations: { readOnlyHint: true }, + }, + async ({ name }) => runTool(() => client.skills.listVersions({ name })), + ); +} diff --git a/packages/trueforge/src/schemas/agent.ts b/packages/trueforge/src/schemas/agent.ts index 4a77b7f4f..e6276e8f1 100644 --- a/packages/trueforge/src/schemas/agent.ts +++ b/packages/trueforge/src/schemas/agent.ts @@ -8,9 +8,7 @@ import { CreatedBySubjectSchema, TokenPaginationSchema, } from '@truefoundry/trueforge-core/agent-session'; -import { NameSchema } from './common'; - -const RESERVED_AGENT_NAMES = new Set(['tfg', 'trueforge']); +import { isReservedResourceName, NameSchema } from './common'; export const AGENT_DESCRIPTION_MAX_LENGTH = 1024; @@ -19,7 +17,7 @@ export const AgentDescriptionSchema = z.string().trim().min(1).max(AGENT_DESCRIP /** Create body: unique immutable `name` plus manifest. `id` is never client-supplied. */ export const CreateAgentRequestSchema = z .object({ - name: NameSchema.refine(name => !RESERVED_AGENT_NAMES.has(name), { + name: NameSchema.refine(name => !isReservedResourceName(name), { message: 'Agent name is reserved, cannot be used', }), description: AgentDescriptionSchema, diff --git a/packages/trueforge/src/schemas/common.ts b/packages/trueforge/src/schemas/common.ts index f2b37e39d..f5e2f6c81 100644 --- a/packages/trueforge/src/schemas/common.ts +++ b/packages/trueforge/src/schemas/common.ts @@ -25,6 +25,17 @@ export const NameSchema = z export type ResourceName = z.infer; +/** + * First-party brand namespaces reserved across all user-named resources (agents, MCP servers, + * sandbox environments); a user-supplied name may not claim them. + */ +export const RESERVED_RESOURCE_NAMES = new Set(['tfg', 'trueforge', 'truefoundry']); + +/** True when `name` collides with a reserved first-party brand namespace. */ +export function isReservedResourceName(name: string): boolean { + return RESERVED_RESOURCE_NAMES.has(name); +} + export const PAGE_LIMIT = 25; /** Session/turn event list page size (default = max). */ export const EVENTS_PAGE_LIMIT = 100; diff --git a/packages/trueforge/src/schemas/mcpServer.ts b/packages/trueforge/src/schemas/mcpServer.ts index 2cd65bfe5..93bcfcd2c 100644 --- a/packages/trueforge/src/schemas/mcpServer.ts +++ b/packages/trueforge/src/schemas/mcpServer.ts @@ -11,7 +11,7 @@ */ import { z } from '@hono/zod-openapi'; import type { OAuthToken } from '../mcp/auth/types'; -import { NameSchema } from './common'; +import { isReservedResourceName, NameSchema } from './common'; const MCP_SERVER_TYPES = ['remote', 'truefoundry'] as const; @@ -84,6 +84,12 @@ export const McpServerManifestSchema = z .discriminatedUnion('type', [RemoteMcpServerManifestSchema, TrueFoundryMcpServerManifestSchema]) .openapi('MCPServerManifest'); +/** Request-body manifest — rejects reserved first-party names (responses may still carry them). */ +const McpServerManifestRequestSchema = McpServerManifestSchema.refine( + manifest => !isReservedResourceName(manifest.name), + { message: 'MCP server name is reserved, cannot be used', path: ['name'] }, +).openapi('MCPServerManifestRequest'); + export const McpAuthStatusSchema = z .object({ status: z @@ -110,14 +116,14 @@ export const ConfiguredMcpServerSchema = z export const CreateMcpServerRequestSchema = z .object({ - manifest: McpServerManifestSchema, + manifest: McpServerManifestRequestSchema, }) .strict() .openapi('CreateMCPServerRequest'); export const UpdateMcpServerRequestSchema = z .object({ - manifest: McpServerManifestSchema, + manifest: McpServerManifestRequestSchema, }) .strict() .openapi('UpdateMCPServerRequest'); diff --git a/packages/trueforge/src/schemas/sandboxEnvironment.ts b/packages/trueforge/src/schemas/sandboxEnvironment.ts index 9cc9f3974..076130de1 100644 --- a/packages/trueforge/src/schemas/sandboxEnvironment.ts +++ b/packages/trueforge/src/schemas/sandboxEnvironment.ts @@ -8,7 +8,7 @@ import { z } from '@hono/zod-openapi'; import { CreatedBySubjectSchema, TokenPaginationSchema } from '@truefoundry/trueforge-core/agent-session'; import configuration from '../config'; -import { NameSchema } from './common'; +import { isReservedResourceName, NameSchema } from './common'; /** Same max as NameSchema (shared resource name). */ export const SANDBOX_ENVIRONMENT_NAME_MAX_LENGTH = 64; @@ -146,10 +146,10 @@ const SandboxEnvironmentManifestFieldsSchema = z export const SandboxEnvironmentManifestSchema = SandboxEnvironmentManifestFieldsSchema.openapi('SandboxEnvironmentManifest'); -/** PUT body manifest — rejects reserved name `"default"`. */ +/** PUT body manifest — rejects the system `"default"` env and reserved first-party brand names. */ const SandboxEnvironmentManifestRequestSchema = SandboxEnvironmentManifestFieldsSchema.refine( - manifest => manifest.name !== DEFAULT_SANDBOX_ENVIRONMENT_NAME, - { message: 'name "default" is reserved', path: ['name'] }, + manifest => manifest.name !== DEFAULT_SANDBOX_ENVIRONMENT_NAME && !isReservedResourceName(manifest.name), + { message: 'name is reserved', path: ['name'] }, ).openapi('SandboxEnvironmentManifestRequest'); /** diff --git a/packages/trueforge/tests/unit/apis/mcpServers.test.ts b/packages/trueforge/tests/unit/apis/mcpServers.test.ts index b4e4dba5d..87620bff3 100644 --- a/packages/trueforge/tests/unit/apis/mcpServers.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpServers.test.ts @@ -237,6 +237,14 @@ describe('mcp-servers routers', () => { }); }); + it('PUT and POST reject reserved first-party names', async () => { + for (const name of ['trueforge', 'truefoundry']) { + const reserved = { type: 'remote' as const, name, url: 'https://mcp.example.com/x', description: 'x.' }; + expect((await settingsRouter.request('/', putInit(wrapManifest(reserved)))).status).toBe(400); + expect((await settingsRouter.request('/', postInit(wrapManifest(reserved)))).status).toBe(400); + } + }); + it('PUT and POST reject private outbound URLs', async () => { const blocked = { type: 'remote' as const, diff --git a/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts b/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts new file mode 100644 index 000000000..5da606fc4 --- /dev/null +++ b/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts @@ -0,0 +1,79 @@ +import type { ISessionStore } from '@truefoundry/trueforge-core/agent-session'; +import { HTTPException } from 'hono/http-exception'; +import type { RequestContext } from '../../../../../src/auth/identity'; +import { validateDraftInvocation } from '../../../../../src/mcp/server/trueforge/route'; + +const CTX: RequestContext = { + tenant_id: 'acme', + subject: { id: 'alice', type: 'user', display_name: 'Alice' }, + roles: [], + user_credential: 'tok', +}; + +function store(overrides: { session?: unknown; turn?: unknown }): ISessionStore { + return { + getSession: async () => overrides.session, + getTurn: async () => overrides.turn, + } as unknown as ISessionStore; +} + +function inlineSession(extra?: { owner?: string; connector?: boolean; tenant?: string }) { + return { + tenant_id: extra?.tenant ?? 'acme', + session_id: 's1', + created_by_subject: { subject_id: extra?.owner ?? 'alice' }, + agent: { + type: 'inline', + spec: { mcp_servers: (extra?.connector ?? true) ? [{ name: 'trueforge' }] : [{ name: 'other' }] }, + }, + }; +} + +async function statusOf(promise: Promise): Promise { + try { + await promise; + return 200; + } catch (error) { + if (error instanceof HTTPException) { + return error.status; + } + throw error; + } +} + +describe('validateDraftInvocation', () => { + const base = { requestContext: CTX, sessionId: 's1', turnId: 't1' }; + + it('passes for an owned inline draft that includes the connector with a matching turn', async () => { + const sessionStore = store({ session: inlineSession(), turn: { session_id: 's1', turn_id: 't1' } }); + await expect(validateDraftInvocation({ sessionStore, ...base })).resolves.toBeUndefined(); + }); + + it('404 when the session is absent (covers cross-tenant ids)', async () => { + const sessionStore = store({ session: undefined }); + await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(404); + }); + + it('403 when the caller does not own the session', async () => { + const sessionStore = store({ session: inlineSession({ owner: 'mallory' }), turn: {} }); + await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(403); + }); + + it('403 for a non-inline (reference) session', async () => { + const sessionStore = store({ + session: { tenant_id: 'acme', created_by_subject: { subject_id: 'alice' }, agent: { type: 'reference' } }, + turn: {}, + }); + await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(403); + }); + + it('403 when the inline spec no longer includes the connector', async () => { + const sessionStore = store({ session: inlineSession({ connector: false }), turn: {} }); + await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(403); + }); + + it('404 when the turn does not belong to the session', async () => { + const sessionStore = store({ session: inlineSession(), turn: undefined }); + await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(404); + }); +}); From 1424898e2ae57e8d028d2de04e305017cb966601 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Thu, 8 Oct 2026 15:23:49 +0530 Subject: [PATCH 04/20] wip --- packages/trueforge-core/src/core/index.ts | 1 + .../src/core/util/outboundFetch.ts | 29 +++++++++++++++++++ .../tests/core/util/ssrfGuard.test.ts | 19 ++++++++++++ packages/trueforge/src/main.ts | 6 ++++ .../src/mcp/server/trueforge/client.ts | 7 ++++- .../trueforge/src/runtime/sessionResources.ts | 10 ++----- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index ea60be1ba..d936fe7a9 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -173,6 +173,7 @@ export { DEFAULT_OUTBOUND_HTTP_HEADERS_TIMEOUT_MS, DEFAULT_OUTBOUND_HTTP_MAX_RETRIES, configureOutboundFetches, + configureTrustedMcpEndpoint, defaultMcpOutboundFetchOptions, defaultModelOutboundFetchOptions, defaultOutboundFetchOptions, diff --git a/packages/trueforge-core/src/core/util/outboundFetch.ts b/packages/trueforge-core/src/core/util/outboundFetch.ts index 2c4f2f52b..9030db3a3 100644 --- a/packages/trueforge-core/src/core/util/outboundFetch.ts +++ b/packages/trueforge-core/src/core/util/outboundFetch.ts @@ -67,6 +67,24 @@ export function defaultMcpOutboundFetchOptions(): OutboundFetchOptions { let outbound: OutboundFetch = createOutboundFetch(defaultOutboundFetchOptions()); let modelOutbound: OutboundFetch = createOutboundFetch(defaultModelOutboundFetchOptions()); let mcpOutbound: OutboundFetch = createOutboundFetch(defaultMcpOutboundFetchOptions()); +let trustedMcpEndpoint: + | { + url: string; + fetch: (input: string | URL | Request, init?: RequestInit) => Promise; + } + | undefined; + +/** Configure the one server-owned MCP endpoint allowed to bypass the general SSRF guard. */ +export function configureTrustedMcpEndpoint( + endpoint: + | { + url: string; + fetch: (input: string | URL | Request, init?: RequestInit) => Promise; + } + | undefined, +): void { + trustedMcpEndpoint = endpoint === undefined ? undefined : { url: new URL(endpoint.url).href, fetch: endpoint.fetch }; +} /** Replace outbound Agents (timeouts/retries). Does not change SSRF allow/block lists. */ export function configureOutboundFetches(config: { @@ -97,5 +115,16 @@ export async function modelSsrfFetch(input: string | URL | Request, init?: Reque /** Drop-in fetch for remote MCP (long bodyTimeout; does not retry headers timeouts). */ export async function mcpSsrfFetch(input: string | URL | Request, init?: RequestInit): Promise { + if (trustedMcpEndpoint !== undefined) { + let url: string | undefined; + try { + url = new URL(input instanceof Request ? input.url : input).href; + } catch { + // The guarded fetch below produces the standard blocked-URL error. + } + if (url === trustedMcpEndpoint.url) { + return trustedMcpEndpoint.fetch(input, { ...init, redirect: 'manual' }); + } + } return mcpOutbound.fetch(input, init); } diff --git a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts index d49236bbb..73fc33eb1 100644 --- a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts +++ b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts @@ -1,6 +1,7 @@ import http from 'node:http'; import { configureOutboundFetches, + configureTrustedMcpEndpoint, defaultMcpOutboundFetchOptions, defaultModelOutboundFetchOptions, defaultOutboundFetchOptions, @@ -12,6 +13,7 @@ import { assertSafeOutboundUrl, configureOutboundUrlGuard } from '../../../src/c function resetOutbound(): void { configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); + configureTrustedMcpEndpoint(undefined); configureOutboundFetches({ outbound: defaultOutboundFetchOptions(), model: defaultModelOutboundFetchOptions(), @@ -351,6 +353,23 @@ describe('modelSsrfFetch', () => { }); describe('mcpSsrfFetch', () => { + it('bypasses SSRF only for the exact configured trusted MCP endpoint', async () => { + const trustedFetch = jest.fn(async () => new Response(null, { status: 200 })); + configureTrustedMcpEndpoint({ + url: 'http://127.0.0.1:8790/mcp', + fetch: trustedFetch, + }); + + await expect(mcpSsrfFetch('http://127.0.0.1:8790/mcp', { method: 'POST' })).resolves.toMatchObject({ + status: 200, + }); + expect(trustedFetch).toHaveBeenCalledWith('http://127.0.0.1:8790/mcp', { + method: 'POST', + redirect: 'manual', + }); + await expect(mcpSsrfFetch('http://127.0.0.1:8790/private')).rejects.toThrow('Outbound URL blocked'); + }); + it('retries statuses where the request was not processed but not 409/502/520/524', async () => { configureOutboundUrlGuard({ allowedHosts: ['127.0.0.1'], blockedHosts: [] }); configureOutboundFetches({ diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 609848285..ea31c52f2 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -12,11 +12,13 @@ import { configureOutboundFetches, configureOutboundUrlGuard, + configureTrustedMcpEndpoint, extractErrorLogFields, } from '@truefoundry/trueforge-core/core'; import type { Context } from 'hono'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; +import { createTlsFetch, normalizeTlsUrl } from './http/tls'; import { ensureLocalSandboxRootParent, prepareCodeModeSocketParent, @@ -68,6 +70,10 @@ try { idempotent: false, }, }); + const tls = { enabled: configuration.MTLS_ENABLED, dir: configuration.MTLS_CERTS_DIR }; + const trustedFetch = createTlsFetch(tls) ?? fetch; + const serverUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: tls.enabled }); + configureTrustedMcpEndpoint({ url: new URL('/mcp', serverUrl).href, fetch: trustedFetch }); } catch (error) { console.error( 'Failed to start server: Failed to load configuration:', diff --git a/packages/trueforge/src/mcp/server/trueforge/client.ts b/packages/trueforge/src/mcp/server/trueforge/client.ts index e2a151936..63ab323b2 100644 --- a/packages/trueforge/src/mcp/server/trueforge/client.ts +++ b/packages/trueforge/src/mcp/server/trueforge/client.ts @@ -6,11 +6,16 @@ * `TRUEFORGE_API_KEY` is intentionally never used here. */ import { TrueForge } from '@truefoundry/trueforge-sdk'; +import configuration from '../../../config'; +import { createTlsFetch, normalizeTlsUrl } from '../../../http/tls'; export function createTrueForgeClient(input: { baseUrl: string; userCredential: string | null }): TrueForge { + const tls = { enabled: configuration.MTLS_ENABLED, dir: configuration.MTLS_CERTS_DIR }; + const tlsFetch = createTlsFetch(tls); return new TrueForge({ - baseUrl: input.baseUrl, + baseUrl: normalizeTlsUrl({ url: input.baseUrl, enabled: tls.enabled }), timeoutInSeconds: 60, ...(input.userCredential === null ? { auth: false } : { token: input.userCredential }), + ...(tlsFetch === undefined ? {} : { fetch: tlsFetch }), }); } diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index d2bd82962..93482cb49 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -296,14 +296,8 @@ export async function validateAgentSpec({ const requestedMcpServers = spec.mcp_servers ?? []; if (requestedMcpServers.length > 0) { const names = requestedMcpServers.map(server => server.name); - const configuredNames = new Set( - ( - await mcpServerStore.listServers({ - tenant_id, - names, - }) - ).map(record => record.name), - ); + const records = await mcpServerStore.listServers({ tenant_id, names }); + const configuredNames = new Set(records.map(record => record.name)); const unknown = requestedMcpServers.find(server => !configuredNames.has(server.name)); if (unknown !== undefined) { throw new HTTPException(422, { From af89ab0e4de62cb677b0feb9bd4652054fc453a5 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 13 Oct 2026 15:19:11 +0530 Subject: [PATCH 05/20] make name vlaidation on nameschema --- packages/trueforge/src/app.ts | 1 - packages/trueforge/src/main.ts | 6 +- .../src/mcp/server/trueforge/headers.ts | 14 ++++ .../src/mcp/server/trueforge/route.ts | 64 +++-------------- packages/trueforge/src/schemas/common.ts | 23 +++--- packages/trueforge/src/schemas/mcpServer.ts | 12 +--- .../unit/mcp/server/trueforge/headers.test.ts | 22 ++++++ .../unit/mcp/server/trueforge/route.test.ts | 71 +++---------------- 8 files changed, 74 insertions(+), 139 deletions(-) create mode 100644 packages/trueforge/src/mcp/server/trueforge/headers.ts create mode 100644 packages/trueforge/tests/unit/mcp/server/trueforge/headers.test.ts diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index 777767932..42772ac9f 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -618,7 +618,6 @@ export function createServerApp(deps: ServerDeps) { app.route( '/mcp', createTrueForgeMcpRouter({ - sessionStore: deps.sessionStore, resolveRequestContext, serverUrl: configuration.SERVER_URL, version: PACKAGE_VERSION, diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index ea31c52f2..d8b9d875c 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -19,6 +19,7 @@ import type { Context } from 'hono'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import { createTlsFetch, normalizeTlsUrl } from './http/tls'; +import { withTrueForgeMcpHeader } from './mcp/server/trueforge/headers'; import { ensureLocalSandboxRootParent, prepareCodeModeSocketParent, @@ -73,7 +74,10 @@ try { const tls = { enabled: configuration.MTLS_ENABLED, dir: configuration.MTLS_CERTS_DIR }; const trustedFetch = createTlsFetch(tls) ?? fetch; const serverUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: tls.enabled }); - configureTrustedMcpEndpoint({ url: new URL('/mcp', serverUrl).href, fetch: trustedFetch }); + configureTrustedMcpEndpoint({ + url: new URL('/mcp', serverUrl).href, + fetch: withTrueForgeMcpHeader(trustedFetch), + }); } catch (error) { console.error( 'Failed to start server: Failed to load configuration:', diff --git a/packages/trueforge/src/mcp/server/trueforge/headers.ts b/packages/trueforge/src/mcp/server/trueforge/headers.ts new file mode 100644 index 000000000..2bce075ea --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/headers.ts @@ -0,0 +1,14 @@ +export const X_TFG_TRUEFORGE_MCP = 'x-tfg-trueforge-mcp'; + +/** + * Adds the TrueForge MCP marker without dropping headers already present on either a Request or + * RequestInit. This is a coarse invocation gate, not an authentication boundary. + */ +export function withTrueForgeMcpHeader(fetchFn: typeof fetch): typeof fetch { + return (input, init) => { + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init?.headers).forEach((value, name) => headers.set(name, value)); + headers.set(X_TFG_TRUEFORGE_MCP, 'true'); + return fetchFn(input, { ...init, headers }); + }; +} diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts index ff7c4e987..22ee59a47 100644 --- a/packages/trueforge/src/mcp/server/trueforge/route.ts +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -1,22 +1,19 @@ /** * Authenticated, stateless Streamable HTTP endpoint for the TrueForge MCP (mounted at /mcp). * - * Each request: authenticate → parse server-generated session/turn metadata → revalidate draft - * state → build a request-scoped SDK client + stateless MCP server → hand the Web Request to the - * transport. Nothing is retained across requests. + * Each request: authenticate → require the trusted invocation marker → build a request-scoped SDK client + + * stateless MCP server → hand the Web Request to the transport. Nothing is retained across requests. */ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import type { ISessionStore } from '@truefoundry/trueforge-core/agent-session'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Logger } from 'winston'; -import type { RequestContext, ResolveRequestContext } from '../../../auth/identity'; -import { parseGatewayMetadataHeader, TFG_METADATA_PREFIX, X_TFY_METADATA } from '../../../truefoundry/gatewayMetadata'; +import type { ResolveRequestContext } from '../../../auth/identity'; import { createTrueForgeClient } from './client'; -import { createTrueForgeMcpServer, TRUEFORGE_CONNECTOR_NAME } from './server'; +import { X_TFG_TRUEFORGE_MCP } from './headers'; +import { createTrueForgeMcpServer } from './server'; export interface TrueForgeMcpRouterDeps { - sessionStore: ISessionStore; resolveRequestContext: ResolveRequestContext; /** Trusted self-origin base URL */ serverUrl: string; @@ -24,53 +21,9 @@ export interface TrueForgeMcpRouterDeps { logger: Logger; } -function requireMetadata(raw: string | undefined): { sessionId: string; turnId: string } { +export function requireTrueForgeMcpHeader(raw: string | undefined): void { if (raw === undefined) { - throw new HTTPException(400, { message: `Missing ${X_TFY_METADATA} turn metadata` }); - } - const metadata = parseGatewayMetadataHeader(raw); - const sessionId = metadata[`${TFG_METADATA_PREFIX}.session_id`]; - const turnId = metadata[`${TFG_METADATA_PREFIX}.turn_id`]; - if (sessionId === undefined || turnId === undefined) { - throw new HTTPException(400, { message: 'Turn metadata must include session_id and turn_id' }); - } - return { sessionId, turnId }; -} - -/** - * Server-side draft-only enforcement. Routing metadata is revalidated against persisted session/turn - * state — a Bearer token alone is insufficient. See docs-internal/trueforge-mcp-server.md §3.6. - */ -export async function validateDraftInvocation(input: { - sessionStore: ISessionStore; - requestContext: RequestContext; - sessionId: string; - turnId: string; -}): Promise { - const { sessionStore, requestContext, sessionId, turnId } = input; - - // Tenant scoping + existence (getSession is tenant-scoped, so a cross-tenant id reads as not found). - const session = await sessionStore.getSession({ tenant_id: requestContext.tenant_id, session_id: sessionId }); - if (session === undefined) { - throw new HTTPException(404, { message: 'Session not found' }); - } - // Ownership: the authenticated subject must own the session. - if (session.created_by_subject.subject_id !== requestContext.subject.id) { - throw new HTTPException(403, { message: 'Session not owned by caller' }); - } - // Draft sessions only: inline agent binding. - if (session.agent.type !== 'inline') { - throw new HTTPException(403, { message: 'The trueforge MCP is available only for draft (inline) sessions' }); - } - // The inline spec must currently include the connector. - const mcpServers = session.agent.spec.mcp_servers ?? []; - if (!mcpServers.some(entry => entry.name === TRUEFORGE_CONNECTOR_NAME)) { - throw new HTTPException(403, { message: 'Draft spec does not include the trueforge connector' }); - } - // The turn must belong to the supplied session. - const turn = await sessionStore.getTurn({ session_id: sessionId, turn_id: turnId }); - if (turn === undefined) { - throw new HTTPException(404, { message: 'Turn not found for session' }); + throw new HTTPException(403, { message: `Missing ${X_TFG_TRUEFORGE_MCP} invocation marker` }); } } @@ -78,8 +31,7 @@ export function createTrueForgeMcpRouter(deps: TrueForgeMcpRouterDeps): Hono { const router = new Hono(); router.all('/', async c => { const requestContext = deps.resolveRequestContext(c); - const { sessionId, turnId } = requireMetadata(c.req.header(X_TFY_METADATA)); - await validateDraftInvocation({ sessionStore: deps.sessionStore, requestContext, sessionId, turnId }); + requireTrueForgeMcpHeader(c.req.header(X_TFG_TRUEFORGE_MCP)); // Built per request: the server's tools close over this caller's credential, and stateless // Streamable HTTP wants a fresh server+transport per request to isolate concurrent callers. diff --git a/packages/trueforge/src/schemas/common.ts b/packages/trueforge/src/schemas/common.ts index f5e2f6c81..7e6937935 100644 --- a/packages/trueforge/src/schemas/common.ts +++ b/packages/trueforge/src/schemas/common.ts @@ -3,6 +3,17 @@ */ import { z } from '@hono/zod-openapi'; +/** + * First-party brand namespaces reserved across all user-named resources (agents, MCP servers, + * sandbox environments); a user-supplied name may not claim them. + */ +export const RESERVED_RESOURCE_NAMES = new Set(['tfg', 'trueforge', 'truefoundry']); + +/** True when `name` collides with a reserved first-party brand namespace. */ +export function isReservedResourceName(name: string): boolean { + return RESERVED_RESOURCE_NAMES.has(name); +} + /** * Shared local resource name (agents, skills, MCP, models, schedules). * 2–64 lowercase chars, letter start, letter/digit end, hyphens only (matches SVC). @@ -21,21 +32,11 @@ export const NameSchema = z /^[a-z][a-z0-9-]{0,62}[a-z0-9]$/, 'must be 2–64 lowercase chars: start with a letter, end with alphanumeric, hyphens only in between', ) + .refine(name => !isReservedResourceName(name), { message: 'name is reserved' }) .openapi('ResourceName'); export type ResourceName = z.infer; -/** - * First-party brand namespaces reserved across all user-named resources (agents, MCP servers, - * sandbox environments); a user-supplied name may not claim them. - */ -export const RESERVED_RESOURCE_NAMES = new Set(['tfg', 'trueforge', 'truefoundry']); - -/** True when `name` collides with a reserved first-party brand namespace. */ -export function isReservedResourceName(name: string): boolean { - return RESERVED_RESOURCE_NAMES.has(name); -} - export const PAGE_LIMIT = 25; /** Session/turn event list page size (default = max). */ export const EVENTS_PAGE_LIMIT = 100; diff --git a/packages/trueforge/src/schemas/mcpServer.ts b/packages/trueforge/src/schemas/mcpServer.ts index 93bcfcd2c..2cd65bfe5 100644 --- a/packages/trueforge/src/schemas/mcpServer.ts +++ b/packages/trueforge/src/schemas/mcpServer.ts @@ -11,7 +11,7 @@ */ import { z } from '@hono/zod-openapi'; import type { OAuthToken } from '../mcp/auth/types'; -import { isReservedResourceName, NameSchema } from './common'; +import { NameSchema } from './common'; const MCP_SERVER_TYPES = ['remote', 'truefoundry'] as const; @@ -84,12 +84,6 @@ export const McpServerManifestSchema = z .discriminatedUnion('type', [RemoteMcpServerManifestSchema, TrueFoundryMcpServerManifestSchema]) .openapi('MCPServerManifest'); -/** Request-body manifest — rejects reserved first-party names (responses may still carry them). */ -const McpServerManifestRequestSchema = McpServerManifestSchema.refine( - manifest => !isReservedResourceName(manifest.name), - { message: 'MCP server name is reserved, cannot be used', path: ['name'] }, -).openapi('MCPServerManifestRequest'); - export const McpAuthStatusSchema = z .object({ status: z @@ -116,14 +110,14 @@ export const ConfiguredMcpServerSchema = z export const CreateMcpServerRequestSchema = z .object({ - manifest: McpServerManifestRequestSchema, + manifest: McpServerManifestSchema, }) .strict() .openapi('CreateMCPServerRequest'); export const UpdateMcpServerRequestSchema = z .object({ - manifest: McpServerManifestRequestSchema, + manifest: McpServerManifestSchema, }) .strict() .openapi('UpdateMCPServerRequest'); diff --git a/packages/trueforge/tests/unit/mcp/server/trueforge/headers.test.ts b/packages/trueforge/tests/unit/mcp/server/trueforge/headers.test.ts new file mode 100644 index 000000000..572be409a --- /dev/null +++ b/packages/trueforge/tests/unit/mcp/server/trueforge/headers.test.ts @@ -0,0 +1,22 @@ +import { withTrueForgeMcpHeader, X_TFG_TRUEFORGE_MCP } from '../../../../../src/mcp/server/trueforge/headers'; + +describe('withTrueForgeMcpHeader', () => { + it('adds the invocation marker and preserves request and init headers', async () => { + let receivedHeaders: Headers | undefined; + const fetchFn = (async (_input, init) => { + receivedHeaders = new Headers(init?.headers); + return new Response(); + }) as typeof fetch; + const wrappedFetch = withTrueForgeMcpHeader(fetchFn); + const request = new Request('https://trueforge.example/mcp', { + headers: { authorization: 'Bearer token', 'x-request-header': 'request' }, + }); + + await wrappedFetch(request, { headers: { 'x-init-header': 'init' } }); + + expect(receivedHeaders?.get(X_TFG_TRUEFORGE_MCP)).toBe('true'); + expect(receivedHeaders?.get('authorization')).toBe('Bearer token'); + expect(receivedHeaders?.get('x-request-header')).toBe('request'); + expect(receivedHeaders?.get('x-init-header')).toBe('init'); + }); +}); diff --git a/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts b/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts index 5da606fc4..c4f2283c8 100644 --- a/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts +++ b/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts @@ -1,37 +1,9 @@ -import type { ISessionStore } from '@truefoundry/trueforge-core/agent-session'; import { HTTPException } from 'hono/http-exception'; -import type { RequestContext } from '../../../../../src/auth/identity'; -import { validateDraftInvocation } from '../../../../../src/mcp/server/trueforge/route'; +import { requireTrueForgeMcpHeader } from '../../../../../src/mcp/server/trueforge/route'; -const CTX: RequestContext = { - tenant_id: 'acme', - subject: { id: 'alice', type: 'user', display_name: 'Alice' }, - roles: [], - user_credential: 'tok', -}; - -function store(overrides: { session?: unknown; turn?: unknown }): ISessionStore { - return { - getSession: async () => overrides.session, - getTurn: async () => overrides.turn, - } as unknown as ISessionStore; -} - -function inlineSession(extra?: { owner?: string; connector?: boolean; tenant?: string }) { - return { - tenant_id: extra?.tenant ?? 'acme', - session_id: 's1', - created_by_subject: { subject_id: extra?.owner ?? 'alice' }, - agent: { - type: 'inline', - spec: { mcp_servers: (extra?.connector ?? true) ? [{ name: 'trueforge' }] : [{ name: 'other' }] }, - }, - }; -} - -async function statusOf(promise: Promise): Promise { +function statusOf(fn: () => void): number { try { - await promise; + fn(); return 200; } catch (error) { if (error instanceof HTTPException) { @@ -41,39 +13,16 @@ async function statusOf(promise: Promise): Promise { } } -describe('validateDraftInvocation', () => { - const base = { requestContext: CTX, sessionId: 's1', turnId: 't1' }; - - it('passes for an owned inline draft that includes the connector with a matching turn', async () => { - const sessionStore = store({ session: inlineSession(), turn: { session_id: 's1', turn_id: 't1' } }); - await expect(validateDraftInvocation({ sessionStore, ...base })).resolves.toBeUndefined(); - }); - - it('404 when the session is absent (covers cross-tenant ids)', async () => { - const sessionStore = store({ session: undefined }); - await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(404); - }); - - it('403 when the caller does not own the session', async () => { - const sessionStore = store({ session: inlineSession({ owner: 'mallory' }), turn: {} }); - await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(403); - }); - - it('403 for a non-inline (reference) session', async () => { - const sessionStore = store({ - session: { tenant_id: 'acme', created_by_subject: { subject_id: 'alice' }, agent: { type: 'reference' } }, - turn: {}, - }); - await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(403); +describe('requireTrueForgeMcpHeader', () => { + it('rejects a missing invocation marker', () => { + expect(statusOf(() => requireTrueForgeMcpHeader(undefined))).toBe(403); }); - it('403 when the inline spec no longer includes the connector', async () => { - const sessionStore = store({ session: inlineSession({ connector: false }), turn: {} }); - await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(403); + it('accepts the invocation marker without inspecting its contents', () => { + expect(statusOf(() => requireTrueForgeMcpHeader('not-validated'))).toBe(200); }); - it('404 when the turn does not belong to the session', async () => { - const sessionStore = store({ session: inlineSession(), turn: undefined }); - await expect(statusOf(validateDraftInvocation({ sessionStore, ...base }))).resolves.toBe(404); + it('accepts an empty invocation marker because only presence is required', () => { + expect(statusOf(() => requireTrueForgeMcpHeader(''))).toBe(200); }); }); From d7408293c3967718f33fef44fd2589c4e03a382c Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 13 Oct 2026 15:28:15 +0530 Subject: [PATCH 06/20] more fixes --- .../src/mcp/server/trueforge/tools/agents.ts | 8 +++----- .../server/trueforge/tools/sandboxEnvironments.ts | 15 +++++++++------ .../src/mcp/server/trueforge/tools/schedules.ts | 12 +++--------- .../trueforge/src/schemas/sandboxEnvironment.ts | 8 ++++---- 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts b/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts index cdcb9ecd3..b78783aab 100644 --- a/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts +++ b/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts @@ -1,11 +1,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; import type { TrueForge } from '@truefoundry/trueforge-sdk'; import { z } from 'zod'; import { runTool } from '../result'; -/** Agent manifest is passed through to the API, which validates all references. */ -const manifestSchema = z.record(z.string(), z.unknown()); - export function registerAgentTools(server: McpServer, client: TrueForge): void { server.registerTool( 'list_agents', @@ -53,7 +51,7 @@ export function registerAgentTools(server: McpServer, client: TrueForge): void { inputSchema: { name: z.string(), description: z.string(), - manifest: manifestSchema, + manifest: AgentSpecSchema, }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, }, @@ -73,7 +71,7 @@ export function registerAgentTools(server: McpServer, client: TrueForge): void { inputSchema: { agent_id: z.string(), description: z.string().optional(), - manifest: manifestSchema, + manifest: AgentSpecSchema, }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, }, diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts index d628fcf95..9e30c644c 100644 --- a/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts +++ b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { TrueForgeError, type TrueForge } from '@truefoundry/trueforge-sdk'; import { z } from 'zod'; +import { SandboxEnvironmentManifestRequestSchema } from '../../../../schemas/sandboxEnvironment'; import { runTool } from '../result'; /** @@ -8,8 +9,6 @@ import { runTool } from '../result'; * preflight GET so intent is explicit (create fails if present; update fails if absent). Best-effort: * two concurrent creates can still race (no conditional-create at the API layer). */ -const manifestSchema = z.looseObject({ name: z.string() }); - async function environmentExists(client: TrueForge, name: string): Promise { try { await client.sandboxEnvironments.get(name); @@ -30,7 +29,7 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF 'Create a reusable sandbox environment that agents run in — defining its resources, environment ' + 'variables, networking, and build configuration — from a complete manifest. Fails if an environment ' + 'with the same name already exists; the reserved `default` environment cannot be created.', - inputSchema: { manifest: manifestSchema }, + inputSchema: { manifest: SandboxEnvironmentManifestRequestSchema }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, }, async ({ manifest }) => @@ -41,7 +40,9 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF body: { error: { message: `Sandbox environment already exists: ${manifest.name}` } }, }); } - return client.sandboxEnvironments.createOrUpdate({ manifest }); + return client.sandboxEnvironments.createOrUpdate({ manifest } as unknown as Parameters< + typeof client.sandboxEnvironments.createOrUpdate + >[0]); }), ); @@ -86,7 +87,7 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF 'Change an existing sandbox environment — the resources, environment variables, networking, and build ' + 'configuration agents run in — by replacing it with a complete manifest. The input name and manifest ' + 'name must match, and this tool never creates a missing environment (use create_sandbox_environment for that).', - inputSchema: { name: z.string(), manifest: manifestSchema }, + inputSchema: { name: z.string(), manifest: SandboxEnvironmentManifestRequestSchema }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, }, async ({ name, manifest }) => @@ -103,7 +104,9 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF body: { error: { message: `Sandbox environment not found: ${name}` } }, }); } - return client.sandboxEnvironments.createOrUpdate({ manifest }); + return client.sandboxEnvironments.createOrUpdate({ manifest } as unknown as Parameters< + typeof client.sandboxEnvironments.createOrUpdate + >[0]); }), ); } diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts b/packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts index 365002380..a186d774b 100644 --- a/packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts +++ b/packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts @@ -1,15 +1,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TrueForge } from '@truefoundry/trueforge-sdk'; import { z } from 'zod'; +import { ScheduleManifestSchema } from '../../../../schemas/schedule'; import { runTool } from '../result'; -const manifestSchema = z.object({ - task: z.string(), - cron: z.string(), - timezone: z.string().optional(), - status: z.enum(['active', 'paused']).optional(), -}); - export function registerScheduleTools(server: McpServer, client: TrueForge): void { server.registerTool( 'create_schedule', @@ -20,7 +14,7 @@ export function registerScheduleTools(server: McpServer, client: TrueForge): voi inputSchema: { agent_name: z.string(), name: z.string(), - manifest: manifestSchema, + manifest: ScheduleManifestSchema, }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, }, @@ -103,7 +97,7 @@ export function registerScheduleTools(server: McpServer, client: TrueForge): voi inputSchema: { schedule_id: z.string(), name: z.string(), - manifest: manifestSchema, + manifest: ScheduleManifestSchema, }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, }, diff --git a/packages/trueforge/src/schemas/sandboxEnvironment.ts b/packages/trueforge/src/schemas/sandboxEnvironment.ts index 076130de1..fee633b64 100644 --- a/packages/trueforge/src/schemas/sandboxEnvironment.ts +++ b/packages/trueforge/src/schemas/sandboxEnvironment.ts @@ -8,7 +8,7 @@ import { z } from '@hono/zod-openapi'; import { CreatedBySubjectSchema, TokenPaginationSchema } from '@truefoundry/trueforge-core/agent-session'; import configuration from '../config'; -import { isReservedResourceName, NameSchema } from './common'; +import { NameSchema } from './common'; /** Same max as NameSchema (shared resource name). */ export const SANDBOX_ENVIRONMENT_NAME_MAX_LENGTH = 64; @@ -146,9 +146,9 @@ const SandboxEnvironmentManifestFieldsSchema = z export const SandboxEnvironmentManifestSchema = SandboxEnvironmentManifestFieldsSchema.openapi('SandboxEnvironmentManifest'); -/** PUT body manifest — rejects the system `"default"` env and reserved first-party brand names. */ -const SandboxEnvironmentManifestRequestSchema = SandboxEnvironmentManifestFieldsSchema.refine( - manifest => manifest.name !== DEFAULT_SANDBOX_ENVIRONMENT_NAME && !isReservedResourceName(manifest.name), +/** PUT body manifest — rejects the reserved system `"default"` environment. */ +export const SandboxEnvironmentManifestRequestSchema = SandboxEnvironmentManifestFieldsSchema.refine( + manifest => manifest.name !== DEFAULT_SANDBOX_ENVIRONMENT_NAME, { message: 'name is reserved', path: ['name'] }, ).openapi('SandboxEnvironmentManifestRequest'); From 7b8d175138119472d10cd6c4ac107775ee113152 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 13 Oct 2026 15:45:51 +0530 Subject: [PATCH 07/20] refactoring --- packages/trueforge-core/src/core/index.ts | 1 - .../src/core/util/outboundFetch.ts | 29 -------------- .../trueforge-core/src/core/util/ssrfGuard.ts | 24 +++++++++++- .../tests/core/util/ssrfGuard.test.ts | 15 +++++--- packages/trueforge/src/app.ts | 2 - .../src/controller/internalTrueForgeClient.ts | 11 ++---- packages/trueforge/src/http/tls.ts | 14 ++++++- packages/trueforge/src/main.ts | 12 +----- .../src/mcp/server/trueforge/client.ts | 21 ---------- .../src/mcp/server/trueforge/endpoint.ts | 36 ++++++++++++++++++ .../src/mcp/server/trueforge/errors.ts | 38 ------------------- .../src/mcp/server/trueforge/headers.ts | 14 ------- .../src/mcp/server/trueforge/result.ts | 34 ++++++++++++++++- .../src/mcp/server/trueforge/route.ts | 12 +----- .../src/mcp/server/trueforge/server.ts | 14 ++++++- .../src/mcp/server/trueforge/tools/index.ts | 18 --------- .../unit/mcp/server/trueforge/headers.test.ts | 22 ----------- .../unit/mcp/server/trueforge/route.test.ts | 28 -------------- 18 files changed, 133 insertions(+), 212 deletions(-) delete mode 100644 packages/trueforge/src/mcp/server/trueforge/client.ts create mode 100644 packages/trueforge/src/mcp/server/trueforge/endpoint.ts delete mode 100644 packages/trueforge/src/mcp/server/trueforge/errors.ts delete mode 100644 packages/trueforge/src/mcp/server/trueforge/headers.ts delete mode 100644 packages/trueforge/src/mcp/server/trueforge/tools/index.ts delete mode 100644 packages/trueforge/tests/unit/mcp/server/trueforge/headers.test.ts delete mode 100644 packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index d936fe7a9..ea60be1ba 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -173,7 +173,6 @@ export { DEFAULT_OUTBOUND_HTTP_HEADERS_TIMEOUT_MS, DEFAULT_OUTBOUND_HTTP_MAX_RETRIES, configureOutboundFetches, - configureTrustedMcpEndpoint, defaultMcpOutboundFetchOptions, defaultModelOutboundFetchOptions, defaultOutboundFetchOptions, diff --git a/packages/trueforge-core/src/core/util/outboundFetch.ts b/packages/trueforge-core/src/core/util/outboundFetch.ts index 9030db3a3..2c4f2f52b 100644 --- a/packages/trueforge-core/src/core/util/outboundFetch.ts +++ b/packages/trueforge-core/src/core/util/outboundFetch.ts @@ -67,24 +67,6 @@ export function defaultMcpOutboundFetchOptions(): OutboundFetchOptions { let outbound: OutboundFetch = createOutboundFetch(defaultOutboundFetchOptions()); let modelOutbound: OutboundFetch = createOutboundFetch(defaultModelOutboundFetchOptions()); let mcpOutbound: OutboundFetch = createOutboundFetch(defaultMcpOutboundFetchOptions()); -let trustedMcpEndpoint: - | { - url: string; - fetch: (input: string | URL | Request, init?: RequestInit) => Promise; - } - | undefined; - -/** Configure the one server-owned MCP endpoint allowed to bypass the general SSRF guard. */ -export function configureTrustedMcpEndpoint( - endpoint: - | { - url: string; - fetch: (input: string | URL | Request, init?: RequestInit) => Promise; - } - | undefined, -): void { - trustedMcpEndpoint = endpoint === undefined ? undefined : { url: new URL(endpoint.url).href, fetch: endpoint.fetch }; -} /** Replace outbound Agents (timeouts/retries). Does not change SSRF allow/block lists. */ export function configureOutboundFetches(config: { @@ -115,16 +97,5 @@ export async function modelSsrfFetch(input: string | URL | Request, init?: Reque /** Drop-in fetch for remote MCP (long bodyTimeout; does not retry headers timeouts). */ export async function mcpSsrfFetch(input: string | URL | Request, init?: RequestInit): Promise { - if (trustedMcpEndpoint !== undefined) { - let url: string | undefined; - try { - url = new URL(input instanceof Request ? input.url : input).href; - } catch { - // The guarded fetch below produces the standard blocked-URL error. - } - if (url === trustedMcpEndpoint.url) { - return trustedMcpEndpoint.fetch(input, { ...init, redirect: 'manual' }); - } - } return mcpOutbound.fetch(input, init); } diff --git a/packages/trueforge-core/src/core/util/ssrfGuard.ts b/packages/trueforge-core/src/core/util/ssrfGuard.ts index 55dbcdea1..72e8075d9 100644 --- a/packages/trueforge-core/src/core/util/ssrfGuard.ts +++ b/packages/trueforge-core/src/core/util/ssrfGuard.ts @@ -31,6 +31,11 @@ export interface OutboundFetchOptions { * timeouts and 409/520/524 are not retried. */ idempotent: boolean; + /** Exact server-owned endpoint allowed to bypass SSRF through a caller-supplied fetch. */ + trustedEndpoint?: { + url: string; + fetch: (input: string | URL | Request, init?: RequestInit) => Promise; + }; } export interface OutboundFetch { @@ -484,8 +489,25 @@ export function createOutboundFetch(options: OutboundFetchOptions): OutboundFetc }); const maxRetries = options.maxRetries; const idempotent = options.idempotent; + const trustedEndpoint = + options.trustedEndpoint === undefined + ? undefined + : { url: new URL(options.trustedEndpoint.url).href, fetch: options.trustedEndpoint.fetch }; return { - fetch: (input, init) => withOutboundHttpRetries(input, init ?? {}, agent, { maxRetries, idempotent }), + fetch: (input, init) => { + if (trustedEndpoint !== undefined) { + let url: string | undefined; + try { + url = new URL(input instanceof Request ? input.url : input).href; + } catch { + // The guarded fetch below produces the standard blocked-URL error. + } + if (url === trustedEndpoint.url) { + return trustedEndpoint.fetch(input, { ...init, redirect: 'manual' }); + } + } + return withOutboundHttpRetries(input, init ?? {}, agent, { maxRetries, idempotent }); + }, close: async () => { await agent.close().catch(() => undefined); }, diff --git a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts index 73fc33eb1..b76fa055b 100644 --- a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts +++ b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts @@ -1,7 +1,6 @@ import http from 'node:http'; import { configureOutboundFetches, - configureTrustedMcpEndpoint, defaultMcpOutboundFetchOptions, defaultModelOutboundFetchOptions, defaultOutboundFetchOptions, @@ -13,7 +12,6 @@ import { assertSafeOutboundUrl, configureOutboundUrlGuard } from '../../../src/c function resetOutbound(): void { configureOutboundUrlGuard({ allowedHosts: [], blockedHosts: [] }); - configureTrustedMcpEndpoint(undefined); configureOutboundFetches({ outbound: defaultOutboundFetchOptions(), model: defaultModelOutboundFetchOptions(), @@ -355,9 +353,16 @@ describe('modelSsrfFetch', () => { describe('mcpSsrfFetch', () => { it('bypasses SSRF only for the exact configured trusted MCP endpoint', async () => { const trustedFetch = jest.fn(async () => new Response(null, { status: 200 })); - configureTrustedMcpEndpoint({ - url: 'http://127.0.0.1:8790/mcp', - fetch: trustedFetch, + configureOutboundFetches({ + outbound: defaultOutboundFetchOptions(), + model: defaultModelOutboundFetchOptions(), + mcp: { + ...defaultMcpOutboundFetchOptions(), + trustedEndpoint: { + url: 'http://127.0.0.1:8790/mcp', + fetch: trustedFetch, + }, + }, }); await expect(mcpSsrfFetch('http://127.0.0.1:8790/mcp', { method: 'POST' })).resolves.toMatchObject({ diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index 42772ac9f..450c48cac 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -619,9 +619,7 @@ export function createServerApp(deps: ServerDeps) { '/mcp', createTrueForgeMcpRouter({ resolveRequestContext, - serverUrl: configuration.SERVER_URL, version: PACKAGE_VERSION, - logger: deps.logger, }), ); diff --git a/packages/trueforge/src/controller/internalTrueForgeClient.ts b/packages/trueforge/src/controller/internalTrueForgeClient.ts index 36570085f..4f7fcfa5c 100644 --- a/packages/trueforge/src/controller/internalTrueForgeClient.ts +++ b/packages/trueforge/src/controller/internalTrueForgeClient.ts @@ -3,19 +3,14 @@ */ import { TrueForge } from '@truefoundry/trueforge-sdk'; import configuration from '../config'; -import { createTlsFetch, normalizeTlsUrl } from '../http/tls'; +import { internalTlsFetch, normalizeTlsUrl } from '../http/tls'; function createClient(): TrueForge { - const tls = { - enabled: configuration.MTLS_ENABLED, - dir: configuration.MTLS_CERTS_DIR, - }; - const tlsFetch = createTlsFetch(tls); return new TrueForge({ - baseUrl: normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: tls.enabled }), + baseUrl: normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: configuration.MTLS_ENABLED }), token: configuration.TRUEFORGE_API_KEY, timeoutInSeconds: 60, - ...(tlsFetch === undefined ? {} : { fetch: tlsFetch }), + fetch: internalTlsFetch(configuration), }); } diff --git a/packages/trueforge/src/http/tls.ts b/packages/trueforge/src/http/tls.ts index 8c22e109d..de1a539e9 100644 --- a/packages/trueforge/src/http/tls.ts +++ b/packages/trueforge/src/http/tls.ts @@ -88,8 +88,8 @@ function requestUrlFromFetchInput(input: Parameters[0]): string { return input.url; } -/** `fetch` for the schedule controller SDK client. Undefined when mTLS is off. */ -export function createTlsFetch(options: TlsOptions): typeof fetch | undefined { +/** `fetch` for internal TrueForge SDK and MCP calls. Undefined when mTLS is off. */ +function createTlsFetch(options: TlsOptions): typeof fetch | undefined { const dispatcher = createTlsDispatcher({ ...options, enabledEnvKey: 'MTLS_ENABLED', @@ -101,6 +101,16 @@ export function createTlsFetch(options: TlsOptions): typeof fetch | undefined { return (input, init) => undiciFetch(requestUrlFromFetchInput(input), { ...(init as object), dispatcher }); } +/** mTLS-aware fetch shared by internal SDK handoffs and the first-party MCP. */ +export function internalTlsFetch(config: { MTLS_ENABLED: boolean; MTLS_CERTS_DIR: string }): typeof fetch { + return ( + createTlsFetch({ + enabled: config.MTLS_ENABLED, + dir: config.MTLS_CERTS_DIR, + }) ?? fetch + ); +} + /** HTTPS `serve` options when mTLS is on; undefined → plain HTTP. */ export function serverTlsServeOptions( options: TlsOptions, diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index d8b9d875c..9f6848195 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -12,14 +12,11 @@ import { configureOutboundFetches, configureOutboundUrlGuard, - configureTrustedMcpEndpoint, extractErrorLogFields, } from '@truefoundry/trueforge-core/core'; import type { Context } from 'hono'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; -import { createTlsFetch, normalizeTlsUrl } from './http/tls'; -import { withTrueForgeMcpHeader } from './mcp/server/trueforge/headers'; import { ensureLocalSandboxRootParent, prepareCodeModeSocketParent, @@ -43,6 +40,7 @@ try { getPublicUiBasePath, TrueForgeAuthMode, } = await import('./config')); + const { trueForgeMcpTrustedEndpoint } = await import('./mcp/server/trueforge/endpoint'); configureOutboundUrlGuard({ enabled: configuration.NETWORK_POLICY_ENABLED, allowedHosts: configuration.OUTBOUND_URL_ALLOWED_HOSTS, @@ -69,15 +67,9 @@ try { bodyTimeoutMs: configuration.MCP_HTTP_BODY_TIMEOUT_MS, maxRetries: configuration.MCP_HTTP_MAX_RETRIES, idempotent: false, + trustedEndpoint: trueForgeMcpTrustedEndpoint, }, }); - const tls = { enabled: configuration.MTLS_ENABLED, dir: configuration.MTLS_CERTS_DIR }; - const trustedFetch = createTlsFetch(tls) ?? fetch; - const serverUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: tls.enabled }); - configureTrustedMcpEndpoint({ - url: new URL('/mcp', serverUrl).href, - fetch: withTrueForgeMcpHeader(trustedFetch), - }); } catch (error) { console.error( 'Failed to start server: Failed to load configuration:', diff --git a/packages/trueforge/src/mcp/server/trueforge/client.ts b/packages/trueforge/src/mcp/server/trueforge/client.ts deleted file mode 100644 index 63ab323b2..000000000 --- a/packages/trueforge/src/mcp/server/trueforge/client.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Request-scoped TrueForge SDK client for the TrueForge MCP. - * - * Base URL comes from trusted server config (never a tool arg or Host header). The draft caller's - * bearer is forwarded as-is; a standalone request with no credential uses no auth. The service - * `TRUEFORGE_API_KEY` is intentionally never used here. - */ -import { TrueForge } from '@truefoundry/trueforge-sdk'; -import configuration from '../../../config'; -import { createTlsFetch, normalizeTlsUrl } from '../../../http/tls'; - -export function createTrueForgeClient(input: { baseUrl: string; userCredential: string | null }): TrueForge { - const tls = { enabled: configuration.MTLS_ENABLED, dir: configuration.MTLS_CERTS_DIR }; - const tlsFetch = createTlsFetch(tls); - return new TrueForge({ - baseUrl: normalizeTlsUrl({ url: input.baseUrl, enabled: tls.enabled }), - timeoutInSeconds: 60, - ...(input.userCredential === null ? { auth: false } : { token: input.userCredential }), - ...(tlsFetch === undefined ? {} : { fetch: tlsFetch }), - }); -} diff --git a/packages/trueforge/src/mcp/server/trueforge/endpoint.ts b/packages/trueforge/src/mcp/server/trueforge/endpoint.ts new file mode 100644 index 000000000..01673c405 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/endpoint.ts @@ -0,0 +1,36 @@ +import { TrueForge } from '@truefoundry/trueforge-sdk'; +import configuration from '../../../config'; +import { internalTlsFetch, normalizeTlsUrl } from '../../../http/tls'; + +export const X_TFG_TRUEFORGE_MCP = 'x-tfg-trueforge-mcp'; + +const baseUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: configuration.MTLS_ENABLED }); +const fetch = internalTlsFetch(configuration); + +function withTrueForgeMcpHeader(fetchFn: typeof globalThis.fetch): typeof globalThis.fetch { + return (input, init) => { + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init?.headers).forEach((value, name) => headers.set(name, value)); + headers.set(X_TFG_TRUEFORGE_MCP, 'true'); + return fetchFn(input, { ...init, headers }); + }; +} + +/** Exact self-origin MCP endpoint and marked transport allowed to bypass the outbound SSRF guard. */ +export const trueForgeMcpTrustedEndpoint = { + url: new URL('/mcp', baseUrl).href, + fetch: withTrueForgeMcpHeader(fetch), +}; + +/** + * Request-scoped SDK client carrying the draft caller's credential. The service API key is + * intentionally never used here. + */ +export function createTrueForgeMcpClient(input: { userCredential: string | null }): TrueForge { + return new TrueForge({ + baseUrl, + timeoutInSeconds: 60, + ...(input.userCredential === null ? { auth: false } : { token: input.userCredential }), + fetch, + }); +} diff --git a/packages/trueforge/src/mcp/server/trueforge/errors.ts b/packages/trueforge/src/mcp/server/trueforge/errors.ts deleted file mode 100644 index 0458a1f54..000000000 --- a/packages/trueforge/src/mcp/server/trueforge/errors.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** Convert SDK/API failures into a compact, secret-free shape for MCP tool results. */ -import { TrueForgeError } from '@truefoundry/trueforge-sdk'; - -export interface SafeToolError { - status: number | null; - message: string; -} - -const FALLBACK_MESSAGE = 'The TrueForge API request failed.'; - -/** Best-effort read of the TrueForge `{ error: { message } }` envelope. */ -function messageFromBody(body: unknown): string | undefined { - if (typeof body === 'object' && body !== null && 'error' in body) { - const { error } = body; - if (typeof error === 'object' && error !== null && 'message' in error) { - const { message } = error; - if (typeof message === 'string') { - return message; - } - } - } - return undefined; -} - -/** - * Surface only the HTTP status and the API's own error-envelope message. Anything else (raw body, - * response headers, cause chain, stack, internal URLs in `Error.message`) collapses to a static - * message so transport/network failures can't leak infra detail into the model's context. - */ -export function toSafeToolError(error: unknown): SafeToolError { - if (error instanceof TrueForgeError) { - return { - status: error.statusCode ?? null, - message: messageFromBody(error.body) ?? FALLBACK_MESSAGE, - }; - } - return { status: null, message: FALLBACK_MESSAGE }; -} diff --git a/packages/trueforge/src/mcp/server/trueforge/headers.ts b/packages/trueforge/src/mcp/server/trueforge/headers.ts deleted file mode 100644 index 2bce075ea..000000000 --- a/packages/trueforge/src/mcp/server/trueforge/headers.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const X_TFG_TRUEFORGE_MCP = 'x-tfg-trueforge-mcp'; - -/** - * Adds the TrueForge MCP marker without dropping headers already present on either a Request or - * RequestInit. This is a coarse invocation gate, not an authentication boundary. - */ -export function withTrueForgeMcpHeader(fetchFn: typeof fetch): typeof fetch { - return (input, init) => { - const headers = new Headers(input instanceof Request ? input.headers : undefined); - new Headers(init?.headers).forEach((value, name) => headers.set(name, value)); - headers.set(X_TFG_TRUEFORGE_MCP, 'true'); - return fetchFn(input, { ...init, headers }); - }; -} diff --git a/packages/trueforge/src/mcp/server/trueforge/result.ts b/packages/trueforge/src/mcp/server/trueforge/result.ts index 6b2f60f81..18e221725 100644 --- a/packages/trueforge/src/mcp/server/trueforge/result.ts +++ b/packages/trueforge/src/mcp/server/trueforge/result.ts @@ -1,6 +1,38 @@ /** Shared success/error shaping for TrueForge tool results. */ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { toSafeToolError } from './errors'; +import { TrueForgeError } from '@truefoundry/trueforge-sdk'; + +interface SafeToolError { + status: number | null; + message: string; +} + +const FALLBACK_MESSAGE = 'The TrueForge API request failed.'; + +/** Best-effort read of the TrueForge `{ error: { message } }` envelope. */ +function messageFromBody(body: unknown): string | undefined { + if (typeof body === 'object' && body !== null && 'error' in body) { + const { error } = body; + if (typeof error === 'object' && error !== null && 'message' in error) { + const { message } = error; + if (typeof message === 'string') { + return message; + } + } + } + return undefined; +} + +/** Convert SDK/API failures into a compact, secret-free shape for MCP tool results. */ +function toSafeToolError(error: unknown): SafeToolError { + if (error instanceof TrueForgeError) { + return { + status: error.statusCode ?? null, + message: messageFromBody(error.body) ?? FALLBACK_MESSAGE, + }; + } + return { status: null, message: FALLBACK_MESSAGE }; +} /** Mirror the API envelope as both text and structuredContent; no second prose summary. */ export function toolOk(result: unknown): CallToolResult { diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts index 22ee59a47..7789c3ad1 100644 --- a/packages/trueforge/src/mcp/server/trueforge/route.ts +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -7,18 +7,13 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; -import type { Logger } from 'winston'; import type { ResolveRequestContext } from '../../../auth/identity'; -import { createTrueForgeClient } from './client'; -import { X_TFG_TRUEFORGE_MCP } from './headers'; +import { createTrueForgeMcpClient, X_TFG_TRUEFORGE_MCP } from './endpoint'; import { createTrueForgeMcpServer } from './server'; export interface TrueForgeMcpRouterDeps { resolveRequestContext: ResolveRequestContext; - /** Trusted self-origin base URL */ - serverUrl: string; version: string; - logger: Logger; } export function requireTrueForgeMcpHeader(raw: string | undefined): void { @@ -35,10 +30,7 @@ export function createTrueForgeMcpRouter(deps: TrueForgeMcpRouterDeps): Hono { // Built per request: the server's tools close over this caller's credential, and stateless // Streamable HTTP wants a fresh server+transport per request to isolate concurrent callers. - const client = createTrueForgeClient({ - baseUrl: deps.serverUrl, - userCredential: requestContext.user_credential, - }); + const client = createTrueForgeMcpClient({ userCredential: requestContext.user_credential }); const server = createTrueForgeMcpServer({ client, version: deps.version }); const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true }); await server.connect(transport); diff --git a/packages/trueforge/src/mcp/server/trueforge/server.ts b/packages/trueforge/src/mcp/server/trueforge/server.ts index 18184cdf8..e59cff0f6 100644 --- a/packages/trueforge/src/mcp/server/trueforge/server.ts +++ b/packages/trueforge/src/mcp/server/trueforge/server.ts @@ -1,6 +1,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TrueForge } from '@truefoundry/trueforge-sdk'; -import { registerTrueForgeTools } from './tools'; +import { registerAgentTools } from './tools/agents'; +import { registerMcpServerTools } from './tools/mcpServers'; +import { registerModelTools } from './tools/models'; +import { registerSandboxEnvironmentTools } from './tools/sandboxEnvironments'; +import { registerScheduleTools } from './tools/schedules'; +import { registerSkillTools } from './tools/skills'; /** The active built-in TrueForge connector name. See docs-internal/trueforge-mcp-server.md §3.5/§3.8. */ export const TRUEFORGE_CONNECTOR_NAME = 'trueforge'; @@ -20,6 +25,11 @@ export function createTrueForgeMcpServer(input: { client: TrueForge; version: st { name: TRUEFORGE_CONNECTOR_NAME, title: TRUEFORGE_SERVER_TITLE, version: input.version }, { instructions: TRUEFORGE_INSTRUCTIONS }, ); - registerTrueForgeTools(server, input.client); + registerAgentTools(server, input.client); + registerScheduleTools(server, input.client); + registerSandboxEnvironmentTools(server, input.client); + registerModelTools(server, input.client); + registerMcpServerTools(server, input.client); + registerSkillTools(server, input.client); return server; } diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/index.ts b/packages/trueforge/src/mcp/server/trueforge/tools/index.ts deleted file mode 100644 index b399ae99d..000000000 --- a/packages/trueforge/src/mcp/server/trueforge/tools/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { TrueForge } from '@truefoundry/trueforge-sdk'; -import { registerAgentTools } from './agents'; -import { registerMcpServerTools } from './mcpServers'; -import { registerModelTools } from './models'; -import { registerSandboxEnvironmentTools } from './sandboxEnvironments'; -import { registerScheduleTools } from './schedules'; -import { registerSkillTools } from './skills'; - -/** Register the fixed TrueForge tool catalog against a request-scoped SDK client. */ -export function registerTrueForgeTools(server: McpServer, client: TrueForge): void { - registerAgentTools(server, client); - registerScheduleTools(server, client); - registerSandboxEnvironmentTools(server, client); - registerModelTools(server, client); - registerMcpServerTools(server, client); - registerSkillTools(server, client); -} diff --git a/packages/trueforge/tests/unit/mcp/server/trueforge/headers.test.ts b/packages/trueforge/tests/unit/mcp/server/trueforge/headers.test.ts deleted file mode 100644 index 572be409a..000000000 --- a/packages/trueforge/tests/unit/mcp/server/trueforge/headers.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { withTrueForgeMcpHeader, X_TFG_TRUEFORGE_MCP } from '../../../../../src/mcp/server/trueforge/headers'; - -describe('withTrueForgeMcpHeader', () => { - it('adds the invocation marker and preserves request and init headers', async () => { - let receivedHeaders: Headers | undefined; - const fetchFn = (async (_input, init) => { - receivedHeaders = new Headers(init?.headers); - return new Response(); - }) as typeof fetch; - const wrappedFetch = withTrueForgeMcpHeader(fetchFn); - const request = new Request('https://trueforge.example/mcp', { - headers: { authorization: 'Bearer token', 'x-request-header': 'request' }, - }); - - await wrappedFetch(request, { headers: { 'x-init-header': 'init' } }); - - expect(receivedHeaders?.get(X_TFG_TRUEFORGE_MCP)).toBe('true'); - expect(receivedHeaders?.get('authorization')).toBe('Bearer token'); - expect(receivedHeaders?.get('x-request-header')).toBe('request'); - expect(receivedHeaders?.get('x-init-header')).toBe('init'); - }); -}); diff --git a/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts b/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts deleted file mode 100644 index c4f2283c8..000000000 --- a/packages/trueforge/tests/unit/mcp/server/trueforge/route.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { HTTPException } from 'hono/http-exception'; -import { requireTrueForgeMcpHeader } from '../../../../../src/mcp/server/trueforge/route'; - -function statusOf(fn: () => void): number { - try { - fn(); - return 200; - } catch (error) { - if (error instanceof HTTPException) { - return error.status; - } - throw error; - } -} - -describe('requireTrueForgeMcpHeader', () => { - it('rejects a missing invocation marker', () => { - expect(statusOf(() => requireTrueForgeMcpHeader(undefined))).toBe(403); - }); - - it('accepts the invocation marker without inspecting its contents', () => { - expect(statusOf(() => requireTrueForgeMcpHeader('not-validated'))).toBe(200); - }); - - it('accepts an empty invocation marker because only presence is required', () => { - expect(statusOf(() => requireTrueForgeMcpHeader(''))).toBe(200); - }); -}); From dc1c7a8dae791de344430d352216e09ff395d237 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 13 Oct 2026 16:03:17 +0530 Subject: [PATCH 08/20] add optional fetch input to remote MCP --- .../src/agent-session/TurnResourceResolver.ts | 3 +- packages/trueforge-core/src/core/index.ts | 2 +- .../trueforge-core/src/core/mcp/RemoteMCP.ts | 6 +++ .../src/core/mcp/remoteMcpClient.ts | 7 ++- .../trueforge-core/src/core/util/ssrfGuard.ts | 24 +--------- .../tests/core/mcp/remoteMcpServer.test.ts | 14 +++++- packages/trueforge/src/apis/mcpServers.ts | 1 + packages/trueforge/src/main.ts | 2 - .../src/mcp/server/trueforge/endpoint.ts | 36 --------------- .../src/mcp/server/trueforge/route.ts | 45 ++++++++++++++++--- .../trueforge/src/runtime/sessionResources.ts | 20 ++++++--- 11 files changed, 85 insertions(+), 75 deletions(-) delete mode 100644 packages/trueforge/src/mcp/server/trueforge/endpoint.ts diff --git a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts index f094afb9d..fea1f6583 100644 --- a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts +++ b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts @@ -182,13 +182,14 @@ export class TurnResourceResolver< const source = await this.getOrCreateToolSource({ id: entry.name, create: async () => { - const { url, description, headers } = await this.deps.mcp(entry.name); + const { url, description, headers, fetch } = await this.deps.mcp(entry.name); return new RemoteMCP({ id: entry.name, name: entry.name, url, headers: headers ?? {}, description, + ...(fetch === undefined ? {} : { fetch }), sessionId: previousTurn?.snapshot.mcp_servers?.[entry.name]?.session_id, requestTimeoutMs: this.deps.mcpRequestTimeoutMs, connectTimeoutMs: this.deps.mcpConnectTimeoutMs, diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index ea60be1ba..ac041b41a 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -79,7 +79,7 @@ export type { ToolDefinition } from './mcp/LocalToolMCP'; export { RemoteMCP } from './mcp/RemoteMCP'; export type { RemoteMcpHeaders, ResolveHeadersResult, ResolvedMcpServer } from './mcp/RemoteMCP'; export { DEFAULT_MAX_MCP_RESPONSE_BYTES } from './mcp/remoteMcpClient'; -export type { RemoteMcpConnection, RemoteMcpTransportType } from './mcp/remoteMcpClient'; +export type { RemoteMcpConnection, RemoteMcpFetch, RemoteMcpTransportType } from './mcp/remoteMcpClient'; export type { ToolSelectorConfig } from './mcp/ToolSelectorPolicy'; export { DEFAULT_DISABLE_TOOLS, diff --git a/packages/trueforge-core/src/core/mcp/RemoteMCP.ts b/packages/trueforge-core/src/core/mcp/RemoteMCP.ts index 924bcc1c0..dc2baea2c 100644 --- a/packages/trueforge-core/src/core/mcp/RemoteMCP.ts +++ b/packages/trueforge-core/src/core/mcp/RemoteMCP.ts @@ -20,6 +20,7 @@ import { DEFAULT_MAX_MCP_RESPONSE_BYTES, isSessionExpiredError, type RemoteMcpConnection, + type RemoteMcpFetch, type RemoteMcpTransportType, } from './remoteMcpClient'; @@ -44,6 +45,7 @@ export interface ResolvedMcpServer { url: string; description: string; headers?: RemoteMcpHeaders; + fetch?: RemoteMcpFetch; } type ExecuteResult = { result: T; wasInitialized: MCPServerInitInfo | undefined } | AuthRequiredResponse; @@ -60,6 +62,7 @@ export class RemoteMCP implements ToolSource { private readonly url: string; private readonly headers: RemoteMcpHeaders; + private readonly fetch: RemoteMcpFetch | undefined; private readonly signal: AbortSignal; private readonly logger: Logger; private readonly tracing: AgentTracing; @@ -85,6 +88,7 @@ export class RemoteMCP implements ToolSource { description: string | undefined; url: string; headers: RemoteMcpHeaders; + fetch?: RemoteMcpFetch; logger: Logger; tracing?: AgentTracing | undefined; sessionId?: string | undefined; @@ -99,6 +103,7 @@ export class RemoteMCP implements ToolSource { this.description = params.description; this.url = params.url; this.headers = params.headers; + this.fetch = params.fetch; this.signal = params.signal; this.sessionId = params.sessionId; this.resolvedTransportType = params.transportType; @@ -304,6 +309,7 @@ export class RemoteMCP implements ToolSource { const conn = await connectRemoteMcp({ url: this.url, headers, + ...(this.fetch === undefined ? {} : { fetch: this.fetch }), sessionId: this.sessionId ?? undefined, // Hint from a prior connect knownTransportType: this.resolvedTransportType, diff --git a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts index 51b222f4d..4621fdcba 100644 --- a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts +++ b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts @@ -14,6 +14,7 @@ import type { ToolSchema } from './IMCPServer'; /** Networking for remote (url-based) MCP servers, kept separate so it can be mocked in tests. */ export type RemoteMcpTransportType = 'streamable-http' | 'sse'; +export type RemoteMcpFetch = FetchLike; export interface RemoteMcpConnection { readonly transportType: RemoteMcpTransportType; @@ -177,6 +178,7 @@ function buildConnection( export async function connectRemoteMcp(params: { url: string; headers: Record; + fetch?: RemoteMcpFetch; sessionId?: string | undefined; knownTransportType?: RemoteMcpTransportType | undefined; requestTimeoutMs: number; @@ -188,7 +190,10 @@ export async function connectRemoteMcp(params: { }): Promise { const url = new URL(params.url); const requestOptions = { signal: params.signal }; - const fetchFn = withMaxResponseBytes(mcpSsrfFetch, params.maxResponseBytes ?? DEFAULT_MAX_MCP_RESPONSE_BYTES); + const fetchFn = withMaxResponseBytes( + params.fetch ?? mcpSsrfFetch, + params.maxResponseBytes ?? DEFAULT_MAX_MCP_RESPONSE_BYTES, + ); const candidates = params.knownTransportType ? [params.knownTransportType, ...TRANSPORT_PROBE_ORDER.filter(t => t !== params.knownTransportType)] : TRANSPORT_PROBE_ORDER; diff --git a/packages/trueforge-core/src/core/util/ssrfGuard.ts b/packages/trueforge-core/src/core/util/ssrfGuard.ts index 72e8075d9..55dbcdea1 100644 --- a/packages/trueforge-core/src/core/util/ssrfGuard.ts +++ b/packages/trueforge-core/src/core/util/ssrfGuard.ts @@ -31,11 +31,6 @@ export interface OutboundFetchOptions { * timeouts and 409/520/524 are not retried. */ idempotent: boolean; - /** Exact server-owned endpoint allowed to bypass SSRF through a caller-supplied fetch. */ - trustedEndpoint?: { - url: string; - fetch: (input: string | URL | Request, init?: RequestInit) => Promise; - }; } export interface OutboundFetch { @@ -489,25 +484,8 @@ export function createOutboundFetch(options: OutboundFetchOptions): OutboundFetc }); const maxRetries = options.maxRetries; const idempotent = options.idempotent; - const trustedEndpoint = - options.trustedEndpoint === undefined - ? undefined - : { url: new URL(options.trustedEndpoint.url).href, fetch: options.trustedEndpoint.fetch }; return { - fetch: (input, init) => { - if (trustedEndpoint !== undefined) { - let url: string | undefined; - try { - url = new URL(input instanceof Request ? input.url : input).href; - } catch { - // The guarded fetch below produces the standard blocked-URL error. - } - if (url === trustedEndpoint.url) { - return trustedEndpoint.fetch(input, { ...init, redirect: 'manual' }); - } - } - return withOutboundHttpRetries(input, init ?? {}, agent, { maxRetries, idempotent }); - }, + fetch: (input, init) => withOutboundHttpRetries(input, init ?? {}, agent, { maxRetries, idempotent }), close: async () => { await agent.close().catch(() => undefined); }, diff --git a/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts b/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts index 267f85bd6..94bb8212d 100644 --- a/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts +++ b/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts @@ -7,7 +7,7 @@ import { type ToolSchema, } from '../../../src/core/mcp/IMCPServer'; import type { RemoteMcpHeaders } from '../../../src/core/mcp/RemoteMCP'; -import { connectRemoteMcp } from '../../../src/core/mcp/remoteMcpClient'; +import { connectRemoteMcp, type RemoteMcpFetch } from '../../../src/core/mcp/remoteMcpClient'; import type { ToolSelectorConfig } from '../../../src/core/mcp/ToolSelectorPolicy'; import { NOOP_AGENT_TRACING } from '../../../src/core/tracing/NoopAgentTracing'; @@ -127,6 +127,7 @@ function makeServer(params: { sessionId?: string | undefined; transportType?: 'streamable-http' | 'sse' | undefined; headers?: RemoteMcpHeaders | undefined; + fetch?: RemoteMcpFetch; }): ToolSet { const remote = new RemoteMCP({ name: 'my-inline', @@ -134,6 +135,7 @@ function makeServer(params: { description: 'my-inline MCP server', url: 'https://mcp.example.com/mcp', headers: params.headers ?? {}, + ...(params.fetch === undefined ? {} : { fetch: params.fetch }), logger: noopLogger, tracing: NOOP_AGENT_TRACING, sessionId: params.sessionId, @@ -151,6 +153,16 @@ function makeServer(params: { } describe('RemoteMCP + ToolSet', () => { + it('passes a connection-specific fetch to the MCP transport', async () => { + installFakeConnection(); + const fetch = jest.fn() as unknown as RemoteMcpFetch; + const server = makeServer({ fetch }); + + await server.listTools(); + + expect(mockConnect).toHaveBeenCalledWith(expect.objectContaining({ fetch })); + }); + it('connects once, emits init info with session id, and caches across listTools calls', async () => { const state = installFakeConnection({ sessionId: 'sess-1' }); const server = makeServer({}); diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index d8ad768d9..3300b2c2f 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -482,6 +482,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< description: connection.description, url: connection.url, headers: connection.headers ?? {}, + ...(connection.fetch === undefined ? {} : { fetch: connection.fetch }), requestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, connectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, maxResponseBytes: configuration.MCP_TOOL_CALL_MAX_RESPONSE_BYTES, diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 9f6848195..609848285 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -40,7 +40,6 @@ try { getPublicUiBasePath, TrueForgeAuthMode, } = await import('./config')); - const { trueForgeMcpTrustedEndpoint } = await import('./mcp/server/trueforge/endpoint'); configureOutboundUrlGuard({ enabled: configuration.NETWORK_POLICY_ENABLED, allowedHosts: configuration.OUTBOUND_URL_ALLOWED_HOSTS, @@ -67,7 +66,6 @@ try { bodyTimeoutMs: configuration.MCP_HTTP_BODY_TIMEOUT_MS, maxRetries: configuration.MCP_HTTP_MAX_RETRIES, idempotent: false, - trustedEndpoint: trueForgeMcpTrustedEndpoint, }, }); } catch (error) { diff --git a/packages/trueforge/src/mcp/server/trueforge/endpoint.ts b/packages/trueforge/src/mcp/server/trueforge/endpoint.ts deleted file mode 100644 index 01673c405..000000000 --- a/packages/trueforge/src/mcp/server/trueforge/endpoint.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TrueForge } from '@truefoundry/trueforge-sdk'; -import configuration from '../../../config'; -import { internalTlsFetch, normalizeTlsUrl } from '../../../http/tls'; - -export const X_TFG_TRUEFORGE_MCP = 'x-tfg-trueforge-mcp'; - -const baseUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: configuration.MTLS_ENABLED }); -const fetch = internalTlsFetch(configuration); - -function withTrueForgeMcpHeader(fetchFn: typeof globalThis.fetch): typeof globalThis.fetch { - return (input, init) => { - const headers = new Headers(input instanceof Request ? input.headers : undefined); - new Headers(init?.headers).forEach((value, name) => headers.set(name, value)); - headers.set(X_TFG_TRUEFORGE_MCP, 'true'); - return fetchFn(input, { ...init, headers }); - }; -} - -/** Exact self-origin MCP endpoint and marked transport allowed to bypass the outbound SSRF guard. */ -export const trueForgeMcpTrustedEndpoint = { - url: new URL('/mcp', baseUrl).href, - fetch: withTrueForgeMcpHeader(fetch), -}; - -/** - * Request-scoped SDK client carrying the draft caller's credential. The service API key is - * intentionally never used here. - */ -export function createTrueForgeMcpClient(input: { userCredential: string | null }): TrueForge { - return new TrueForge({ - baseUrl, - timeoutInSeconds: 60, - ...(input.userCredential === null ? { auth: false } : { token: input.userCredential }), - fetch, - }); -} diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts index 7789c3ad1..e5ff4a20e 100644 --- a/packages/trueforge/src/mcp/server/trueforge/route.ts +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -5,15 +5,26 @@ * stateless MCP server → hand the Web Request to the transport. Nothing is retained across requests. */ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { TrueForge } from '@truefoundry/trueforge-sdk'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { ResolveRequestContext } from '../../../auth/identity'; -import { createTrueForgeMcpClient, X_TFG_TRUEFORGE_MCP } from './endpoint'; +import configuration from '../../../config'; +import { internalTlsFetch, normalizeTlsUrl } from '../../../http/tls'; import { createTrueForgeMcpServer } from './server'; -export interface TrueForgeMcpRouterDeps { - resolveRequestContext: ResolveRequestContext; - version: string; +export const X_TFG_TRUEFORGE_MCP = 'x-tfg-trueforge-mcp'; + +const baseUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: configuration.MTLS_ENABLED }); +const internalFetch = internalTlsFetch(configuration); + +function withTrueForgeMcpHeader(fetchFn: typeof globalThis.fetch): typeof globalThis.fetch { + return (input, init) => { + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init?.headers).forEach((value, name) => headers.set(name, value)); + headers.set(X_TFG_TRUEFORGE_MCP, 'true'); + return fetchFn(input, { ...init, headers, redirect: 'manual' }); + }; } export function requireTrueForgeMcpHeader(raw: string | undefined): void { @@ -22,6 +33,30 @@ export function requireTrueForgeMcpHeader(raw: string | undefined): void { } } +/** + * Request-scoped SDK client carrying the draft caller's credential. The service API key is + * intentionally never used here. + */ +function createTrueForgeSDKClient(input: { userCredential: string | null }): TrueForge { + return new TrueForge({ + baseUrl, + timeoutInSeconds: 60, + ...(input.userCredential === null ? { auth: false } : { token: input.userCredential }), + fetch: internalFetch, + }); +} + +/** Canonical self-origin MCP connection with its marked, mTLS-aware fetch. */ +export const trueForgeMcpConnection = { + url: new URL('/mcp', baseUrl).href, + fetch: withTrueForgeMcpHeader(internalFetch), +}; + +export interface TrueForgeMcpRouterDeps { + resolveRequestContext: ResolveRequestContext; + version: string; +} + export function createTrueForgeMcpRouter(deps: TrueForgeMcpRouterDeps): Hono { const router = new Hono(); router.all('/', async c => { @@ -30,7 +65,7 @@ export function createTrueForgeMcpRouter(deps: TrueForgeMcpRouterDeps): Hono { // Built per request: the server's tools close over this caller's credential, and stateless // Streamable HTTP wants a fresh server+transport per request to isolate concurrent callers. - const client = createTrueForgeMcpClient({ userCredential: requestContext.user_credential }); + const client = createTrueForgeSDKClient({ userCredential: requestContext.user_credential }); const server = createTrueForgeMcpServer({ client, version: deps.version }); const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true }); await server.connect(transport); diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 93482cb49..44b5fd119 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -22,6 +22,8 @@ import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; import type { ISkillStore } from '../db/skillStore'; import type { TurnMetadata } from '../db/turnMetadata'; import type { IWebSearchProviderStore } from '../db/webSearchProviderStore'; +import { trueForgeMcpConnection } from '../mcp/server/trueforge/route'; +import { TRUEFORGE_CONNECTOR_NAME } from '../mcp/server/trueforge/server'; import { LocalSandboxProvider } from '../sandbox/local/provider/LocalSandboxProvider'; import { getCachedLocalSandboxSupport, isLocalSandboxFallbackEnabled } from '../sandbox/localRuntime'; import { @@ -128,14 +130,22 @@ export async function resolveMcpServer({ if (record === undefined) { return undefined; } + const headers = store.resolveInvokeHeaders({ + record, + userRef, + ...(turnMetadata === undefined ? {} : { turnMetadata }), + }); + if (name === TRUEFORGE_CONNECTOR_NAME) { + return { + ...trueForgeMcpConnection, + headers, + description: 'Manage resources in this TrueForge deployment.', + }; + } return { url: record.manifest.url, description: record.manifest.description, - headers: store.resolveInvokeHeaders({ - record, - userRef, - ...(turnMetadata === undefined ? {} : { turnMetadata }), - }), + headers, }; } From 0df5429cb135688581ba78ffd928a84aa8ef3c3a Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 13 Oct 2026 17:31:57 +0530 Subject: [PATCH 09/20] add connection --- .../builderServer.ts | 4 +- .../chatServer.ts | 19 ++++++- .../harnessBuilderServer.test.ts | 1 + .../harnessServer.test.ts | 2 +- packages/trueforge/src/apis/mcpServers.ts | 9 ++-- packages/trueforge/src/apis/schedules.ts | 5 +- packages/trueforge/src/apis/turns.ts | 25 ++++++---- .../trueforge/src/runtime/sessionResources.ts | 50 +++++++++++++++---- .../unit/runtime/resolveMcpServer.test.ts | 35 ++++++++++++- .../unit/runtime/sessionResources.test.ts | 15 ++++++ 10 files changed, 134 insertions(+), 31 deletions(-) diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts index 93e5dcb95..84f1904d1 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/builderServer.ts @@ -6,7 +6,7 @@ import type { TrueForge, TrueForgeApi } from '@truefoundry/trueforge-sdk'; import type { AgentBuilderServer, AgentLibraryEntry, ModelSelection, SearchAgentsParams } from '../../server/types.js'; import { AGENTS_PAGE_DEFAULT, clampAgentsPageSize, drainAgentsList, listAgentsPage } from './agentsList.js'; import { toUiConnectorFromReadEntry, toUiTool } from './catalogs/connectorCatalog.js'; -import { toHarnessAgentSpec, toUiAgentSpec } from './chatServer.js'; +import { toHarnessSavedAgentSpec, toUiAgentSpec } from './chatServer.js'; import { createTrueForgeClient, type CreateTrueForgeClientOptions } from './client.js'; import { listConfiguredMcpServers, listSkills } from './lists.js'; import type { HarnessAgentSpec } from './types.js'; @@ -138,7 +138,7 @@ export function createHarnessBuilderServer( }, async saveAgent({ agentName, description: descriptionRaw, agentSpec, intent }) { - const manifest = toHarnessAgentSpec(agentSpec); + const manifest = toHarnessSavedAgentSpec(agentSpec); const description = descriptionRaw?.trim(); if (intent === 'update') { const agents = await drainAgentsList(client); diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts index 8907091e6..40eca06db 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts @@ -74,6 +74,21 @@ export function toHarnessAgentSpec(spec: HarnessAgentSpec): TrueForgeApi.AgentSp }; } +function toHarnessDraftAgentSpec(spec: HarnessAgentSpec): TrueForgeApi.AgentSpec { + const converted = toHarnessAgentSpec(spec); + const mcpServers = converted.mcpServers ?? []; + if (mcpServers.some(server => server.name === 'trueforge')) { + return converted; + } + return { ...converted, mcpServers: [...mcpServers, { name: 'trueforge' }] }; +} + +export function toHarnessSavedAgentSpec(spec: HarnessAgentSpec): TrueForgeApi.AgentSpec { + const { mcpServers, ...rest } = toHarnessAgentSpec(spec); + const savedMcpServers = mcpServers?.filter(server => server.name !== 'trueforge') ?? []; + return savedMcpServers.length === 0 ? rest : { ...rest, mcpServers: savedMcpServers }; +} + export function toUiAgentSpec(spec: TrueForgeApi.AgentSpec): HarnessAgentSpec { const { mcpServers, skills, ...rest } = spec; return { @@ -207,7 +222,7 @@ export function createHarnessChatServer( } if (request.agentSpec !== undefined) { const created = await client.sessions.create({ - agent: { spec: toHarnessAgentSpec(request.agentSpec) }, + agent: { spec: toHarnessDraftAgentSpec(request.agentSpec) }, ...(metadata === undefined ? {} : { metadata }), }); return toUiSession(created.data); @@ -242,7 +257,7 @@ export function createHarnessChatServer( async updateSession({ sessionId, agentSpec, title, shared }) { // Named (reference) sessions reject agent updates server-side. const body: TrueForgeApi.UpdateSessionRequest = { - ...(agentSpec === undefined ? {} : { agent: { spec: toHarnessAgentSpec(agentSpec) } }), + ...(agentSpec === undefined ? {} : { agent: { spec: toHarnessDraftAgentSpec(agentSpec) } }), ...(title === undefined ? {} : { title }), ...(shared === undefined ? {} : { shared }), }; diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessBuilderServer.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessBuilderServer.test.ts index 75f2b78c3..ad50d5144 100644 --- a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessBuilderServer.test.ts +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessBuilderServer.test.ts @@ -449,6 +449,7 @@ describe('harnessBuilderServer', () => { agentSpec: { model: { name: 'test/model' }, skills: [{ name: 'review' }], + mcpServers: [{ name: 'trueforge' }], }, intent: 'create', }); diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts index 6bd78b29f..dc52fdceb 100644 --- a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts @@ -114,7 +114,7 @@ describe('createHarnessChatServer', () => { assert.deepEqual(sent.agent, { spec: { model: { name: 'test/model' }, - mcp_servers: [{ name: 'github', enable_tools: ['@all'] }], + mcp_servers: [{ name: 'github', enable_tools: ['@all'] }, { name: 'trueforge' }], skills: [{ name: skillName, preload: true }], }, }); diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index 3300b2c2f..90b165677 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -36,7 +36,7 @@ import { putMcpServerRoute, } from '../routes/mcpServerRoutes'; import { findCatalogUsageConflict } from '../runtime/catalogUsage'; -import { resolveMcpServer } from '../runtime/sessionResources'; +import { createMcpConnectionResolver } from '../runtime/sessionResources'; import type { AvailableMcpServer, ConfiguredMcpServer, @@ -466,11 +466,14 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const { name } = c.req.valid('param'); const requestContext = deps.resolveRequestContext(c); const userRef = requestContext.subject.id; + const getMcpConnection = createMcpConnectionResolver({ + store: deps.resolveMcpServerStore(c), + requestContext, + }); // Same url + header resolution as turn execution (store Bearer, DCR, or static headers). - const connection = await resolveMcpServer({ + const connection = await getMcpConnection({ tenant_id: requestContext.tenant_id, name, - store: deps.resolveMcpServerStore(c), userRef, }); if (connection === undefined) { diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index b790267b1..e05934f44 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -57,6 +57,7 @@ import { import type { ActiveTurnRegistry } from '../runtime/activeTurns'; import { minIntervalSeconds, nextTriggerAfter } from '../runtime/cron'; import type { EventSubscriptionRegistry } from '../runtime/event-subscription'; +import { createMcpConnectionResolver } from '../runtime/sessionResources'; import { InvalidCronError, SCHEDULE_MIN_INTERVAL_SECONDS, @@ -120,7 +121,9 @@ export async function startScheduleRunOnRequest(params: { eventSubscriptions: deps.eventSubscriptions, agentStore: deps.agentStore, modelProviderStore: deps.resolveModelProviderStore({ c, runAsAgent: prepared.agent }), - mcpServerStore: deps.resolveMcpServerStore({ c, runAsAgent: prepared.agent }), + getMcpConnection: createMcpConnectionResolver({ + store: deps.resolveMcpServerStore({ c, runAsAgent: prepared.agent }), + }), sandboxProviderStore: deps.resolveSandboxProviderStore(c), sandboxEnvironmentStore: deps.sandboxEnvironmentStore, webSearchProviderStore: deps.resolveWebSearchProviderStore(c), diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index c3402b361..29047330b 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -61,10 +61,11 @@ import { StreamGoneError, type EventSubscription, type EventSubscriptionRegistry import { validateSandboxFilePath } from '../runtime/sandboxFilePath'; import { buildTurnSandbox, + createMcpConnectionResolver, getModelDetails, - resolveMcpServer, resolveSandboxEnvironment, resolveSandboxProvider, + type GetMcpConnection, } from '../runtime/sessionResources'; import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; @@ -144,7 +145,7 @@ export type BeginTurnExecutionDeps = Pick< > & { agentStore: IAgentStore; modelProviderStore: IModelProviderStore; - mcpServerStore: IMcpServerWithAuthStore; + getMcpConnection: GetMcpConnection; sandboxProviderStore: ISandboxProviderStore; webSearchProviderStore: IWebSearchProviderStore; skillStore: Pick; @@ -165,7 +166,7 @@ interface BeginTurnExecutionParams { * the same way: async factories over the corresponding stores. */ function createTurnResolver(deps: { - mcpServerStore: IMcpServerWithAuthStore; + getMcpConnection: GetMcpConnection; skillStore: Pick; sandboxProviderStore: ISandboxProviderStore; sandboxEnvironmentStore: ISandboxEnvironmentStore; @@ -179,7 +180,7 @@ function createTurnResolver(deps: { turnMetadata: TurnMetadata; }): TurnResourceResolver { const { - mcpServerStore, + getMcpConnection, skillStore, sandboxProviderStore, sandboxEnvironmentStore, @@ -214,10 +215,9 @@ function createTurnResolver(deps: { }; }, mcp: async name => { - const connection = await resolveMcpServer({ + const connection = await getMcpConnection({ tenant_id, name, - store: mcpServerStore, userRef, turnMetadata, }); @@ -444,7 +444,7 @@ export async function beginTurnExecution( store: deps.webSearchProviderStore, }); const resolver = createTurnResolver({ - mcpServerStore: deps.mcpServerStore, + getMcpConnection: deps.getMcpConnection, skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, sandboxEnvironmentStore: deps.sandboxEnvironmentStore, @@ -828,10 +828,13 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { allowInlineResources, ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), - mcpServerStore: deps.resolveMcpServerStore({ - c, - allowInlineResources, - ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), + getMcpConnection: createMcpConnectionResolver({ + store: deps.resolveMcpServerStore({ + c, + allowInlineResources, + ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), + }), + requestContext, }), skillStore: deps.resolveTurnSkillsStore({ c, allowInlineResources }), agentStore: deps.resolveAgentStore(c), diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 44b5fd119..24da557ed 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -14,6 +14,7 @@ import { import { HTTPException } from 'hono/http-exception'; import { join } from 'node:path'; import type { Logger } from 'winston'; +import type { RequestContext } from '../auth/identity'; import configuration from '../config'; import type { IMcpServerStore, IMcpServerWithAuthStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; @@ -35,6 +36,41 @@ import type { ReasoningEffort } from '../schemas/modelProvider'; import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { hasConfiguredWebSearchProvider } from '../websearch/providers'; +export interface GetMcpConnectionInput { + tenant_id: string; + name: string; + userRef: string; + turnMetadata?: TurnMetadata; +} + +export type GetMcpConnection = (input: GetMcpConnectionInput) => Promise; + +/** + * Request-scoped MCP resolver. The built-in server is available only with a live request + * context; its `null` credential is valid in standalone mode. + */ +export function createMcpConnectionResolver(input: { + store: IMcpServerWithAuthStore; + requestContext?: RequestContext; +}): GetMcpConnection { + return async params => { + if (params.name === TRUEFORGE_CONNECTOR_NAME) { + if (input.requestContext === undefined) { + return undefined; + } + return { + ...trueForgeMcpConnection, + description: 'Manage resources in this TrueForge deployment.', + headers: + input.requestContext.user_credential === null + ? {} + : { Authorization: `Bearer ${input.requestContext.user_credential}` }, + }; + } + return resolveMcpServer({ ...params, store: input.store }); + }; +} + /** Split `provider/model` FQN. Returns undefined when the shape is not exactly one slash. */ export function parseModelFqn(name: string): { providerName: string; modelName: string } | undefined { const slash = name.indexOf('/'); @@ -135,13 +171,6 @@ export async function resolveMcpServer({ userRef, ...(turnMetadata === undefined ? {} : { turnMetadata }), }); - if (name === TRUEFORGE_CONNECTOR_NAME) { - return { - ...trueForgeMcpConnection, - headers, - description: 'Manage resources in this TrueForge deployment.', - }; - } return { url: record.manifest.url, description: record.manifest.description, @@ -305,10 +334,11 @@ export async function validateAgentSpec({ const requestedMcpServers = spec.mcp_servers ?? []; if (requestedMcpServers.length > 0) { - const names = requestedMcpServers.map(server => server.name); - const records = await mcpServerStore.listServers({ tenant_id, names }); + const configuredRequests = requestedMcpServers.filter(server => server.name !== TRUEFORGE_CONNECTOR_NAME); + const names = configuredRequests.map(server => server.name); + const records = names.length === 0 ? [] : await mcpServerStore.listServers({ tenant_id, names }); const configuredNames = new Set(records.map(record => record.name)); - const unknown = requestedMcpServers.find(server => !configuredNames.has(server.name)); + const unknown = configuredRequests.find(server => !configuredNames.has(server.name)); if (unknown !== undefined) { throw new HTTPException(422, { message: `Unknown MCP server "${unknown.name}" — not configured`, diff --git a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts index 74a4f3f8f..65d28f71e 100644 --- a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts +++ b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts @@ -7,7 +7,7 @@ import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; -import { resolveMcpServer } from '../../../src/runtime/sessionResources'; +import { createMcpConnectionResolver, resolveMcpServer } from '../../../src/runtime/sessionResources'; jest.mock('undici', () => { const actual = jest.requireActual('undici'); @@ -198,6 +198,39 @@ describe('resolveMcpServer', () => { expect(connection.headers).toEqual({}); }); + it('resolves the built-in trueforge MCP without a stored server', async () => { + const getMcpConnection = createMcpConnectionResolver({ + store: mcpServerStore, + requestContext: { + ...STANDALONE_REQUEST_CONTEXT, + user_credential: 'draft-token', + }, + }); + const connection = await getMcpConnection({ + tenant_id: 'default', + name: 'trueforge', + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, + }); + + expect(connection?.url).toMatch(/\/mcp$/); + expect(connection?.headers).toEqual({ Authorization: 'Bearer draft-token' }); + expect(connection?.fetch).toEqual(expect.any(Function)); + }); + + it('does not resolve the built-in trueforge MCP without a request context', async () => { + const getMcpConnection = createMcpConnectionResolver({ + store: mcpServerStore, + }); + + await expect( + getMcpConnection({ + tenant_id: 'default', + name: 'trueforge', + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, + }), + ).resolves.toBeUndefined(); + }); + it('returns configured static headers for header-auth servers', async () => { await mcpServerStore.upsertServer({ tenant_id: 'default', diff --git a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts index f69a001e7..8580a36c1 100644 --- a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts +++ b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts @@ -184,6 +184,21 @@ describe('validateAgentSpec', () => { } satisfies Partial); }); + it('allows the built-in trueforge MCP without a configured server', async () => { + const stores = await setup(); + await expect( + validateAgentSpec({ + spec: AgentSpecSchema.parse({ + model: { name: 'test-provider/test-model' }, + mcp_servers: [{ name: 'trueforge' }], + }), + tenant_id: 'default', + created_by_subject_id: 'test-subject', + ...stores, + }), + ).resolves.toBeUndefined(); + }); + it('rejects unknown skill with 422', async () => { const stores = await setup(); await expect( From 27074ef1b4d7ba7a32052a87c7e6e4334da6e6a4 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 20 Oct 2026 00:58:06 +0530 Subject: [PATCH 10/20] ensure agent spec does not allow trueforge. add changeset --- .changeset/nine-spies-rhyme.md | 8 ++++++ packages/trueforge/src/apis/agents.ts | 6 ++++ .../trueforge/tests/unit/apis/agents.test.ts | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .changeset/nine-spies-rhyme.md diff --git a/.changeset/nine-spies-rhyme.md b/.changeset/nine-spies-rhyme.md new file mode 100644 index 000000000..427415a41 --- /dev/null +++ b/.changeset/nine-spies-rhyme.md @@ -0,0 +1,8 @@ +--- +"@truefoundry/trueforge-core": patch +"@truefoundry/trueforge": patch +"@truefoundry/trueforge-ui": patch +--- + +Add the built-in TrueForge MCP server for managing agents and supporting resources from draft sessions. + diff --git a/packages/trueforge/src/apis/agents.ts b/packages/trueforge/src/apis/agents.ts index b083460c1..eb0816413 100644 --- a/packages/trueforge/src/apis/agents.ts +++ b/packages/trueforge/src/apis/agents.ts @@ -21,6 +21,7 @@ import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; import type { ISkillStore } from '../db/skillStore'; import type { WithTransaction } from '../db/transaction'; import type { IWebSearchProviderStore } from '../db/webSearchProviderStore'; +import { TRUEFORGE_CONNECTOR_NAME } from '../mcp/server/trueforge/server'; import { createAgentRoute, deleteAgentRoute, @@ -80,6 +81,11 @@ async function validateManifest({ tenant_id: string; created_by_subject_id: string; }): Promise { + if (spec.mcp_servers?.some(server => server.name === TRUEFORGE_CONNECTOR_NAME)) { + throw new HTTPException(422, { + message: `Built-in MCP server "${TRUEFORGE_CONNECTOR_NAME}" cannot be persisted in an agent manifest`, + }); + } await validateAgentSpec({ spec, tenant_id, diff --git a/packages/trueforge/tests/unit/apis/agents.test.ts b/packages/trueforge/tests/unit/apis/agents.test.ts index 450ceb4c5..e9af46c6e 100644 --- a/packages/trueforge/tests/unit/apis/agents.test.ts +++ b/packages/trueforge/tests/unit/apis/agents.test.ts @@ -183,6 +183,34 @@ describe('agents router', () => { expect(put.status).toBe(400); }); + it('POST and PUT reject the built-in trueforge MCP in saved agent manifests', async () => { + const manifestWithTrueForge = { + ...manifest, + mcp_servers: [{ name: 'trueforge' }], + }; + const create = await router.request( + '/', + jsonInit('POST', { ...writeBody, name: 'built-in-mcp-create', manifest: manifestWithTrueForge }), + ); + expect(create.status).toBe(422); + await expect(create.text()).resolves.toBe( + 'Built-in MCP server "trueforge" cannot be persisted in an agent manifest', + ); + + const created = await router.request('/', jsonInit('POST', { ...writeBody, name: 'built-in-mcp-update' })); + expect(created.status).toBe(201); + const createdJson = (await created.json()) as { data: WireAgent }; + + const update = await router.request( + `/${createdJson.data.id}`, + jsonInit('PUT', { ...updateBody, manifest: manifestWithTrueForge }), + ); + expect(update.status).toBe(422); + await expect(update.text()).resolves.toBe( + 'Built-in MCP server "trueforge" cannot be persisted in an agent manifest', + ); + }); + it('GET and PUT return 404 for unknown ids', async () => { const get = await router.request('/missing-agent-id'); expect(get.status).toBe(404); From 61c2c19a5bb4dbd5912240af880cedb3029e4ea5 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 20 Oct 2026 01:03:41 +0530 Subject: [PATCH 11/20] model as ifc --- packages/trueforge/src/apis/mcpServers.ts | 6 +-- packages/trueforge/src/apis/schedules.ts | 4 +- packages/trueforge/src/apis/turns.ts | 16 ++++---- .../trueforge/src/runtime/sessionResources.ts | 40 ++++++++++--------- .../unit/runtime/resolveMcpServer.test.ts | 10 ++--- 5 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index 90b165677..c63537b72 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -36,7 +36,7 @@ import { putMcpServerRoute, } from '../routes/mcpServerRoutes'; import { findCatalogUsageConflict } from '../runtime/catalogUsage'; -import { createMcpConnectionResolver } from '../runtime/sessionResources'; +import { createMcpConnectionProvider } from '../runtime/sessionResources'; import type { AvailableMcpServer, ConfiguredMcpServer, @@ -466,12 +466,12 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const { name } = c.req.valid('param'); const requestContext = deps.resolveRequestContext(c); const userRef = requestContext.subject.id; - const getMcpConnection = createMcpConnectionResolver({ + const mcpConnectionProvider = createMcpConnectionProvider({ store: deps.resolveMcpServerStore(c), requestContext, }); // Same url + header resolution as turn execution (store Bearer, DCR, or static headers). - const connection = await getMcpConnection({ + const connection = await mcpConnectionProvider.getConnection({ tenant_id: requestContext.tenant_id, name, userRef, diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index e05934f44..774928742 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -57,7 +57,7 @@ import { import type { ActiveTurnRegistry } from '../runtime/activeTurns'; import { minIntervalSeconds, nextTriggerAfter } from '../runtime/cron'; import type { EventSubscriptionRegistry } from '../runtime/event-subscription'; -import { createMcpConnectionResolver } from '../runtime/sessionResources'; +import { createMcpConnectionProvider } from '../runtime/sessionResources'; import { InvalidCronError, SCHEDULE_MIN_INTERVAL_SECONDS, @@ -121,7 +121,7 @@ export async function startScheduleRunOnRequest(params: { eventSubscriptions: deps.eventSubscriptions, agentStore: deps.agentStore, modelProviderStore: deps.resolveModelProviderStore({ c, runAsAgent: prepared.agent }), - getMcpConnection: createMcpConnectionResolver({ + mcpConnectionProvider: createMcpConnectionProvider({ store: deps.resolveMcpServerStore({ c, runAsAgent: prepared.agent }), }), sandboxProviderStore: deps.resolveSandboxProviderStore(c), diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 29047330b..d4461cf3b 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -61,11 +61,11 @@ import { StreamGoneError, type EventSubscription, type EventSubscriptionRegistry import { validateSandboxFilePath } from '../runtime/sandboxFilePath'; import { buildTurnSandbox, - createMcpConnectionResolver, + createMcpConnectionProvider, getModelDetails, resolveSandboxEnvironment, resolveSandboxProvider, - type GetMcpConnection, + type IMcpConnectionProvider, } from '../runtime/sessionResources'; import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; @@ -145,7 +145,7 @@ export type BeginTurnExecutionDeps = Pick< > & { agentStore: IAgentStore; modelProviderStore: IModelProviderStore; - getMcpConnection: GetMcpConnection; + mcpConnectionProvider: IMcpConnectionProvider; sandboxProviderStore: ISandboxProviderStore; webSearchProviderStore: IWebSearchProviderStore; skillStore: Pick; @@ -166,7 +166,7 @@ interface BeginTurnExecutionParams { * the same way: async factories over the corresponding stores. */ function createTurnResolver(deps: { - getMcpConnection: GetMcpConnection; + mcpConnectionProvider: IMcpConnectionProvider; skillStore: Pick; sandboxProviderStore: ISandboxProviderStore; sandboxEnvironmentStore: ISandboxEnvironmentStore; @@ -180,7 +180,7 @@ function createTurnResolver(deps: { turnMetadata: TurnMetadata; }): TurnResourceResolver { const { - getMcpConnection, + mcpConnectionProvider, skillStore, sandboxProviderStore, sandboxEnvironmentStore, @@ -215,7 +215,7 @@ function createTurnResolver(deps: { }; }, mcp: async name => { - const connection = await getMcpConnection({ + const connection = await mcpConnectionProvider.getConnection({ tenant_id, name, userRef, @@ -444,7 +444,7 @@ export async function beginTurnExecution( store: deps.webSearchProviderStore, }); const resolver = createTurnResolver({ - getMcpConnection: deps.getMcpConnection, + mcpConnectionProvider: deps.mcpConnectionProvider, skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, sandboxEnvironmentStore: deps.sandboxEnvironmentStore, @@ -828,7 +828,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { allowInlineResources, ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), - getMcpConnection: createMcpConnectionResolver({ + mcpConnectionProvider: createMcpConnectionProvider({ store: deps.resolveMcpServerStore({ c, allowInlineResources, diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 24da557ed..c83c361c2 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -43,31 +43,35 @@ export interface GetMcpConnectionInput { turnMetadata?: TurnMetadata; } -export type GetMcpConnection = (input: GetMcpConnectionInput) => Promise; +export interface IMcpConnectionProvider { + getConnection(input: GetMcpConnectionInput): Promise; +} /** - * Request-scoped MCP resolver. The built-in server is available only with a live request + * Request-scoped MCP connection provider. The built-in server is available only with a live request * context; its `null` credential is valid in standalone mode. */ -export function createMcpConnectionResolver(input: { +export function createMcpConnectionProvider(input: { store: IMcpServerWithAuthStore; requestContext?: RequestContext; -}): GetMcpConnection { - return async params => { - if (params.name === TRUEFORGE_CONNECTOR_NAME) { - if (input.requestContext === undefined) { - return undefined; +}): IMcpConnectionProvider { + return { + getConnection: async params => { + if (params.name === TRUEFORGE_CONNECTOR_NAME) { + if (input.requestContext === undefined) { + return undefined; + } + return { + ...trueForgeMcpConnection, + description: 'Manage resources in this TrueForge deployment.', + headers: + input.requestContext.user_credential === null + ? {} + : { Authorization: `Bearer ${input.requestContext.user_credential}` }, + }; } - return { - ...trueForgeMcpConnection, - description: 'Manage resources in this TrueForge deployment.', - headers: - input.requestContext.user_credential === null - ? {} - : { Authorization: `Bearer ${input.requestContext.user_credential}` }, - }; - } - return resolveMcpServer({ ...params, store: input.store }); + return resolveMcpServer({ ...params, store: input.store }); + }, }; } diff --git a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts index 65d28f71e..023d1cf76 100644 --- a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts +++ b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts @@ -7,7 +7,7 @@ import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; -import { createMcpConnectionResolver, resolveMcpServer } from '../../../src/runtime/sessionResources'; +import { createMcpConnectionProvider, resolveMcpServer } from '../../../src/runtime/sessionResources'; jest.mock('undici', () => { const actual = jest.requireActual('undici'); @@ -199,14 +199,14 @@ describe('resolveMcpServer', () => { }); it('resolves the built-in trueforge MCP without a stored server', async () => { - const getMcpConnection = createMcpConnectionResolver({ + const provider = createMcpConnectionProvider({ store: mcpServerStore, requestContext: { ...STANDALONE_REQUEST_CONTEXT, user_credential: 'draft-token', }, }); - const connection = await getMcpConnection({ + const connection = await provider.getConnection({ tenant_id: 'default', name: 'trueforge', userRef: STANDALONE_REQUEST_CONTEXT.subject.id, @@ -218,12 +218,12 @@ describe('resolveMcpServer', () => { }); it('does not resolve the built-in trueforge MCP without a request context', async () => { - const getMcpConnection = createMcpConnectionResolver({ + const provider = createMcpConnectionProvider({ store: mcpServerStore, }); await expect( - getMcpConnection({ + provider.getConnection({ tenant_id: 'default', name: 'trueforge', userRef: STANDALONE_REQUEST_CONTEXT.subject.id, From 5f1e9f4533b7d7f1705806a88b50c04dada45748 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 20 Oct 2026 01:16:57 +0530 Subject: [PATCH 12/20] more cleanup --- .../src/agent-session/TurnResourceResolver.ts | 8 +-- packages/trueforge-core/src/core/index.ts | 18 ++++-- .../trueforge-core/src/core/mcp/RemoteMCP.ts | 45 +++++++------- .../src/core/mcp/remoteMcpClient.ts | 39 +++++++++++- .../tests/agent-session/turnStream.test.ts | 6 +- .../tests/core/mcp/connectRemoteMcp.test.ts | 32 +++++++++- .../tests/core/mcp/remoteMcpServer.test.ts | 28 +++++++-- packages/trueforge/src/apis/mcpServers.ts | 14 ++--- packages/trueforge/src/apis/schedules.ts | 5 +- packages/trueforge/src/apis/turns.ts | 33 +++++----- .../src/mcp/server/trueforge/route.ts | 23 +++---- .../trueforge/src/runtime/sessionResources.ts | 62 +++++++------------ .../unit/runtime/resolveMcpServer.test.ts | 47 +++++++------- 13 files changed, 215 insertions(+), 145 deletions(-) diff --git a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts index fea1f6583..aecd89bcd 100644 --- a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts +++ b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts @@ -182,14 +182,12 @@ export class TurnResourceResolver< const source = await this.getOrCreateToolSource({ id: entry.name, create: async () => { - const { url, description, headers, fetch } = await this.deps.mcp(entry.name); + const resolved = await this.deps.mcp(entry.name); return new RemoteMCP({ id: entry.name, name: entry.name, - url, - headers: headers ?? {}, - description, - ...(fetch === undefined ? {} : { fetch }), + description: resolved.description, + endpoint: resolved, sessionId: previousTurn?.snapshot.mcp_servers?.[entry.name]?.session_id, requestTimeoutMs: this.deps.mcpRequestTimeoutMs, connectTimeoutMs: this.deps.mcpConnectTimeoutMs, diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index ac041b41a..d24903952 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -73,13 +73,23 @@ export type { export { LocalToolMCP, defineTool } from './mcp/LocalToolMCP'; export type { ToolDefinition } from './mcp/LocalToolMCP'; -// Remote MCP server, split into a shared, policy-free connection (`RemoteMCP`) and a per-agent policy -// wrapper (`ToolSet`). `RemoteMCP` connects itself from a `url` + `headers`; the networking helpers +// Remote MCP server, split into a shared, tool-policy-free connection (`RemoteMCP`) and a per-agent +// policy wrapper (`ToolSet`). `RemoteMCP` connects itself from a `RemoteMcpEndpoint`; networking helpers // live in the harness-internal `remoteMcpClient` module. export { RemoteMCP } from './mcp/RemoteMCP'; -export type { RemoteMcpHeaders, ResolveHeadersResult, ResolvedMcpServer } from './mcp/RemoteMCP'; +export type { + RemoteMcpEndpoint, + RemoteMcpHeaders, + ResolveHeadersResult, + ResolvedMcpServer, +} from './mcp/RemoteMCP'; export { DEFAULT_MAX_MCP_RESPONSE_BYTES } from './mcp/remoteMcpClient'; -export type { RemoteMcpConnection, RemoteMcpFetch, RemoteMcpTransportType } from './mcp/remoteMcpClient'; +export type { + RemoteMcpConnection, + RemoteMcpFetch, + RemoteMcpNetworkPolicy, + RemoteMcpTransportType, +} from './mcp/remoteMcpClient'; export type { ToolSelectorConfig } from './mcp/ToolSelectorPolicy'; export { DEFAULT_DISABLE_TOOLS, diff --git a/packages/trueforge-core/src/core/mcp/RemoteMCP.ts b/packages/trueforge-core/src/core/mcp/RemoteMCP.ts index dc2baea2c..00724d70f 100644 --- a/packages/trueforge-core/src/core/mcp/RemoteMCP.ts +++ b/packages/trueforge-core/src/core/mcp/RemoteMCP.ts @@ -20,7 +20,7 @@ import { DEFAULT_MAX_MCP_RESPONSE_BYTES, isSessionExpiredError, type RemoteMcpConnection, - type RemoteMcpFetch, + type RemoteMcpNetworkPolicy, type RemoteMcpTransportType, } from './remoteMcpClient'; @@ -40,29 +40,31 @@ export type ResolveHeadersResult = { headers: Record } | { authR /** Static headers, or a resolver (invoked at connect) returning headers or signalling auth-required. */ export type RemoteMcpHeaders = Record | (() => Promise); -/** A configured MCP server resolved for a turn */ -export interface ResolvedMcpServer { +/** Immutable endpoint and network behavior needed to establish an MCP connection. */ +export interface RemoteMcpEndpoint { url: string; - description: string; headers?: RemoteMcpHeaders; - fetch?: RemoteMcpFetch; + networkPolicy?: RemoteMcpNetworkPolicy; +} + +/** A configured MCP server resolved for a turn, including the description deferred tools show. */ +export interface ResolvedMcpServer extends RemoteMcpEndpoint { + description: string; } type ExecuteResult = { result: T; wasInitialized: MCPServerInitInfo | undefined } | AuthRequiredResponse; /** - * Connection/session half of a remote MCP server: owns the transport (connects itself from `url` + - * `headers`), session id, raw tool cache, connect single-flight and session-expiry retry. Policy-free - * — a per-agent {@link ToolSet} layers policy. + * Connection/session half of a remote MCP server: owns the transport endpoint, session id, raw tool + * cache, connect single-flight and session-expiry retry. Tool-policy-free — a per-agent + * {@link ToolSet} layers tool selection and approval policy. */ export class RemoteMCP implements ToolSource { readonly name: string; readonly id: string; readonly description?: string | undefined; - private readonly url: string; - private readonly headers: RemoteMcpHeaders; - private readonly fetch: RemoteMcpFetch | undefined; + private readonly endpoint: RemoteMcpEndpoint; private readonly signal: AbortSignal; private readonly logger: Logger; private readonly tracing: AgentTracing; @@ -86,9 +88,7 @@ export class RemoteMCP implements ToolSource { name: string; id: string; description: string | undefined; - url: string; - headers: RemoteMcpHeaders; - fetch?: RemoteMcpFetch; + endpoint: RemoteMcpEndpoint; logger: Logger; tracing?: AgentTracing | undefined; sessionId?: string | undefined; @@ -101,9 +101,7 @@ export class RemoteMCP implements ToolSource { this.name = params.name; this.id = params.id; this.description = params.description; - this.url = params.url; - this.headers = params.headers; - this.fetch = params.fetch; + this.endpoint = params.endpoint; this.signal = params.signal; this.sessionId = params.sessionId; this.resolvedTransportType = params.transportType; @@ -112,7 +110,7 @@ export class RemoteMCP implements ToolSource { this.maxResponseBytes = params.maxResponseBytes ?? DEFAULT_MAX_MCP_RESPONSE_BYTES; this.logger = params.logger; this.tracing = params.tracing ?? NOOP_AGENT_TRACING; - this.traceUrl = redactUrlForTrace(params.url); + this.traceUrl = redactUrlForTrace(params.endpoint.url); } getSessionId(): string | undefined { @@ -166,10 +164,11 @@ export class RemoteMCP implements ToolSource { } private async resolveHeaders(): Promise { - if (typeof this.headers !== 'function') { - return { headers: this.headers }; + const headers = this.endpoint.headers; + if (typeof headers !== 'function') { + return { headers: headers ?? {} }; } - return await this.headers(); + return await headers(); } private async executeWithSessionRetry( @@ -307,9 +306,9 @@ export class RemoteMCP implements ToolSource { { method: 'initialize', serverName: this.name, serverId: this.id, serverUrl: this.traceUrl, enabled: true }, async span => { const conn = await connectRemoteMcp({ - url: this.url, + url: this.endpoint.url, headers, - ...(this.fetch === undefined ? {} : { fetch: this.fetch }), + ...(this.endpoint.networkPolicy === undefined ? {} : { networkPolicy: this.endpoint.networkPolicy }), sessionId: this.sessionId ?? undefined, // Hint from a prior connect knownTransportType: this.resolvedTransportType, diff --git a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts index 4621fdcba..1cc13f82f 100644 --- a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts +++ b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts @@ -15,6 +15,13 @@ import type { ToolSchema } from './IMCPServer'; export type RemoteMcpTransportType = 'streamable-http' | 'sse'; export type RemoteMcpFetch = FetchLike; +export type RemoteMcpNetworkPolicy = + | { type: 'external' } + | { + type: 'trusted-internal'; + endpoint: string; + fetch: RemoteMcpFetch; + }; export interface RemoteMcpConnection { readonly transportType: RemoteMcpTransportType; @@ -63,6 +70,31 @@ export function withMaxResponseBytes(fetchFn: FetchLike, maxBytes: number): Fetc }; } +function fetchInputUrl(input: Parameters[0]): URL { + return new URL(input); +} + +/** Select the transport fetch while keeping internal trust exact-endpoint scoped. */ +export function fetchForMcpNetworkPolicy(params: { url: URL; policy?: RemoteMcpNetworkPolicy }): RemoteMcpFetch { + const policy = params.policy; + if (policy === undefined || policy.type === 'external') { + return mcpSsrfFetch; + } + + const trustedEndpoint = new URL(policy.endpoint); + if (params.url.href !== trustedEndpoint.href) { + throw new Error(`Trusted MCP policy endpoint does not match connection URL: ${params.url.href}`); + } + + return (input, init) => { + const requestUrl = fetchInputUrl(input); + if (requestUrl.href !== trustedEndpoint.href) { + throw new Error(`Trusted MCP fetch attempted an unexpected URL: ${requestUrl.href}`); + } + return policy.fetch(input, { ...init, redirect: 'manual' }); + }; +} + class McpClientWithTimeout extends Client { constructor(private readonly requestTimeoutMs: number) { super(CLIENT_INFO, { capabilities: {} }); @@ -178,7 +210,7 @@ function buildConnection( export async function connectRemoteMcp(params: { url: string; headers: Record; - fetch?: RemoteMcpFetch; + networkPolicy?: RemoteMcpNetworkPolicy; sessionId?: string | undefined; knownTransportType?: RemoteMcpTransportType | undefined; requestTimeoutMs: number; @@ -191,7 +223,10 @@ export async function connectRemoteMcp(params: { const url = new URL(params.url); const requestOptions = { signal: params.signal }; const fetchFn = withMaxResponseBytes( - params.fetch ?? mcpSsrfFetch, + fetchForMcpNetworkPolicy({ + url, + ...(params.networkPolicy === undefined ? {} : { policy: params.networkPolicy }), + }), params.maxResponseBytes ?? DEFAULT_MAX_MCP_RESPONSE_BYTES, ); const candidates = params.knownTransportType diff --git a/packages/trueforge-core/tests/agent-session/turnStream.test.ts b/packages/trueforge-core/tests/agent-session/turnStream.test.ts index c1044fcac..c0419bb9a 100644 --- a/packages/trueforge-core/tests/agent-session/turnStream.test.ts +++ b/packages/trueforge-core/tests/agent-session/turnStream.test.ts @@ -449,8 +449,10 @@ describe('TurnResourceResolver caches', () => { id: 'svc', name: 'svc', description: 'Example MCP server.', - url: 'http://example.invalid', - headers: {}, + endpoint: { + url: 'http://example.invalid', + headers: {}, + }, logger, tracing: this.createTracing(), requestTimeoutMs: 60_000, diff --git a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts index 386cb15d8..bc035bb17 100644 --- a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts +++ b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts @@ -1,5 +1,5 @@ import { McpConnectionError } from '../../../src/core/errors'; -import { connectRemoteMcp } from '../../../src/core/mcp/remoteMcpClient'; +import { connectRemoteMcp, fetchForMcpNetworkPolicy, type RemoteMcpFetch } from '../../../src/core/mcp/remoteMcpClient'; // Records every transport type client.connect() was attempted with, in order. const mockConnectAttempts: string[] = []; @@ -85,6 +85,36 @@ const baseParams = () => ({ signal: new AbortController().signal, }); +describe('fetchForMcpNetworkPolicy', () => { + const endpoint = 'https://trueforge.internal/mcp'; + + it('allows only the exact trusted endpoint and disables redirects', async () => { + const fetch = jest.fn(async () => new Response(null, { status: 200 })) as unknown as RemoteMcpFetch; + const trustedFetch = fetchForMcpNetworkPolicy({ + url: new URL(endpoint), + policy: { type: 'trusted-internal', endpoint, fetch }, + }); + + await trustedFetch(endpoint, { redirect: 'follow' }); + + expect(fetch).toHaveBeenCalledWith(endpoint, expect.objectContaining({ redirect: 'manual' })); + expect(() => trustedFetch('https://trueforge.internal/other')).toThrow( + 'Trusted MCP fetch attempted an unexpected URL', + ); + }); + + it('rejects a trusted policy whose endpoint differs from the connection URL', () => { + const fetch = jest.fn() as unknown as RemoteMcpFetch; + + expect(() => + fetchForMcpNetworkPolicy({ + url: new URL(endpoint), + policy: { type: 'trusted-internal', endpoint: 'https://other.internal/mcp', fetch }, + }), + ).toThrow('Trusted MCP policy endpoint does not match connection URL'); + }); +}); + describe('connectRemoteMcp transport selection', () => { beforeEach(() => { mockConnectAttempts.length = 0; diff --git a/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts b/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts index 94bb8212d..9517090eb 100644 --- a/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts +++ b/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts @@ -133,9 +133,19 @@ function makeServer(params: { name: 'my-inline', id: 'inline-id-123', description: 'my-inline MCP server', - url: 'https://mcp.example.com/mcp', - headers: params.headers ?? {}, - ...(params.fetch === undefined ? {} : { fetch: params.fetch }), + endpoint: { + url: 'https://mcp.example.com/mcp', + headers: params.headers ?? {}, + ...(params.fetch === undefined + ? {} + : { + networkPolicy: { + type: 'trusted-internal' as const, + endpoint: 'https://mcp.example.com/mcp', + fetch: params.fetch, + }, + }), + }, logger: noopLogger, tracing: NOOP_AGENT_TRACING, sessionId: params.sessionId, @@ -153,14 +163,22 @@ function makeServer(params: { } describe('RemoteMCP + ToolSet', () => { - it('passes a connection-specific fetch to the MCP transport', async () => { + it('passes a connection-specific network policy to the MCP transport', async () => { installFakeConnection(); const fetch = jest.fn() as unknown as RemoteMcpFetch; const server = makeServer({ fetch }); await server.listTools(); - expect(mockConnect).toHaveBeenCalledWith(expect.objectContaining({ fetch })); + expect(mockConnect).toHaveBeenCalledWith( + expect.objectContaining({ + networkPolicy: { + type: 'trusted-internal', + endpoint: 'https://mcp.example.com/mcp', + fetch, + }, + }), + ); }); it('connects once, emits init info with session id, and caches across listTools calls', async () => { diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index c63537b72..ec23cc6a4 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -36,7 +36,7 @@ import { putMcpServerRoute, } from '../routes/mcpServerRoutes'; import { findCatalogUsageConflict } from '../runtime/catalogUsage'; -import { createMcpConnectionProvider } from '../runtime/sessionResources'; +import { getMcpConnection } from '../runtime/sessionResources'; import type { AvailableMcpServer, ConfiguredMcpServer, @@ -466,15 +466,13 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const { name } = c.req.valid('param'); const requestContext = deps.resolveRequestContext(c); const userRef = requestContext.subject.id; - const mcpConnectionProvider = createMcpConnectionProvider({ - store: deps.resolveMcpServerStore(c), - requestContext, - }); // Same url + header resolution as turn execution (store Bearer, DCR, or static headers). - const connection = await mcpConnectionProvider.getConnection({ + const connection = await getMcpConnection({ tenant_id: requestContext.tenant_id, name, + store: deps.resolveMcpServerStore(c), userRef, + requestContext, }); if (connection === undefined) { return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); @@ -483,9 +481,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< id: name, name, description: connection.description, - url: connection.url, - headers: connection.headers ?? {}, - ...(connection.fetch === undefined ? {} : { fetch: connection.fetch }), + endpoint: connection, requestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, connectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, maxResponseBytes: configuration.MCP_TOOL_CALL_MAX_RESPONSE_BYTES, diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index 774928742..b790267b1 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -57,7 +57,6 @@ import { import type { ActiveTurnRegistry } from '../runtime/activeTurns'; import { minIntervalSeconds, nextTriggerAfter } from '../runtime/cron'; import type { EventSubscriptionRegistry } from '../runtime/event-subscription'; -import { createMcpConnectionProvider } from '../runtime/sessionResources'; import { InvalidCronError, SCHEDULE_MIN_INTERVAL_SECONDS, @@ -121,9 +120,7 @@ export async function startScheduleRunOnRequest(params: { eventSubscriptions: deps.eventSubscriptions, agentStore: deps.agentStore, modelProviderStore: deps.resolveModelProviderStore({ c, runAsAgent: prepared.agent }), - mcpConnectionProvider: createMcpConnectionProvider({ - store: deps.resolveMcpServerStore({ c, runAsAgent: prepared.agent }), - }), + mcpServerStore: deps.resolveMcpServerStore({ c, runAsAgent: prepared.agent }), sandboxProviderStore: deps.resolveSandboxProviderStore(c), sandboxEnvironmentStore: deps.sandboxEnvironmentStore, webSearchProviderStore: deps.resolveWebSearchProviderStore(c), diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index d4461cf3b..7493ca4fd 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -36,7 +36,7 @@ import { streamSSE } from 'hono/streaming'; import type { Logger } from 'winston'; import type { ResolveMcpServerStoreInput, ResolveModelProviderStoreInput, ResolveTurnSkillsStoreInput } from '../app'; import type { Authorizer } from '../auth/authorizer'; -import type { ResolveRequestContext } from '../auth/identity'; +import type { RequestContext, ResolveRequestContext } from '../auth/identity'; import configuration from '../config'; import type { AgentRecord, IAgentStore } from '../db/agentStore'; import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; @@ -61,11 +61,10 @@ import { StreamGoneError, type EventSubscription, type EventSubscriptionRegistry import { validateSandboxFilePath } from '../runtime/sandboxFilePath'; import { buildTurnSandbox, - createMcpConnectionProvider, + getMcpConnection, getModelDetails, resolveSandboxEnvironment, resolveSandboxProvider, - type IMcpConnectionProvider, } from '../runtime/sessionResources'; import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { MAX_SESSION_TITLE_LENGTH } from '../schemas/session'; @@ -145,7 +144,8 @@ export type BeginTurnExecutionDeps = Pick< > & { agentStore: IAgentStore; modelProviderStore: IModelProviderStore; - mcpConnectionProvider: IMcpConnectionProvider; + mcpServerStore: IMcpServerWithAuthStore; + requestContext?: RequestContext; sandboxProviderStore: ISandboxProviderStore; webSearchProviderStore: IWebSearchProviderStore; skillStore: Pick; @@ -166,7 +166,8 @@ interface BeginTurnExecutionParams { * the same way: async factories over the corresponding stores. */ function createTurnResolver(deps: { - mcpConnectionProvider: IMcpConnectionProvider; + mcpServerStore: IMcpServerWithAuthStore; + requestContext?: RequestContext; skillStore: Pick; sandboxProviderStore: ISandboxProviderStore; sandboxEnvironmentStore: ISandboxEnvironmentStore; @@ -180,7 +181,8 @@ function createTurnResolver(deps: { turnMetadata: TurnMetadata; }): TurnResourceResolver { const { - mcpConnectionProvider, + mcpServerStore, + requestContext, skillStore, sandboxProviderStore, sandboxEnvironmentStore, @@ -215,11 +217,13 @@ function createTurnResolver(deps: { }; }, mcp: async name => { - const connection = await mcpConnectionProvider.getConnection({ + const connection = await getMcpConnection({ tenant_id, name, + store: mcpServerStore, userRef, turnMetadata, + ...(requestContext === undefined ? {} : { requestContext }), }); if (connection === undefined) { throw new HTTPException(422, { @@ -444,7 +448,8 @@ export async function beginTurnExecution( store: deps.webSearchProviderStore, }); const resolver = createTurnResolver({ - mcpConnectionProvider: deps.mcpConnectionProvider, + mcpServerStore: deps.mcpServerStore, + ...(deps.requestContext === undefined ? {} : { requestContext: deps.requestContext }), skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, sandboxEnvironmentStore: deps.sandboxEnvironmentStore, @@ -828,14 +833,12 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { allowInlineResources, ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), - mcpConnectionProvider: createMcpConnectionProvider({ - store: deps.resolveMcpServerStore({ - c, - allowInlineResources, - ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), - }), - requestContext, + mcpServerStore: deps.resolveMcpServerStore({ + c, + allowInlineResources, + ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), + requestContext, skillStore: deps.resolveTurnSkillsStore({ c, allowInlineResources }), agentStore: deps.resolveAgentStore(c), sandboxProviderStore: deps.resolveSandboxProviderStore(c), diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts index e5ff4a20e..6ae7e9c21 100644 --- a/packages/trueforge/src/mcp/server/trueforge/route.ts +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -17,15 +17,7 @@ export const X_TFG_TRUEFORGE_MCP = 'x-tfg-trueforge-mcp'; const baseUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: configuration.MTLS_ENABLED }); const internalFetch = internalTlsFetch(configuration); - -function withTrueForgeMcpHeader(fetchFn: typeof globalThis.fetch): typeof globalThis.fetch { - return (input, init) => { - const headers = new Headers(input instanceof Request ? input.headers : undefined); - new Headers(init?.headers).forEach((value, name) => headers.set(name, value)); - headers.set(X_TFG_TRUEFORGE_MCP, 'true'); - return fetchFn(input, { ...init, headers, redirect: 'manual' }); - }; -} +const trueForgeMcpUrl = new URL('/mcp', baseUrl).href; export function requireTrueForgeMcpHeader(raw: string | undefined): void { if (raw === undefined) { @@ -46,10 +38,15 @@ function createTrueForgeSDKClient(input: { userCredential: string | null }): Tru }); } -/** Canonical self-origin MCP connection with its marked, mTLS-aware fetch. */ -export const trueForgeMcpConnection = { - url: new URL('/mcp', baseUrl).href, - fetch: withTrueForgeMcpHeader(internalFetch), +/** Canonical self-origin MCP endpoint with its marker header and trusted mTLS-aware network policy. */ +export const trueForgeMcpEndpoint = { + url: trueForgeMcpUrl, + headers: { [X_TFG_TRUEFORGE_MCP]: 'true' }, + networkPolicy: { + type: 'trusted-internal' as const, + endpoint: trueForgeMcpUrl, + fetch: internalFetch, + }, }; export interface TrueForgeMcpRouterDeps { diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index c83c361c2..5f5bdb7e2 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -23,7 +23,7 @@ import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; import type { ISkillStore } from '../db/skillStore'; import type { TurnMetadata } from '../db/turnMetadata'; import type { IWebSearchProviderStore } from '../db/webSearchProviderStore'; -import { trueForgeMcpConnection } from '../mcp/server/trueforge/route'; +import { trueForgeMcpEndpoint } from '../mcp/server/trueforge/route'; import { TRUEFORGE_CONNECTOR_NAME } from '../mcp/server/trueforge/server'; import { LocalSandboxProvider } from '../sandbox/local/provider/LocalSandboxProvider'; import { getCachedLocalSandboxSupport, isLocalSandboxFallbackEnabled } from '../sandbox/localRuntime'; @@ -39,40 +39,10 @@ import { hasConfiguredWebSearchProvider } from '../websearch/providers'; export interface GetMcpConnectionInput { tenant_id: string; name: string; + store: IMcpServerWithAuthStore; userRef: string; turnMetadata?: TurnMetadata; -} - -export interface IMcpConnectionProvider { - getConnection(input: GetMcpConnectionInput): Promise; -} - -/** - * Request-scoped MCP connection provider. The built-in server is available only with a live request - * context; its `null` credential is valid in standalone mode. - */ -export function createMcpConnectionProvider(input: { - store: IMcpServerWithAuthStore; requestContext?: RequestContext; -}): IMcpConnectionProvider { - return { - getConnection: async params => { - if (params.name === TRUEFORGE_CONNECTOR_NAME) { - if (input.requestContext === undefined) { - return undefined; - } - return { - ...trueForgeMcpConnection, - description: 'Manage resources in this TrueForge deployment.', - headers: - input.requestContext.user_credential === null - ? {} - : { Authorization: `Bearer ${input.requestContext.user_credential}` }, - }; - } - return resolveMcpServer({ ...params, store: input.store }); - }, - }; } /** Split `provider/model` FQN. Returns undefined when the shape is not exactly one slash. */ @@ -153,19 +123,31 @@ export async function getModelDetails({ * * TODO: OAuth header resolvers re-run on every RemoteMCP listTools/callTool; cache or gate later. */ -export async function resolveMcpServer({ +export async function getMcpConnection({ tenant_id, name, store, userRef, turnMetadata, -}: { - tenant_id: string; - name: string; - store: IMcpServerWithAuthStore; - userRef: string; - turnMetadata?: TurnMetadata; -}): Promise { + requestContext, +}: GetMcpConnectionInput): Promise { + if (name === TRUEFORGE_CONNECTOR_NAME) { + if (requestContext === undefined) { + return undefined; + } + return { + ...trueForgeMcpEndpoint, + description: 'Manage resources in this TrueForge deployment.', + headers: + requestContext.user_credential === null + ? trueForgeMcpEndpoint.headers + : { + ...trueForgeMcpEndpoint.headers, + Authorization: `Bearer ${requestContext.user_credential}`, + }, + }; + } + const record = await store.getServer({ tenant_id, name }); if (record === undefined) { return undefined; diff --git a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts index 023d1cf76..8fd41cc5b 100644 --- a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts +++ b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts @@ -7,7 +7,8 @@ import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; -import { createMcpConnectionProvider, resolveMcpServer } from '../../../src/runtime/sessionResources'; +import { X_TFG_TRUEFORGE_MCP } from '../../../src/mcp/server/trueforge/route'; +import { getMcpConnection } from '../../../src/runtime/sessionResources'; jest.mock('undici', () => { const actual = jest.requireActual('undici'); @@ -20,7 +21,7 @@ jest.mock('undici', () => { }; }); -describe('resolveMcpServer', () => { +describe('getMcpConnection', () => { let db: ReturnType; let mcpServerStore: IMcpServerWithAuthStore; let tokenStore: SqliteOAuthTokenStore; @@ -96,7 +97,7 @@ describe('resolveMcpServer', () => { }, }); - const connection = await resolveMcpServer({ + const connection = await getMcpConnection({ tenant_id: 'default', name: 'oauth-mcp', store: mcpServerStore, @@ -108,7 +109,6 @@ describe('resolveMcpServer', () => { } expect(connection.url).toBe(mcpUrl); - expect(connection.description).toBe('OAuth MCP server.'); expect(typeof connection.headers).toBe('function'); if (typeof connection.headers !== 'function') { throw new Error('expected async headers resolver'); @@ -155,7 +155,7 @@ describe('resolveMcpServer', () => { }, }); - const connection = await resolveMcpServer({ + const connection = await getMcpConnection({ tenant_id: 'default', name: 'tokened-mcp', store: mcpServerStore, @@ -182,7 +182,7 @@ describe('resolveMcpServer', () => { }, }); - const connection = await resolveMcpServer({ + const connection = await getMcpConnection({ tenant_id: 'default', name: 'open-mcp', store: mcpServerStore, @@ -199,33 +199,36 @@ describe('resolveMcpServer', () => { }); it('resolves the built-in trueforge MCP without a stored server', async () => { - const provider = createMcpConnectionProvider({ + const connection = await getMcpConnection({ + tenant_id: 'default', + name: 'trueforge', store: mcpServerStore, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, requestContext: { ...STANDALONE_REQUEST_CONTEXT, user_credential: 'draft-token', }, }); - const connection = await provider.getConnection({ - tenant_id: 'default', - name: 'trueforge', - userRef: STANDALONE_REQUEST_CONTEXT.subject.id, - }); expect(connection?.url).toMatch(/\/mcp$/); - expect(connection?.headers).toEqual({ Authorization: 'Bearer draft-token' }); - expect(connection?.fetch).toEqual(expect.any(Function)); + expect(connection?.description).toBe('Manage resources in this TrueForge deployment.'); + expect(connection?.headers).toEqual({ + Authorization: 'Bearer draft-token', + [X_TFG_TRUEFORGE_MCP]: 'true', + }); + expect(connection?.networkPolicy).toEqual({ + type: 'trusted-internal', + endpoint: connection?.url, + fetch: expect.any(Function), + }); }); it('does not resolve the built-in trueforge MCP without a request context', async () => { - const provider = createMcpConnectionProvider({ - store: mcpServerStore, - }); - await expect( - provider.getConnection({ + getMcpConnection({ tenant_id: 'default', name: 'trueforge', + store: mcpServerStore, userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }), ).resolves.toBeUndefined(); @@ -244,7 +247,7 @@ describe('resolveMcpServer', () => { }, }); - const connection = await resolveMcpServer({ + const connection = await getMcpConnection({ tenant_id: 'default', name: 'header-mcp', store: mcpServerStore, @@ -284,7 +287,7 @@ describe('resolveMcpServer', () => { }, }); - const connection = await resolveMcpServer({ + const connection = await getMcpConnection({ tenant_id: 'default', name: 'tfy-mcp', store, @@ -303,7 +306,7 @@ describe('resolveMcpServer', () => { it('returns undefined when the server is not registered', async () => { await expect( - resolveMcpServer({ + getMcpConnection({ tenant_id: 'default', name: 'missing-mcp', store: mcpServerStore, From 4cea7a05ce6d0a9eb198ecab86ee354e598e72e8 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 20 Oct 2026 01:17:36 +0530 Subject: [PATCH 13/20] remove header --- .../src/mcp/server/trueforge/route.ts | 18 ++++-------------- .../unit/runtime/resolveMcpServer.test.ts | 2 -- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts index 6ae7e9c21..70276f086 100644 --- a/packages/trueforge/src/mcp/server/trueforge/route.ts +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -1,30 +1,21 @@ /** * Authenticated, stateless Streamable HTTP endpoint for the TrueForge MCP (mounted at /mcp). * - * Each request: authenticate → require the trusted invocation marker → build a request-scoped SDK client + - * stateless MCP server → hand the Web Request to the transport. Nothing is retained across requests. + * Each request: authenticate → build a request-scoped SDK client + stateless MCP server → hand the Web + * Request to the transport. Nothing is retained across requests. */ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { TrueForge } from '@truefoundry/trueforge-sdk'; import { Hono } from 'hono'; -import { HTTPException } from 'hono/http-exception'; import type { ResolveRequestContext } from '../../../auth/identity'; import configuration from '../../../config'; import { internalTlsFetch, normalizeTlsUrl } from '../../../http/tls'; import { createTrueForgeMcpServer } from './server'; -export const X_TFG_TRUEFORGE_MCP = 'x-tfg-trueforge-mcp'; - const baseUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: configuration.MTLS_ENABLED }); const internalFetch = internalTlsFetch(configuration); const trueForgeMcpUrl = new URL('/mcp', baseUrl).href; -export function requireTrueForgeMcpHeader(raw: string | undefined): void { - if (raw === undefined) { - throw new HTTPException(403, { message: `Missing ${X_TFG_TRUEFORGE_MCP} invocation marker` }); - } -} - /** * Request-scoped SDK client carrying the draft caller's credential. The service API key is * intentionally never used here. @@ -38,10 +29,10 @@ function createTrueForgeSDKClient(input: { userCredential: string | null }): Tru }); } -/** Canonical self-origin MCP endpoint with its marker header and trusted mTLS-aware network policy. */ +/** Canonical self-origin MCP endpoint with its trusted mTLS-aware network policy. */ export const trueForgeMcpEndpoint = { url: trueForgeMcpUrl, - headers: { [X_TFG_TRUEFORGE_MCP]: 'true' }, + headers: {}, networkPolicy: { type: 'trusted-internal' as const, endpoint: trueForgeMcpUrl, @@ -58,7 +49,6 @@ export function createTrueForgeMcpRouter(deps: TrueForgeMcpRouterDeps): Hono { const router = new Hono(); router.all('/', async c => { const requestContext = deps.resolveRequestContext(c); - requireTrueForgeMcpHeader(c.req.header(X_TFG_TRUEFORGE_MCP)); // Built per request: the server's tools close over this caller's credential, and stateless // Streamable HTTP wants a fresh server+transport per request to isolate concurrent callers. diff --git a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts index 8fd41cc5b..e337611c7 100644 --- a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts +++ b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts @@ -7,7 +7,6 @@ import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; -import { X_TFG_TRUEFORGE_MCP } from '../../../src/mcp/server/trueforge/route'; import { getMcpConnection } from '../../../src/runtime/sessionResources'; jest.mock('undici', () => { @@ -214,7 +213,6 @@ describe('getMcpConnection', () => { expect(connection?.description).toBe('Manage resources in this TrueForge deployment.'); expect(connection?.headers).toEqual({ Authorization: 'Bearer draft-token', - [X_TFG_TRUEFORGE_MCP]: 'true', }); expect(connection?.networkPolicy).toEqual({ type: 'trusted-internal', From 8db0e749c48994c8a9469ef6af64a17ac03b07f4 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 20 Oct 2026 01:31:08 +0530 Subject: [PATCH 14/20] cleanup --- .../src/core/mcp/remoteMcpClient.ts | 48 +++++++------------ .../tests/core/mcp/connectRemoteMcp.test.ts | 44 ++++++++++++----- packages/trueforge/src/apis/schedules.ts | 1 + packages/trueforge/src/apis/turns.ts | 6 +-- 4 files changed, 51 insertions(+), 48 deletions(-) diff --git a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts index 1cc13f82f..2530bc1e6 100644 --- a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts +++ b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts @@ -70,31 +70,6 @@ export function withMaxResponseBytes(fetchFn: FetchLike, maxBytes: number): Fetc }; } -function fetchInputUrl(input: Parameters[0]): URL { - return new URL(input); -} - -/** Select the transport fetch while keeping internal trust exact-endpoint scoped. */ -export function fetchForMcpNetworkPolicy(params: { url: URL; policy?: RemoteMcpNetworkPolicy }): RemoteMcpFetch { - const policy = params.policy; - if (policy === undefined || policy.type === 'external') { - return mcpSsrfFetch; - } - - const trustedEndpoint = new URL(policy.endpoint); - if (params.url.href !== trustedEndpoint.href) { - throw new Error(`Trusted MCP policy endpoint does not match connection URL: ${params.url.href}`); - } - - return (input, init) => { - const requestUrl = fetchInputUrl(input); - if (requestUrl.href !== trustedEndpoint.href) { - throw new Error(`Trusted MCP fetch attempted an unexpected URL: ${requestUrl.href}`); - } - return policy.fetch(input, { ...init, redirect: 'manual' }); - }; -} - class McpClientWithTimeout extends Client { constructor(private readonly requestTimeoutMs: number) { super(CLIENT_INFO, { capabilities: {} }); @@ -222,13 +197,22 @@ export async function connectRemoteMcp(params: { }): Promise { const url = new URL(params.url); const requestOptions = { signal: params.signal }; - const fetchFn = withMaxResponseBytes( - fetchForMcpNetworkPolicy({ - url, - ...(params.networkPolicy === undefined ? {} : { policy: params.networkPolicy }), - }), - params.maxResponseBytes ?? DEFAULT_MAX_MCP_RESPONSE_BYTES, - ); + let transportFetch: RemoteMcpFetch = mcpSsrfFetch; + if (params.networkPolicy?.type === 'trusted-internal') { + const policy = params.networkPolicy; + const trustedEndpoint = new URL(policy.endpoint); + if (url.href !== trustedEndpoint.href) { + throw new Error(`Trusted MCP policy endpoint does not match connection URL: ${url.href}`); + } + transportFetch = (input, init) => { + const requestUrl = new URL(input); + if (requestUrl.href !== trustedEndpoint.href) { + throw new Error(`Trusted MCP fetch attempted an unexpected URL: ${requestUrl.href}`); + } + return policy.fetch(input, { ...init, redirect: 'manual' }); + }; + } + const fetchFn = withMaxResponseBytes(transportFetch, params.maxResponseBytes ?? DEFAULT_MAX_MCP_RESPONSE_BYTES); const candidates = params.knownTransportType ? [params.knownTransportType, ...TRANSPORT_PROBE_ORDER.filter(t => t !== params.knownTransportType)] : TRANSPORT_PROBE_ORDER; diff --git a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts index bc035bb17..58030fa1a 100644 --- a/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts +++ b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts @@ -1,10 +1,11 @@ import { McpConnectionError } from '../../../src/core/errors'; -import { connectRemoteMcp, fetchForMcpNetworkPolicy, type RemoteMcpFetch } from '../../../src/core/mcp/remoteMcpClient'; +import { connectRemoteMcp, type RemoteMcpFetch } from '../../../src/core/mcp/remoteMcpClient'; // Records every transport type client.connect() was attempted with, in order. const mockConnectAttempts: string[] = []; const mockConnectSignals: Array = []; const mockRequestTimeouts: number[] = []; +const mockTransportFetches: RemoteMcpFetch[] = []; let mockCloseCalls = 0; // When set, connect hangs until the AbortSignal passed to it is aborted. let hangUntilAbort = false; @@ -62,8 +63,11 @@ jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ StreamableHTTPClientTransport: function StreamableHTTPClientTransport( _url: URL, - opts?: { sessionId?: string }, + opts?: { sessionId?: string; fetch?: RemoteMcpFetch }, ): MockTransport { + if (opts?.fetch !== undefined) { + mockTransportFetches.push(opts.fetch); + } return { __type: 'streamable-http', ...(opts?.sessionId !== undefined ? { sessionId: opts.sessionId } : {}), @@ -85,33 +89,46 @@ const baseParams = () => ({ signal: new AbortController().signal, }); -describe('fetchForMcpNetworkPolicy', () => { +describe('connectRemoteMcp network policy', () => { const endpoint = 'https://trueforge.internal/mcp'; + beforeEach(() => { + mockConnectAttempts.length = 0; + mockConnectSignals.length = 0; + mockTransportFetches.length = 0; + }); + it('allows only the exact trusted endpoint and disables redirects', async () => { const fetch = jest.fn(async () => new Response(null, { status: 200 })) as unknown as RemoteMcpFetch; - const trustedFetch = fetchForMcpNetworkPolicy({ - url: new URL(endpoint), - policy: { type: 'trusted-internal', endpoint, fetch }, + await connectRemoteMcp({ + ...baseParams(), + url: endpoint, + knownTransportType: 'streamable-http', + networkPolicy: { type: 'trusted-internal', endpoint, fetch }, }); + const trustedFetch = mockTransportFetches[0]; + if (trustedFetch === undefined) { + throw new Error('expected transport fetch'); + } await trustedFetch(endpoint, { redirect: 'follow' }); expect(fetch).toHaveBeenCalledWith(endpoint, expect.objectContaining({ redirect: 'manual' })); - expect(() => trustedFetch('https://trueforge.internal/other')).toThrow( + await expect(trustedFetch('https://trueforge.internal/other')).rejects.toThrow( 'Trusted MCP fetch attempted an unexpected URL', ); }); - it('rejects a trusted policy whose endpoint differs from the connection URL', () => { + it('rejects a trusted policy whose endpoint differs from the connection URL', async () => { const fetch = jest.fn() as unknown as RemoteMcpFetch; - expect(() => - fetchForMcpNetworkPolicy({ - url: new URL(endpoint), - policy: { type: 'trusted-internal', endpoint: 'https://other.internal/mcp', fetch }, + await expect( + connectRemoteMcp({ + ...baseParams(), + url: endpoint, + networkPolicy: { type: 'trusted-internal', endpoint: 'https://other.internal/mcp', fetch }, }), - ).toThrow('Trusted MCP policy endpoint does not match connection URL'); + ).rejects.toThrow('Trusted MCP policy endpoint does not match connection URL'); }); }); @@ -120,6 +137,7 @@ describe('connectRemoteMcp transport selection', () => { mockConnectAttempts.length = 0; mockConnectSignals.length = 0; mockRequestTimeouts.length = 0; + mockTransportFetches.length = 0; mockCloseCalls = 0; hangUntilAbort = false; mockConnectImpl = () => { diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index b790267b1..f31baf6ad 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -121,6 +121,7 @@ export async function startScheduleRunOnRequest(params: { agentStore: deps.agentStore, modelProviderStore: deps.resolveModelProviderStore({ c, runAsAgent: prepared.agent }), mcpServerStore: deps.resolveMcpServerStore({ c, runAsAgent: prepared.agent }), + requestContext: undefined, sandboxProviderStore: deps.resolveSandboxProviderStore(c), sandboxEnvironmentStore: deps.sandboxEnvironmentStore, webSearchProviderStore: deps.resolveWebSearchProviderStore(c), diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 7493ca4fd..5468cf885 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -145,7 +145,7 @@ export type BeginTurnExecutionDeps = Pick< agentStore: IAgentStore; modelProviderStore: IModelProviderStore; mcpServerStore: IMcpServerWithAuthStore; - requestContext?: RequestContext; + requestContext: RequestContext | undefined; sandboxProviderStore: ISandboxProviderStore; webSearchProviderStore: IWebSearchProviderStore; skillStore: Pick; @@ -167,7 +167,7 @@ interface BeginTurnExecutionParams { */ function createTurnResolver(deps: { mcpServerStore: IMcpServerWithAuthStore; - requestContext?: RequestContext; + requestContext: RequestContext | undefined; skillStore: Pick; sandboxProviderStore: ISandboxProviderStore; sandboxEnvironmentStore: ISandboxEnvironmentStore; @@ -449,7 +449,7 @@ export async function beginTurnExecution( }); const resolver = createTurnResolver({ mcpServerStore: deps.mcpServerStore, - ...(deps.requestContext === undefined ? {} : { requestContext: deps.requestContext }), + requestContext: deps.requestContext, skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, sandboxEnvironmentStore: deps.sandboxEnvironmentStore, From cff9c0dc0eb076d70467747fb4a874bba697a866 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 20 Oct 2026 01:54:26 +0530 Subject: [PATCH 15/20] mcp interface refactor --- packages/frontend/src/authFetch.ts | 10 +- packages/frontend/tests/authFetch.test.ts | 13 +++ packages/trueforge/scripts/write-openapi.ts | 18 ++-- packages/trueforge/src/apis/mcpServers.ts | 48 ++++----- packages/trueforge/src/apis/schedules.ts | 6 +- packages/trueforge/src/apis/settings.ts | 11 ++- packages/trueforge/src/apis/turns.ts | 20 ++-- packages/trueforge/src/app.ts | 29 +++--- packages/trueforge/src/db/mcpServerStore.ts | 47 +-------- .../PostgresMcpServerStore.ts | 6 +- .../mcp-server-store/SqliteMcpServerStore.ts | 6 +- packages/trueforge/src/main.ts | 93 ++++++++++------- .../src/mcp/server/trueforge/route.ts | 94 +++++++++++++++++- .../McpServerResolver.ts} | 99 +++++++++---------- .../trueforge/src/runtime/sessionResources.ts | 37 +++---- .../src/truefoundry/InlineMcpServerStore.ts | 62 +++++++----- .../truefoundry/TrueFoundryMcpServerStore.ts | 17 ++-- .../tests/db/mcpServerStoreContractSuite.ts | 6 +- .../unit/apis/deletedSessionCrud.test.ts | 9 +- .../tests/unit/apis/mcpOAuth.test.ts | 17 +++- .../tests/unit/apis/mcpServers.test.ts | 17 +++- .../tests/unit/apis/modelProviders.test.ts | 17 ++-- .../unit/apis/sandboxFileDownload.test.ts | 6 +- .../tests/unit/apis/schedules.test.ts | 2 +- .../tests/unit/apis/sessionHttp.test.ts | 6 +- .../unit/apis/turnHarnessErrorStatus.test.ts | 6 +- .../trueforge/tests/unit/apis/turns.test.ts | 22 ++--- .../unit/runtime/resolveMcpServer.test.ts | 71 +++++++++---- .../unit/truefoundry/inlineResources.test.ts | 10 +- 29 files changed, 485 insertions(+), 320 deletions(-) rename packages/trueforge/src/{db/McpServerWithAuthStore.ts => runtime/McpServerResolver.ts} (61%) diff --git a/packages/frontend/src/authFetch.ts b/packages/frontend/src/authFetch.ts index 4c512d166..dcb0f2658 100644 --- a/packages/frontend/src/authFetch.ts +++ b/packages/frontend/src/authFetch.ts @@ -8,6 +8,9 @@ */ import { apiPath, UI_BASE_PATH } from './publicPath'; +const TRUEFORGE_CLIENT_HEADER = 'x-trueforge-client'; +const TRUEFORGE_BROWSER_CLIENT = 'browser'; + /** Browser entry for login (not available as an SDK method). */ export const AUTH_LOGIN_HREF = apiPath('/api/v1/auth/login'); @@ -24,7 +27,12 @@ export function createAuthAwareFetch(baseFetch: typeof fetch = globalThis.fetch. let redirecting = false; return async (input, init) => { - const response = await baseFetch(input, init); + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init?.headers).forEach((value, name) => { + headers.set(name, value); + }); + headers.set(TRUEFORGE_CLIENT_HEADER, TRUEFORGE_BROWSER_CLIENT); + const response = await baseFetch(input, { ...init, headers }); if (response.status !== 401 || typeof window === 'undefined') { return response; diff --git a/packages/frontend/tests/authFetch.test.ts b/packages/frontend/tests/authFetch.test.ts index 3a53afa32..12950c195 100644 --- a/packages/frontend/tests/authFetch.test.ts +++ b/packages/frontend/tests/authFetch.test.ts @@ -17,6 +17,19 @@ describe('buildLoginHref', () => { }); describe('createAuthAwareFetch', () => { + it('marks requests as coming from the browser client', async () => { + let receivedHeaders: Headers | undefined; + const wrapped = createAuthAwareFetch(async (_input, init) => { + receivedHeaders = new Headers(init?.headers); + return new Response('ok', { status: 200 }); + }); + + await wrapped('/api/v1/models', { headers: { 'x-existing': 'preserved' } }); + + assert.equal(receivedHeaders?.get('x-trueforge-client'), 'browser'); + assert.equal(receivedHeaders?.get('x-existing'), 'preserved'); + }); + it('passes through non-401 responses', async () => { const wrapped = createAuthAwareFetch(async () => new Response('ok', { status: 200 })); const response = await wrapped('/api/v1/models'); diff --git a/packages/trueforge/scripts/write-openapi.ts b/packages/trueforge/scripts/write-openapi.ts index 0ccac01ae..ecc2ed45d 100644 --- a/packages/trueforge/scripts/write-openapi.ts +++ b/packages/trueforge/scripts/write-openapi.ts @@ -22,7 +22,6 @@ import { SandboxCatalog } from '../src/catalog/SandboxCatalog'; import { SkillCatalog } from '../src/catalog/SkillCatalog'; import { WebSearchCatalog } from '../src/catalog/WebSearchCatalog'; import configuration from '../src/config'; -import { McpServerWithAuthStore } from '../src/db/McpServerWithAuthStore'; import { SqliteAgentStore } from '../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; @@ -36,6 +35,7 @@ import { SqliteOAuthTokenStore } from '../src/db/sqlite/token-store/SqliteOAuthT import { SqliteWebSearchProviderStore } from '../src/db/sqlite/web-search-provider-store/SqliteWebSearchProviderStore'; import { ActiveTurnRegistry } from '../src/runtime/activeTurns'; import { EventSubscriptionRegistry } from '../src/runtime/event-subscription'; +import { McpServerResolver } from '../src/runtime/McpServerResolver'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; @@ -64,6 +64,12 @@ function canonicalise(value: unknown): unknown { const sessionStore = new InMemorySessionStore(); const db = createSqliteDb(':memory:'); const tokenStore = new SqliteOAuthTokenStore(db); +const mcpServerStore = new SqliteMcpServerStore(db); +const mcpServerResolver = new McpServerResolver({ + store: mcpServerStore, + tokenStore, + clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, +}); const agentStore = new SqliteAgentStore(db); const sandboxEnvironmentStore = new SqliteSandboxEnvironmentStore(db); const skillStore = new SqliteSkillStore(db); @@ -76,12 +82,10 @@ const app = createServerApp({ sandboxCatalog: SandboxCatalog.load(), webSearchCatalog: WebSearchCatalog.load(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), - resolveMcpServerStore: () => - new McpServerWithAuthStore({ - store: new SqliteMcpServerStore(db), - tokenStore, - clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, - }), + mcpOAuthClientStore: mcpServerStore, + resolveMcpServerStore: () => mcpServerStore, + resolveMcpOAuthClientStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, resolveSkillStore: () => skillStore, resolveTurnSkillsStore: () => skillStore, resolveSandboxProviderStore: () => sandboxProviderStore, diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index ec23cc6a4..15c0dc87a 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -16,13 +16,13 @@ import type { IAgentStore } from '../db/agentStore'; import { McpServerNameConflictError, McpServerNotFoundError, - type IMcpServerWithAuthStore, + type IMcpServerStore, type McpServerRecord, } from '../db/mcpServerStore'; import type { WithTransaction } from '../db/transaction'; import { createMcpOAuthClient } from '../mcp/auth/mcpDcr'; import { mcpOAuthCallbackUrl } from '../mcp/auth/mcpOAuthHelpers'; -import type { IOAuthTokenStore, OAuthClientRecord } from '../mcp/auth/types'; +import type { IOAuthClientStore, IOAuthTokenStore, OAuthClientRecord } from '../mcp/auth/types'; import { authorizeMcpServerRoute, createMcpServerRoute, @@ -36,6 +36,7 @@ import { putMcpServerRoute, } from '../routes/mcpServerRoutes'; import { findCatalogUsageConflict } from '../runtime/catalogUsage'; +import type { IMcpServerResolver } from '../runtime/McpServerResolver'; import { getMcpConnection } from '../runtime/sessionResources'; import type { AvailableMcpServer, @@ -48,7 +49,9 @@ import type { import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretValue } from '../utils/secretRedaction'; export interface McpServersRouterDeps { - resolveMcpServerStore: (c: Context) => IMcpServerWithAuthStore; + resolveMcpServerStore: (c: Context) => IMcpServerStore; + resolveMcpOAuthClientStore: (c: Context) => IOAuthClientStore; + resolveMcpServerResolver: (c: Context) => IMcpServerResolver; tokenStore: IOAuthTokenStore; withTransaction: WithTransaction; logger: Logger; @@ -114,12 +117,12 @@ function resolveMcpServerManifestForWrite({ }; } -async function toConfiguredMcpServer(params: { - store: IMcpServerWithAuthStore; +async function toConfiguredMcpServer(params: { + resolver: IMcpServerResolver; record: McpServerRecord; userRef: string; }): Promise { - const statuses = await params.store.resolveAuthStatuses({ + const statuses = await params.resolver.resolveAuthStatuses({ records: [params.record], userRef: params.userRef, }); @@ -140,12 +143,12 @@ function toAvailableMcpServer(params: { record: McpServerRecord; authStatus: Mcp }; } -async function resolveAvailableMcpServer(params: { - store: IMcpServerWithAuthStore; +async function resolveAvailableMcpServer(params: { + resolver: IMcpServerResolver; record: McpServerRecord; userRef: string; }): Promise { - const statuses = await params.store.resolveAuthStatuses({ + const statuses = await params.resolver.resolveAuthStatuses({ records: [params.record], userRef: params.userRef, }); @@ -164,7 +167,7 @@ export function createSettingsMcpServersRouter(deps: SettingsMcpSe tenant_id: requestContext.tenant_id, names: undefined, }); - const statuses = await deps.resolveMcpServerStore(c).resolveAuthStatuses({ + const statuses = await deps.resolveMcpServerResolver(c).resolveAuthStatuses({ records, userRef, }); @@ -190,7 +193,7 @@ export function createSettingsMcpServersRouter(deps: SettingsMcpSe return c.json( { data: await toConfiguredMcpServer({ - store: deps.resolveMcpServerStore(c), + resolver: deps.resolveMcpServerResolver(c), record, userRef, }), @@ -255,7 +258,7 @@ export function createSettingsMcpServersRouter(deps: SettingsMcpSe transaction, ); if (dcrClientToSave !== undefined) { - await deps.resolveMcpServerStore(c).saveClient({ id: saved.id, record: dcrClientToSave }, transaction); + await deps.resolveMcpOAuthClientStore(c).saveClient({ id: saved.id, record: dcrClientToSave }, transaction); } return saved; }); @@ -263,7 +266,7 @@ export function createSettingsMcpServersRouter(deps: SettingsMcpSe return c.json( { data: await toConfiguredMcpServer({ - store: deps.resolveMcpServerStore(c), + resolver: deps.resolveMcpServerResolver(c), record, userRef: requestContext.subject.id, }), @@ -311,7 +314,7 @@ export function createSettingsMcpServersRouter(deps: SettingsMcpSe existing !== undefined && existing.manifest.url !== manifest.url && manifest.auth?.type === 'dcr'; if (manifest.auth?.type === 'dcr') { const existingClient = existing - ? await deps.resolveMcpServerStore(c).getClient({ id: existing.id }, transaction) + ? await deps.resolveMcpOAuthClientStore(c).getClient({ id: existing.id }, transaction) : undefined; // Register when: brand-new server, client was cleared (e.g. invalid_client), or MCP URL changed // (resource/AS may differ; reuse would keep stale oauth_server/client for the old AS). @@ -336,7 +339,7 @@ export function createSettingsMcpServersRouter(deps: SettingsMcpSe ); if (dcrClientToSave !== undefined) { // New DCR registration (create, missing client, or URL change): replace the shared client. - await deps.resolveMcpServerStore(c).saveClient({ id: saved.id, record: dcrClientToSave }, transaction); + await deps.resolveMcpOAuthClientStore(c).saveClient({ id: saved.id, record: dcrClientToSave }, transaction); } if (urlChanged) { // URL is the OAuth resource/audience — drop every user's tokens and in-flight authorizes. @@ -349,7 +352,7 @@ export function createSettingsMcpServersRouter(deps: SettingsMcpSe return c.json( { data: await toConfiguredMcpServer({ - store: deps.resolveMcpServerStore(c), + resolver: deps.resolveMcpServerResolver(c), record, userRef, }), @@ -430,7 +433,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< } try { - const authStatus: McpAuthStatus = await deps.resolveMcpServerStore(c).authorize({ + const authStatus: McpAuthStatus = await deps.resolveMcpServerResolver(c).authorize({ tenant_id: requestContext.tenant_id, name, userRef, @@ -470,9 +473,8 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const connection = await getMcpConnection({ tenant_id: requestContext.tenant_id, name, - store: deps.resolveMcpServerStore(c), + resolver: deps.resolveMcpServerResolver(c), userRef, - requestContext, }); if (connection === undefined) { return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); @@ -519,7 +521,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< if (!record) { return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); } - await deps.resolveMcpServerStore(c).deleteAuthorization({ + await deps.resolveMcpServerResolver(c).deleteAuthorization({ tenant_id: requestContext.tenant_id, name, userRef, @@ -527,7 +529,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< return c.json( { data: await toConfiguredMcpServer({ - store: deps.resolveMcpServerStore(c), + resolver: deps.resolveMcpServerResolver(c), record, userRef, }), @@ -555,7 +557,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< return c.json( { data: await resolveAvailableMcpServer({ - store: deps.resolveMcpServerStore(c), + resolver: deps.resolveMcpServerResolver(c), record, userRef: requestContext.subject.id, }), @@ -572,7 +574,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< tenant_id: requestContext.tenant_id, names: undefined, }); - const statuses = await deps.resolveMcpServerStore(c).resolveAuthStatuses({ + const statuses = await deps.resolveMcpServerResolver(c).resolveAuthStatuses({ records, userRef, }); diff --git a/packages/trueforge/src/apis/schedules.ts b/packages/trueforge/src/apis/schedules.ts index f31baf6ad..8b3afb88e 100644 --- a/packages/trueforge/src/apis/schedules.ts +++ b/packages/trueforge/src/apis/schedules.ts @@ -27,7 +27,6 @@ import { startScheduleRun, } from '../controller/scheduleDispatch'; import type { AgentRecord, IAgentStore } from '../db/agentStore'; -import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxEnvironmentStore } from '../db/sandboxEnvironmentStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; @@ -57,6 +56,7 @@ import { import type { ActiveTurnRegistry } from '../runtime/activeTurns'; import { minIntervalSeconds, nextTriggerAfter } from '../runtime/cron'; import type { EventSubscriptionRegistry } from '../runtime/event-subscription'; +import type { IMcpServerResolver } from '../runtime/McpServerResolver'; import { InvalidCronError, SCHEDULE_MIN_INTERVAL_SECONDS, @@ -75,7 +75,7 @@ export interface ScheduleTurnExecutionDeps { eventSubscriptions: EventSubscriptionRegistry; logger: Logger; resolveModelProviderStore: (input: { c: Context; runAsAgent?: AgentRecord }) => IModelProviderStore; - resolveMcpServerStore: (input: { c: Context; runAsAgent?: AgentRecord }) => IMcpServerWithAuthStore; + resolveMcpServerResolver: (input: { c: Context; runAsAgent?: AgentRecord }) => IMcpServerResolver; resolveSandboxProviderStore: (c: Context) => ISandboxProviderStore; sandboxEnvironmentStore: ISandboxEnvironmentStore; resolveWebSearchProviderStore: (c: Context) => IWebSearchProviderStore; @@ -120,7 +120,7 @@ export async function startScheduleRunOnRequest(params: { eventSubscriptions: deps.eventSubscriptions, agentStore: deps.agentStore, modelProviderStore: deps.resolveModelProviderStore({ c, runAsAgent: prepared.agent }), - mcpServerStore: deps.resolveMcpServerStore({ c, runAsAgent: prepared.agent }), + mcpServerStore: deps.resolveMcpServerResolver({ c, runAsAgent: prepared.agent }), requestContext: undefined, sandboxProviderStore: deps.resolveSandboxProviderStore(c), sandboxEnvironmentStore: deps.sandboxEnvironmentStore, diff --git a/packages/trueforge/src/apis/settings.ts b/packages/trueforge/src/apis/settings.ts index 5de65847f..7ae0b79fa 100644 --- a/packages/trueforge/src/apis/settings.ts +++ b/packages/trueforge/src/apis/settings.ts @@ -8,13 +8,14 @@ import type { Context } from 'hono'; import type { Logger } from 'winston'; import type { ResolveRequestContext } from '../auth/identity'; import type { IAgentStore } from '../db/agentStore'; -import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; +import type { IMcpServerStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxEnvironmentStore } from '../db/sandboxEnvironmentStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; import type { WithTransaction } from '../db/transaction'; import type { IWebSearchProviderStore } from '../db/webSearchProviderStore'; -import type { IOAuthTokenStore } from '../mcp/auth/types'; +import type { IOAuthClientStore, IOAuthTokenStore } from '../mcp/auth/types'; +import type { IMcpServerResolver } from '../runtime/McpServerResolver'; import { createSettingsMcpServersRouter } from './mcpServers'; import { createModelProvidersRouter } from './modelProviders'; import { createSandboxProvidersRouter } from './sandboxProviders'; @@ -23,7 +24,9 @@ import { createWebSearchProvidersRouter } from './webSearchProviders'; export interface SettingsRouterDeps { resolveModelProviderStore: (input: { c: Context }) => IModelProviderStore; - resolveMcpServerStore: (input: { c: Context }) => IMcpServerWithAuthStore; + resolveMcpServerStore: (input: { c: Context }) => IMcpServerStore; + resolveMcpOAuthClientStore: (input: { c: Context }) => IOAuthClientStore; + resolveMcpServerResolver: (input: { c: Context }) => IMcpServerResolver; tokenStore: IOAuthTokenStore; resolveSkillStore: ResolveSkillStore; resolveSandboxProviderStore: (c: Context) => ISandboxProviderStore; @@ -51,6 +54,8 @@ export function createSettingsRouter(deps: SettingsRouterDeps deps.resolveMcpServerStore({ c }), + resolveMcpOAuthClientStore: c => deps.resolveMcpOAuthClientStore({ c }), + resolveMcpServerResolver: c => deps.resolveMcpServerResolver({ c }), resolveAgentStore: deps.resolveAgentStore, tokenStore: deps.tokenStore, withTransaction: deps.withTransaction, diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 5468cf885..23f0baa06 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -39,7 +39,6 @@ import type { Authorizer } from '../auth/authorizer'; import type { RequestContext, ResolveRequestContext } from '../auth/identity'; import configuration from '../config'; import type { AgentRecord, IAgentStore } from '../db/agentStore'; -import type { IMcpServerWithAuthStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxEnvironmentStore } from '../db/sandboxEnvironmentStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; @@ -47,6 +46,7 @@ import type { ISkillStore } from '../db/skillStore'; import type { TurnMetadata } from '../db/turnMetadata'; import type { IWebSearchProviderStore } from '../db/webSearchProviderStore'; import { currentLogContext, updateLogContext } from '../logger'; +import { TrueForgeMcpServerResolver } from '../mcp/server/trueforge/route'; import { createAndExecuteTurnRoute, createTurnEventRoute, @@ -58,6 +58,7 @@ import { } from '../routes/turnRoutes'; import type { ActiveTurnRegistry } from '../runtime/activeTurns'; import { StreamGoneError, type EventSubscription, type EventSubscriptionRegistry } from '../runtime/event-subscription'; +import type { IMcpServerResolver } from '../runtime/McpServerResolver'; import { validateSandboxFilePath } from '../runtime/sandboxFilePath'; import { buildTurnSandbox, @@ -120,7 +121,7 @@ export interface TurnsRouterDeps { sessionStore: ISessionStore; activeTurns: ActiveTurnRegistry; resolveModelProviderStore: (input: ResolveModelProviderStoreInput) => IModelProviderStore; - resolveMcpServerStore: (input: ResolveMcpServerStoreInput) => IMcpServerWithAuthStore; + resolveMcpServerResolver: (input: ResolveMcpServerStoreInput) => IMcpServerResolver; resolveTurnSkillsStore: (input: ResolveTurnSkillsStoreInput) => Pick; resolveAgentStore: (c: Context) => IAgentStore; /** Resumable live turn-event transport: create-turn writes, subscribe polls. */ @@ -144,7 +145,7 @@ export type BeginTurnExecutionDeps = Pick< > & { agentStore: IAgentStore; modelProviderStore: IModelProviderStore; - mcpServerStore: IMcpServerWithAuthStore; + mcpServerStore: IMcpServerResolver; requestContext: RequestContext | undefined; sandboxProviderStore: ISandboxProviderStore; webSearchProviderStore: IWebSearchProviderStore; @@ -166,7 +167,7 @@ interface BeginTurnExecutionParams { * the same way: async factories over the corresponding stores. */ function createTurnResolver(deps: { - mcpServerStore: IMcpServerWithAuthStore; + mcpServerStore: IMcpServerResolver; requestContext: RequestContext | undefined; skillStore: Pick; sandboxProviderStore: ISandboxProviderStore; @@ -197,6 +198,10 @@ function createTurnResolver(deps: { } = deps; const tenant_id = session.tenant_id; const sessionId = session.session_id; + const mcpServerResolver = + requestContext === undefined + ? mcpServerStore + : new TrueForgeMcpServerResolver({ inner: mcpServerStore, requestContext }); return new TurnResourceResolver({ llm: async name => { @@ -220,10 +225,9 @@ function createTurnResolver(deps: { const connection = await getMcpConnection({ tenant_id, name, - store: mcpServerStore, + resolver: mcpServerResolver, userRef, turnMetadata, - ...(requestContext === undefined ? {} : { requestContext }), }); if (connection === undefined) { throw new HTTPException(422, { @@ -833,13 +837,13 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { allowInlineResources, ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), - mcpServerStore: deps.resolveMcpServerStore({ + mcpServerStore: deps.resolveMcpServerResolver({ c, allowInlineResources, ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), - requestContext, skillStore: deps.resolveTurnSkillsStore({ c, allowInlineResources }), + requestContext, agentStore: deps.resolveAgentStore(c), sandboxProviderStore: deps.resolveSandboxProviderStore(c), sandboxEnvironmentStore: deps.sandboxEnvironmentStore, diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index 450c48cac..f6bedd940 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -42,7 +42,7 @@ import type { SkillCatalog } from './catalog/SkillCatalog'; import type { WebSearchCatalog } from './catalog/WebSearchCatalog'; import configuration, { getPublicUiBasePath, getTrueForgeAuthMode, TrueForgeAuthMode } from './config'; import type { AgentRecord, IAgentStore } from './db/agentStore'; -import type { IMcpServerWithAuthStore } from './db/mcpServerStore'; +import type { IMcpServerStore } from './db/mcpServerStore'; import type { IModelProviderStore } from './db/modelProviderStore'; import type { ISandboxEnvironmentStore } from './db/sandboxEnvironmentStore'; import type { ISandboxProviderStore } from './db/sandboxProviderStore'; @@ -54,12 +54,13 @@ import type { IWebSearchProviderStore } from './db/webSearchProviderStore'; import { createHttpMetrics } from './http/prometheus'; import { createClientCertificateMiddleware } from './http/tls'; import { runWithLogContext, updateLogContext, type LogContext } from './logger'; -import type { IOAuthTokenStore } from './mcp/auth/types'; +import type { IOAuthClientStore, IOAuthTokenStore } from './mcp/auth/types'; import { createTrueForgeMcpRouter } from './mcp/server/trueforge/route'; import { PACKAGE_VERSION } from './packageVersion'; import { OPENAPI_DOCUMENT_TAGS } from './routes/openapiTags'; import type { ActiveTurnRegistry } from './runtime/activeTurns'; import type { EventSubscriptionRegistry } from './runtime/event-subscription'; +import type { IMcpServerResolver } from './runtime/McpServerResolver'; import { InvalidCronError } from './schemas/schedule'; import { zodErrorResponse, zodValidationHook } from './zodErrorResponse'; @@ -247,9 +248,9 @@ export interface ResolveModelProviderStoreInput { allowInlineResources?: boolean; } -/** HTTP MCP store resolve. Omit `c` for the unauthenticated OAuth callback. */ +/** HTTP MCP capability resolve. */ export interface ResolveMcpServerStoreInput { - c?: Context; + c: Context; runAsAgent?: AgentRecord; allowInlineResources?: boolean; } @@ -277,11 +278,11 @@ export interface ServerDeps { webSearchCatalog: WebSearchCatalog; /** Per-request store: DB singleton, or a token-bound TrueFoundry store in TrueFoundry mode. */ resolveModelProviderStore: (input: ResolveModelProviderStoreInput) => IModelProviderStore; - /** - * Per-request store: DB singleton, or a token-bound TrueFoundry store in TrueFoundry mode. - * The unauthenticated OAuth callback omits `c` and gets the DB persistence store. - */ - resolveMcpServerStore: (input: ResolveMcpServerStoreInput) => IMcpServerWithAuthStore; + /** OAuth client persistence for the public callback, which has no authenticated request context. */ + mcpOAuthClientStore: IOAuthClientStore; + resolveMcpServerStore: (input: ResolveMcpServerStoreInput) => IMcpServerStore; + resolveMcpOAuthClientStore: (input: ResolveMcpServerStoreInput) => IOAuthClientStore; + resolveMcpServerResolver: (input: ResolveMcpServerStoreInput) => IMcpServerResolver; /** Per-request store: DB singleton, or a token-bound TrueFoundry decorator in TrueFoundry mode. */ resolveAgentStore: (c: Context) => IAgentStore; /** @@ -341,7 +342,7 @@ export function createServerApp(deps: ServerDeps) { eventSubscriptions: deps.eventSubscriptions, logger: deps.logger, resolveModelProviderStore: deps.resolveModelProviderStore, - resolveMcpServerStore: deps.resolveMcpServerStore, + resolveMcpServerResolver: deps.resolveMcpServerResolver, resolveSandboxProviderStore: deps.resolveSandboxProviderStore, sandboxEnvironmentStore: deps.sandboxEnvironmentStore, resolveWebSearchProviderStore: deps.resolveWebSearchProviderStore, @@ -417,7 +418,7 @@ export function createServerApp(deps: ServerDeps) { '/api/v1/mcp-servers/oauth', createMcpOAuthRouter({ tokenStore: deps.tokenStore, - mcpServerStore: deps.resolveMcpServerStore({}), + mcpServerStore: deps.mcpOAuthClientStore, logger: deps.logger, resolveSession: c => tryAuthenticate({ authenticator: deps.authenticator, context: c }), }), @@ -427,6 +428,8 @@ export function createServerApp(deps: ServerDeps) { withAuth( createMcpServersRouter({ resolveMcpServerStore: c => deps.resolveMcpServerStore({ c }), + resolveMcpOAuthClientStore: c => deps.resolveMcpOAuthClientStore({ c }), + resolveMcpServerResolver: c => deps.resolveMcpServerResolver({ c }), tokenStore: deps.tokenStore, withTransaction: deps.withTransaction, logger: deps.logger, @@ -511,6 +514,8 @@ export function createServerApp(deps: ServerDeps) { createSettingsRouter({ resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, + resolveMcpOAuthClientStore: deps.resolveMcpOAuthClientStore, + resolveMcpServerResolver: deps.resolveMcpServerResolver, tokenStore: deps.tokenStore, resolveSkillStore: c => deps.resolveSkillStore({ c }), resolveSandboxProviderStore: deps.resolveSandboxProviderStore, @@ -598,7 +603,7 @@ export function createServerApp(deps: ServerDeps) { sessionStore: deps.sessionStore, activeTurns: deps.activeTurns, resolveModelProviderStore: deps.resolveModelProviderStore, - resolveMcpServerStore: deps.resolveMcpServerStore, + resolveMcpServerResolver: deps.resolveMcpServerResolver, resolveTurnSkillsStore: deps.resolveTurnSkillsStore, resolveAgentStore: deps.resolveAgentStore, eventSubscriptions: deps.eventSubscriptions, diff --git a/packages/trueforge/src/db/mcpServerStore.ts b/packages/trueforge/src/db/mcpServerStore.ts index 2c847fc5b..bdf04a94a 100644 --- a/packages/trueforge/src/db/mcpServerStore.ts +++ b/packages/trueforge/src/db/mcpServerStore.ts @@ -1,13 +1,10 @@ -import type { RemoteMcpHeaders } from '@truefoundry/trueforge-core/core'; import type { OAuthClientRecord as ContractOAuthClientRecord, OAuthPendingAuthorization as ContractOAuthPendingAuthorization, OAuthToken as ContractOAuthToken, - IOAuthClientStore, } from '../mcp/auth/types'; import type { ResourceName } from '../schemas/common'; -import type { McpAuthStatus, McpServerManifest } from '../schemas/mcpServer'; -import type { TurnMetadata } from './turnMetadata'; +import type { McpServerManifest } from '../schemas/mcpServer'; export interface McpServerRecord { id: string; @@ -69,27 +66,8 @@ export class McpServerNotFoundError extends Error { } } -export interface ResolveMcpAuthStatusesInput { - records: readonly McpServerRecord[]; - userRef: string; -} - -export interface AuthorizeMcpServerInput { - tenant_id: string; - name: string; - userRef: string; - /** Same-origin relative path to land after consent. */ - returnTo?: string; -} - -export interface DeleteMcpAuthorizationInput { - tenant_id: string; - name: string; - userRef: string; -} - -/** Row persistence + DCR client columns — no authorize/status/revoke. */ -export interface IMcpServerStore extends IOAuthClientStore { +/** MCP definition persistence only. OAuth client persistence is a separate capability. */ +export interface IMcpServerStore { listServers(input: ListMcpServersInput, transaction?: TTransaction): Promise; getServer(input: GetMcpServerInput, transaction?: TTransaction): Promise; /** @@ -112,25 +90,6 @@ export interface IMcpServerStore extends IOAuthClientStore deleteServer(input: DeleteMcpServerInput, transaction?: TTransaction): Promise; } -/** Persistence plus authorize / status / revoke and invoke headers. */ -export interface IMcpServerWithAuthStore extends IMcpServerStore { - /** Wire `auth_status` for Connect UX, keyed by server name. */ - resolveAuthStatuses(input: ResolveMcpAuthStatusesInput): Promise>; - - /** Start or resume authorization; returns `auth_required` + URL or `authenticated`. */ - authorize(input: AuthorizeMcpServerInput): Promise; - - /** Revoke this subject's authorization for the named server. */ - deleteAuthorization(input: DeleteMcpAuthorizationInput): Promise; - - /** Headers for MCP invoke; may be static or an async resolver that can return `authRequired`. */ - resolveInvokeHeaders(input: { - record: McpServerRecord; - userRef: string; - turnMetadata?: TurnMetadata; - }): RemoteMcpHeaders; -} - /** * How long a pending authorization stays redeemable — long enough to finish a consent screen, * short enough that an abandoned `state` and its PKCE verifier stop being usable. Applied as a diff --git a/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts b/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts index c6f76ea8f..52a4119f5 100644 --- a/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts +++ b/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts @@ -1,5 +1,5 @@ import type { Kysely, Selectable, Transaction } from 'kysely'; -import type { OAuthClientRecord } from '../../../mcp/auth/types'; +import type { IOAuthClientStore, OAuthClientRecord } from '../../../mcp/auth/types'; import { newId } from '../../../utils/id'; import { fromStoredOAuthClientRecord, @@ -28,7 +28,9 @@ function toRecord(row: Selectable): McpServerRecord { }; } -export class PostgresMcpServerStore implements IMcpServerStore> { +export class PostgresMcpServerStore + implements IMcpServerStore>, IOAuthClientStore> +{ readonly #db: Kysely; constructor(db: Kysely) { diff --git a/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts b/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts index 4144d09a9..036e7b10d 100644 --- a/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts +++ b/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts @@ -1,5 +1,5 @@ import type { ExpressionBuilder, Kysely, Transaction } from 'kysely'; -import type { OAuthClientRecord } from '../../../mcp/auth/types'; +import type { IOAuthClientStore, OAuthClientRecord } from '../../../mcp/auth/types'; import type { McpServerManifest } from '../../../schemas/mcpServer'; import { newId } from '../../../utils/id'; import { @@ -32,7 +32,9 @@ function recordColumns(eb: ExpressionBuilder) { ]; } -export class SqliteMcpServerStore implements IMcpServerStore> { +export class SqliteMcpServerStore + implements IMcpServerStore>, IOAuthClientStore> +{ readonly #db: Kysely; constructor(db: Kysely) { diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 609848285..3d2f2c5eb 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -106,8 +106,7 @@ import { WebSearchCatalog } from './catalog/WebSearchCatalog'; import { type DistributedServerConfiguration } from './config'; import { createController } from './controller'; import type { AgentRecord, IAgentStore } from './db/agentStore'; -import type { IMcpServerStore, IMcpServerWithAuthStore } from './db/mcpServerStore'; -import { McpServerWithAuthStore } from './db/McpServerWithAuthStore'; +import type { IMcpServerStore } from './db/mcpServerStore'; import type { IModelProviderStore } from './db/modelProviderStore'; import type { PostgresAgentStore } from './db/postgres/agent-store/PostgresAgentStore'; import type { Database as PostgresDatabase } from './db/postgres/types'; @@ -122,10 +121,11 @@ import type { IWebSearchProviderStore } from './db/webSearchProviderStore'; import { mountFrontend } from './frontend'; import { serverTlsServeOptions } from './http/tls'; import { createServerLogger, shouldColorize } from './logger'; -import type { IOAuthTokenStore } from './mcp/auth/types'; +import type { IOAuthClientStore, IOAuthTokenStore } from './mcp/auth/types'; import { PACKAGE_VERSION } from './packageVersion'; import { ActiveTurnRegistry } from './runtime/activeTurns'; import { EventSubscriptionRegistry } from './runtime/event-subscription'; +import { McpServerResolver, type IMcpServerResolver } from './runtime/McpServerResolver'; import type { ConnectedRedis } from './runtime/redis'; import { initSentry } from './sentry'; import { printStandaloneStartupBanner } from './startupBanner'; @@ -159,6 +159,12 @@ import { TrueFoundryServiceFoundryServerClient } from './truefoundry/TrueFoundry import { TrueFoundryAdminSkillStore, TrueFoundrySkillStore } from './truefoundry/TrueFoundrySkillStore'; import { TrueFoundryWebSearchProviderStore } from './truefoundry/TrueFoundryWebSearchProviderStore'; +interface McpServerCapabilities { + store: IMcpServerStore; + oauthClientStore: IOAuthClientStore; + resolver: IMcpServerResolver; +} + /** Persistence-layer model store resolve (RequestContext, not Hono Context). */ type ResolveModelProviderStore = (input: { rc: RequestContext; @@ -166,13 +172,13 @@ type ResolveModelProviderStore = (input: { inlineModelProviders?: InlineModelProviders; }) => IModelProviderStore; -/** Persistence-layer MCP store resolve (RequestContext, not Hono Context). */ -type ResolveMcpServerStore = (input: { +/** Persistence-layer MCP capability resolve (RequestContext, not Hono Context). */ +type ResolveMcpServerCapabilities = (input: { rc: RequestContext; runAsAgent?: AgentRecord; perServerHeaders?: PerServerMcpHeaders; inlineMcpServers?: InlineMcpServers; -}) => IMcpServerWithAuthStore; +}) => McpServerCapabilities; /** Turn skill mounts: closed-set `x-tfg-skills`, else the registry/DB store. */ type ResolveTurnSkillsStore = (input: { @@ -186,9 +192,9 @@ interface ServerPersistence { sessionMetricsStore: ISessionMetricsStore; tokenStore: IOAuthTokenStore; scheduleStore: IScheduleStore; - mcpOAuthStore: IMcpServerWithAuthStore; + mcpOAuthClientStore: IOAuthClientStore; resolveModelProviderStore: ResolveModelProviderStore; - resolveMcpServerStore: ResolveMcpServerStore; + resolveMcpServerCapabilities: ResolveMcpServerCapabilities; resolveSandboxProviderStore: (rc: RequestContext) => ISandboxProviderStore; resolveWebSearchProviderStore: (rc: RequestContext) => IWebSearchProviderStore; /** Per-request store: DB git skills, or TrueFoundry registry catalog (caller JWT). */ @@ -295,19 +301,20 @@ function buildResolveModelProviderStore(options: { /** * Per-request MCP store over ServiceFoundry. `runAsAgent` selects a saved agent's token for turns. */ -function buildResolveMcpServerStore(options: { - persistenceStore: IMcpServerStore; +function buildResolveMcpServerCapabilities(options: { + persistenceStore: IMcpServerStore & IOAuthClientStore; tokenStore: IOAuthTokenStore; client: TrueFoundryServiceFoundryServerClient | undefined; logger: Logger; -}): ResolveMcpServerStore { - const withAuthMcpPersistenceStore = new McpServerWithAuthStore({ +}): ResolveMcpServerCapabilities { + const resolver = new McpServerResolver({ store: options.persistenceStore, tokenStore: options.tokenStore, clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, }); return input => { const { client } = options; + let capabilities: McpServerCapabilities; if (client) { const store = new TrueFoundryMcpServerStore({ client, @@ -316,12 +323,24 @@ function buildResolveMcpServerStore(options: { logger: options.logger, ...(input.perServerHeaders === undefined ? {} : { perServerHeaders: input.perServerHeaders }), }); - if (input.inlineMcpServers !== undefined) { - return new InlineMcpServerStore({ inner: store, inline: input.inlineMcpServers }); - } - return store; + capabilities = { store, oauthClientStore: store, resolver: store }; + } else { + capabilities = { + store: options.persistenceStore, + oauthClientStore: options.persistenceStore, + resolver, + }; + } + if (input.inlineMcpServers === undefined) { + return capabilities; } - return withAuthMcpPersistenceStore; + const inline = new InlineMcpServerStore({ + store: capabilities.store, + oauthClientStore: capabilities.oauthClientStore, + resolver: capabilities.resolver, + inline: input.inlineMcpServers, + }); + return { store: inline, oauthClientStore: inline, resolver: inline }; }; } @@ -428,8 +447,9 @@ async function createStandalonePersistence(options: { const agentStore = new SqliteAgentStore(db); const sandboxEnvironmentStore = wrapSandboxEnvironmentStore(new SqliteSandboxEnvironmentStore(db)); const modelProviderStore = new SqliteModelProviderStore(db); - const mcpServerStore = new McpServerWithAuthStore({ - store: new SqliteMcpServerStore(db), + const mcpServerPersistenceStore = new SqliteMcpServerStore(db); + const mcpServerResolver = new McpServerResolver({ + store: mcpServerPersistenceStore, tokenStore, clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, }); @@ -440,11 +460,15 @@ async function createStandalonePersistence(options: { withTransaction: callback => db.transaction().execute(callback), sessionStore: new SqliteSessionStore(db), sessionMetricsStore: new SqliteSessionMetricsStore(db), - mcpOAuthStore: mcpServerStore, + mcpOAuthClientStore: mcpServerPersistenceStore, tokenStore, scheduleStore: new SqliteScheduleStore(db), resolveModelProviderStore: () => modelProviderStore, - resolveMcpServerStore: () => mcpServerStore, + resolveMcpServerCapabilities: () => ({ + store: mcpServerPersistenceStore, + oauthClientStore: mcpServerPersistenceStore, + resolver: mcpServerResolver, + }), resolveSandboxProviderStore: () => sandboxProviderStore, resolveWebSearchProviderStore: () => webSearchProviderStore, resolveSkillStore: () => skillStore, @@ -539,11 +563,6 @@ async function createDistributedPersistence(options: { const tokenStore = new PostgresOAuthTokenStore(db); const modelProviderStore = new PostgresModelProviderStore(db); const mcpServerStore = new PostgresMcpServerStore(db); - const mcpServerWithAuthStore = new McpServerWithAuthStore({ - store: mcpServerStore, - tokenStore, - clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, - }); const sandboxProviderStore = new PostgresSandboxProviderStore(db); const webSearchProviderStore = new PostgresWebSearchProviderStore(db); const skillStore = new PostgresSkillStore(db); @@ -558,7 +577,7 @@ async function createDistributedPersistence(options: { client: serviceFoundryClient, logger, }); - const resolveMcpServerStore = buildResolveMcpServerStore({ + const resolveMcpServerCapabilities = buildResolveMcpServerCapabilities({ persistenceStore: mcpServerStore, tokenStore, client: serviceFoundryClient, @@ -583,11 +602,11 @@ async function createDistributedPersistence(options: { withTransaction: callback => db.transaction().execute(callback), sessionStore: new PostgresSessionStore(db), sessionMetricsStore: new PostgresSessionMetricsStore(db), - mcpOAuthStore: mcpServerWithAuthStore, + mcpOAuthClientStore: mcpServerStore, tokenStore, scheduleStore: new PostgresScheduleStore(db), resolveModelProviderStore, - resolveMcpServerStore, + resolveMcpServerCapabilities, resolveSandboxProviderStore, resolveWebSearchProviderStore, resolveSkillStore, @@ -624,7 +643,7 @@ async function createServerRuntime(persistence: ServerPersistence< sessionMetricsStore, tokenStore, scheduleStore, - mcpOAuthStore, + mcpOAuthClientStore, agentStore, sandboxEnvironmentStore, resolveTurnSkillsStore, @@ -699,11 +718,8 @@ async function createServerRuntime(persistence: ServerPersistence< : {}), }); }; - const resolveMcpServerStore = (input: ResolveMcpServerStoreInput) => { + const resolveMcpServerCapabilities = (input: ResolveMcpServerStoreInput): McpServerCapabilities => { const { c, runAsAgent, allowInlineResources = false } = input; - if (c === undefined) { - return mcpOAuthStore; - } const rawPerServerHeaders = c.req.header(X_TFG_MCP_HEADERS); const rawInlineMcpServersHeader = c.req.header(X_TFG_MCP); if (rawInlineMcpServersHeader !== undefined) { @@ -713,7 +729,7 @@ async function createServerRuntime(persistence: ServerPersistence< allowInlineResources, }); } - return persistence.resolveMcpServerStore({ + return persistence.resolveMcpServerCapabilities({ rc: resolveRequestContext(c), ...(runAsAgent !== undefined ? { runAsAgent } : {}), ...(rawPerServerHeaders !== undefined ? { perServerHeaders: parsePerServerMcpHeaders(rawPerServerHeaders) } : {}), @@ -722,6 +738,10 @@ async function createServerRuntime(persistence: ServerPersistence< : {}), }); }; + const resolveMcpServerStore = (input: ResolveMcpServerStoreInput) => resolveMcpServerCapabilities(input).store; + const resolveMcpOAuthClientStore = (input: ResolveMcpServerStoreInput) => + resolveMcpServerCapabilities(input).oauthClientStore; + const resolveMcpServerResolver = (input: ResolveMcpServerStoreInput) => resolveMcpServerCapabilities(input).resolver; const resolveAgentStore = (c: Context) => persistence.resolveAgentStore(resolveRequestContext(c)); const resolveSandboxProviderStore = (c: Context) => persistence.resolveSandboxProviderStore(resolveRequestContext(c)); const resolveWebSearchProviderStore = (c: Context) => @@ -767,7 +787,10 @@ async function createServerRuntime(persistence: ServerPersistence< sandboxCatalog: SandboxCatalog.load(), webSearchCatalog: WebSearchCatalog.load(), resolveModelProviderStore, + mcpOAuthClientStore, resolveMcpServerStore, + resolveMcpOAuthClientStore, + resolveMcpServerResolver, resolveAgentStore, resolveSandboxProviderStore, resolveWebSearchProviderStore, diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts index 70276f086..e5f58e630 100644 --- a/packages/trueforge/src/mcp/server/trueforge/route.ts +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -5,17 +5,30 @@ * Request to the transport. Nothing is retained across requests. */ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import type { RemoteMcpHeaders } from '@truefoundry/trueforge-core/core'; import { TrueForge } from '@truefoundry/trueforge-sdk'; import { Hono } from 'hono'; -import type { ResolveRequestContext } from '../../../auth/identity'; +import type { RequestContext, ResolveRequestContext } from '../../../auth/identity'; import configuration from '../../../config'; +import type { GetMcpServerInput, McpServerRecord } from '../../../db/mcpServerStore'; import { internalTlsFetch, normalizeTlsUrl } from '../../../http/tls'; -import { createTrueForgeMcpServer } from './server'; +import type { + AuthorizeMcpServerInput, + DeleteMcpAuthorizationInput, + IMcpServerResolver, + ResolveMcpAuthStatusesInput, + ResolveMcpInvokeHeadersInput, +} from '../../../runtime/McpServerResolver'; +import type { McpAuthStatus } from '../../../schemas/mcpServer'; +import { createTrueForgeMcpServer, TRUEFORGE_CONNECTOR_NAME } from './server'; const baseUrl = normalizeTlsUrl({ url: configuration.SERVER_URL, enabled: configuration.MTLS_ENABLED }); const internalFetch = internalTlsFetch(configuration); const trueForgeMcpUrl = new URL('/mcp', baseUrl).href; +export const TRUEFORGE_CLIENT_HEADER = 'x-trueforge-client'; +export const TRUEFORGE_BROWSER_CLIENT = 'browser'; + /** * Request-scoped SDK client carrying the draft caller's credential. The service API key is * intentionally never used here. @@ -40,6 +53,80 @@ export const trueForgeMcpEndpoint = { }, }; +/** Request-scoped virtual MCP definition and auth behavior layered over configured MCPs. */ +export class TrueForgeMcpServerResolver implements IMcpServerResolver { + readonly #inner: IMcpServerResolver; + readonly #requestContext: RequestContext; + + constructor(input: { inner: IMcpServerResolver; requestContext: RequestContext }) { + this.#inner = input.inner; + this.#requestContext = input.requestContext; + } + + getServer(input: GetMcpServerInput): Promise { + if (input.name !== TRUEFORGE_CONNECTOR_NAME) { + return this.#inner.getServer(input); + } + const now = new Date().toISOString(); + return Promise.resolve({ + id: TRUEFORGE_CONNECTOR_NAME, + tenant_id: input.tenant_id, + name: TRUEFORGE_CONNECTOR_NAME, + manifest: { + type: 'remote', + name: TRUEFORGE_CONNECTOR_NAME, + url: trueForgeMcpEndpoint.url, + description: 'Manage resources in this TrueForge deployment.', + }, + created_at: now, + updated_at: now, + }); + } + + resolveInvokeHeaders(input: ResolveMcpInvokeHeadersInput): RemoteMcpHeaders { + if (input.record.name !== TRUEFORGE_CONNECTOR_NAME) { + return this.#inner.resolveInvokeHeaders(input); + } + const trueForgeHeaders = Object.fromEntries( + Object.entries(input.turnMetadata?.requestHeaders ?? {}).filter(([header]) => + header.toLowerCase().startsWith('x-trueforge-'), + ), + ); + return { + ...trueForgeHeaders, + ...(this.#requestContext.user_credential === null + ? {} + : { Authorization: `Bearer ${this.#requestContext.user_credential}` }), + }; + } + + async resolveAuthStatuses(input: ResolveMcpAuthStatusesInput): Promise> { + const trueForgeRecords = input.records.filter(record => record.name === TRUEFORGE_CONNECTOR_NAME); + const otherRecords = input.records.filter(record => record.name !== TRUEFORGE_CONNECTOR_NAME); + const statuses = new Map( + otherRecords.length === 0 ? [] : await this.#inner.resolveAuthStatuses({ ...input, records: otherRecords }), + ); + for (const record of trueForgeRecords) { + statuses.set(record.name, { status: 'authenticated' }); + } + return statuses; + } + + authorize(input: AuthorizeMcpServerInput): Promise { + if (input.name === TRUEFORGE_CONNECTOR_NAME) { + return Promise.resolve({ status: 'authenticated' }); + } + return this.#inner.authorize(input); + } + + deleteAuthorization(input: DeleteMcpAuthorizationInput): Promise { + if (input.name === TRUEFORGE_CONNECTOR_NAME) { + return Promise.resolve(); + } + return this.#inner.deleteAuthorization(input); + } +} + export interface TrueForgeMcpRouterDeps { resolveRequestContext: ResolveRequestContext; version: string; @@ -48,6 +135,9 @@ export interface TrueForgeMcpRouterDeps { export function createTrueForgeMcpRouter(deps: TrueForgeMcpRouterDeps): Hono { const router = new Hono(); router.all('/', async c => { + if (c.req.header(TRUEFORGE_CLIENT_HEADER) !== TRUEFORGE_BROWSER_CLIENT) { + return c.json({ error: { message: 'TrueForge MCP is available only to browser draft sessions' } }, 403); + } const requestContext = deps.resolveRequestContext(c); // Built per request: the server's tools close over this caller's credential, and stateless diff --git a/packages/trueforge/src/db/McpServerWithAuthStore.ts b/packages/trueforge/src/runtime/McpServerResolver.ts similarity index 61% rename from packages/trueforge/src/db/McpServerWithAuthStore.ts rename to packages/trueforge/src/runtime/McpServerResolver.ts index 792fc9e2f..ad0324ce0 100644 --- a/packages/trueforge/src/db/McpServerWithAuthStore.ts +++ b/packages/trueforge/src/runtime/McpServerResolver.ts @@ -1,30 +1,55 @@ import type { RemoteMcpHeaders } from '@truefoundry/trueforge-core/core'; -import { isMcpAuthRequired, resolveMcpAuth } from '../mcp/auth/mcpDcr'; -import type { IOAuthTokenStore, OAuthClientRecord } from '../mcp/auth/types'; -import { resolveConfiguredMcpRequestHeaders, resolveMcpAuthStatus, type McpAuthStatus } from '../schemas/mcpServer'; import { McpServerNotFoundError, - type AuthorizeMcpServerInput, - type CreateMcpServerInput, - type DeleteMcpAuthorizationInput, - type DeleteMcpServerInput, type GetMcpServerInput, type IMcpServerStore, - type IMcpServerWithAuthStore, - type ListMcpServersInput, type McpServerRecord, - type ResolveMcpAuthStatusesInput, - type UpsertMcpServerInput, -} from './mcpServerStore'; -import type { TurnMetadata } from './turnMetadata'; +} from '../db/mcpServerStore'; +import type { TurnMetadata } from '../db/turnMetadata'; +import { isMcpAuthRequired, resolveMcpAuth } from '../mcp/auth/mcpDcr'; +import type { IOAuthClientStore, IOAuthTokenStore } from '../mcp/auth/types'; +import { resolveConfiguredMcpRequestHeaders, resolveMcpAuthStatus, type McpAuthStatus } from '../schemas/mcpServer'; + +export interface ResolveMcpAuthStatusesInput { + records: readonly McpServerRecord[]; + userRef: string; +} + +export interface AuthorizeMcpServerInput { + tenant_id: string; + name: string; + userRef: string; + /** Same-origin relative path to land after consent. */ + returnTo?: string; +} -export class McpServerWithAuthStore implements IMcpServerWithAuthStore { - readonly #store: IMcpServerStore; +export interface DeleteMcpAuthorizationInput { + tenant_id: string; + name: string; + userRef: string; +} + +export interface ResolveMcpInvokeHeadersInput { + record: McpServerRecord; + userRef: string; + turnMetadata?: TurnMetadata; +} + +export interface IMcpServerResolver { + getServer(input: GetMcpServerInput): Promise; + resolveAuthStatuses(input: ResolveMcpAuthStatusesInput): Promise>; + authorize(input: AuthorizeMcpServerInput): Promise; + deleteAuthorization(input: DeleteMcpAuthorizationInput): Promise; + resolveInvokeHeaders(input: ResolveMcpInvokeHeadersInput): RemoteMcpHeaders; +} + +export class McpServerResolver implements IMcpServerResolver { + readonly #store: IMcpServerStore & IOAuthClientStore; readonly #tokenStore: IOAuthTokenStore; readonly #clientName: string; constructor(input: { - store: IMcpServerStore; + store: IMcpServerStore & IOAuthClientStore; tokenStore: IOAuthTokenStore; clientName: string; }) { @@ -33,35 +58,11 @@ export class McpServerWithAuthStore implements IMcpServerW this.#clientName = input.clientName; } - listServers(input: ListMcpServersInput, transaction?: TTransaction): Promise { - return this.#store.listServers(input, transaction); - } - - getServer(input: GetMcpServerInput, transaction?: TTransaction): Promise { - return this.#store.getServer(input, transaction); - } - - getServerForUpdate(input: GetMcpServerInput, transaction: TTransaction): Promise { - return this.#store.getServerForUpdate(input, transaction); - } - - createServer(input: CreateMcpServerInput, transaction?: TTransaction): Promise { - return this.#store.createServer(input, transaction); - } - - upsertServer(input: UpsertMcpServerInput, transaction?: TTransaction): Promise { - return this.#store.upsertServer(input, transaction); + getServer(input: GetMcpServerInput): Promise { + return this.#store.getServer(input); } - deleteServer(input: DeleteMcpServerInput, transaction?: TTransaction): Promise { - return this.#store.deleteServer(input, transaction); - } - - resolveInvokeHeaders(input: { - record: McpServerRecord; - userRef: string; - turnMetadata?: TurnMetadata; - }): RemoteMcpHeaders { + resolveInvokeHeaders(input: ResolveMcpInvokeHeadersInput): RemoteMcpHeaders { const { record, userRef } = input; if (record.manifest.auth?.type === 'dcr') { return async () => { @@ -88,18 +89,6 @@ export class McpServerWithAuthStore implements IMcpServerW return resolveConfiguredMcpRequestHeaders(record.manifest); } - saveClient(params: { id: string; record: OAuthClientRecord }, transaction?: TTransaction): Promise { - return this.#store.saveClient(params, transaction); - } - - getClient(params: { id: string }, transaction?: TTransaction): Promise { - return this.#store.getClient(params, transaction); - } - - deleteClient(params: { id: string }, transaction?: TTransaction): Promise { - return this.#store.deleteClient(params, transaction); - } - async resolveAuthStatuses(input: ResolveMcpAuthStatusesInput): Promise> { const dcrIds = input.records.filter(record => record.manifest.auth?.type === 'dcr').map(record => record.id); const tokens = await this.#tokenStore.getTokens({ ids: dcrIds, userRef: input.userRef }); diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 5f5bdb7e2..13584acd4 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -14,9 +14,8 @@ import { import { HTTPException } from 'hono/http-exception'; import { join } from 'node:path'; import type { Logger } from 'winston'; -import type { RequestContext } from '../auth/identity'; import configuration from '../config'; -import type { IMcpServerStore, IMcpServerWithAuthStore } from '../db/mcpServerStore'; +import type { IMcpServerStore } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxEnvironmentStore } from '../db/sandboxEnvironmentStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; @@ -35,14 +34,14 @@ import { import type { ReasoningEffort } from '../schemas/modelProvider'; import { DEFAULT_SANDBOX_ENVIRONMENT_NAME } from '../schemas/sandboxEnvironment'; import { hasConfiguredWebSearchProvider } from '../websearch/providers'; +import type { IMcpServerResolver } from './McpServerResolver'; export interface GetMcpConnectionInput { tenant_id: string; name: string; - store: IMcpServerWithAuthStore; + resolver: IMcpServerResolver; userRef: string; turnMetadata?: TurnMetadata; - requestContext?: RequestContext; } /** Split `provider/model` FQN. Returns undefined when the shape is not exactly one slash. */ @@ -119,44 +118,30 @@ export async function getModelDetails({ } /** - * Resolve a configured MCP server for a turn. + * Load a configured MCP server for a turn, including the description deferred tools show. + * Returns undefined when unregistered. * * TODO: OAuth header resolvers re-run on every RemoteMCP listTools/callTool; cache or gate later. */ export async function getMcpConnection({ tenant_id, name, - store, + resolver, userRef, turnMetadata, - requestContext, }: GetMcpConnectionInput): Promise { - if (name === TRUEFORGE_CONNECTOR_NAME) { - if (requestContext === undefined) { - return undefined; - } - return { - ...trueForgeMcpEndpoint, - description: 'Manage resources in this TrueForge deployment.', - headers: - requestContext.user_credential === null - ? trueForgeMcpEndpoint.headers - : { - ...trueForgeMcpEndpoint.headers, - Authorization: `Bearer ${requestContext.user_credential}`, - }, - }; - } - - const record = await store.getServer({ tenant_id, name }); + const record = await resolver.getServer({ tenant_id, name }); if (record === undefined) { return undefined; } - const headers = store.resolveInvokeHeaders({ + const headers = resolver.resolveInvokeHeaders({ record, userRef, ...(turnMetadata === undefined ? {} : { turnMetadata }), }); + if (record.name === TRUEFORGE_CONNECTOR_NAME) { + return { ...trueForgeMcpEndpoint, headers, description: record.manifest.description }; + } return { url: record.manifest.url, description: record.manifest.description, diff --git a/packages/trueforge/src/truefoundry/InlineMcpServerStore.ts b/packages/trueforge/src/truefoundry/InlineMcpServerStore.ts index 514549523..b444e74c4 100644 --- a/packages/trueforge/src/truefoundry/InlineMcpServerStore.ts +++ b/packages/trueforge/src/truefoundry/InlineMcpServerStore.ts @@ -1,18 +1,21 @@ import type { RemoteMcpHeaders } from '@truefoundry/trueforge-core/core'; import type { - AuthorizeMcpServerInput, CreateMcpServerInput, - DeleteMcpAuthorizationInput, DeleteMcpServerInput, GetMcpServerInput, - IMcpServerWithAuthStore, + IMcpServerStore, ListMcpServersInput, McpServerRecord, - ResolveMcpAuthStatusesInput, UpsertMcpServerInput, } from '../db/mcpServerStore'; import type { TurnMetadata } from '../db/turnMetadata'; -import type { OAuthClientRecord } from '../mcp/auth/types'; +import type { IOAuthClientStore, OAuthClientRecord } from '../mcp/auth/types'; +import type { + AuthorizeMcpServerInput, + DeleteMcpAuthorizationInput, + IMcpServerResolver, + ResolveMcpAuthStatusesInput, +} from '../runtime/McpServerResolver'; import { resolveConfiguredMcpRequestHeaders, resolveMcpAuthStatus, type McpAuthStatus } from '../schemas/mcpServer'; import { gatewayMetadataHeadersForTurn } from './gatewayMetadata'; import type { InlineMcpServers } from './inlineResources'; @@ -25,12 +28,23 @@ import type { InlineMcpServers } from './inlineResources'; * up in the tenant's settings. Writes and OAuth delegate too: there is no row to write or * authorize against. */ -export class InlineMcpServerStore implements IMcpServerWithAuthStore { - readonly #inner: IMcpServerWithAuthStore; +export class InlineMcpServerStore + implements IMcpServerStore, IOAuthClientStore, IMcpServerResolver +{ + readonly #store: IMcpServerStore; + readonly #oauthClientStore: IOAuthClientStore; + readonly #resolver: IMcpServerResolver; readonly #inline: InlineMcpServers; - constructor(input: { inner: IMcpServerWithAuthStore; inline: InlineMcpServers }) { - this.#inner = input.inner; + constructor(input: { + store: IMcpServerStore; + oauthClientStore: IOAuthClientStore; + resolver: IMcpServerResolver; + inline: InlineMcpServers; + }) { + this.#store = input.store; + this.#oauthClientStore = input.oauthClientStore; + this.#resolver = input.resolver; this.#inline = input.inline; } @@ -46,7 +60,7 @@ export class InlineMcpServerStore implements IMcpServerWit }): RemoteMcpHeaders { const manifest = this.#inline[input.record.name]; if (manifest === undefined) { - return this.#inner.resolveInvokeHeaders(input); + return this.#resolver.resolveInvokeHeaders(input); } const configured = resolveConfiguredMcpRequestHeaders(manifest); if (input.turnMetadata === undefined) { @@ -57,12 +71,12 @@ export class InlineMcpServerStore implements IMcpServerWit async getServer(input: GetMcpServerInput, transaction?: TTransaction): Promise { const record = this.#toRecord({ tenant_id: input.tenant_id, name: input.name }); - return record ?? (await this.#inner.getServer(input, transaction)); + return record ?? (await this.#store.getServer(input, transaction)); } async listServers(input: ListMcpServersInput, transaction?: TTransaction): Promise { if (input.names === undefined) { - return this.#inner.listServers(input, transaction); + return this.#store.listServers(input, transaction); } if (input.names.length === 0) { return []; @@ -73,7 +87,7 @@ export class InlineMcpServerStore implements IMcpServerWit .filter((record): record is McpServerRecord => record !== undefined); const registryNames = input.names.filter(name => this.#inline[name] === undefined); const registryRecords = - registryNames.length > 0 ? await this.#inner.listServers({ ...input, names: registryNames }, transaction) : []; + registryNames.length > 0 ? await this.#store.listServers({ ...input, names: registryNames }, transaction) : []; return [...inlineRecords, ...registryRecords]; } @@ -83,7 +97,9 @@ export class InlineMcpServerStore implements IMcpServerWit const registryRecords = input.records.filter(record => this.#inline[record.name] === undefined); const statuses = new Map( - registryRecords.length > 0 ? await this.#inner.resolveAuthStatuses({ ...input, records: registryRecords }) : [], + registryRecords.length > 0 + ? await this.#resolver.resolveAuthStatuses({ ...input, records: registryRecords }) + : [], ); for (const record of inlineRecords) { statuses.set(record.name, resolveMcpAuthStatus({ manifest: record.manifest })); @@ -92,39 +108,39 @@ export class InlineMcpServerStore implements IMcpServerWit } getServerForUpdate(input: GetMcpServerInput, transaction: TTransaction): Promise { - return this.#inner.getServerForUpdate(input, transaction); + return this.#store.getServerForUpdate(input, transaction); } createServer(input: CreateMcpServerInput, transaction?: TTransaction): Promise { - return this.#inner.createServer(input, transaction); + return this.#store.createServer(input, transaction); } upsertServer(input: UpsertMcpServerInput, transaction?: TTransaction): Promise { - return this.#inner.upsertServer(input, transaction); + return this.#store.upsertServer(input, transaction); } deleteServer(input: DeleteMcpServerInput, transaction?: TTransaction): Promise { - return this.#inner.deleteServer(input, transaction); + return this.#store.deleteServer(input, transaction); } authorize(input: AuthorizeMcpServerInput): Promise { - return this.#inner.authorize(input); + return this.#resolver.authorize(input); } deleteAuthorization(input: DeleteMcpAuthorizationInput): Promise { - return this.#inner.deleteAuthorization(input); + return this.#resolver.deleteAuthorization(input); } saveClient(params: { id: string; record: OAuthClientRecord }, transaction?: TTransaction): Promise { - return this.#inner.saveClient(params, transaction); + return this.#oauthClientStore.saveClient(params, transaction); } getClient(params: { id: string }, transaction?: TTransaction): Promise { - return this.#inner.getClient(params, transaction); + return this.#oauthClientStore.getClient(params, transaction); } deleteClient(params: { id: string }, transaction?: TTransaction): Promise { - return this.#inner.deleteClient(params, transaction); + return this.#oauthClientStore.deleteClient(params, transaction); } /** `id` is the name: nothing is persisted, and the name is what identifies these downstream. */ diff --git a/packages/trueforge/src/truefoundry/TrueFoundryMcpServerStore.ts b/packages/trueforge/src/truefoundry/TrueFoundryMcpServerStore.ts index 5ecc4da10..527fc8c9e 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryMcpServerStore.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryMcpServerStore.ts @@ -7,19 +7,22 @@ import { getPublicUiBasePath } from '../config'; import type { AgentRecord } from '../db/agentStore'; import { McpServerNotFoundError, - type AuthorizeMcpServerInput, type CreateMcpServerInput, - type DeleteMcpAuthorizationInput, type DeleteMcpServerInput, type GetMcpServerInput, - type IMcpServerWithAuthStore, + type IMcpServerStore, type ListMcpServersInput, type McpServerRecord, - type ResolveMcpAuthStatusesInput, type UpsertMcpServerInput, } from '../db/mcpServerStore'; import type { TurnMetadata } from '../db/turnMetadata'; -import type { OAuthClientRecord } from '../mcp/auth/types'; +import type { IOAuthClientStore, OAuthClientRecord } from '../mcp/auth/types'; +import type { + AuthorizeMcpServerInput, + DeleteMcpAuthorizationInput, + IMcpServerResolver, + ResolveMcpAuthStatusesInput, +} from '../runtime/McpServerResolver'; import { resolveMcpAuthStatus, type McpAuthStatus } from '../schemas/mcpServer'; import { accessTokenForRequest, @@ -80,7 +83,9 @@ export function resolveAuthorizeRedirectURL(input: { returnTo?: string; publicBa } /** Read-only MCP registry for TrueFoundry mode (writes managed elsewhere). */ -export class TrueFoundryMcpServerStore implements IMcpServerWithAuthStore { +export class TrueFoundryMcpServerStore + implements IMcpServerStore, IOAuthClientStore, IMcpServerResolver +{ readonly #client: TrueFoundryMcpApiClient; readonly #resolveServiceFoundryAuthorization: ResolveServiceFoundryAuthorization; readonly #resolveGatewayAuthorization: ResolveGatewayAuthorization; diff --git a/packages/trueforge/tests/db/mcpServerStoreContractSuite.ts b/packages/trueforge/tests/db/mcpServerStoreContractSuite.ts index c5d1eef26..b8be30801 100644 --- a/packages/trueforge/tests/db/mcpServerStoreContractSuite.ts +++ b/packages/trueforge/tests/db/mcpServerStoreContractSuite.ts @@ -1,9 +1,9 @@ /** - * Backend-agnostic behavioural contract for IMcpServerStore. + * Backend-agnostic behavioural contract for MCP definition and OAuth client persistence. * Runs under jest against a fresh store per test (see backend test files). */ import { McpServerNameConflictError, type IMcpServerStore } from '../../src/db/mcpServerStore'; -import type { OAuthClientRecord } from '../../src/mcp/auth/types'; +import type { IOAuthClientStore, OAuthClientRecord } from '../../src/mcp/auth/types'; import type { RemoteMcpServerManifest } from '../../src/schemas/mcpServer'; const TENANT = 'default'; @@ -33,7 +33,7 @@ const sampleOAuthClient: OAuthClientRecord = { const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; -export function runMcpServerStoreContractSuite(getStore: () => IMcpServerStore): void { +export function runMcpServerStoreContractSuite(getStore: () => IMcpServerStore & IOAuthClientStore): void { it('upsert creates a server and round-trips the manifest', async () => { const store = getStore(); const created = await store.upsertServer({ diff --git a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts index 17409f077..754ba0879 100644 --- a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts +++ b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts @@ -7,7 +7,6 @@ import { createSessionsRouter } from '../../../src/apis/sessions'; import { createTurnsRouter } from '../../../src/apis/turns'; import { TrueForgeAuthorizer } from '../../../src/auth/authorizer'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; @@ -21,6 +20,7 @@ import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/Sqlite import { SqliteWebSearchProviderStore } from '../../../src/db/sqlite/web-search-provider-store/SqliteWebSearchProviderStore'; import { ActiveTurnRegistry } from '../../../src/runtime/activeTurns'; import { EventSubscriptionRegistry } from '../../../src/runtime/event-subscription/index.js'; +import { McpServerResolver } from '../../../src/runtime/McpServerResolver'; import { ListSessionsResponseSchema } from '../../../src/schemas/session'; describe('public CRUD after session deletion', () => { @@ -32,8 +32,9 @@ describe('public CRUD after session deletion', () => { const activeTurns = new ActiveTurnRegistry(); const modelProviderStore = new SqliteModelProviderStore(db); const tokenStore = new SqliteOAuthTokenStore(db); - const mcpServerStore = new McpServerWithAuthStore({ - store: new SqliteMcpServerStore(db), + const mcpServerStore = new SqliteMcpServerStore(db); + const mcpServerResolver = new McpServerResolver({ + store: mcpServerStore, tokenStore, clientName: 'test-client', }); @@ -71,7 +72,7 @@ describe('public CRUD after session deletion', () => { sessionStore, activeTurns, resolveModelProviderStore: () => modelProviderStore, - resolveMcpServerStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, resolveTurnSkillsStore: () => skillStore, resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), diff --git a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts index 0a41d4b34..53b120e6b 100644 --- a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts @@ -9,14 +9,13 @@ import { createMcpServersRouter, createSettingsMcpServersRouter } from '../../.. import type { RequestContext } from '../../../src/auth/identity'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; import configuration from '../../../src/config'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; -import type { IMcpServerWithAuthStore } from '../../../src/db/mcpServerStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; +import { McpServerResolver, type IMcpServerResolver } from '../../../src/runtime/McpServerResolver'; jest.mock('undici', () => { const actual = jest.requireActual('undici'); @@ -92,7 +91,8 @@ describe('MCP OAuth authorize + callback', () => { let settingsRouter: ReturnType; let mcpServersRouter: ReturnType; let oauthRouter: ReturnType; - let mcpServerStore: IMcpServerWithAuthStore; + let mcpServerStore: SqliteMcpServerStore; + let mcpServerResolver: IMcpServerResolver; let tokenStore: SqliteOAuthTokenStore; let withTransaction: (callback: (transaction: unknown) => Promise) => Promise; let logger: ReturnType; @@ -106,8 +106,9 @@ describe('MCP OAuth authorize + callback', () => { const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); tokenStore = new SqliteOAuthTokenStore(db); - mcpServerStore = new McpServerWithAuthStore({ - store: new SqliteMcpServerStore(db), + mcpServerStore = new SqliteMcpServerStore(db); + mcpServerResolver = new McpServerResolver({ + store: mcpServerStore, tokenStore, clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, }); @@ -115,6 +116,8 @@ describe('MCP OAuth authorize + callback', () => { logger = winston.createLogger({ silent: true }); settingsRouter = createSettingsMcpServersRouter({ resolveMcpServerStore: () => mcpServerStore, + resolveMcpOAuthClientStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, resolveAgentStore: () => new SqliteAgentStore(db), tokenStore, withTransaction, @@ -123,6 +126,8 @@ describe('MCP OAuth authorize + callback', () => { }); mcpServersRouter = createMcpServersRouter({ resolveMcpServerStore: () => mcpServerStore, + resolveMcpOAuthClientStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, tokenStore, withTransaction, logger, @@ -280,6 +285,8 @@ describe('MCP OAuth authorize + callback', () => { it('authorize for one user does not authenticate another user on the same server', async () => { const otherRouter = createMcpServersRouter({ resolveMcpServerStore: () => mcpServerStore, + resolveMcpOAuthClientStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, tokenStore, withTransaction, logger, diff --git a/packages/trueforge/tests/unit/apis/mcpServers.test.ts b/packages/trueforge/tests/unit/apis/mcpServers.test.ts index 87620bff3..c797d9916 100644 --- a/packages/trueforge/tests/unit/apis/mcpServers.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpServers.test.ts @@ -10,14 +10,13 @@ import { SandboxCatalog } from '../../../src/catalog/SandboxCatalog'; import { SkillCatalog } from '../../../src/catalog/SkillCatalog'; import { WebSearchCatalog } from '../../../src/catalog/WebSearchCatalog'; import configuration from '../../../src/config'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; -import type { IMcpServerWithAuthStore } from '../../../src/db/mcpServerStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; +import { McpServerResolver, type IMcpServerResolver } from '../../../src/runtime/McpServerResolver'; import { mountWithErrorHandler } from '../mountWithErrorHandler'; jest.mock('undici', () => { @@ -101,7 +100,8 @@ describe('mcp-servers routers', () => { let settingsRouter: ReturnType; let catalogRouter: ReturnType; let mcpServersRouter: ReturnType; - let mcpServerStore: IMcpServerWithAuthStore; + let mcpServerStore: SqliteMcpServerStore; + let mcpServerResolver: IMcpServerResolver; let agentStore: SqliteAgentStore; let tokenStore: SqliteOAuthTokenStore; let withTransaction: (callback: (transaction: unknown) => Promise) => Promise; @@ -129,8 +129,9 @@ describe('mcp-servers routers', () => { const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); tokenStore = new SqliteOAuthTokenStore(db); - mcpServerStore = new McpServerWithAuthStore({ - store: new SqliteMcpServerStore(db), + mcpServerStore = new SqliteMcpServerStore(db); + mcpServerResolver = new McpServerResolver({ + store: mcpServerStore, tokenStore, clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, }); @@ -140,6 +141,8 @@ describe('mcp-servers routers', () => { settingsRouter = mountWithErrorHandler( createSettingsMcpServersRouter({ resolveMcpServerStore: () => mcpServerStore, + resolveMcpOAuthClientStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, resolveAgentStore: () => agentStore, tokenStore, withTransaction, @@ -156,6 +159,8 @@ describe('mcp-servers routers', () => { }); mcpServersRouter = createMcpServersRouter({ resolveMcpServerStore: () => mcpServerStore, + resolveMcpOAuthClientStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, tokenStore, withTransaction, logger, @@ -719,6 +724,8 @@ describe('mcp-servers routers', () => { const otherRouter = createMcpServersRouter({ resolveMcpServerStore: () => mcpServerStore, + resolveMcpOAuthClientStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, tokenStore, withTransaction, logger, diff --git a/packages/trueforge/tests/unit/apis/modelProviders.test.ts b/packages/trueforge/tests/unit/apis/modelProviders.test.ts index 334791203..cf0ed82a3 100644 --- a/packages/trueforge/tests/unit/apis/modelProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/modelProviders.test.ts @@ -11,7 +11,6 @@ import { SandboxCatalog } from '../../../src/catalog/SandboxCatalog'; import { SkillCatalog } from '../../../src/catalog/SkillCatalog'; import { WebSearchCatalog } from '../../../src/catalog/WebSearchCatalog'; import configuration from '../../../src/config'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import type { IModelProviderStore } from '../../../src/db/modelProviderStore'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; @@ -23,6 +22,7 @@ import { SqliteSandboxProviderStore } from '../../../src/db/sqlite/sandbox-provi import { SqliteSkillStore } from '../../../src/db/sqlite/skill-store/SqliteSkillStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { SqliteWebSearchProviderStore } from '../../../src/db/sqlite/web-search-provider-store/SqliteWebSearchProviderStore'; +import { McpServerResolver } from '../../../src/runtime/McpServerResolver'; import { toRedactedSecretValue } from '../../../src/utils/secretRedaction'; import { mountWithErrorHandler } from '../mountWithErrorHandler'; @@ -126,16 +126,19 @@ async function createRouters(): Promise<{ const modelProviderStore = new SqliteModelProviderStore(db); const tokenStore = new SqliteOAuthTokenStore(db); const agentStore = new SqliteAgentStore(db); + const mcpServerStore = new SqliteMcpServerStore(db); + const mcpServerResolver = new McpServerResolver({ + store: mcpServerStore, + tokenStore, + clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, + }); return { settingsRouter: mountWithErrorHandler( createSettingsRouter({ resolveModelProviderStore: () => modelProviderStore, - resolveMcpServerStore: () => - new McpServerWithAuthStore({ - store: new SqliteMcpServerStore(db), - tokenStore, - clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, - }), + resolveMcpServerStore: () => mcpServerStore, + resolveMcpOAuthClientStore: () => mcpServerStore, + resolveMcpServerResolver: () => mcpServerResolver, tokenStore, resolveSkillStore: () => new SqliteSkillStore(db), resolveSandboxProviderStore: () => new SqliteSandboxProviderStore(db), diff --git a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts index 2bbda5453..6087cc425 100644 --- a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts @@ -5,7 +5,6 @@ import { createLogger } from 'winston'; import { createTurnsRouter, toContentDisposition } from '../../../src/apis/turns'; import { TrueForgeAuthorizer } from '../../../src/auth/authorizer'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; @@ -19,6 +18,7 @@ import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/Sqlite import { SqliteWebSearchProviderStore } from '../../../src/db/sqlite/web-search-provider-store/SqliteWebSearchProviderStore'; import { ActiveTurnRegistry } from '../../../src/runtime/activeTurns'; import { EventSubscriptionRegistry } from '../../../src/runtime/event-subscription'; +import { McpServerResolver } from '../../../src/runtime/McpServerResolver'; /** Parsed rather than built literally, so config defaults match what the create route stores. */ function agentSpec(): AgentSpec { @@ -43,8 +43,8 @@ async function buildApp() { sessionStore, activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), - resolveMcpServerStore: () => - new McpServerWithAuthStore({ + resolveMcpServerResolver: () => + new McpServerResolver({ store: new SqliteMcpServerStore(db), tokenStore, clientName: 'test-client', diff --git a/packages/trueforge/tests/unit/apis/schedules.test.ts b/packages/trueforge/tests/unit/apis/schedules.test.ts index 51fa79f64..91cde9152 100644 --- a/packages/trueforge/tests/unit/apis/schedules.test.ts +++ b/packages/trueforge/tests/unit/apis/schedules.test.ts @@ -69,7 +69,7 @@ function stubTurnExecutionDeps(agentStore: SqliteAgentStore, scheduleStore: Sqli eventSubscriptions: new EventSubscriptionRegistry(undefined), logger: winston.createLogger({ silent: true }), resolveModelProviderStore: () => ({}) as never, - resolveMcpServerStore: () => ({}) as never, + resolveMcpServerResolver: () => ({}) as never, resolveTurnSkillsStore: () => ({ resolveTurnSkills: async () => [] }), resolveSandboxProviderStore: () => ({}) as never, sandboxEnvironmentStore: {} as never, diff --git a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts index cc34322be..2538e76a0 100644 --- a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts @@ -13,7 +13,6 @@ import { import { createTurnsRouter } from '../../../src/apis/turns'; import { TrueForgeAuthorizer, type Authorizer } from '../../../src/auth/authorizer'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; @@ -28,6 +27,7 @@ import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/Sqlite import { SqliteWebSearchProviderStore } from '../../../src/db/sqlite/web-search-provider-store/SqliteWebSearchProviderStore'; import { ActiveTurnRegistry } from '../../../src/runtime/activeTurns'; import { EventSubscriptionRegistry } from '../../../src/runtime/event-subscription/index.js'; +import { McpServerResolver } from '../../../src/runtime/McpServerResolver'; import { ListSessionsResponseSchema } from '../../../src/schemas/session'; import { GetSessionMetricsChartDataResponseSchema, @@ -142,8 +142,8 @@ describe('sessions HTTP agent binding', () => { sessionStore, activeTurns: deps.activeTurns, resolveModelProviderStore: () => modelProviderStore, - resolveMcpServerStore: () => - new McpServerWithAuthStore({ + resolveMcpServerResolver: () => + new McpServerResolver({ store: mcpServerStore, tokenStore, clientName: 'test-client', diff --git a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts index 026e609db..93e7418d7 100644 --- a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts +++ b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts @@ -6,7 +6,6 @@ import { createTurnsRouter } from '../../../src/apis/turns'; import { createAppErrorHandler } from '../../../src/app'; import { TrueForgeAuthorizer } from '../../../src/auth/authorizer'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; @@ -20,6 +19,7 @@ import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/Sqlite import { SqliteWebSearchProviderStore } from '../../../src/db/sqlite/web-search-provider-store/SqliteWebSearchProviderStore'; import { ActiveTurnRegistry } from '../../../src/runtime/activeTurns'; import { EventSubscriptionRegistry } from '../../../src/runtime/event-subscription/index.js'; +import { McpServerResolver } from '../../../src/runtime/McpServerResolver'; async function postTurnRejectingWith(error: AgentHarnessError): Promise { const db = createSqliteDb(':memory:'); @@ -72,8 +72,8 @@ async function postTurnRejectingWith(error: AgentHarnessError): Promise modelProviderStore, - resolveMcpServerStore: () => - new McpServerWithAuthStore({ + resolveMcpServerResolver: () => + new McpServerResolver({ store: new SqliteMcpServerStore(db), tokenStore, clientName: 'test-client', diff --git a/packages/trueforge/tests/unit/apis/turns.test.ts b/packages/trueforge/tests/unit/apis/turns.test.ts index 961cededf..f288bd58e 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -11,7 +11,6 @@ import { createLogger } from 'winston'; import { createTurnsRouter, deriveSessionTitle, turnStreamId } from '../../../src/apis/turns'; import { TrueForgeAuthorizer, type Authorizer } from '../../../src/auth/authorizer'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { SqliteAgentStore } from '../../../src/db/sqlite/agent-store/SqliteAgentStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; @@ -27,9 +26,10 @@ import { SqliteWebSearchProviderStore } from '../../../src/db/sqlite/web-search- import { runWithLogContext } from '../../../src/logger'; import { ActiveTurnRegistry } from '../../../src/runtime/activeTurns'; import { EventSubscriptionRegistry } from '../../../src/runtime/event-subscription/index.js'; +import { McpServerResolver } from '../../../src/runtime/McpServerResolver'; -function mcpServerStoreWithAuth(db: Kysely, tokenStore: SqliteOAuthTokenStore) { - return new McpServerWithAuthStore({ +function createMcpServerResolver(db: Kysely, tokenStore: SqliteOAuthTokenStore) { + return new McpServerResolver({ store: new SqliteMcpServerStore(db), tokenStore, clientName: 'test-client', @@ -123,7 +123,7 @@ describe('turns', () => { sessionStore, activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), - resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), + resolveMcpServerResolver: () => createMcpServerResolver(db, tokenStore), resolveTurnSkillsStore: () => new SqliteSkillStore(db), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), @@ -219,7 +219,7 @@ describe('turns', () => { sessionStore, activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), - resolveMcpServerStore: () => mcpServerStoreWithAuth(db, new SqliteOAuthTokenStore(db)), + resolveMcpServerResolver: () => createMcpServerResolver(db, new SqliteOAuthTokenStore(db)), resolveTurnSkillsStore: () => new SqliteSkillStore(db), resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), @@ -321,7 +321,7 @@ describe('turns', () => { sessionStore, activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), - resolveMcpServerStore: () => mcpServerStoreWithAuth(db, new SqliteOAuthTokenStore(db)), + resolveMcpServerResolver: () => createMcpServerResolver(db, new SqliteOAuthTokenStore(db)), resolveTurnSkillsStore: () => new SqliteSkillStore(db), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), @@ -392,7 +392,7 @@ describe('turns', () => { sessionStore, activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), - resolveMcpServerStore: () => mcpServerStoreWithAuth(db, new SqliteOAuthTokenStore(db)), + resolveMcpServerResolver: () => createMcpServerResolver(db, new SqliteOAuthTokenStore(db)), resolveTurnSkillsStore: () => new SqliteSkillStore(db), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), @@ -500,7 +500,7 @@ describe('turns', () => { sessionStore: new SqliteSessionStore(db), activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => modelProviderStore, - resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), + resolveMcpServerResolver: () => createMcpServerResolver(db, tokenStore), resolveAgentStore: () => new SqliteAgentStore(db), resolveTurnSkillsStore: () => new SqliteSkillStore(db), eventSubscriptions, @@ -631,7 +631,7 @@ describe('turns', () => { sessionStore: new SqliteSessionStore(db), activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => modelProviderStore, - resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), + resolveMcpServerResolver: () => createMcpServerResolver(db, tokenStore), resolveAgentStore: () => new SqliteAgentStore(db), resolveTurnSkillsStore: () => new SqliteSkillStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), @@ -750,7 +750,7 @@ describe('turns', () => { sessionStore: new SqliteSessionStore(db), activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => modelProviderStore, - resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), + resolveMcpServerResolver: () => createMcpServerResolver(db, tokenStore), resolveTurnSkillsStore: () => new SqliteSkillStore(db), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), @@ -837,7 +837,7 @@ describe('turns', () => { sessionStore, activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), - resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), + resolveMcpServerResolver: () => createMcpServerResolver(db, tokenStore), resolveTurnSkillsStore: () => new SqliteSkillStore(db), resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), diff --git a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts index e337611c7..2eafbb007 100644 --- a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts +++ b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts @@ -1,12 +1,12 @@ import { configureOutboundUrlGuard } from '@truefoundry/trueforge-core/core'; import { STANDALONE_REQUEST_CONTEXT } from '../../../src/auth/identity'; -import { McpServerWithAuthStore } from '../../../src/db/McpServerWithAuthStore'; -import type { IMcpServerWithAuthStore } from '../../../src/db/mcpServerStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteOAuthTokenStore } from '../../../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { mcpOAuthCallbackUrl } from '../../../src/mcp/auth/mcpOAuthHelpers'; +import { TrueForgeMcpServerResolver } from '../../../src/mcp/server/trueforge/route'; +import { McpServerResolver, type IMcpServerResolver } from '../../../src/runtime/McpServerResolver'; import { getMcpConnection } from '../../../src/runtime/sessionResources'; jest.mock('undici', () => { @@ -22,7 +22,8 @@ jest.mock('undici', () => { describe('getMcpConnection', () => { let db: ReturnType; - let mcpServerStore: IMcpServerWithAuthStore; + let mcpServerStore: SqliteMcpServerStore; + let mcpServerResolver: IMcpServerResolver; let tokenStore: SqliteOAuthTokenStore; beforeAll(async () => { @@ -33,8 +34,9 @@ describe('getMcpConnection', () => { db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); tokenStore = new SqliteOAuthTokenStore(db); - mcpServerStore = new McpServerWithAuthStore({ - store: new SqliteMcpServerStore(db), + mcpServerStore = new SqliteMcpServerStore(db); + mcpServerResolver = new McpServerResolver({ + store: mcpServerStore, tokenStore, clientName: 'test-client', }); @@ -99,7 +101,7 @@ describe('getMcpConnection', () => { const connection = await getMcpConnection({ tenant_id: 'default', name: 'oauth-mcp', - store: mcpServerStore, + resolver: mcpServerResolver, userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toBeDefined(); @@ -157,7 +159,7 @@ describe('getMcpConnection', () => { const connection = await getMcpConnection({ tenant_id: 'default', name: 'tokened-mcp', - store: mcpServerStore, + resolver: mcpServerResolver, userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toBeDefined(); @@ -184,7 +186,7 @@ describe('getMcpConnection', () => { const connection = await getMcpConnection({ tenant_id: 'default', name: 'open-mcp', - store: mcpServerStore, + resolver: mcpServerResolver, userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toBeDefined(); @@ -198,14 +200,26 @@ describe('getMcpConnection', () => { }); it('resolves the built-in trueforge MCP without a stored server', async () => { + const resolver = new TrueForgeMcpServerResolver({ + inner: mcpServerResolver, + requestContext: { + ...STANDALONE_REQUEST_CONTEXT, + user_credential: 'draft-token', + }, + }); const connection = await getMcpConnection({ tenant_id: 'default', name: 'trueforge', - store: mcpServerStore, + resolver, userRef: STANDALONE_REQUEST_CONTEXT.subject.id, - requestContext: { - ...STANDALONE_REQUEST_CONTEXT, - user_credential: 'draft-token', + turnMetadata: { + sessionId: 'draft-session', + turnId: 'draft-turn', + requestHeaders: { + 'x-trueforge-client': 'browser', + 'X-TrueForge-Test': 'forwarded', + 'x-unrelated': 'not-forwarded', + }, }, }); @@ -213,6 +227,8 @@ describe('getMcpConnection', () => { expect(connection?.description).toBe('Manage resources in this TrueForge deployment.'); expect(connection?.headers).toEqual({ Authorization: 'Bearer draft-token', + 'X-TrueForge-Test': 'forwarded', + 'x-trueforge-client': 'browser', }); expect(connection?.networkPolicy).toEqual({ type: 'trusted-internal', @@ -221,17 +237,32 @@ describe('getMcpConnection', () => { }); }); - it('does not resolve the built-in trueforge MCP without a request context', async () => { + it('does not resolve the built-in trueforge MCP without the virtual resolver', async () => { await expect( getMcpConnection({ tenant_id: 'default', name: 'trueforge', - store: mcpServerStore, + resolver: mcpServerResolver, userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }), ).resolves.toBeUndefined(); }); + it('does not invent the browser marker when it was absent from the request', async () => { + const resolver = new TrueForgeMcpServerResolver({ + inner: mcpServerResolver, + requestContext: STANDALONE_REQUEST_CONTEXT, + }); + const connection = await getMcpConnection({ + tenant_id: 'default', + name: 'trueforge', + resolver, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, + }); + + expect(connection?.headers).toEqual({}); + }); + it('returns configured static headers for header-auth servers', async () => { await mcpServerStore.upsertServer({ tenant_id: 'default', @@ -248,7 +279,7 @@ describe('getMcpConnection', () => { const connection = await getMcpConnection({ tenant_id: 'default', name: 'header-mcp', - store: mcpServerStore, + resolver: mcpServerResolver, userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }); expect(connection).toBeDefined(); @@ -277,9 +308,9 @@ describe('getMcpConnection', () => { created_at: '2026-01-15T12:00:00.000Z', updated_at: '2026-01-16T12:00:00.000Z', }; - const store = Object.create(mcpServerStore) as IMcpServerWithAuthStore; - store.getServer = async () => record; - store.resolveInvokeHeaders = () => async () => ({ + const resolver = Object.create(mcpServerResolver) as IMcpServerResolver; + resolver.getServer = async () => record; + resolver.resolveInvokeHeaders = () => async () => ({ authRequired: { servers: [{ id: 'tfy-mcp', name: 'tfy-mcp', auth_url: 'https://consent.example/authorize' }], }, @@ -288,7 +319,7 @@ describe('getMcpConnection', () => { const connection = await getMcpConnection({ tenant_id: 'default', name: 'tfy-mcp', - store, + resolver, userRef: 'user-1', }); if (connection === undefined || typeof connection.headers !== 'function') { @@ -307,7 +338,7 @@ describe('getMcpConnection', () => { getMcpConnection({ tenant_id: 'default', name: 'missing-mcp', - store: mcpServerStore, + resolver: mcpServerResolver, userRef: STANDALONE_REQUEST_CONTEXT.subject.id, }), ).resolves.toBeUndefined(); diff --git a/packages/trueforge/tests/unit/truefoundry/inlineResources.test.ts b/packages/trueforge/tests/unit/truefoundry/inlineResources.test.ts index bbf82f1fe..3bc2cc881 100644 --- a/packages/trueforge/tests/unit/truefoundry/inlineResources.test.ts +++ b/packages/trueforge/tests/unit/truefoundry/inlineResources.test.ts @@ -1,6 +1,8 @@ import { HTTPException } from 'hono/http-exception'; -import type { IMcpServerWithAuthStore, McpServerRecord } from '../../../src/db/mcpServerStore'; +import type { IMcpServerStore, McpServerRecord } from '../../../src/db/mcpServerStore'; import type { IModelProviderStore, ModelProviderRecord } from '../../../src/db/modelProviderStore'; +import type { IOAuthClientStore } from '../../../src/mcp/auth/types'; +import type { IMcpServerResolver } from '../../../src/runtime/McpServerResolver'; import { TFG_METADATA_PREFIX, X_TFY_METADATA } from '../../../src/truefoundry/gatewayMetadata'; import { InlineMcpServerStore } from '../../../src/truefoundry/InlineMcpServerStore'; import { InlineModelProviderStore } from '../../../src/truefoundry/InlineModelProviderStore'; @@ -72,9 +74,11 @@ function mcpStoreWith(inlineRaw: object) { listServers: jest.fn().mockResolvedValue([registryServer]), resolveInvokeHeaders: jest.fn().mockReturnValue({ Authorization: 'Bearer caller-token' }), resolveAuthStatuses: jest.fn().mockResolvedValue(new Map([['team-mcp', { status: 'not_required' }]])), - } as unknown as IMcpServerWithAuthStore; + } as unknown as IMcpServerStore & IOAuthClientStore & IMcpServerResolver; const store = new InlineMcpServerStore({ - inner, + store: inner, + oauthClientStore: inner, + resolver: inner, inline: parseInlineMcpServers(JSON.stringify(inlineRaw)), }); return { store, inner }; From a205fee38a0584e19533487e1115e2ff6beb2513 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 20 Oct 2026 06:46:26 +0530 Subject: [PATCH 16/20] fix import --- .../src/mcp/server/trueforge/result.ts | 29 +++++++++++++++++-- .../trueforge/tools/sandboxEnvironments.ts | 21 ++++---------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/packages/trueforge/src/mcp/server/trueforge/result.ts b/packages/trueforge/src/mcp/server/trueforge/result.ts index 18e221725..37cad183b 100644 --- a/packages/trueforge/src/mcp/server/trueforge/result.ts +++ b/packages/trueforge/src/mcp/server/trueforge/result.ts @@ -1,12 +1,29 @@ /** Shared success/error shaping for TrueForge tool results. */ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { TrueForgeError } from '@truefoundry/trueforge-sdk'; interface SafeToolError { status: number | null; message: string; } +interface SdkRequestError { + statusCode?: number; + body?: unknown; +} + +/** Local failure with the same status and body shape as an SDK error. */ +export class ToolResultError extends Error implements SdkRequestError { + readonly statusCode: number; + readonly body: { error: { message: string } }; + + constructor(statusCode: number, message: string) { + super(message); + this.name = 'ToolResultError'; + this.statusCode = statusCode; + this.body = { error: { message } }; + } +} + const FALLBACK_MESSAGE = 'The TrueForge API request failed.'; /** Best-effort read of the TrueForge `{ error: { message } }` envelope. */ @@ -23,9 +40,17 @@ function messageFromBody(body: unknown): string | undefined { return undefined; } +/** + * The generated SDK re-exports these classes with `export *`. tsx loads that package as CommonJS + * in dev, where those names are not visible, so identify the errors by the name the SDK sets. + */ +export function isSdkRequestError(error: unknown): error is SdkRequestError { + return error instanceof Error && (error.name === 'TrueForgeError' || error.name === 'TrueForgeTimeoutError'); +} + /** Convert SDK/API failures into a compact, secret-free shape for MCP tool results. */ function toSafeToolError(error: unknown): SafeToolError { - if (error instanceof TrueForgeError) { + if (error instanceof ToolResultError || isSdkRequestError(error)) { return { status: error.statusCode ?? null, message: messageFromBody(error.body) ?? FALLBACK_MESSAGE, diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts index 9e30c644c..1eb8090ff 100644 --- a/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts +++ b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts @@ -1,8 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { TrueForgeError, type TrueForge } from '@truefoundry/trueforge-sdk'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; import { z } from 'zod'; import { SandboxEnvironmentManifestRequestSchema } from '../../../../schemas/sandboxEnvironment'; -import { runTool } from '../result'; +import { isSdkRequestError, runTool, ToolResultError } from '../result'; /** * The HTTP API exposes a single create-or-update PUT. We split create and update over it and add a @@ -14,7 +14,7 @@ async function environmentExists(client: TrueForge, name: string): Promise runTool(async () => { if (await environmentExists(client, manifest.name)) { - throw new TrueForgeError({ - statusCode: 409, - body: { error: { message: `Sandbox environment already exists: ${manifest.name}` } }, - }); + throw new ToolResultError(409, `Sandbox environment already exists: ${manifest.name}`); } return client.sandboxEnvironments.createOrUpdate({ manifest } as unknown as Parameters< typeof client.sandboxEnvironments.createOrUpdate @@ -93,16 +90,10 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF async ({ name, manifest }) => runTool(async () => { if (name !== manifest.name) { - throw new TrueForgeError({ - statusCode: 400, - body: { error: { message: 'input name and manifest.name must match' } }, - }); + throw new ToolResultError(400, 'input name and manifest.name must match'); } if (!(await environmentExists(client, name))) { - throw new TrueForgeError({ - statusCode: 404, - body: { error: { message: `Sandbox environment not found: ${name}` } }, - }); + throw new ToolResultError(404, `Sandbox environment not found: ${name}`); } return client.sandboxEnvironments.createOrUpdate({ manifest } as unknown as Parameters< typeof client.sandboxEnvironments.createOrUpdate From 9ccf90536e32ebd6b54e4440fd1f0c22741c37a0 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Wed, 21 Oct 2026 02:31:16 +0530 Subject: [PATCH 17/20] Drop the stale trusted-endpoint SSRF test. Trusted MCP fetches now live on the connection network policy, so the outbound options no longer accept trustedEndpoint. Co-authored-by: Cursor --- .../tests/core/util/ssrfGuard.test.ts | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts index b76fa055b..d49236bbb 100644 --- a/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts +++ b/packages/trueforge-core/tests/core/util/ssrfGuard.test.ts @@ -351,30 +351,6 @@ describe('modelSsrfFetch', () => { }); describe('mcpSsrfFetch', () => { - it('bypasses SSRF only for the exact configured trusted MCP endpoint', async () => { - const trustedFetch = jest.fn(async () => new Response(null, { status: 200 })); - configureOutboundFetches({ - outbound: defaultOutboundFetchOptions(), - model: defaultModelOutboundFetchOptions(), - mcp: { - ...defaultMcpOutboundFetchOptions(), - trustedEndpoint: { - url: 'http://127.0.0.1:8790/mcp', - fetch: trustedFetch, - }, - }, - }); - - await expect(mcpSsrfFetch('http://127.0.0.1:8790/mcp', { method: 'POST' })).resolves.toMatchObject({ - status: 200, - }); - expect(trustedFetch).toHaveBeenCalledWith('http://127.0.0.1:8790/mcp', { - method: 'POST', - redirect: 'manual', - }); - await expect(mcpSsrfFetch('http://127.0.0.1:8790/private')).rejects.toThrow('Outbound URL blocked'); - }); - it('retries statuses where the request was not processed but not 409/502/520/524', async () => { configureOutboundUrlGuard({ allowedHosts: ['127.0.0.1'], blockedHosts: [] }); configureOutboundFetches({ From 93b80202899bc2af17468639f5856c82679947ea Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Wed, 21 Oct 2026 02:52:35 +0530 Subject: [PATCH 18/20] ensure errors propagate --- packages/trueforge/src/mcp/server/trueforge/result.ts | 7 ++++--- packages/trueforge/src/mcp/server/trueforge/route.ts | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/trueforge/src/mcp/server/trueforge/result.ts b/packages/trueforge/src/mcp/server/trueforge/result.ts index 37cad183b..070bdef46 100644 --- a/packages/trueforge/src/mcp/server/trueforge/result.ts +++ b/packages/trueforge/src/mcp/server/trueforge/result.ts @@ -41,11 +41,12 @@ function messageFromBody(body: unknown): string | undefined { } /** - * The generated SDK re-exports these classes with `export *`. tsx loads that package as CommonJS - * in dev, where those names are not visible, so identify the errors by the name the SDK sets. + * The generated SDK re-exports its error classes with `export *`. tsx loads that package as CommonJS + * in dev, where those names are not visible, so match by shape. Status subclasses rename themselves + * (`NotFoundError`, `ConflictError`, ...), so `name` cannot identify them. */ export function isSdkRequestError(error: unknown): error is SdkRequestError { - return error instanceof Error && (error.name === 'TrueForgeError' || error.name === 'TrueForgeTimeoutError'); + return error instanceof Error && 'statusCode' in error && 'body' in error; } /** Convert SDK/API failures into a compact, secret-free shape for MCP tool results. */ diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts index e5f58e630..66df4449c 100644 --- a/packages/trueforge/src/mcp/server/trueforge/route.ts +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -138,6 +138,11 @@ export function createTrueForgeMcpRouter(deps: TrueForgeMcpRouterDeps): Hono { if (c.req.header(TRUEFORGE_CLIENT_HEADER) !== TRUEFORGE_BROWSER_CLIENT) { return c.json({ error: { message: 'TrueForge MCP is available only to browser draft sessions' } }, 403); } + // No standalone event stream. 405 tells the client this server has nothing to listen for. + if (c.req.method === 'GET') { + c.header('Allow', 'POST, DELETE'); + return c.body(null, 405); + } const requestContext = deps.resolveRequestContext(c); // Built per request: the server's tools close over this caller's credential, and stateless From 4f45b4bf3187f00dc32cebdc39936399ecb1899d Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Wed, 21 Oct 2026 02:53:51 +0530 Subject: [PATCH 19/20] fix tool description for env --- .../trueforge/tools/sandboxEnvironments.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts index 1eb8090ff..0e47c0fe6 100644 --- a/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts +++ b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts @@ -28,7 +28,9 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF description: 'Create a reusable sandbox environment that agents run in — defining its resources, environment ' + 'variables, networking, and build configuration — from a complete manifest. Fails if an environment ' + - 'with the same name already exists; the reserved `default` environment cannot be created.', + 'with the same name already exists; the reserved `default` environment cannot be created. ' + + 'Never ask the user for a secret value and never send networking.secrets. When the environment needs ' + + 'secrets, ask the user to create them in the sandbox environment settings.', inputSchema: { manifest: SandboxEnvironmentManifestRequestSchema }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, }, @@ -48,7 +50,8 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF { description: 'Discover existing sandbox environments you can reference from an agent, or find one to update. ' + - "Returns one paginated page; environment values and secrets follow the HTTP API's redaction behavior.", + 'Returns one paginated page. Secret values are redacted; never ask the user for them. When an ' + + 'environment needs secrets, ask the user to create them in the sandbox environment settings.', inputSchema: { page_size: z.number().int().positive().optional(), page_token: z.string().optional(), @@ -70,7 +73,9 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF { description: 'Fetch one sandbox environment by name — its manifest, build status, resources, environment ' + - 'variables, and networking configuration. Use it to review an environment or get a template before updating it.', + 'variables, and networking configuration. Use it to review an environment or get a template before updating it. ' + + 'Secret values are redacted; never ask the user for them. When the environment needs secrets, ask the user ' + + 'to create them in the sandbox environment settings.', inputSchema: { name: z.string() }, annotations: { readOnlyHint: true }, }, @@ -83,7 +88,10 @@ export function registerSandboxEnvironmentTools(server: McpServer, client: TrueF description: 'Change an existing sandbox environment — the resources, environment variables, networking, and build ' + 'configuration agents run in — by replacing it with a complete manifest. The input name and manifest ' + - 'name must match, and this tool never creates a missing environment (use create_sandbox_environment for that).', + 'name must match, and this tool never creates a missing environment (use create_sandbox_environment for that). ' + + 'Never ask the user for a secret value and never send networking.secrets; keep any redacted secret ' + + 'placeholders returned by get_sandbox_environment. When the environment needs secrets, ask the user to ' + + 'create them in the sandbox environment settings.', inputSchema: { name: z.string(), manifest: SandboxEnvironmentManifestRequestSchema }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, }, From b590ab59a5b9c8e28583b622b92ad8ae918beb9c Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Thu, 22 Oct 2026 02:26:57 +0530 Subject: [PATCH 20/20] description --- packages/trueforge/src/mcp/server/trueforge/route.ts | 3 ++- .../trueforge/tests/unit/runtime/resolveMcpServer.test.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/trueforge/src/mcp/server/trueforge/route.ts b/packages/trueforge/src/mcp/server/trueforge/route.ts index 66df4449c..39a695fd5 100644 --- a/packages/trueforge/src/mcp/server/trueforge/route.ts +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -76,7 +76,8 @@ export class TrueForgeMcpServerResolver implements IMcpServerResolver { type: 'remote', name: TRUEFORGE_CONNECTOR_NAME, url: trueForgeMcpEndpoint.url, - description: 'Manage resources in this TrueForge deployment.', + description: + 'Create, update, and read agents, schedules, and sandbox environments in this deployment. Read configured models, MCP servers, and skills. Does not run agents or turns.', }, created_at: now, updated_at: now, diff --git a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts index 2eafbb007..23d8c19b8 100644 --- a/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts +++ b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts @@ -224,7 +224,9 @@ describe('getMcpConnection', () => { }); expect(connection?.url).toMatch(/\/mcp$/); - expect(connection?.description).toBe('Manage resources in this TrueForge deployment.'); + expect(connection?.description).toBe( + 'Create, update, and read agents, schedules, and sandbox environments in this deployment. Read configured models, MCP servers, and skills. Does not run agents or turns.', + ); expect(connection?.headers).toEqual({ Authorization: 'Bearer draft-token', 'X-TrueForge-Test': 'forwarded',