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/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); + }); +}); 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']); + }); +});