From 0da6f6c6991f8ec42f08e8b55a101c89957d0ab9 Mon Sep 17 00:00:00 2001 From: Pranjal Trivedi Date: Fri, 4 Sep 2026 14:11:35 +0530 Subject: [PATCH 1/2] feat: resolve MCP servers and skills from x-tfg-mcp and x-tfg-skills In TrueFoundry mode a request can define the MCP servers and skills its agent spec names, instead of them being configured in the tenant's registry. The definitions take precedence for that request only and are never persisted, so they stay out of tenant settings and the gateway's metrics, and a rotating credential can ride each turn rather than being stored somewhere it goes stale. Both resolve through the store seam the spec validator and the turn resolver already use, so no call site learns about the headers. Inline MCP credentials come from the manifest's own auth.headers and reach the upstream unchanged -- no caller Bearer is layered over them. An unfiltered list still returns only configured resources. The skill store becomes request-scoped to carry this, mirroring the model and MCP resolvers. Co-authored-by: Cursor --- .../20260904140000-inline-mcp-and-skills.md | 5 + packages/trueforge/scripts/write-openapi.ts | 2 +- packages/trueforge/src/apis/agents.ts | 8 +- packages/trueforge/src/apis/sessions.ts | 10 +- packages/trueforge/src/apis/turns.ts | 10 +- packages/trueforge/src/app.ts | 16 +- packages/trueforge/src/main.ts | 47 +++- .../src/truefoundry/InlineMcpServerStore.ts | 140 ++++++++++++ .../src/truefoundry/InlineSkillStore.ts | 63 ++++++ .../src/truefoundry/inlineResources.ts | 80 +++++++ .../trueforge/tests/unit/apis/agents.test.ts | 2 +- .../unit/apis/deletedSessionCrud.test.ts | 4 +- .../unit/apis/sandboxFileDownload.test.ts | 2 +- .../tests/unit/apis/sessionHttp.test.ts | 2 +- .../unit/apis/turnHarnessErrorStatus.test.ts | 2 +- .../trueforge/tests/unit/apis/turns.test.ts | 6 +- .../unit/truefoundry/inlineResources.test.ts | 200 ++++++++++++++++++ 17 files changed, 564 insertions(+), 35 deletions(-) create mode 100644 .changeset/20260904140000-inline-mcp-and-skills.md create mode 100644 packages/trueforge/src/truefoundry/InlineMcpServerStore.ts create mode 100644 packages/trueforge/src/truefoundry/InlineSkillStore.ts create mode 100644 packages/trueforge/src/truefoundry/inlineResources.ts create mode 100644 packages/trueforge/tests/unit/truefoundry/inlineResources.test.ts diff --git a/.changeset/20260904140000-inline-mcp-and-skills.md b/.changeset/20260904140000-inline-mcp-and-skills.md new file mode 100644 index 000000000..6855e6324 --- /dev/null +++ b/.changeset/20260904140000-inline-mcp-and-skills.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Accept `x-tfg-mcp` and `x-tfg-skills` in TrueFoundry mode: MCP servers and skills a request defines by name, taking precedence over the tenant registry for that request only. Both resolve for spec validation and turn execution; an unfiltered list still shows only configured resources, so request-scoped ones never appear in settings. Inline MCP credentials come from the manifest's own `auth.headers`, which lets a rotating token ride each turn. diff --git a/packages/trueforge/scripts/write-openapi.ts b/packages/trueforge/scripts/write-openapi.ts index 50249169b..fc18a85a4 100644 --- a/packages/trueforge/scripts/write-openapi.ts +++ b/packages/trueforge/scripts/write-openapi.ts @@ -74,7 +74,7 @@ const app = createServerApp({ }), tokenStore, skillCatalog: SkillCatalog.load(), - skillStore: new SqliteSkillStore(db), + resolveSkillStore: () => new SqliteSkillStore(db), sandboxCatalog: SandboxCatalog.load(), sandboxProviderStore: new SqliteSandboxProviderStore(db), resolveAgentStore: () => agentStore, diff --git a/packages/trueforge/src/apis/agents.ts b/packages/trueforge/src/apis/agents.ts index 02fc2f315..573665949 100644 --- a/packages/trueforge/src/apis/agents.ts +++ b/packages/trueforge/src/apis/agents.ts @@ -32,7 +32,7 @@ export interface AgentsRouterDeps { resolveAgentStore: (c: Context) => IAgentStore; resolveModelProviderStore: (c: Context) => IModelProviderStore; resolveMcpServerStore: (c: Context) => IMcpServerStore; - skillStore: ISkillStore; + resolveSkillStore: (c: Context) => ISkillStore; sandboxProviderStore: ISandboxProviderStore; withTransaction: WithTransaction; resolveRequestContext: ResolveRequestContext; @@ -53,12 +53,14 @@ async function validateManifest({ deps, modelProviderStore, mcpServerStore, + skillStore, tenant_id, }: { spec: AgentSpec; deps: AgentsRouterDeps; modelProviderStore: IModelProviderStore; mcpServerStore: IMcpServerStore; + skillStore: ISkillStore; tenant_id: string; }): Promise { await validateAgentSpec({ @@ -66,7 +68,7 @@ async function validateManifest({ tenant_id, modelProviderStore, mcpServerStore, - skillStore: deps.skillStore, + skillStore, sandboxProviderStore: deps.sandboxProviderStore, }); return spec; @@ -87,6 +89,7 @@ export function createAgentsRouter(deps: AgentsRouterDeps(deps: AgentsRouterDeps IModelProviderStore; resolveMcpServerStore: (c: Context) => IMcpServerStore; - skillStore: ISkillStore; + resolveSkillStore: (c: Context) => ISkillStore; resolveAgentStore: (c: Context) => IAgentStore; sandboxProviderStore: ISandboxProviderStore; redis?: RedisClientType | undefined; @@ -229,7 +229,7 @@ type InternalSessionsRouterDeps = Pick< | 'sessions' | 'resolveModelProviderStore' | 'resolveMcpServerStore' - | 'skillStore' + | 'resolveSkillStore' | 'resolveAgentStore' | 'sandboxProviderStore' | 'resolveRequestContext' @@ -274,7 +274,7 @@ function createGetOrCreateSessionByExternalIdHandler( tenant_id: requestContext.tenant_id, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), - skillStore: deps.skillStore, + skillStore: deps.resolveSkillStore(c), sandboxProviderStore: deps.sandboxProviderStore, }); agent = { type: 'inline', spec: body.agent.spec }; @@ -337,7 +337,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { tenant_id: requestContext.tenant_id, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), - skillStore: deps.skillStore, + skillStore: deps.resolveSkillStore(c), sandboxProviderStore: deps.sandboxProviderStore, }); const session = await deps.sessions.create({ @@ -425,7 +425,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { tenant_id: requestContext.tenant_id, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), - skillStore: deps.skillStore, + skillStore: deps.resolveSkillStore(c), sandboxProviderStore: deps.sandboxProviderStore, }); } diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index b4ca00eba..f19cae1d0 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -105,7 +105,7 @@ export interface TurnsRouterDeps { activeTurns: ActiveTurnRegistry; resolveModelProviderStore: (c: Context) => IModelProviderStore; resolveMcpServerStore: (c: Context) => IMcpServerWithAuthStore; - skillStore: ISkillStore; + resolveSkillStore: (c: Context) => ISkillStore; resolveAgentStore: (c: Context) => IAgentStore; /** Resumable live turn-event transport: create-turn writes, subscribe polls. */ eventSubscriptions: EventSubscriptionRegistry; @@ -116,15 +116,16 @@ export interface TurnsRouterDeps { /** * Deps needed to create a turn and drain events in-process (no HTTP). Unlike the HTTP path, this - * carries already-resolved `modelProviderStore` / `mcpServerStore` / `agentStore` (the scheduler has no request - * context to resolve them). + * carries already-resolved `modelProviderStore` / `mcpServerStore` / `skillStore` / `agentStore` + * (the scheduler has no request context to resolve them). */ export type BeginTurnExecutionDeps = Pick< TurnsRouterDeps, - 'activeTurns' | 'eventSubscriptions' | 'skillStore' | 'sandboxProviderStore' | 'logger' + 'activeTurns' | 'eventSubscriptions' | 'sandboxProviderStore' | 'logger' > & { modelProviderStore: IModelProviderStore; mcpServerStore: IMcpServerWithAuthStore; + skillStore: ISkillStore; agentStore: IAgentStore; }; @@ -722,6 +723,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { ...deps, modelProviderStore: deps.resolveModelProviderStore(c), mcpServerStore: deps.resolveMcpServerStore(c), + skillStore: deps.resolveSkillStore(c), agentStore: deps.resolveAgentStore(c), }, }; diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index 6094aedc8..982bdc365 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -181,7 +181,7 @@ export interface ServerDeps { resolveAgentStore: (c?: Context) => IAgentStore; withTransaction: WithTransaction; tokenStore: IOAuthTokenStore; - skillStore: ISkillStore; + resolveSkillStore: (c?: Context) => ISkillStore; sandboxProviderStore: ISandboxProviderStore; scheduleStore: IScheduleStore; sessionStore: ISessionStore; @@ -284,7 +284,7 @@ export function createServerApp(deps: ServerDeps) { '/api/v1/skills', withAuth( createAvailableSkillsRouter({ - skillStore: deps.skillStore, + skillStore: deps.resolveSkillStore(), withTransaction: deps.withTransaction, resolveRequestContext, }), @@ -298,7 +298,7 @@ export function createServerApp(deps: ServerDeps) { resolveAgentStore: deps.resolveAgentStore, resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, - skillStore: deps.skillStore, + resolveSkillStore: deps.resolveSkillStore, sandboxProviderStore: deps.sandboxProviderStore, withTransaction: deps.withTransaction, resolveRequestContext, @@ -318,7 +318,7 @@ export function createServerApp(deps: ServerDeps) { eventSubscriptions: deps.eventSubscriptions, modelProviderStore: deps.resolveModelProviderStore(), mcpServerStore: deps.resolveMcpServerStore(), - skillStore: deps.skillStore, + skillStore: deps.resolveSkillStore(), agentStore: deps.resolveAgentStore(), sandboxProviderStore: deps.sandboxProviderStore, logger: deps.logger, @@ -336,7 +336,7 @@ export function createServerApp(deps: ServerDeps) { resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, tokenStore: deps.tokenStore, - skillStore: deps.skillStore, + skillStore: deps.resolveSkillStore(), sandboxProviderStore: deps.sandboxProviderStore, withTransaction: deps.withTransaction, logger: deps.logger, @@ -352,7 +352,7 @@ export function createServerApp(deps: ServerDeps) { sessions: deps.sessions, resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, - skillStore: deps.skillStore, + resolveSkillStore: deps.resolveSkillStore, resolveAgentStore: deps.resolveAgentStore, sandboxProviderStore: deps.sandboxProviderStore, resolveRequestContext, @@ -379,7 +379,7 @@ export function createServerApp(deps: ServerDeps) { activeTurns: deps.activeTurns, resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, - skillStore: deps.skillStore, + resolveSkillStore: deps.resolveSkillStore, resolveAgentStore: deps.resolveAgentStore, sandboxProviderStore: deps.sandboxProviderStore, redis: deps.redis, @@ -399,7 +399,7 @@ export function createServerApp(deps: ServerDeps) { activeTurns: deps.activeTurns, resolveModelProviderStore: deps.resolveModelProviderStore, resolveMcpServerStore: deps.resolveMcpServerStore, - skillStore: deps.skillStore, + resolveSkillStore: deps.resolveSkillStore, resolveAgentStore: deps.resolveAgentStore, eventSubscriptions: deps.eventSubscriptions, sandboxProviderStore: deps.sandboxProviderStore, diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 62a6d1611..338542afb 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -84,6 +84,9 @@ import { PACKAGE_VERSION } from './packageVersion'; import { ActiveTurnRegistry } from './runtime/activeTurns'; import { EventSubscriptionRegistry } from './runtime/event-subscription'; import { printStandaloneStartupBanner } from './startupBanner'; +import { InlineMcpServerStore } from './truefoundry/InlineMcpServerStore'; +import { parseInlineMcpServers, parseInlineSkills, X_TFG_MCP, X_TFG_SKILLS } from './truefoundry/inlineResources'; +import { InlineSkillStore } from './truefoundry/InlineSkillStore'; import { parsePerServerMcpHeaders, X_TFG_MCP_HEADERS } from './truefoundry/perServerMcpHeaders'; import { TrueFoundryAgentStore } from './truefoundry/TrueFoundryAgentStore'; import { TrueFoundryMcpServerStore } from './truefoundry/TrueFoundryMcpServerStore'; @@ -99,7 +102,7 @@ interface ServerPersistence { resolveAgentStore: (c?: Context) => IAgentStore; withTransaction: WithTransaction; tokenStore: IOAuthTokenStore; - skillStore: ISkillStore; + resolveSkillStore: (c?: Context) => ISkillStore; sandboxProviderStore: ISandboxProviderStore; scheduleStore: IScheduleStore; destroyDb: () => Promise; @@ -179,12 +182,38 @@ function buildResolveMcpServerStore(options: { } const requestContext = resolveRequestContext(c); const rawPerServerHeaders = c.req.header(X_TFG_MCP_HEADERS); - return new TrueFoundryMcpServerStore({ + const store = new TrueFoundryMcpServerStore({ client, accessToken: requireRequestCredentialToken(c), subject: requestContext.subject, perServerHeaders: rawPerServerHeaders ? parsePerServerMcpHeaders(rawPerServerHeaders) : {}, }); + const rawInline = c.req.header(X_TFG_MCP); + if (rawInline === undefined) { + return store; + } + return new InlineMcpServerStore({ inner: store, inline: parseInlineMcpServers(rawInline) }); + }; +} + +/** + * Per-request skill store resolver. Skills are always DB-backed; in TrueFoundry mode a request may + * additionally carry its own definitions, which take precedence over the tenant's registry. + */ +function buildResolveSkillStore(options: { + persistenceStore: ISkillStore; + trueFoundryMode: boolean; +}): (c?: Context) => ISkillStore { + const { persistenceStore, trueFoundryMode } = options; + if (!trueFoundryMode) { + return () => persistenceStore; + } + return c => { + const rawInline = c?.req.header(X_TFG_SKILLS); + if (rawInline === undefined) { + return persistenceStore; + } + return new InlineSkillStore({ inner: persistenceStore, inline: parseInlineSkills(rawInline) }); }; } @@ -267,7 +296,10 @@ async function createStandalonePersistence(options: { resolveAgentStore: () => agentStore, withTransaction: callback => db.transaction().execute(callback), tokenStore, - skillStore: new SqliteSkillStore(db), + resolveSkillStore: buildResolveSkillStore({ + persistenceStore: new SqliteSkillStore(db), + trueFoundryMode: false, + }), sandboxProviderStore: new SqliteSandboxProviderStore(db), scheduleStore: new SqliteScheduleStore(db), destroyDb: () => db.destroy(), @@ -349,7 +381,10 @@ async function createDistributedPersistence(options: { }), withTransaction: callback => db.transaction().execute(callback), tokenStore, - skillStore: new PostgresSkillStore(db), + resolveSkillStore: buildResolveSkillStore({ + persistenceStore: new PostgresSkillStore(db), + trueFoundryMode: serviceFoundryClient !== undefined, + }), sandboxProviderStore: new PostgresSandboxProviderStore(db), scheduleStore: new PostgresScheduleStore(db), destroyDb: () => db.destroy(), @@ -367,7 +402,7 @@ async function createServerRuntime(persistence: ServerPersistence< resolveAgentStore, withTransaction, tokenStore, - skillStore, + resolveSkillStore, sandboxProviderStore, scheduleStore, destroyDb, @@ -428,7 +463,7 @@ async function createServerRuntime(persistence: ServerPersistence< resolveAgentStore, withTransaction, tokenStore, - skillStore, + resolveSkillStore, sandboxProviderStore, scheduleStore, sessionStore, diff --git a/packages/trueforge/src/truefoundry/InlineMcpServerStore.ts b/packages/trueforge/src/truefoundry/InlineMcpServerStore.ts new file mode 100644 index 000000000..a36b2f2d0 --- /dev/null +++ b/packages/trueforge/src/truefoundry/InlineMcpServerStore.ts @@ -0,0 +1,140 @@ +import type { TokenPagination } from '@truefoundry/trueforge-core/agent-session'; +import { + decodeOffsetPageToken, + paginateOffsetRows, +} from '@truefoundry/trueforge-core/agent-session/store/OffsetPageToken'; +import type { RemoteMcpHeaders } from '@truefoundry/trueforge-core/core'; +import type { + AuthorizeMcpServerInput, + CreateMcpServerInput, + DeleteMcpAuthorizationInput, + GetMcpServerInput, + IMcpServerWithAuthStore, + ListMcpServersInput, + McpServerRecord, + ResolveMcpAuthStatusesInput, + UpsertMcpServerInput, +} from '../db/mcpServerStore'; +import type { OAuthClientRecord } from '../mcp/auth/types'; +import { resolveConfiguredMcpRequestHeaders, resolveMcpAuthStatus, type McpAuthStatus } from '../schemas/mcpServer'; +import type { InlineMcpServers } from './inlineResources'; + +/** + * Serves the MCP servers a request brought with it, and delegates everything else. + * + * Only the resolve paths are overlaid — by-name lookup, name-filtered list, invoke headers and + * auth status. An unfiltered list passes straight through, so a request-scoped server never shows + * 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; + readonly #inline: InlineMcpServers; + + constructor(input: { inner: IMcpServerWithAuthStore; inline: InlineMcpServers }) { + this.#inner = input.inner; + this.#inline = input.inline; + } + + /** + * The manifest carries its own credentials, so they go to the upstream as written — no caller + * bearer is added and nothing is stripped. That is what lets a token rotate per request. + */ + resolveInvokeHeaders(input: { record: McpServerRecord; userRef: string }): RemoteMcpHeaders { + const manifest = this.#inline[input.record.name]; + if (manifest === undefined) { + return this.#inner.resolveInvokeHeaders(input); + } + return resolveConfiguredMcpRequestHeaders(manifest); + } + + async getServer(input: GetMcpServerInput, transaction?: TTransaction): Promise { + const record = this.#toRecord(input.tenant_id, input.name); + return record ?? (await this.#inner.getServer(input, transaction)); + } + + async listServers( + input: ListMcpServersInput, + transaction?: TTransaction, + ): Promise<{ data: McpServerRecord[]; pagination: TokenPagination }> { + if (input.names === undefined) { + return this.#inner.listServers(input, transaction); + } + + const inlineRecords = input.names + .map(name => this.#toRecord(input.tenant_id, name)) + .filter((record): record is McpServerRecord => record !== undefined); + const registryNames = input.names.filter(name => this.#inline[name] === undefined); + + // An `IN (...)` filter cannot return more rows than names asked for, so one unpaged read + // gives the whole match set and the merged result can be paginated here. + const registryRecords = + registryNames.length > 0 + ? ( + await this.#inner.listServers( + { ...input, names: registryNames, limit: registryNames.length, page_token: undefined }, + transaction, + ) + ).data + : []; + + const offset = decodeOffsetPageToken(input.page_token); + const merged = [...inlineRecords, ...registryRecords]; + return paginateOffsetRows(merged.slice(offset, offset + input.limit + 1), input.limit, offset); + } + + async resolveAuthStatuses(input: ResolveMcpAuthStatusesInput): Promise> { + const inlineRecords = input.records.filter(record => this.#inline[record.name] !== undefined); + 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 }) : [], + ); + for (const record of inlineRecords) { + statuses.set(record.name, resolveMcpAuthStatus({ manifest: record.manifest })); + } + return statuses; + } + + getServerForUpdate(input: GetMcpServerInput, transaction: TTransaction): Promise { + return this.#inner.getServerForUpdate(input, transaction); + } + + createServer(input: CreateMcpServerInput, transaction?: TTransaction): Promise { + return this.#inner.createServer(input, transaction); + } + + upsertServer(input: UpsertMcpServerInput, transaction?: TTransaction): Promise { + return this.#inner.upsertServer(input, transaction); + } + + authorize(input: AuthorizeMcpServerInput): Promise { + return this.#inner.authorize(input); + } + + deleteAuthorization(input: DeleteMcpAuthorizationInput): Promise { + return this.#inner.deleteAuthorization(input); + } + + saveClient(params: { id: string; record: OAuthClientRecord }, transaction?: TTransaction): Promise { + return this.#inner.saveClient(params, transaction); + } + + getClient(params: { id: string }, transaction?: TTransaction): Promise { + return this.#inner.getClient(params, transaction); + } + + deleteClient(params: { id: string }, transaction?: TTransaction): Promise { + return this.#inner.deleteClient(params, transaction); + } + + /** `id` is the name: nothing is persisted, and the name is what identifies these downstream. */ + #toRecord(tenant_id: string, name: string): McpServerRecord | undefined { + const manifest = this.#inline[name]; + if (manifest === undefined) { + return undefined; + } + const now = new Date().toISOString(); + return { id: name, tenant_id, name, manifest, created_at: now, updated_at: now }; + } +} diff --git a/packages/trueforge/src/truefoundry/InlineSkillStore.ts b/packages/trueforge/src/truefoundry/InlineSkillStore.ts new file mode 100644 index 000000000..33a6287cd --- /dev/null +++ b/packages/trueforge/src/truefoundry/InlineSkillStore.ts @@ -0,0 +1,63 @@ +import type { + CreateSkillInput, + GetSkillInput, + ISkillStore, + ListSkillsInput, + SkillRecord, + UpsertSkillInput, +} from '../db/skillStore'; +import type { InlineSkills } from './inlineResources'; + +/** + * Serves the skills a request brought with it, and delegates everything else. + * + * Mirrors {@link InlineMcpServerStore}: by-name lookup and name-filtered list are overlaid, an + * unfiltered list passes through so request-scoped skills stay out of the tenant's settings, and + * writes delegate because there is no row to write. + */ +export class InlineSkillStore implements ISkillStore { + readonly #inner: ISkillStore; + readonly #inline: InlineSkills; + + constructor(input: { inner: ISkillStore; inline: InlineSkills }) { + this.#inner = input.inner; + this.#inline = input.inline; + } + + async getSkill(input: GetSkillInput, transaction?: TTransaction): Promise { + const record = this.#toRecord(input.tenant_id, input.name); + return record ?? (await this.#inner.getSkill(input, transaction)); + } + + async listSkills(input: ListSkillsInput, transaction?: TTransaction): Promise { + if (input.names === undefined) { + return this.#inner.listSkills(input, transaction); + } + + const inlineRecords = input.names + .map(name => this.#toRecord(input.tenant_id, name)) + .filter((record): record is SkillRecord => record !== undefined); + const registryNames = input.names.filter(name => this.#inline[name] === undefined); + const registryRecords = + registryNames.length > 0 ? await this.#inner.listSkills({ ...input, names: registryNames }, transaction) : []; + + return [...inlineRecords, ...registryRecords]; + } + + createSkill(input: CreateSkillInput, transaction?: TTransaction): Promise { + return this.#inner.createSkill(input, transaction); + } + + upsertSkill(input: UpsertSkillInput, transaction?: TTransaction): Promise { + return this.#inner.upsertSkill(input, transaction); + } + + #toRecord(tenant_id: string, name: string): SkillRecord | undefined { + const manifest = this.#inline[name]; + if (manifest === undefined) { + return undefined; + } + const now = new Date().toISOString(); + return { tenant_id, name, manifest, created_at: now, updated_at: now }; + } +} diff --git a/packages/trueforge/src/truefoundry/inlineResources.ts b/packages/trueforge/src/truefoundry/inlineResources.ts new file mode 100644 index 000000000..621fc7620 --- /dev/null +++ b/packages/trueforge/src/truefoundry/inlineResources.ts @@ -0,0 +1,80 @@ +/** + * MCP servers and skills supplied per request instead of configured in the tenant's registry, + * each keyed by name. + * + * A caller that owns its own agent definition brings resources the tenant never registered and + * should not see in its settings or its gateway metrics. Sending them per request keeps them out + * of the registry entirely, and lets a rotating credential ride each turn rather than being + * stored somewhere it will go stale. + */ +import { HTTPException } from 'hono/http-exception'; +import { McpServerManifestSchema, type McpServerManifest } from '../schemas/mcpServer'; +import { SkillManifestSchema, type SkillManifest } from '../schemas/skill'; + +export const X_TFG_MCP = 'x-tfg-mcp'; +export const X_TFG_SKILLS = 'x-tfg-skills'; + +/** Manifests by name. `type` and `name` are implied by the header and the key, so callers omit them. */ +export type InlineMcpServers = Readonly>; +export type InlineSkills = Readonly>; + +export function parseInlineMcpServers(raw: string): InlineMcpServers { + return parseByName(raw, X_TFG_MCP, (name, definition) => { + const parsed = McpServerManifestSchema.safeParse({ ...definition, type: 'remote', name }); + if (!parsed.success) { + return { ok: false, reason: 'is not a valid MCP server definition' }; + } + if (parsed.data.auth?.type === 'dcr') { + return { ok: false, reason: 'cannot use dcr auth — it needs a registered client and a stored token' }; + } + return { ok: true, manifest: parsed.data }; + }); +} + +export function parseInlineSkills(raw: string): InlineSkills { + return parseByName(raw, X_TFG_SKILLS, (name, definition) => { + const parsed = SkillManifestSchema.safeParse({ ...definition, type: 'git', name }); + return parsed.success + ? { ok: true, manifest: parsed.data } + : { ok: false, reason: 'is not a valid skill definition' }; + }); +} + +type EntryResult = { ok: true; manifest: TManifest } | { ok: false; reason: string }; + +/** + * Rejects a malformed value rather than dropping it. Falling back to the tenant registry, where + * these resources do not exist, would surface as a confusing "not configured" much later on. + */ +function parseByName( + raw: string, + header: string, + parseEntry: (name: string, definition: object) => EntryResult, +): Readonly> { + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch (error) { + throw new HTTPException(400, { message: `${header} must be a JSON object`, cause: error }); + } + if (!isPlainObject(decoded)) { + throw new HTTPException(400, { message: `${header} must map each name to a definition` }); + } + + const manifests: Record = {}; + for (const [name, definition] of Object.entries(decoded)) { + if (!isPlainObject(definition)) { + throw new HTTPException(400, { message: `${header} entry "${name}" must be an object` }); + } + const parsed = parseEntry(name, definition); + if (!parsed.ok) { + throw new HTTPException(400, { message: `${header} entry "${name}" ${parsed.reason}` }); + } + manifests[name] = parsed.manifest; + } + return manifests; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/trueforge/tests/unit/apis/agents.test.ts b/packages/trueforge/tests/unit/apis/agents.test.ts index 2f5475632..09cbac5a8 100644 --- a/packages/trueforge/tests/unit/apis/agents.test.ts +++ b/packages/trueforge/tests/unit/apis/agents.test.ts @@ -82,7 +82,7 @@ describe('agents router', () => { resolveAgentStore: () => agentStore, resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => new SqliteMcpServerStore(db), - skillStore: new SqliteSkillStore(db), + resolveSkillStore: () => new SqliteSkillStore(db), sandboxProviderStore: new SqliteSandboxProviderStore(db), withTransaction: callback => db.transaction().execute(callback), resolveRequestContext: () => STANDALONE_REQUEST_CONTEXT, diff --git a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts index fb1a4d058..57577d809 100644 --- a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts +++ b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts @@ -47,7 +47,7 @@ describe('public CRUD after session deletion', () => { activeTurns, resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStore, - skillStore, + resolveSkillStore: () => skillStore, resolveAgentStore: () => agentStore, sandboxProviderStore, redis: createClient(), @@ -64,7 +64,7 @@ describe('public CRUD after session deletion', () => { activeTurns, resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStore, - skillStore, + resolveSkillStore: () => skillStore, resolveAgentStore: () => agentStore, eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore, diff --git a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts index fb6a9f2a6..213c0dbad 100644 --- a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts @@ -46,7 +46,7 @@ async function buildApp() { tokenStore, clientName: 'test-client', }), - skillStore: new SqliteSkillStore(db), + resolveSkillStore: () => new SqliteSkillStore(db), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), diff --git a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts index e7ad30b62..4aae75dc2 100644 --- a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts @@ -80,7 +80,7 @@ describe('sessions HTTP agent binding', () => { activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStore, - skillStore, + resolveSkillStore: () => skillStore, resolveAgentStore: () => agentStore, sandboxProviderStore, redis: createClient(), diff --git a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts index af43ae0a1..6e50769a2 100644 --- a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts +++ b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts @@ -73,7 +73,7 @@ async function postTurnRejectingWith(error: AgentHarnessError): Promise new SqliteSkillStore(db), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), diff --git a/packages/trueforge/tests/unit/apis/turns.test.ts b/packages/trueforge/tests/unit/apis/turns.test.ts index 50dc51e6f..33fbe11df 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -65,7 +65,7 @@ describe('turns', () => { activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => new SqliteModelProviderStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), - skillStore: new SqliteSkillStore(db), + resolveSkillStore: () => new SqliteSkillStore(db), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), @@ -193,7 +193,7 @@ describe('turns', () => { resolveModelProviderStore: () => modelProviderStore, resolveAgentStore: () => new SqliteAgentStore(db), resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), - skillStore: new SqliteSkillStore(db), + resolveSkillStore: () => new SqliteSkillStore(db), eventSubscriptions, sandboxProviderStore: new SqliteSandboxProviderStore(db), logger, @@ -298,7 +298,7 @@ describe('turns', () => { activeTurns: new ActiveTurnRegistry(), resolveModelProviderStore: () => modelProviderStore, resolveMcpServerStore: () => mcpServerStoreWithAuth(db, tokenStore), - skillStore: new SqliteSkillStore(db), + resolveSkillStore: () => new SqliteSkillStore(db), resolveAgentStore: () => new SqliteAgentStore(db), eventSubscriptions: new EventSubscriptionRegistry(undefined), sandboxProviderStore: new SqliteSandboxProviderStore(db), diff --git a/packages/trueforge/tests/unit/truefoundry/inlineResources.test.ts b/packages/trueforge/tests/unit/truefoundry/inlineResources.test.ts new file mode 100644 index 000000000..6bc31e552 --- /dev/null +++ b/packages/trueforge/tests/unit/truefoundry/inlineResources.test.ts @@ -0,0 +1,200 @@ +import { HTTPException } from 'hono/http-exception'; +import type { IMcpServerWithAuthStore, McpServerRecord } from '../../../src/db/mcpServerStore'; +import type { ISkillStore, SkillRecord } from '../../../src/db/skillStore'; +import { InlineMcpServerStore } from '../../../src/truefoundry/InlineMcpServerStore'; +import { parseInlineMcpServers, parseInlineSkills } from '../../../src/truefoundry/inlineResources'; +import { InlineSkillStore } from '../../../src/truefoundry/InlineSkillStore'; + +const DOCS_MCP = { + url: 'https://docs.example/mcp', + description: 'Search the product documentation.', + auth: { type: 'header', headers: { Authorization: 'Bearer saas-token' } }, +}; + +const ASK_AI_SKILL = { + url: 'https://github.com/truefoundry/skills', + ref: 'a1b2c3d', + path: 'ask-ai', + description: 'How to answer questions about the platform.', +}; + +const registryServer: McpServerRecord = { + id: '01JREGISTRY', + tenant_id: 'default', + name: 'team-mcp', + manifest: { type: 'remote', name: 'team-mcp', url: 'https://team.example/mcp', description: 'Team server.' }, + created_at: '2026-01-15T12:00:00.000Z', + updated_at: '2026-01-15T12:00:00.000Z', +}; + +const registrySkill: SkillRecord = { + tenant_id: 'default', + name: 'team-skill', + manifest: { + type: 'git', + name: 'team-skill', + url: 'https://github.com/acme/skills', + ref: 'main', + description: 'A skill the tenant configured.', + }, + created_at: '2026-01-15T12:00:00.000Z', + updated_at: '2026-01-15T12:00:00.000Z', +}; + +function mcpStoreWith(inlineRaw: object) { + const inner = { + getServer: jest.fn().mockResolvedValue(registryServer), + listServers: jest.fn().mockResolvedValue({ data: [registryServer], pagination: { limit: 10 } }), + resolveInvokeHeaders: jest.fn().mockReturnValue({ Authorization: 'Bearer caller-token' }), + resolveAuthStatuses: jest.fn().mockResolvedValue(new Map([['team-mcp', { status: 'not_required' }]])), + } as unknown as IMcpServerWithAuthStore; + const store = new InlineMcpServerStore({ + inner, + inline: parseInlineMcpServers(JSON.stringify(inlineRaw)), + }); + return { store, inner }; +} + +function skillStoreWith(inlineRaw: object) { + const inner = { + getSkill: jest.fn().mockResolvedValue(registrySkill), + listSkills: jest.fn().mockResolvedValue([registrySkill]), + } as unknown as ISkillStore; + const store = new InlineSkillStore({ inner, inline: parseInlineSkills(JSON.stringify(inlineRaw)) }); + return { store, inner }; +} + +describe('parseInlineMcpServers', () => { + it('fills in the type and the name the caller left implicit', () => { + expect(parseInlineMcpServers(JSON.stringify({ 'docs-mcp': DOCS_MCP }))).toEqual({ + 'docs-mcp': { ...DOCS_MCP, type: 'remote', name: 'docs-mcp' }, + }); + }); + + it('rejects dcr auth, which has no registered client or stored token to use', () => { + const raw = JSON.stringify({ 'docs-mcp': { ...DOCS_MCP, auth: { type: 'dcr' } } }); + + expect(() => parseInlineMcpServers(raw)).toThrow(HTTPException); + }); + + it.each([ + ['not json', 'not-json'], + ['an array', '[]'], + ['a scalar', '"nope"'], + ['a server mapped to a string', JSON.stringify({ 'docs-mcp': 'https://docs.example/mcp' })], + ['a server with no url', JSON.stringify({ 'docs-mcp': { description: 'no url' } })], + ['a name the registry would reject', JSON.stringify({ 'Docs MCP': DOCS_MCP })], + ['an unknown field', JSON.stringify({ 'docs-mcp': { ...DOCS_MCP, preload: true } })], + ])('rejects %s rather than falling back to a registry that has no such server', (_case, raw) => { + expect(() => parseInlineMcpServers(raw)).toThrow(HTTPException); + }); + + it('keeps the parse failure as the cause, so a bad header can be debugged', () => { + expect(() => parseInlineMcpServers('not-json')).toThrow( + expect.objectContaining({ cause: expect.any(SyntaxError) }), + ); + }); +}); + +describe('parseInlineSkills', () => { + it('fills in the type and the name the caller left implicit', () => { + expect(parseInlineSkills(JSON.stringify({ 'ask-ai': ASK_AI_SKILL }))).toEqual({ + 'ask-ai': { ...ASK_AI_SKILL, type: 'git', name: 'ask-ai' }, + }); + }); + + it.each([ + ['no ref, which would silently mount a moving HEAD', { url: ASK_AI_SKILL.url, description: 'no ref' }], + ['a non-git host', { ...ASK_AI_SKILL, url: 'https://example.com/skills' }], + ['a path escaping the repository', { ...ASK_AI_SKILL, path: '../secrets' }], + ])('rejects a skill with %s', (_case, definition) => { + expect(() => parseInlineSkills(JSON.stringify({ 'ask-ai': definition }))).toThrow(HTTPException); + }); +}); + +describe('InlineMcpServerStore', () => { + it('sends the credentials the manifest carries, with no caller Bearer added over them', () => { + const { store } = mcpStoreWith({ 'docs-mcp': DOCS_MCP }); + const record = { ...registryServer, name: 'docs-mcp' }; + + expect(store.resolveInvokeHeaders({ record, userRef: 'user-1' })).toEqual({ + Authorization: 'Bearer saas-token', + }); + }); + + it('leaves a registry server to the store that knows how to authenticate it', () => { + const { store, inner } = mcpStoreWith({ 'docs-mcp': DOCS_MCP }); + + expect(store.resolveInvokeHeaders({ record: registryServer, userRef: 'user-1' })).toEqual({ + Authorization: 'Bearer caller-token', + }); + expect(inner.resolveInvokeHeaders).toHaveBeenCalled(); + }); + + it('resolves an inline server by name without asking the registry', async () => { + const { store, inner } = mcpStoreWith({ 'docs-mcp': DOCS_MCP }); + + const record = await store.getServer({ tenant_id: 'default', name: 'docs-mcp' }); + + expect(record?.manifest.url).toBe(DOCS_MCP.url); + expect(inner.getServer).not.toHaveBeenCalled(); + }); + + it('falls through to the registry for a name the request did not bring', async () => { + const { store } = mcpStoreWith({ 'docs-mcp': DOCS_MCP }); + + expect(await store.getServer({ tenant_id: 'default', name: 'team-mcp' })).toEqual(registryServer); + }); + + it('answers a name-filtered list from both sources, which is what spec validation asks for', async () => { + const { store } = mcpStoreWith({ 'docs-mcp': DOCS_MCP }); + + const { data } = await store.listServers({ + tenant_id: 'default', + names: ['docs-mcp', 'team-mcp'], + limit: 10, + page_token: undefined, + }); + + expect(data.map(record => record.name)).toEqual(['docs-mcp', 'team-mcp']); + }); + + it('keeps request-scoped servers out of an unfiltered list, so they never reach tenant settings', async () => { + const { store } = mcpStoreWith({ 'docs-mcp': DOCS_MCP }); + + const { data } = await store.listServers({ + tenant_id: 'default', + names: undefined, + limit: 10, + page_token: undefined, + }); + + expect(data.map(record => record.name)).toEqual(['team-mcp']); + }); +}); + +describe('InlineSkillStore', () => { + it('answers a name-filtered list from both sources', async () => { + const { store } = skillStoreWith({ 'ask-ai': ASK_AI_SKILL }); + + const records = await store.listSkills({ tenant_id: 'default', names: ['ask-ai', 'team-skill'] }); + + expect(records.map(record => record.name)).toEqual(['ask-ai', 'team-skill']); + }); + + it('exposes the git mount fields that turn execution expands', async () => { + const { store } = skillStoreWith({ 'ask-ai': ASK_AI_SKILL }); + + const record = await store.getSkill({ tenant_id: 'default', name: 'ask-ai' }); + + expect(record?.manifest).toEqual({ ...ASK_AI_SKILL, type: 'git', name: 'ask-ai' }); + }); + + it('keeps request-scoped skills out of an unfiltered list, so they never reach tenant settings', async () => { + const { store } = skillStoreWith({ 'ask-ai': ASK_AI_SKILL }); + + const records = await store.listSkills({ tenant_id: 'default', names: undefined }); + + expect(records.map(record => record.name)).toEqual(['team-skill']); + }); +}); From 060883578c76a078e75c804008ad326a9691c374 Mon Sep 17 00:00:00 2001 From: Pranjal Trivedi Date: Fri, 4 Sep 2026 16:32:09 +0530 Subject: [PATCH 2/2] test: pin the Ask TFY wire contract against what sfy-server sends sfy-server builds the spec and the two headers in its own repo, so nothing checked that the two agreed. This runs its exact output through AgentSpecSchema and both inline parsers, and holds the header block to ASCII within one 8KB ingress buffer. Co-authored-by: Cursor --- .../unit/truefoundry/askTfyContract.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/trueforge/tests/unit/truefoundry/askTfyContract.test.ts diff --git a/packages/trueforge/tests/unit/truefoundry/askTfyContract.test.ts b/packages/trueforge/tests/unit/truefoundry/askTfyContract.test.ts new file mode 100644 index 000000000..1fbf64683 --- /dev/null +++ b/packages/trueforge/tests/unit/truefoundry/askTfyContract.test.ts @@ -0,0 +1,103 @@ +/** + * The Ask TFY wire contract, checked against what sfy-server actually sends. + * + * sfy-server builds the spec and the two headers in its own repo, so nothing here imports that + * code. These fixtures mirror its output byte for byte; if it changes shape, this fails rather than + * the install discovering it on the first turn. + */ +import { AgentSpecSchema } from '@truefoundry/trueforge-core/agent-session'; +import { parseInlineMcpServers, parseInlineSkills } from '../../../src/truefoundry/inlineResources'; + +const MCP_NAMES = ['tfy-platform-mcp', 'tfy-pylon-mcp', 'tfy-docs-mcp', 'tfy-web-search-mcp'] as const; +const SKILL_NAME = 'tfy-platform-skills'; + +/** Inert filler the length of a TrueFoundry JWT, to size the header realistically. */ +const FILLER = 'A'.repeat(1450); + +/** The em dash is in the shipped skill description, and a header value cannot carry it raw. */ +const SKILL_DESCRIPTION = + 'Answer questions about TrueFoundry, an enterprise AI platform. Covers two products — AI Gateway ' + + '(LLM proxy, MCP servers, agents, governance) and AI Engineering.'; + +/** Mirrors sfy-server's toHeaderJson: JSON with every non-ASCII character escaped. */ +function toHeaderJson(value: unknown): string { + return JSON.stringify(value).replace( + /[\u007f-\uffff]/g, + char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); +} + +const spec = { + model: { name: 'openai/gpt-5' }, + instructions: 'You are Ask TFY.', + mcp_servers: MCP_NAMES.map(name => ({ name })), + skills: [{ name: SKILL_NAME }], + config: { sandbox: { enabled: true } }, +}; + +const mcpHeader = toHeaderJson( + Object.fromEntries( + MCP_NAMES.map(name => [ + name, + { + url: `https://proxy.truefoundry.com/v1/mcp/${name}/server`, + description: `Search ${name} — the product’s data.`, + auth: { type: 'header', headers: { Authorization: `Bearer ${FILLER}` } }, + }, + ]), + ), +); + +const skillsHeader = toHeaderJson({ + [SKILL_NAME]: { + url: 'https://github.com/truefoundry/tfy-ai-gateway-skills', + ref: 'a1b2c3d4e5f6', + path: 'skills', + description: SKILL_DESCRIPTION, + }, +}); + +describe('Ask TFY wire contract', () => { + it('accepts the spec sfy-server sends, filling the defaults it leaves out', () => { + const parsed = AgentSpecSchema.parse(spec); + + expect(parsed.config.sandbox.enabled).toBe(true); + expect(parsed.mcp_servers?.map(server => server.name)).toEqual([...MCP_NAMES]); + expect(parsed.skills).toEqual([{ name: SKILL_NAME }]); + }); + + it('resolves every name in the spec from the headers, so none falls through to the registry', () => { + const servers = parseInlineMcpServers(mcpHeader); + const skills = parseInlineSkills(skillsHeader); + + for (const name of spec.mcp_servers) { + expect(servers[name.name]).toBeDefined(); + } + for (const skill of spec.skills) { + expect(skills[skill.name]).toBeDefined(); + } + }); + + it('restores the characters a header cannot carry raw', () => { + expect(parseInlineSkills(skillsHeader)[SKILL_NAME].description).toBe(SKILL_DESCRIPTION); + expect(parseInlineMcpServers(mcpHeader)['tfy-docs-mcp'].description).toContain('—'); + }); + + /** Node rejects a header value outside latin-1 outright, so this is a hard requirement. */ + it.each([ + ['x-tfg-mcp', mcpHeader], + ['x-tfg-skills', skillsHeader], + ])('sends %s as ASCII only', (_name, value) => { + expect(value).not.toMatch(/[^\u0000-\u007f]/); + }); + + /** + * Node's default max header size is 16KB for the whole block, and an ingress in front is often + * tighter. Four servers each carrying a JWT is the realistic worst case. + */ + it('keeps the headers within one 8KB ingress buffer', () => { + const total = Buffer.byteLength(mcpHeader) + Buffer.byteLength(skillsHeader); + + expect(total).toBeLessThan(8 * 1024); + }); +});