Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/nine-spies-rhyme.md
Original file line number Diff line number Diff line change
@@ -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.

10 changes: 9 additions & 1 deletion packages/frontend/src/authFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions packages/frontend/tests/authFetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ResolvedModel>;
/**
* 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<ResolvedMcpServer>;
mcpRequestTimeoutMs: number;
mcpConnectTimeoutMs: number;
mcpMaxResponseBytes?: number | undefined;
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions packages/trueforge-core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 27 additions & 15 deletions packages/trueforge-core/src/core/mcp/RemoteMCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
DEFAULT_MAX_MCP_RESPONSE_BYTES,
isSessionExpiredError,
type RemoteMcpConnection,
type RemoteMcpNetworkPolicy,
type RemoteMcpTransportType,
} from './remoteMcpClient';

Expand All @@ -39,20 +40,31 @@ export type ResolveHeadersResult = { headers: Record<string, string> } | { authR
/** Static headers, or a resolver (invoked at connect) returning headers or signalling auth-required. */
export type RemoteMcpHeaders = Record<string, string> | (() => Promise<ResolveHeadersResult>);

/** 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<T> = { 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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -154,10 +164,11 @@ export class RemoteMCP implements ToolSource {
}

private async resolveHeaders(): Promise<ResolveHeadersResult> {
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<T>(
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 25 additions & 1 deletion packages/trueforge-core/src/core/mcp/remoteMcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -177,6 +185,7 @@ function buildConnection(
export async function connectRemoteMcp(params: {
url: string;
headers: Record<string, string>;
networkPolicy?: RemoteMcpNetworkPolicy;
sessionId?: string | undefined;
knownTransportType?: RemoteMcpTransportType | undefined;
requestTimeoutMs: number;
Expand All @@ -188,7 +197,22 @@ export async function connectRemoteMcp(params: {
}): Promise<RemoteMcpConnection> {
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;
Expand Down
13 changes: 8 additions & 5 deletions packages/trueforge-core/tests/agent-session/turnStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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: () => {
Expand Down
Loading
Loading