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/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-core/src/agent-session/TurnResourceResolver.ts b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts index 0c19b32ba..aecd89bcd 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,12 @@ export class TurnResourceResolver< const source = await this.getOrCreateToolSource({ id: entry.name, create: async () => { - const { url, headers } = 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: 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 6ff8d866a..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 } from './mcp/RemoteMCP'; +export type { + RemoteMcpEndpoint, + 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, + 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 a825b739d..00724d70f 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 RemoteMcpNetworkPolicy, type RemoteMcpTransportType, } from './remoteMcpClient'; @@ -39,20 +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); +/** Immutable endpoint and network behavior needed to establish an MCP connection. */ +export interface RemoteMcpEndpoint { + url: string; + headers?: RemoteMcpHeaders; + 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 endpoint: RemoteMcpEndpoint; private readonly signal: AbortSignal; private readonly logger: Logger; private readonly tracing: AgentTracing; @@ -75,9 +87,8 @@ export class RemoteMCP implements ToolSource { constructor(params: { name: string; id: string; - description?: string | undefined; - url: string; - headers: RemoteMcpHeaders; + description: string | undefined; + endpoint: RemoteMcpEndpoint; logger: Logger; tracing?: AgentTracing | undefined; sessionId?: string | undefined; @@ -90,8 +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.endpoint = params.endpoint; this.signal = params.signal; this.sessionId = params.sessionId; this.resolvedTransportType = params.transportType; @@ -100,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 { @@ -154,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( @@ -295,8 +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.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 51b222f4d..2530bc1e6 100644 --- a/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts +++ b/packages/trueforge-core/src/core/mcp/remoteMcpClient.ts @@ -14,6 +14,14 @@ 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 type RemoteMcpNetworkPolicy = + | { type: 'external' } + | { + type: 'trusted-internal'; + endpoint: string; + fetch: RemoteMcpFetch; + }; export interface RemoteMcpConnection { readonly transportType: RemoteMcpTransportType; @@ -177,6 +185,7 @@ function buildConnection( export async function connectRemoteMcp(params: { url: string; headers: Record; + networkPolicy?: RemoteMcpNetworkPolicy; sessionId?: string | undefined; knownTransportType?: RemoteMcpTransportType | undefined; requestTimeoutMs: number; @@ -188,7 +197,22 @@ 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); + 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/agent-session/turnStream.test.ts b/packages/trueforge-core/tests/agent-session/turnStream.test.ts index 15b857110..c0419bb9a 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), @@ -448,8 +448,11 @@ describe('TurnResourceResolver caches', () => { return new RemoteMCP({ id: 'svc', name: 'svc', - url: 'http://example.invalid', - headers: {}, + description: 'Example MCP server.', + endpoint: { + url: 'http://example.invalid', + headers: {}, + }, logger, tracing: this.createTracing(), requestTimeoutMs: 60_000, @@ -465,7 +468,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 +512,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-core/tests/core/mcp/connectRemoteMcp.test.ts b/packages/trueforge-core/tests/core/mcp/connectRemoteMcp.test.ts index 386cb15d8..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 } 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,11 +89,55 @@ const baseParams = () => ({ signal: new AbortController().signal, }); +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; + 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' })); + 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', async () => { + const fetch = jest.fn() as unknown as RemoteMcpFetch; + + await expect( + connectRemoteMcp({ + ...baseParams(), + url: endpoint, + networkPolicy: { type: 'trusted-internal', endpoint: 'https://other.internal/mcp', fetch }, + }), + ).rejects.toThrow('Trusted MCP policy endpoint does not match connection URL'); + }); +}); + describe('connectRemoteMcp transport selection', () => { beforeEach(() => { mockConnectAttempts.length = 0; mockConnectSignals.length = 0; mockRequestTimeouts.length = 0; + mockTransportFetches.length = 0; mockCloseCalls = 0; hangUntilAbort = false; mockConnectImpl = () => { diff --git a/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts b/packages/trueforge-core/tests/core/mcp/remoteMcpServer.test.ts index 9cfaee087..9517090eb 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,12 +127,25 @@ function makeServer(params: { sessionId?: string | undefined; transportType?: 'streamable-http' | 'sse' | undefined; headers?: RemoteMcpHeaders | undefined; + fetch?: RemoteMcpFetch; }): ToolSet { const remote = new RemoteMCP({ name: 'my-inline', id: 'inline-id-123', - url: 'https://mcp.example.com/mcp', - headers: params.headers ?? {}, + description: 'my-inline MCP server', + 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, @@ -150,6 +163,24 @@ function makeServer(params: { } describe('RemoteMCP + ToolSet', () => { + 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({ + 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 () => { const state = installFakeConnection({ sessionId: 'sess-1' }); const server = makeServer({}); 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/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/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/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index 347ba6860..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,7 +473,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const connection = await getMcpConnection({ tenant_id: requestContext.tenant_id, name, - store: deps.resolveMcpServerStore(c), + resolver: deps.resolveMcpServerResolver(c), userRef, }); if (connection === undefined) { @@ -479,8 +482,8 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const remote = new RemoteMCP({ id: name, name, - url: connection.url, - headers: connection.headers, + description: connection.description, + endpoint: connection, requestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, connectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, maxResponseBytes: configuration.MCP_TOOL_CALL_MAX_RESPONSE_BYTES, @@ -518,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, @@ -526,7 +529,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< return c.json( { data: await toConfiguredMcpServer({ - store: deps.resolveMcpServerStore(c), + resolver: deps.resolveMcpServerResolver(c), record, userRef, }), @@ -554,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, }), @@ -571,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 b790267b1..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,8 @@ 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, webSearchProviderStore: deps.resolveWebSearchProviderStore(c), 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 af77bd6e1..23f0baa06 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -36,10 +36,9 @@ 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'; 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,8 @@ export type BeginTurnExecutionDeps = Pick< > & { agentStore: IAgentStore; modelProviderStore: IModelProviderStore; - mcpServerStore: IMcpServerWithAuthStore; + mcpServerStore: IMcpServerResolver; + requestContext: RequestContext | undefined; sandboxProviderStore: ISandboxProviderStore; webSearchProviderStore: IWebSearchProviderStore; skillStore: Pick; @@ -165,7 +167,8 @@ 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; sandboxEnvironmentStore: ISandboxEnvironmentStore; @@ -180,6 +183,7 @@ function createTurnResolver(deps: { }): TurnResourceResolver { const { mcpServerStore, + requestContext, skillStore, sandboxProviderStore, sandboxEnvironmentStore, @@ -194,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 => { @@ -217,7 +225,7 @@ function createTurnResolver(deps: { const connection = await getMcpConnection({ tenant_id, name, - store: mcpServerStore, + resolver: mcpServerResolver, userRef, turnMetadata, }); @@ -226,10 +234,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, @@ -448,6 +453,7 @@ export async function beginTurnExecution( }); const resolver = createTurnResolver({ mcpServerStore: deps.mcpServerStore, + requestContext: deps.requestContext, skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, sandboxEnvironmentStore: deps.sandboxEnvironmentStore, @@ -831,12 +837,13 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { allowInlineResources, ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), - mcpServerStore: deps.resolveMcpServerStore({ + mcpServerStore: deps.resolveMcpServerResolver({ c, allowInlineResources, ...(referencedAgent !== undefined ? { runAsAgent: referencedAgent } : {}), }), 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 b66bbab55..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,11 +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'; @@ -246,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; } @@ -276,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; /** @@ -340,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, @@ -416,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 }), }), @@ -426,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, @@ -510,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, @@ -597,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, @@ -612,6 +618,16 @@ export function createServerApp(deps: ServerDeps) { ), ); + // First-party TrueForge MCP (Streamable HTTP). Protocol endpoint. + app.use('/mcp', authMiddleware); + app.route( + '/mcp', + createTrueForgeMcpRouter({ + resolveRequestContext, + version: PACKAGE_VERSION, + }), + ); + const uiBasePath = getPublicUiBasePath(); if (configuration.SWAGGER_ENABLED) { const openApiSpecPath = `${uiBasePath}api/v1/openapi.json`; 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/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/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 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/result.ts b/packages/trueforge/src/mcp/server/trueforge/result.ts new file mode 100644 index 000000000..070bdef46 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/result.ts @@ -0,0 +1,86 @@ +/** Shared success/error shaping for TrueForge tool results. */ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +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. */ +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; +} + +/** + * 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 && 'statusCode' in error && 'body' in error; +} + +/** Convert SDK/API failures into a compact, secret-free shape for MCP tool results. */ +function toSafeToolError(error: unknown): SafeToolError { + if (error instanceof ToolResultError || isSdkRequestError(error)) { + 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 { + 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..39a695fd5 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/route.ts @@ -0,0 +1,162 @@ +/** + * Authenticated, stateless Streamable HTTP endpoint for the TrueForge MCP (mounted at /mcp). + * + * 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 type { RemoteMcpHeaders } from '@truefoundry/trueforge-core/core'; +import { TrueForge } from '@truefoundry/trueforge-sdk'; +import { Hono } from 'hono'; +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 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. + */ +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 endpoint with its trusted mTLS-aware network policy. */ +export const trueForgeMcpEndpoint = { + url: trueForgeMcpUrl, + headers: {}, + networkPolicy: { + type: 'trusted-internal' as const, + endpoint: trueForgeMcpUrl, + fetch: internalFetch, + }, +}; + +/** 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: + '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, + }); + } + + 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; +} + +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); + } + // 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 + // Streamable HTTP wants a fresh server+transport per request to isolate concurrent callers. + const client = createTrueForgeSDKClient({ 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..e59cff0f6 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/server.ts @@ -0,0 +1,35 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +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'; + +/** 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 }, + ); + 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/agents.ts b/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts new file mode 100644 index 000000000..b78783aab --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/agents.ts @@ -0,0 +1,85 @@ +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'; + +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: AgentSpecSchema, + }, + 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: AgentSpecSchema, + }, + 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/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..0e47c0fe6 --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/sandboxEnvironments.ts @@ -0,0 +1,111 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { TrueForge } from '@truefoundry/trueforge-sdk'; +import { z } from 'zod'; +import { SandboxEnvironmentManifestRequestSchema } from '../../../../schemas/sandboxEnvironment'; +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 + * 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). + */ +async function environmentExists(client: TrueForge, name: string): Promise { + try { + await client.sandboxEnvironments.get(name); + return true; + } catch (error) { + if (isSdkRequestError(error) && 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. ' + + '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 }, + }, + async ({ manifest }) => + runTool(async () => { + if (await environmentExists(client, 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 + >[0]); + }), + ); + + 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. 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(), + }, + 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. ' + + '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 }, + }, + 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). ' + + '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 }, + }, + async ({ name, manifest }) => + runTool(async () => { + if (name !== manifest.name) { + throw new ToolResultError(400, 'input name and manifest.name must match'); + } + if (!(await environmentExists(client, name))) { + throw new ToolResultError(404, `Sandbox environment not found: ${name}`); + } + 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 new file mode 100644 index 000000000..a186d774b --- /dev/null +++ b/packages/trueforge/src/mcp/server/trueforge/tools/schedules.ts @@ -0,0 +1,109 @@ +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'; + +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: ScheduleManifestSchema, + }, + 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: ScheduleManifestSchema, + }, + 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/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 4d33def07..13584acd4 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, @@ -15,13 +15,15 @@ import { HTTPException } from 'hono/http-exception'; import { join } from 'node:path'; import type { Logger } from 'winston'; 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'; import type { ISkillStore } from '../db/skillStore'; import type { TurnMetadata } from '../db/turnMetadata'; import type { IWebSearchProviderStore } from '../db/webSearchProviderStore'; +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'; import { @@ -32,10 +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 McpConnection { - url: string; - headers: RemoteMcpHeaders; +export interface GetMcpConnectionInput { + tenant_id: string; + name: string; + resolver: IMcpServerResolver; + userRef: string; + turnMetadata?: TurnMetadata; } /** Split `provider/model` FQN. Returns undefined when the shape is not exactly one slash. */ @@ -112,34 +118,34 @@ export async function getModelDetails({ } /** - * Load MCP url + headers for a configured server. Returns undefined when unregistered. + * 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, -}: { - tenant_id: string; - name: string; - store: IMcpServerWithAuthStore; - userRef: string; - turnMetadata?: TurnMetadata; -}): Promise { - const record = await store.getServer({ tenant_id, name }); +}: GetMcpConnectionInput): Promise { + const record = await resolver.getServer({ tenant_id, name }); if (record === undefined) { return undefined; } + 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, - headers: store.resolveInvokeHeaders({ - record, - userRef, - ...(turnMetadata === undefined ? {} : { turnMetadata }), - }), + description: record.manifest.description, + headers, }; } @@ -299,16 +305,11 @@ 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 unknown = requestedMcpServers.find(server => !configuredNames.has(server.name)); + 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 = 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/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..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,6 +32,7 @@ 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; diff --git a/packages/trueforge/src/schemas/sandboxEnvironment.ts b/packages/trueforge/src/schemas/sandboxEnvironment.ts index 9cc9f3974..fee633b64 100644 --- a/packages/trueforge/src/schemas/sandboxEnvironment.ts +++ b/packages/trueforge/src/schemas/sandboxEnvironment.ts @@ -146,10 +146,10 @@ const SandboxEnvironmentManifestFieldsSchema = z export const SandboxEnvironmentManifestSchema = SandboxEnvironmentManifestFieldsSchema.openapi('SandboxEnvironmentManifest'); -/** PUT body manifest — rejects reserved name `"default"`. */ -const SandboxEnvironmentManifestRequestSchema = SandboxEnvironmentManifestFieldsSchema.refine( +/** PUT body manifest — rejects the reserved system `"default"` environment. */ +export const SandboxEnvironmentManifestRequestSchema = SandboxEnvironmentManifestFieldsSchema.refine( manifest => manifest.name !== DEFAULT_SANDBOX_ENVIRONMENT_NAME, - { message: 'name "default" is reserved', path: ['name'] }, + { message: 'name is reserved', path: ['name'] }, ).openapi('SandboxEnvironmentManifestRequest'); /** 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/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); 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 b4e4dba5d..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, @@ -237,6 +242,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, @@ -711,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/getMcpConnection.test.ts b/packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts similarity index 75% rename from packages/trueforge/tests/unit/runtime/getMcpConnection.test.ts rename to packages/trueforge/tests/unit/runtime/resolveMcpServer.test.ts index 3ebac3a5d..23d8c19b8 100644 --- a/packages/trueforge/tests/unit/runtime/getMcpConnection.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(); @@ -193,9 +195,76 @@ describe('getMcpConnection', () => { } expect(connection.url).toBe('https://mcp.open.example/mcp'); + expect(connection.description).toBe('Open MCP server.'); expect(connection.headers).toEqual({}); }); + 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', + resolver, + userRef: STANDALONE_REQUEST_CONTEXT.subject.id, + turnMetadata: { + sessionId: 'draft-session', + turnId: 'draft-turn', + requestHeaders: { + 'x-trueforge-client': 'browser', + 'X-TrueForge-Test': 'forwarded', + 'x-unrelated': 'not-forwarded', + }, + }, + }); + + expect(connection?.url).toMatch(/\/mcp$/); + 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', + 'x-trueforge-client': 'browser', + }); + expect(connection?.networkPolicy).toEqual({ + type: 'trusted-internal', + endpoint: connection?.url, + fetch: expect.any(Function), + }); + }); + + it('does not resolve the built-in trueforge MCP without the virtual resolver', async () => { + await expect( + getMcpConnection({ + tenant_id: 'default', + name: 'trueforge', + 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', @@ -212,7 +281,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(); @@ -221,6 +290,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' }); }); @@ -240,9 +310,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' }], }, @@ -251,7 +321,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') { @@ -270,7 +340,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/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( 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 };