diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index c3968209a..fc248c333 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -7626,6 +7626,16 @@ } }, "description": "The server cannot satisfy `auth.type: dcr` (e.g. it advertises no registration_endpoint)." + }, + "424": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Resource is managed by TrueFoundry (`TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL` is set)." } }, "summary": "Create an MCP server", @@ -7680,6 +7690,16 @@ } }, "description": "The server cannot satisfy `auth.type: dcr` (e.g. it advertises no registration_endpoint)." + }, + "424": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Resource is managed by TrueFoundry (`TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL` is set)." } }, "summary": "Create or replace an MCP server", diff --git a/docs/openapi.json b/docs/openapi.json index c3968209a..fc248c333 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -7626,6 +7626,16 @@ } }, "description": "The server cannot satisfy `auth.type: dcr` (e.g. it advertises no registration_endpoint)." + }, + "424": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Resource is managed by TrueFoundry (`TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL` is set)." } }, "summary": "Create an MCP server", @@ -7680,6 +7690,16 @@ } }, "description": "The server cannot satisfy `auth.type: dcr` (e.g. it advertises no registration_endpoint)." + }, + "424": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Resource is managed by TrueFoundry (`TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL` is set)." } }, "summary": "Create or replace an MCP server", diff --git a/packages/trueforge-sdk/src/api/resources/settings/resources/mcpServers/client/Client.ts b/packages/trueforge-sdk/src/api/resources/settings/resources/mcpServers/client/Client.ts index df89c39e0..41c4c89cd 100644 --- a/packages/trueforge-sdk/src/api/resources/settings/resources/mcpServers/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/settings/resources/mcpServers/client/Client.ts @@ -124,6 +124,7 @@ export class McpServersClient { * @throws {@link TrueForge.BadRequestError} * @throws {@link TrueForge.ConflictError} * @throws {@link TrueForge.UnprocessableEntityError} + * @throws {@link TrueForge.FailedDependencyError} * @throws {@link errors.TrueForgeError} * @throws {@link errors.TrueForgeTimeoutError} * @@ -228,6 +229,17 @@ export class McpServersClient { }), _response.rawResponse, ); + case 424: + throw new TrueForge.FailedDependencyError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); default: throw new errors.TrueForgeError({ statusCode: _response.error.statusCode, @@ -248,6 +260,7 @@ export class McpServersClient { * * @throws {@link TrueForge.BadRequestError} * @throws {@link TrueForge.UnprocessableEntityError} + * @throws {@link TrueForge.FailedDependencyError} * @throws {@link errors.TrueForgeError} * @throws {@link errors.TrueForgeTimeoutError} * @@ -341,6 +354,17 @@ export class McpServersClient { }), _response.rawResponse, ); + case 424: + throw new TrueForge.FailedDependencyError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); default: throw new errors.TrueForgeError({ statusCode: _response.error.statusCode, diff --git a/packages/trueforge-sdk/tests/wire/settings/mcpServers.test.ts b/packages/trueforge-sdk/tests/wire/settings/mcpServers.test.ts index 6228b045b..9e92895a3 100644 --- a/packages/trueforge-sdk/tests/wire/settings/mcpServers.test.ts +++ b/packages/trueforge-sdk/tests/wire/settings/mcpServers.test.ts @@ -220,6 +220,33 @@ describe("McpServersClient", () => { }).rejects.toThrow(TrueForgeTypes.UnprocessableEntityError); }); + test("create (5)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + const rawRequestBody = { manifest: { description: "x", name: "xy", type: "remote", url: "url" } }; + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .post("/api/v1/settings/mcp-servers") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(424) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.settings.mcpServers.create({ + manifest: { + description: "x", + name: "xy", + type: "remote", + url: "url", + }, + }); + }).rejects.toThrow(TrueForgeTypes.FailedDependencyError); + }); + test("create_or_update (1)", async () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); @@ -329,6 +356,33 @@ describe("McpServersClient", () => { }).rejects.toThrow(TrueForgeTypes.UnprocessableEntityError); }); + test("create_or_update (4)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + const rawRequestBody = { manifest: { description: "x", name: "xy", type: "remote", url: "url" } }; + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .put("/api/v1/settings/mcp-servers") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(424) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.settings.mcpServers.createOrUpdate({ + manifest: { + description: "x", + name: "xy", + type: "remote", + url: "url", + }, + }); + }).rejects.toThrow(TrueForgeTypes.FailedDependencyError); + }); + test("get (1)", async () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); diff --git a/packages/trueforge/src/apis/mcpServers.ts b/packages/trueforge/src/apis/mcpServers.ts index 8a1c1d128..76f5cbcb5 100644 --- a/packages/trueforge/src/apis/mcpServers.ts +++ b/packages/trueforge/src/apis/mcpServers.ts @@ -1,14 +1,22 @@ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import { extractErrorLogFields, isAuthRequired, McpConnectionError, RemoteMCP } from '@truefoundry/trueforge-core/core'; +import { HTTPException } from 'hono/http-exception'; import type { Logger } from 'winston'; import type { ResolveUserContext } from '../auth/identity'; +import { requireAccessToken } from '../auth/middleware'; import { safeReturnTo } from '../auth/safeReturnTo'; -import configuration from '../config'; -import { McpServerNameConflictError, type IMcpServerStore, type McpServerRecord } from '../db/mcpServerStore'; +import configuration, { getPublicBaseUrl, isTrueFoundryModeEnabled } from '../config'; +import { + McpServerNameConflictError, + McpServerNotFoundError, + optionalMcpAccessToken, + type IMcpServerStore, + type McpServerRecord, +} from '../db/mcpServerStore'; import type { WithTransaction } from '../db/transaction'; -import { createMcpOAuthClient, isMcpAuthRequired, resolveMcpAuth } from '../mcp/auth/mcpDcr'; +import { createMcpOAuthClient } from '../mcp/auth/mcpDcr'; import { mcpOAuthCallbackUrl } from '../mcp/auth/mcpOAuthHelpers'; -import type { IOAuthTokenStore, OAuthClientRecord, OAuthToken } from '../mcp/auth/types'; +import type { IOAuthTokenStore, OAuthClientRecord } from '../mcp/auth/types'; import { authorizeMcpServerRoute, createMcpServerRoute, @@ -28,10 +36,26 @@ import type { McpServerManifest, UpdateMcpServerRequest, } from '../schemas/mcpServer'; -import { resolveMcpAuthStatus } from '../schemas/mcpServer'; +import { respondTrueFoundryManaged } from '../truefoundry/trueFoundryManaged'; import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretValue } from '../utils/secretRedaction'; import { TENANT_ID } from './sessions'; +/** Absolute redirect URL for SFY consent when `return_to` is a same-origin relative path. */ +function authorizeRedirectUrl(returnTo: string | undefined): string | undefined { + if (returnTo === undefined) { + return undefined; + } + return new URL(returnTo, `${getPublicBaseUrl()}/`).href; +} + +/** Caller token when TrueFoundry mode needs it; omitted for local DB stores. */ +function requestAccessToken(c: Parameters[0]): string | undefined { + if (!isTrueFoundryModeEnabled()) { + return undefined; + } + return requireAccessToken(c); +} + export interface McpServersRouterDeps { mcpServerStore: IMcpServerStore; tokenStore: IOAuthTokenStore; @@ -95,44 +119,21 @@ function resolveMcpServerManifestForWrite({ }; } -/** - * `token` is the calling user's DCR access token for this server (keyed by `record.id` + - * `userRef`), or undefined for header/no-auth servers and DCR servers that have never - * authorized for this user. Only DCR reads it. - */ -function toConfiguredMcpServer({ - record, - token, -}: { - record: McpServerRecord; - token: OAuthToken | undefined; -}): ConfiguredMcpServer { - return { - name: record.name, - manifest: redactMcpServerManifest(record.manifest), - auth_status: resolveMcpAuthStatus({ - manifest: record.manifest, - ...(token !== undefined ? { token } : {}), - }), - }; -} - -function toAvailableMcpServer({ - record, - token, -}: { +async function toConfiguredMcpServer(params: { + store: IMcpServerStore; record: McpServerRecord; - token: OAuthToken | undefined; -}): AvailableMcpServer { - const authType = record.manifest.auth?.type; + userRef: string; + accessToken?: string | undefined; +}): Promise { + const statuses = await params.store.resolveAuthStatuses({ + records: [params.record], + userRef: params.userRef, + ...optionalMcpAccessToken(params.accessToken), + }); return { - name: record.name, - url: record.manifest.url, - ...(authType !== undefined ? { auth: { type: authType } } : {}), - auth_status: resolveMcpAuthStatus({ - manifest: record.manifest, - ...(token !== undefined ? { token } : {}), - }), + name: params.record.name, + manifest: redactMcpServerManifest(params.record.manifest), + auth_status: statuses.get(params.record.name) ?? { status: 'auth_required' }, }; } @@ -140,31 +141,47 @@ function toAvailableMcpServer({ export function createSettingsMcpServersRouter(deps: McpServersRouterDeps) { const listHandler: RouteHandler = async c => { const userRef = deps.resolveUserContext(c).userRef; - const records = await deps.mcpServerStore.listServers({ tenant_id: TENANT_ID, names: undefined }); - // Only DCR servers have tokens; batch the lookup for this user. - const dcrIds = records.filter(record => record.manifest.auth?.type === 'dcr').map(record => record.id); - const tokens = await deps.tokenStore.getTokens({ ids: dcrIds, userRef }); - return c.json( - { data: records.map(record => toConfiguredMcpServer({ record, token: tokens.get(record.id) })) }, - 200, - ); + const accessToken = requestAccessToken(c); + const records = await deps.mcpServerStore.listServers({ + tenant_id: TENANT_ID, + names: undefined, + ...optionalMcpAccessToken(accessToken), + }); + const statuses = await deps.mcpServerStore.resolveAuthStatuses({ + records, + userRef, + ...optionalMcpAccessToken(accessToken), + }); + const data: ConfiguredMcpServer[] = records.map(record => ({ + name: record.name, + manifest: redactMcpServerManifest(record.manifest), + auth_status: statuses.get(record.name) ?? { status: 'auth_required' }, + })); + return c.json({ data }, 200); }; const getHandler: RouteHandler = async c => { const { name } = c.req.valid('param'); const userRef = deps.resolveUserContext(c).userRef; - const record = await deps.mcpServerStore.getServer({ tenant_id: TENANT_ID, name }); + const accessToken = requestAccessToken(c); + const record = await deps.mcpServerStore.getServer({ + tenant_id: TENANT_ID, + name, + ...optionalMcpAccessToken(accessToken), + }); if (!record) { return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); } - let token: OAuthToken | undefined; - if (record.manifest.auth?.type === 'dcr') { - token = await deps.tokenStore.getToken({ id: record.id, userRef }); - } - return c.json({ data: toConfiguredMcpServer({ record, token }) }, 200); + return c.json( + { data: await toConfiguredMcpServer({ store: deps.mcpServerStore, record, userRef, accessToken }) }, + 200, + ); }; const createHandler: RouteHandler = async c => { + if (isTrueFoundryModeEnabled()) { + return respondTrueFoundryManaged(c); + } const body: CreateMcpServerRequest = c.req.valid('json'); const incomingManifest = body.manifest; @@ -217,7 +234,16 @@ export function createSettingsMcpServersRouter(deps: McpServersRou return saved; }); - return c.json({ data: toConfiguredMcpServer({ record, token: undefined }) }, 201); + return c.json( + { + data: await toConfiguredMcpServer({ + store: deps.mcpServerStore, + record, + userRef: deps.resolveUserContext(c).userRef, + }), + }, + 201, + ); } catch (error) { if (error instanceof McpServerNameConflictError) { return c.json({ error: { message: error.message } }, 409); @@ -227,6 +253,9 @@ export function createSettingsMcpServersRouter(deps: McpServersRou }; const putHandler: RouteHandler = async c => { + if (isTrueFoundryModeEnabled()) { + return respondTrueFoundryManaged(c); + } const userRef = deps.resolveUserContext(c).userRef; const body: UpdateMcpServerRequest = c.req.valid('json'); const incomingManifest = body.manifest; @@ -287,11 +316,7 @@ export function createSettingsMcpServersRouter(deps: McpServersRou return saved; }); - // A re-upsert preserves `id`, so a DCR server may already carry a token from a prior authorize - // (unless this PUT changed the URL and cleared all tokens above). - const token = - record.manifest.auth?.type === 'dcr' ? await deps.tokenStore.getToken({ id: record.id, userRef }) : undefined; - return c.json({ data: toConfiguredMcpServer({ record, token }) }, 200); + return c.json({ data: await toConfiguredMcpServer({ store: deps.mcpServerStore, record, userRef }) }, 200); } catch (error) { if (error instanceof MissingStoredSecretError) { return c.json({ error: { message: 'Header secret is required' } }, 400); @@ -321,38 +346,30 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const { name } = c.req.valid('param'); const { return_to: returnTo } = c.req.valid('query'); const userRef = deps.resolveUserContext(c).userRef; - const record = await deps.mcpServerStore.getServer({ tenant_id: TENANT_ID, name }); - if (!record) { - return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); - } - - if (record.manifest.auth?.type !== 'dcr') { - return c.json(resolveMcpAuthStatus({ manifest: record.manifest }), 200); - } if (returnTo && safeReturnTo(returnTo) !== returnTo) { return c.json({ error: { message: 'Invalid return_to: must be a same-origin relative path' } }, 400); } try { - // Reuses a usable/refreshable token when present; only builds an auth URL when needed. - // Client is usually already registered at create/put; concurrent Connect races are harmless - // (last saveClient wins; rare orphan AS registration). - const result = await resolveMcpAuth({ - tokenStore: deps.tokenStore, - mcpServerStore: deps.mcpServerStore, - serverId: record.id, + const accessToken = requestAccessToken(c); + const redirectURL = authorizeRedirectUrl(returnTo); + const authStatus: McpAuthStatus = await deps.mcpServerStore.authorize({ + tenant_id: TENANT_ID, + name, userRef, - mcpServerUrl: record.manifest.url, - mcpServerName: record.name, - clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, + ...optionalMcpAccessToken(accessToken), ...(returnTo !== undefined ? { returnTo } : {}), + ...(redirectURL !== undefined ? { redirectURL } : {}), }); - const authStatus: McpAuthStatus = isMcpAuthRequired(result) - ? { status: 'auth_required', authorization_url: result.authUrl.href } - : { status: 'authenticated' }; return c.json(authStatus, 200); } catch (error) { + if (error instanceof McpServerNotFoundError) { + return c.json({ error: { message: error.message } }, 404); + } + if (error instanceof HTTPException) { + throw error; + } if (error instanceof McpConnectionError) { deps.logger.warn(`MCP authorize failed for "${name}"`, extractErrorLogFields(error)); if (error.statusCode === 400) { @@ -374,6 +391,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const listToolsHandler: RouteHandler = async c => { const { name } = c.req.valid('param'); const userRef = deps.resolveUserContext(c).userRef; + const accessToken = requestAccessToken(c) ?? ''; // Same url + header resolution as turn execution (DCR via resolveMcpAuth, header/no-auth static). const connection = await getMcpConnection({ tenant_id: TENANT_ID, @@ -382,6 +400,7 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< tokenStore: deps.tokenStore, clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, userRef, + ...optionalMcpAccessToken(accessToken), }); if (connection === undefined) { return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); @@ -418,30 +437,60 @@ export function createMcpServersRouter(deps: McpServersRouterDeps< const deleteAuthorizationHandler: RouteHandler = async c => { const { name } = c.req.valid('param'); const userRef = deps.resolveUserContext(c).userRef; - const record = await deps.mcpServerStore.getServer({ tenant_id: TENANT_ID, name }); - if (!record) { - return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); - } - // DCR: drop this user's token only — keep oauth_server / oauth_client so re-authorize can skip DCR. - // Header / no-auth: no-op. - if (record.manifest.auth?.type === 'dcr') { - await deps.tokenStore.deleteToken({ id: record.id, userRef }); + const accessToken = requestAccessToken(c); + try { + const record = await deps.mcpServerStore.getServer({ + tenant_id: TENANT_ID, + name, + ...optionalMcpAccessToken(accessToken), + }); + if (!record) { + return c.json({ error: { message: `MCP server not found: ${name}` } }, 404); + } + await deps.mcpServerStore.deleteAuthorization({ + tenant_id: TENANT_ID, + name, + userRef, + ...optionalMcpAccessToken(accessToken), + }); + return c.json( + { + data: await toConfiguredMcpServer({ store: deps.mcpServerStore, record, userRef, accessToken }), + }, + 200, + ); + } catch (error) { + if (error instanceof McpServerNotFoundError) { + return c.json({ error: { message: error.message } }, 404); + } + throw error; } - return c.json({ data: toConfiguredMcpServer({ record, token: undefined }) }, 200); }; const router = new OpenAPIHono(); router.openapi(listAvailableMcpServersRoute, async c => { const userRef = deps.resolveUserContext(c).userRef; - const records = await deps.mcpServerStore.listServers({ tenant_id: TENANT_ID, names: undefined }); - const dcrIds = records.filter(record => record.manifest.auth?.type === 'dcr').map(record => record.id); - const tokens = await deps.tokenStore.getTokens({ ids: dcrIds, userRef }); - return c.json( - { - data: records.map(record => toAvailableMcpServer({ record, token: tokens.get(record.id) })), - }, - 200, - ); + const accessToken = requestAccessToken(c); + const records = await deps.mcpServerStore.listServers({ + tenant_id: TENANT_ID, + names: undefined, + ...optionalMcpAccessToken(accessToken), + }); + const statuses = await deps.mcpServerStore.resolveAuthStatuses({ + records, + userRef, + ...optionalMcpAccessToken(accessToken), + }); + const data: AvailableMcpServer[] = records.map(record => { + const authType = record.manifest.auth?.type; + return { + name: record.name, + url: record.manifest.url, + ...(authType !== undefined ? { auth: { type: authType } } : {}), + auth_status: statuses.get(record.name) ?? { status: 'auth_required' }, + }; + }); + return c.json({ data }, 200); }); router.openapi(listMcpServerToolsRoute, listToolsHandler); router.openapi(authorizeMcpServerRoute, authorizeHandler); diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index b0d6453ac..5cbb3b114 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -186,6 +186,7 @@ function createTurnResolver(deps: { tokenStore, clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, userRef, + ...optionalAccessToken(accessToken), }); if (connection === undefined) { throw new HTTPException(422, { diff --git a/packages/trueforge/src/db/LocalAuthMcpServerStore.ts b/packages/trueforge/src/db/LocalAuthMcpServerStore.ts new file mode 100644 index 000000000..9bd00de43 --- /dev/null +++ b/packages/trueforge/src/db/LocalAuthMcpServerStore.ts @@ -0,0 +1,134 @@ +/** + * Wraps a DB-backed {@link IMcpServerStore} with local DCR authorize / status / revoke + * so API handlers stay store-agnostic (same methods as TrueFoundryMcpServerStore). + */ +import configuration from '../config'; +import { isMcpAuthRequired, resolveMcpAuth } from '../mcp/auth/mcpDcr'; +import type { IOAuthTokenStore, OAuthClientRecord } from '../mcp/auth/types'; +import { resolveMcpAuthStatus, type McpAuthStatus } from '../schemas/mcpServer'; +import { + McpServerNotFoundError, + type AuthorizeMcpServerInput, + type CreateMcpServerInput, + type DeleteMcpAuthorizationInput, + type GetMcpServerInput, + type IMcpServerStore, + type ListMcpServersInput, + type McpServerRecord, + type ResolveMcpAuthStatusesInput, + type UpsertMcpServerInput, +} from './mcpServerStore'; + +export class LocalAuthMcpServerStore implements IMcpServerStore { + readonly #store: IMcpServerStore; + readonly #tokenStore: IOAuthTokenStore; + readonly #clientName: string; + + constructor(input: { + store: IMcpServerStore; + tokenStore: IOAuthTokenStore; + clientName: string; + }) { + this.#store = input.store; + this.#tokenStore = input.tokenStore; + this.#clientName = input.clientName; + } + + listServers(input: ListMcpServersInput, transaction?: TTransaction): Promise { + return this.#store.listServers(input, transaction); + } + + getServer(input: GetMcpServerInput, transaction?: TTransaction): Promise { + return this.#store.getServer(input, transaction); + } + + getServerForUpdate(input: GetMcpServerInput, transaction: TTransaction): Promise { + return this.#store.getServerForUpdate(input, transaction); + } + + createServer(input: CreateMcpServerInput, transaction?: TTransaction): Promise { + return this.#store.createServer(input, transaction); + } + + upsertServer(input: UpsertMcpServerInput, transaction?: TTransaction): Promise { + return this.#store.upsertServer(input, transaction); + } + + saveClient(params: { id: string; record: OAuthClientRecord }, transaction?: TTransaction): Promise { + return this.#store.saveClient(params, transaction); + } + + getClient(params: { id: string }, transaction?: TTransaction): Promise { + return this.#store.getClient(params, transaction); + } + + deleteClient(params: { id: string }, transaction?: TTransaction): Promise { + return this.#store.deleteClient(params, transaction); + } + + async resolveAuthStatuses(input: ResolveMcpAuthStatusesInput): Promise> { + void input.accessToken; + const dcrIds = input.records.filter(record => record.manifest.auth?.type === 'dcr').map(record => record.id); + const tokens = await this.#tokenStore.getTokens({ ids: dcrIds, userRef: input.userRef }); + const out = new Map(); + for (const record of input.records) { + const token = tokens.get(record.id); + out.set( + record.name, + resolveMcpAuthStatus({ + manifest: record.manifest, + ...(token !== undefined ? { token } : {}), + }), + ); + } + return out; + } + + async authorize(input: AuthorizeMcpServerInput): Promise { + void input.accessToken; + void input.redirectURL; + const record = await this.#store.getServer({ tenant_id: input.tenant_id, name: input.name }); + if (record === undefined) { + throw new McpServerNotFoundError(input.name); + } + if (record.manifest.auth?.type !== 'dcr') { + return resolveMcpAuthStatus({ manifest: record.manifest }); + } + const result = await resolveMcpAuth({ + tokenStore: this.#tokenStore, + mcpServerStore: this.#store, + serverId: record.id, + userRef: input.userRef, + mcpServerUrl: record.manifest.url, + mcpServerName: record.name, + clientName: this.#clientName, + ...(input.returnTo !== undefined ? { returnTo: input.returnTo } : {}), + }); + return isMcpAuthRequired(result) + ? { status: 'auth_required', authorization_url: result.authUrl.href } + : { status: 'authenticated' }; + } + + async deleteAuthorization(input: DeleteMcpAuthorizationInput): Promise { + void input.accessToken; + const record = await this.#store.getServer({ tenant_id: input.tenant_id, name: input.name }); + if (record === undefined) { + throw new McpServerNotFoundError(input.name); + } + if (record.manifest.auth?.type === 'dcr') { + await this.#tokenStore.deleteToken({ id: record.id, userRef: input.userRef }); + } + } +} + +/** Convenience for main wiring. */ +export function wrapLocalMcpServerStore(input: { + store: IMcpServerStore; + tokenStore: IOAuthTokenStore; +}): LocalAuthMcpServerStore { + return new LocalAuthMcpServerStore({ + store: input.store, + tokenStore: input.tokenStore, + clientName: configuration.MCP_DCR_OAUTH_CLIENT_NAME, + }); +} diff --git a/packages/trueforge/src/db/mcpServerStore.ts b/packages/trueforge/src/db/mcpServerStore.ts index 762f736c8..1a75ee73c 100644 --- a/packages/trueforge/src/db/mcpServerStore.ts +++ b/packages/trueforge/src/db/mcpServerStore.ts @@ -16,7 +16,7 @@ import type { IOAuthClientStore, } from '../mcp/auth/types'; import type { ResourceName } from '../schemas/common'; -import type { McpServerManifest } from '../schemas/mcpServer'; +import type { McpAuthStatus, McpServerManifest } from '../schemas/mcpServer'; export interface McpServerRecord { id: string; @@ -32,12 +32,35 @@ export interface McpServerRecord { export interface GetMcpServerInput { tenant_id: string; name: string; + /** Caller token; required by the TrueFoundry store, ignored by DB stores. */ + accessToken?: string; } export interface ListMcpServersInput { tenant_id: string; /** `undefined` lists all; empty returns `[]` without querying; otherwise `WHERE name IN (...)`. */ names: readonly string[] | undefined; + /** Caller token; required by the TrueFoundry store, ignored by DB stores. */ + accessToken?: string; +} + +/** Spread into read inputs so `accessToken` is omitted when unset/blank. */ +export function optionalMcpAccessToken(accessToken: string | undefined): { accessToken?: string } { + if (accessToken === undefined || accessToken.length === 0) { + return {}; + } + return { accessToken }; +} + +/** Thrown by stores that do not persist MCP server configuration (TrueFoundry). */ +export class McpServerStoreNotImplementedError extends Error { + readonly operation: string; + + constructor(operation: string) { + super(`MCP server store does not implement ${operation}`); + this.name = 'McpServerStoreNotImplementedError'; + this.operation = operation; + } } export interface CreateMcpServerInput { @@ -62,6 +85,42 @@ export class McpServerNameConflictError extends Error { } } +/** Thrown when authorize/revoke/status targets an unknown server name. */ +export class McpServerNotFoundError extends Error { + readonly server_name: string; + + constructor(name: string) { + super(`MCP server not found: ${name}`); + this.name = 'McpServerNotFoundError'; + this.server_name = name; + } +} + +export interface ResolveMcpAuthStatusesInput { + records: readonly McpServerRecord[]; + userRef: string; + /** Caller token; required by the TrueFoundry store, ignored by local auth. */ + accessToken?: string; +} + +export interface AuthorizeMcpServerInput { + tenant_id: string; + name: string; + userRef: string; + accessToken?: string; + /** Relative same-origin path for local DCR pending-auth return. */ + returnTo?: string; + /** Absolute redirect URL for TrueFoundry SFY consent return. */ + redirectURL?: string; +} + +export interface DeleteMcpAuthorizationInput { + tenant_id: string; + name: string; + userRef: string; + accessToken?: string; +} + export interface IMcpServerStore extends IOAuthClientStore { listServers(input: ListMcpServersInput, transaction?: TTransaction): Promise; getServer(input: GetMcpServerInput, transaction?: TTransaction): Promise; @@ -78,6 +137,18 @@ export interface IMcpServerStore extends IOAuthClientStore * Never overwrites `id`, `oauth_server`, or `oauth_client`. */ upsertServer(input: UpsertMcpServerInput, transaction?: TTransaction): Promise; + + /** + * Wire `auth_status` for Connect UX, keyed by server name. + * Local: batch token lookup. TrueFoundry: SFY `/auth/status` (refresh-aware). + */ + resolveAuthStatuses(input: ResolveMcpAuthStatusesInput): Promise>; + + /** Start or resume authorization; returns `auth_required` + URL or `authenticated`. */ + authorize(input: AuthorizeMcpServerInput): Promise; + + /** Revoke this subject's authorization for the named server. */ + deleteAuthorization(input: DeleteMcpAuthorizationInput): Promise; } /** diff --git a/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts b/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts index 148531257..6769069b3 100644 --- a/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts +++ b/packages/trueforge/src/db/postgres/mcp-server-store/PostgresMcpServerStore.ts @@ -1,15 +1,20 @@ import type { Kysely, Selectable, Transaction } from 'kysely'; import type { OAuthClientRecord } from '../../../mcp/auth/types'; +import type { McpAuthStatus } from '../../../schemas/mcpServer'; import { newId } from '../../../utils/id'; import { fromStoredOAuthClientRecord, McpServerNameConflictError, + McpServerStoreNotImplementedError, toStoredOAuthClientRecord, + type AuthorizeMcpServerInput, type CreateMcpServerInput, + type DeleteMcpAuthorizationInput, type GetMcpServerInput, type IMcpServerStore, type ListMcpServersInput, type McpServerRecord, + type ResolveMcpAuthStatusesInput, type UpsertMcpServerInput, } from '../../mcpServerStore'; import { isUniqueViolation } from '../client'; @@ -163,4 +168,17 @@ export class PostgresMcpServerStore implements IMcpServerStore { + return Promise.reject(new McpServerStoreNotImplementedError('authorize')); + } + + deleteAuthorization(_input: DeleteMcpAuthorizationInput): Promise { + return Promise.reject(new McpServerStoreNotImplementedError('deleteAuthorization')); + } } diff --git a/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts b/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts index a4e13824c..c30616e45 100644 --- a/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts +++ b/packages/trueforge/src/db/sqlite/mcp-server-store/SqliteMcpServerStore.ts @@ -1,18 +1,22 @@ import type { ExpressionBuilder, Kysely, Transaction } from 'kysely'; import type { OAuthClientRecord } from '../../../mcp/auth/types'; -import type { McpServerManifest } from '../../../schemas/mcpServer'; +import type { McpAuthStatus, McpServerManifest } from '../../../schemas/mcpServer'; import { newId } from '../../../utils/id'; import { fromStoredOAuthClientRecord, McpServerNameConflictError, + McpServerStoreNotImplementedError, toStoredOAuthClientRecord, + type AuthorizeMcpServerInput, type CreateMcpServerInput, + type DeleteMcpAuthorizationInput, type GetMcpServerInput, type IMcpServerStore, type ListMcpServersInput, type McpServerRecord, type OAuthClient, type OAuthServer, + type ResolveMcpAuthStatusesInput, type UpsertMcpServerInput, } from '../../mcpServerStore'; import { isUniqueViolation } from '../client'; @@ -170,4 +174,17 @@ export class SqliteMcpServerStore implements IMcpServerStore { + return Promise.reject(new McpServerStoreNotImplementedError('authorize')); + } + + deleteAuthorization(_input: DeleteMcpAuthorizationInput): Promise { + return Promise.reject(new McpServerStoreNotImplementedError('deleteAuthorization')); + } } diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 9b76a49d0..5032b8913 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -56,6 +56,7 @@ import { SkillCatalog } from './catalog/SkillCatalog'; import { type DistributedServerConfiguration } from './config'; import { createController } from './controller'; import type { IAgentStore } from './db/agentStore'; +import { wrapLocalMcpServerStore } from './db/LocalAuthMcpServerStore'; import type { IMcpServerStore } from './db/mcpServerStore'; import type { IModelProviderStore } from './db/modelProviderStore'; import type { Database as PostgresDatabase } from './db/postgres/types'; @@ -252,7 +253,7 @@ async function createServerRuntime(persistence: ServerPersistence< sessionMetricsStore, resolveModelProviderStore, withTransaction, - mcpServerStore, + mcpServerStore: persistenceMcpServerStore, tokenStore, skillStore, sandboxProviderStore, @@ -262,6 +263,19 @@ async function createServerRuntime(persistence: ServerPersistence< redis, } = persistence; + let mcpServerStore: IMcpServerStore = wrapLocalMcpServerStore({ + store: persistenceMcpServerStore, + tokenStore, + }); + if (configuration.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL !== undefined) { + const { TrueFoundryMcpServerStore } = await import('./truefoundry/TrueFoundryMcpServerStore'); + mcpServerStore = new TrueFoundryMcpServerStore({ + serviceFoundryServerUrl: configuration.TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL, + logger, + tls: { enabled: configuration.TRUEFOUNDRY_MTLS_ENABLED, dir: configuration.TRUEFOUNDRY_MTLS_CERTS_DIR }, + }); + } + const activeTurns = new ActiveTurnRegistry(); const requestReplyRouter = new RequestReplyRouter(); const eventSubscriptions = new EventSubscriptionRegistry(redis); diff --git a/packages/trueforge/src/routes/mcpServerRoutes.ts b/packages/trueforge/src/routes/mcpServerRoutes.ts index 9bfda2f62..a701c57b5 100644 --- a/packages/trueforge/src/routes/mcpServerRoutes.ts +++ b/packages/trueforge/src/routes/mcpServerRoutes.ts @@ -13,6 +13,7 @@ import { McpAuthStatusSchema, UpdateMcpServerRequestSchema, } from '../schemas/mcpServer'; +import { trueFoundryManagedResponse } from '../truefoundry/trueFoundryManaged'; import { OpenApiTag } from './openapiTags'; /** Chat/composer read view — mounted at /api/v1/mcp-servers (not under settings). */ @@ -122,6 +123,7 @@ export const createMcpServerRoute = createRoute({ content: { 'application/json': { schema: RequestErrorResponseSchema } }, description: 'The server cannot satisfy `auth.type: dcr` (e.g. it advertises no registration_endpoint).', }, + 424: trueFoundryManagedResponse, }, }); @@ -154,6 +156,7 @@ export const putMcpServerRoute = createRoute({ content: { 'application/json': { schema: RequestErrorResponseSchema } }, description: 'The server cannot satisfy `auth.type: dcr` (e.g. it advertises no registration_endpoint).', }, + 424: trueFoundryManagedResponse, }, }); diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 5206df875..0c10dc4ee 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -16,7 +16,7 @@ import { import { HTTPException } from 'hono/http-exception'; import { join } from 'node:path'; import type { Logger } from 'winston'; -import configuration from '../config'; +import configuration, { isTrueFoundryModeEnabled } from '../config'; import type { IMcpServerStore, McpServerRecord } from '../db/mcpServerStore'; import type { IModelProviderStore } from '../db/modelProviderStore'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; @@ -132,9 +132,48 @@ function dcrHeadersResolver(params: { }; } +/** + * TrueFoundry path: talk to the gateway proxy with the caller's Bearer token. + * When upstream OAuth is still required, ask the store to authorize and pause the turn. + */ +function trueFoundryHeadersResolver(params: { + store: IMcpServerStore; + record: McpServerRecord; + tenant_id: string; + userRef: string; + accessToken: string; +}): RemoteMcpHeaders { + const { store, record, tenant_id, userRef, accessToken } = params; + return async () => { + if (record.manifest.auth?.type === 'dcr') { + const status = await store.authorize({ + tenant_id, + name: record.name, + userRef, + accessToken, + }); + if (status.status === 'auth_required') { + const authUrl = status.authorization_url; + if (authUrl === undefined || authUrl.length === 0) { + throw new HTTPException(422, { + message: `MCP server "${record.name}" requires authentication but no authorization URL was returned`, + }); + } + return { + authRequired: { + servers: [{ id: record.name, name: record.name, auth_url: authUrl }], + }, + }; + } + } + return { headers: { Authorization: `Bearer ${accessToken}` } }; + }; +} + /** * Load MCP url + headers for a configured server. * DCR uses resolveMcpAuth; header / no-auth use resolveConfiguredMcpRequestHeaders. + * TrueFoundry mode uses the gateway proxy URL + caller Bearer (and store authorize for upstream OAuth). * Returns undefined when the server is not registered — callers choose the response. */ export async function getMcpConnection({ @@ -144,6 +183,7 @@ export async function getMcpConnection({ tokenStore, clientName, userRef, + accessToken, }: { tenant_id: string; name: string; @@ -151,11 +191,30 @@ export async function getMcpConnection({ tokenStore: IOAuthTokenStore; clientName: string; userRef: string; + /** Caller token for TrueFoundry-backed stores; ignored by DB stores. */ + accessToken?: string; }): Promise { - const record = await store.getServer({ tenant_id, name }); + const record = await store.getServer({ + tenant_id, + name, + ...(accessToken !== undefined && accessToken.length > 0 ? { accessToken } : {}), + }); if (record === undefined) { return undefined; } + + if (isTrueFoundryModeEnabled()) { + if (accessToken === undefined || accessToken.length === 0) { + throw new HTTPException(401, { + message: 'Authentication token required to call TrueFoundry MCP servers', + }); + } + return { + url: record.manifest.url, + headers: trueFoundryHeadersResolver({ store, record, tenant_id, userRef, accessToken }), + }; + } + if (record.manifest.auth?.type === 'dcr') { return { url: record.manifest.url, @@ -301,6 +360,7 @@ export async function validateAgentSpec({ mcpServerStore, skillStore, sandboxProviderStore, + accessToken, }: { spec: AgentSpec; tenant_id: string; @@ -308,6 +368,8 @@ export async function validateAgentSpec({ mcpServerStore: IMcpServerStore; skillStore: ISkillStore; sandboxProviderStore: ISandboxProviderStore; + /** Caller token for TrueFoundry-backed MCP stores; ignored by DB stores. */ + accessToken?: string; }): Promise { const resolved = await getModelDetails({ tenant_id, @@ -330,7 +392,13 @@ export async function validateAgentSpec({ if (requestedMcpServers.length > 0) { const names = requestedMcpServers.map(server => server.name); const configuredNames = new Set( - (await mcpServerStore.listServers({ tenant_id, names })).map(record => record.name), + ( + await mcpServerStore.listServers({ + tenant_id, + names, + ...(accessToken !== undefined && accessToken.length > 0 ? { accessToken } : {}), + }) + ).map(record => record.name), ); const unknown = requestedMcpServers.find(server => !configuredNames.has(server.name)); if (unknown !== undefined) { diff --git a/packages/trueforge/src/truefoundry/TrueFoundryMcpServerStore.ts b/packages/trueforge/src/truefoundry/TrueFoundryMcpServerStore.ts new file mode 100644 index 000000000..2ff4ab15f --- /dev/null +++ b/packages/trueforge/src/truefoundry/TrueFoundryMcpServerStore.ts @@ -0,0 +1,196 @@ +import { HTTPException } from 'hono/http-exception'; +import type { Logger } from 'winston'; +import { + McpServerNotFoundError, + McpServerStoreNotImplementedError, + type AuthorizeMcpServerInput, + type CreateMcpServerInput, + type DeleteMcpAuthorizationInput, + type GetMcpServerInput, + type IMcpServerStore, + type ListMcpServersInput, + type McpServerRecord, + type ResolveMcpAuthStatusesInput, + type UpsertMcpServerInput, +} from '../db/mcpServerStore'; +import type { OAuthClientRecord } from '../mcp/auth/types'; +import type { ResourceName } from '../schemas/common'; +import { resolveMcpAuthStatus, type McpAuthStatus, type McpServerManifest } from '../schemas/mcpServer'; +import type { InternalTlsOptions } from './internalTls'; +import { resolveDefaultGatewayUrl } from './mapEnabledModels'; +import { mapSfyMcpAuthStatus } from './mapMcpAuth'; +import { + resolveMcpProxyUrl, + TrueFoundryServiceFoundryServerClient, + type SfyMcpServerSummary, +} from './TrueFoundryServiceFoundryServerClient'; + +function requireAccessToken(accessToken: string | undefined): string { + if (accessToken === undefined || accessToken.length === 0) { + throw new HTTPException(401, { message: 'Authentication token required to list or call TrueFoundry MCP servers' }); + } + return accessToken; +} + +function notImplemented(operation: string): never { + throw new McpServerStoreNotImplementedError(operation); +} + +/** + * Read-only MCP registry backed by ServiceFoundry + the tenant AI Gateway. + * Writes and local OAuth client columns are not supported — configure servers in TrueFoundry. + */ +export class TrueFoundryMcpServerStore implements IMcpServerStore { + readonly #client: TrueFoundryServiceFoundryServerClient; + + constructor(input: { serviceFoundryServerUrl: string; logger?: Logger; tls?: InternalTlsOptions }) { + this.#client = new TrueFoundryServiceFoundryServerClient({ + serviceFoundryServerUrl: input.serviceFoundryServerUrl, + ...(input.logger === undefined ? {} : { logger: input.logger }), + ...(input.tls === undefined ? {} : { tls: input.tls }), + }); + } + + async listServers(input: ListMcpServersInput, transaction?: TTransaction): Promise { + void transaction; + if (input.names !== undefined && input.names.length === 0) { + return []; + } + const accessToken = requireAccessToken(input.accessToken); + const [servers, installations] = await Promise.all([ + this.#client.listMcpServers(accessToken), + this.#client.listGatewayInstallations(accessToken), + ]); + const gatewayUrl = resolveDefaultGatewayUrl(installations); + const records = servers.map(server => toRecord({ tenant_id: input.tenant_id, server, gatewayUrl })); + if (input.names === undefined) { + return records; + } + const wanted = new Set(input.names); + return records.filter(record => wanted.has(record.name)); + } + + async getServer(input: GetMcpServerInput, transaction?: TTransaction): Promise { + void transaction; + const accessToken = requireAccessToken(input.accessToken); + const [server, installations] = await Promise.all([ + this.#client.getMcpServerByName(accessToken, input.name), + this.#client.listGatewayInstallations(accessToken), + ]); + if (server === undefined) { + return undefined; + } + const gatewayUrl = resolveDefaultGatewayUrl(installations); + return toRecord({ tenant_id: input.tenant_id, server, gatewayUrl }); + } + + getServerForUpdate(input: GetMcpServerInput, transaction: TTransaction): Promise { + void input; + void transaction; + return notImplemented('getServerForUpdate'); + } + + createServer(input: CreateMcpServerInput, transaction?: TTransaction): Promise { + void input; + void transaction; + return notImplemented('createServer'); + } + + upsertServer(input: UpsertMcpServerInput, transaction?: TTransaction): Promise { + void input; + void transaction; + return notImplemented('upsertServer'); + } + + saveClient(params: { id: string; record: OAuthClientRecord }, transaction?: TTransaction): Promise { + void params; + void transaction; + return notImplemented('saveClient'); + } + + getClient(params: { id: string }, transaction?: TTransaction): Promise { + void params; + void transaction; + return notImplemented('getClient'); + } + + deleteClient(params: { id: string }, transaction?: TTransaction): Promise { + void params; + void transaction; + return notImplemented('deleteClient'); + } + + async resolveAuthStatuses(input: ResolveMcpAuthStatusesInput): Promise> { + const accessToken = requireAccessToken(input.accessToken); + const entries = await Promise.all( + input.records.map(async record => { + if (record.manifest.auth?.type !== 'dcr') { + return [record.name, resolveMcpAuthStatus({ manifest: record.manifest })] as const; + } + const response = await this.#client.getMcpAuthStatus(accessToken, record.id, { + subjectId: input.userRef, + subjectType: 'user', + }); + return [record.name, mapSfyMcpAuthStatus(response)] as const; + }), + ); + return new Map(entries); + } + + async authorize(input: AuthorizeMcpServerInput): Promise { + const accessToken = requireAccessToken(input.accessToken); + const server = await this.#client.getMcpServerByName(accessToken, input.name); + if (server === undefined) { + throw new McpServerNotFoundError(input.name); + } + const gatewayBaseURL = resolveDefaultGatewayUrl(await this.#client.listGatewayInstallations(accessToken)); + const response = await this.#client.authorizeMcpServer(accessToken, server.id, { + gatewayBaseURL, + ...(input.redirectURL !== undefined ? { redirectURL: input.redirectURL } : {}), + }); + return mapSfyMcpAuthStatus(response); + } + + async deleteAuthorization(input: DeleteMcpAuthorizationInput): Promise { + const accessToken = requireAccessToken(input.accessToken); + const server = await this.#client.getMcpServerByName(accessToken, input.name); + if (server === undefined) { + throw new McpServerNotFoundError(input.name); + } + if (server.authType === 'oauth2') { + await this.#client.deleteMcpAuth(accessToken, server.id, { + authSource: 'oauth', + subjectId: input.userRef, + subjectType: 'user', + }); + } + } +} + +function toRecord(input: { tenant_id: string; server: SfyMcpServerSummary; gatewayUrl: string }): McpServerRecord { + return { + id: input.server.id, + tenant_id: input.tenant_id, + name: input.server.name as ResourceName, + manifest: toManifest(input.server, input.gatewayUrl), + created_at: input.server.createdAt, + updated_at: input.server.updatedAt, + }; +} + +/** + * Gateway proxy URL as `url`. Upstream per-user OAuth is modelled as `dcr` so existing + * auth_status / Connect UX treats the server as needing user authorization; inbound + * gateway auth is the caller's Bearer token (not stored on the manifest). + */ +function toManifest(server: SfyMcpServerSummary, gatewayUrl: string): McpServerManifest { + const url = resolveMcpProxyUrl(server.proxyUrl, gatewayUrl); + const needsPerUserAuth = server.authType === 'oauth2'; + return { + type: 'remote', + name: server.name as ResourceName, + url, + description: server.description, + ...(needsPerUserAuth ? { auth: { type: 'dcr' as const } } : {}), + }; +} diff --git a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts index d31bacbbe..2725bb3c0 100644 --- a/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts +++ b/packages/trueforge/src/truefoundry/TrueFoundryServiceFoundryServerClient.ts @@ -1,3 +1,14 @@ +// TODO(mcp): +// 1. List + auth status — `GET v1/mcp` is expensive when SFY attaches per-caller +// authorization / auth-status work. Stop requesting/using auth status on list in +// TrueForge, or fix servicefoundry-server so list is a lean registry read (auth status +// only on `/auth/status` / authorize). +// 2. Name → id — `getMcpServerByName` relies on list `name EQUAL` then a full-list +// fallback; no dedicated get-by-name. Prefer a stable SFY lookup (or accept filter as +// canonical and drop the fallback) so authorize/status/revoke do not re-list. +// 5. Subject model — interactive auth should key off the caller Bearer / SFY session; +// `subjectId`/`subjectType` on status/delete are unfinished. Schedules, virtual +// accounts, and agent-identity MCP auth are out of scope for the POC. import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; import { HTTPException } from 'hono/http-exception'; import { fetch as undiciFetch, type Dispatcher } from 'undici'; @@ -5,10 +16,16 @@ import type { Logger } from 'winston'; import { z } from 'zod'; import { createInternalTlsDispatcher, normalizeInternalTlsUrl, type InternalTlsOptions } from './internalTls'; +import type { SfyMcpAuthorizeOrStatusResponse } from './mapMcpAuth'; const INTEGRATIONS_PATH = 'v1/provider-integrations'; const INSTALLATIONS_PATH = 'v1/llm-gateway/installations'; +const MCP_SERVERS_PATH = 'v1/mcp'; const INTEGRATIONS_PAGE_SIZE = 1000; +const MCP_SERVERS_PAGE_SIZE = 100; + +/** Placeholder in SFY `proxyUrl` replaced with the tenant gateway base URL. */ +export const MCP_PROXY_BASE_URL_TEMPLATE = '{{mcpProxyBaseURL}}'; const ListResponseSchema = z.union([ z.array(z.unknown()), @@ -31,6 +48,71 @@ async function readServiceFoundryErrorMessage( return Array.isArray(message) ? message.join(', ') : message; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** Normalize SFY `createdAt` / `updatedAt` (ISO string or Date) to ISO-8601 UTC. */ +function readIsoTimestamp(value: unknown): string | undefined { + if (typeof value === 'string' && value.length > 0) { + const ms = Date.parse(value); + return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined; + } + if (value instanceof Date && Number.isFinite(value.getTime())) { + return value.toISOString(); + } + return undefined; +} + +function readDataArray(payload: unknown): unknown[] { + const parsed = ListResponseSchema.safeParse(payload); + if (!parsed.success) { + return []; + } + return Array.isArray(parsed.data) ? parsed.data : parsed.data.data; +} + +function readPaginationTotal(payload: unknown): number | undefined { + const parsed = ListResponseSchema.safeParse(payload); + if (!parsed.success || Array.isArray(parsed.data)) { + return undefined; + } + return parsed.data.pagination?.total; +} + +/** Fields TrueForge needs from a ServiceFoundry MCP server list/get row. */ +export interface SfyMcpServerSummary { + id: string; + name: string; + tenantName: string; + description: string; + // TODO(mcp): often still contains `{{mcpProxyBaseURL}}` until callers run + // `resolveMcpProxyUrl`; easy to misuse if treated as a dialable URL. + /** Template URL with `{{mcpProxyBaseURL}}`, or an already-absolute proxy URL. */ + proxyUrl: string; + /** Manifest `auth.type` when present (e.g. `oauth2`, `header`). */ + authType: string | undefined; + /** ISO-8601 UTC from SFY `createdAt`. */ + createdAt: string; + /** ISO-8601 UTC from SFY `updatedAt`. */ + updatedAt: string; +} + +export interface SfyMcpAuthorizeParams { + gatewayBaseURL?: string | undefined; + redirectURL?: string | undefined; +} + +export interface SfyMcpDeleteAuthBody { + authSource: 'oauth' | 'auth-override'; + subjectId: string; + subjectType: 'user' | 'virtualaccount'; +} + export class TrueFoundryServiceFoundryServerClient { readonly #baseUrl: string; readonly #logger: Logger | undefined; @@ -50,22 +132,52 @@ export class TrueFoundryServiceFoundryServerClient { const items: unknown[] = []; let offset = 0; for (;;) { - const payload = await this.#getJson( + const payload = await this.#requestJson( this.#url(INTEGRATIONS_PATH, { type: 'model', offset: String(offset), limit: String(INTEGRATIONS_PAGE_SIZE), }), - accessToken, + { accessToken }, ); - const parsed = ListResponseSchema.safeParse(payload); - if (!parsed.success) { + const page = readDataArray(payload); + const total = readPaginationTotal(payload); + items.push(...page); + if (total === undefined || items.length >= total || page.length === 0) { break; } - const page = Array.isArray(parsed.data) ? parsed.data : parsed.data.data; - const total = Array.isArray(parsed.data) ? undefined : parsed.data.pagination?.total; + offset = items.length; + } + return items; + } + + listGatewayInstallations(accessToken: string): Promise { + return this.#requestJson(this.#url(INSTALLATIONS_PATH), { accessToken }); + } + + /** + * Paginated `GET v1/mcp`. + * TODO(mcp): SFY currently loads the full set then slices; this client still walks + * offset/limit as if the server paginated. Fine for small tenants; fix with SFY or + * a single fetch once list is lean (see file-level TODO 1). + */ + async listMcpServers(accessToken: string): Promise { + const items: SfyMcpServerSummary[] = []; + let offset = 0; + for (;;) { + const payload = await this.#requestJson( + this.#url(MCP_SERVERS_PATH, { + offset: String(offset), + limit: String(MCP_SERVERS_PAGE_SIZE), + }), + { accessToken }, + ); + const page = readDataArray(payload) + .map(parseSfyMcpServerSummary) + .filter((row): row is SfyMcpServerSummary => row !== undefined); + const total = readPaginationTotal(payload); items.push(...page); - if (!total || items.length >= total || page.length === 0) { + if (total === undefined || items.length >= total || page.length === 0) { break; } offset = items.length; @@ -73,8 +185,68 @@ export class TrueFoundryServiceFoundryServerClient { return items; } - listGatewayInstallations(accessToken: string): Promise { - return this.#getJson(this.#url(INSTALLATIONS_PATH), accessToken); + /** + * Resolve one MCP server by name (list filter `name EQUAL`, else full list). + * Returns `undefined` when the tenant has no server with that name. + */ + async getMcpServerByName(accessToken: string, name: string): Promise { + const filter = JSON.stringify({ + op: 'AND', + value: [{ field: 'name', op: 'EQUAL', value: name }], + }); + const payload = await this.#requestJson(this.#url(MCP_SERVERS_PATH, { filter, limit: '1', offset: '0' }), { + accessToken, + }); + const filtered = readDataArray(payload) + .map(parseSfyMcpServerSummary) + .filter((row): row is SfyMcpServerSummary => row !== undefined); + const match = filtered.find(row => row.name === name); + if (match !== undefined) { + return match; + } + + // Filter operators vary by deployment; fall back to a full list once. + const all = await this.listMcpServers(accessToken); + return all.find(row => row.name === name); + } + + getMcpAuthStatus( + accessToken: string, + mcpServerId: string, + params: { subjectId: string; subjectType: string }, + ): Promise { + return this.#requestJson( + this.#url(`${MCP_SERVERS_PATH}/${encodeURIComponent(mcpServerId)}/auth/status`, { + subjectId: params.subjectId, + subjectType: params.subjectType, + }), + { accessToken }, + ).then(assertSfyMcpAuthResponse); + } + + authorizeMcpServer( + accessToken: string, + mcpServerId: string, + params: SfyMcpAuthorizeParams = {}, + ): Promise { + const search: Record = {}; + if (params.gatewayBaseURL !== undefined && params.gatewayBaseURL.length > 0) { + search['gatewayBaseURL'] = params.gatewayBaseURL; + } + if (params.redirectURL !== undefined && params.redirectURL.length > 0) { + search['redirectURL'] = params.redirectURL; + } + return this.#requestJson(this.#url(`${MCP_SERVERS_PATH}/${encodeURIComponent(mcpServerId)}/authorize`, search), { + accessToken, + }).then(assertSfyMcpAuthResponse); + } + + async deleteMcpAuth(accessToken: string, mcpServerId: string, body: SfyMcpDeleteAuthBody): Promise { + await this.#requestJson(this.#url(`${MCP_SERVERS_PATH}/${encodeURIComponent(mcpServerId)}/auth`), { + accessToken, + method: 'DELETE', + body, + }); } #url(path: string, search?: Record): URL { @@ -87,21 +259,28 @@ export class TrueFoundryServiceFoundryServerClient { return url; } - async #getJson(url: URL, accessToken: string): Promise { + async #requestJson( + url: URL, + options: { accessToken: string; method?: string; body?: unknown }, + ): Promise { const startedAt = Date.now(); + const method = options.method ?? 'GET'; let response: Awaited>; try { response = await undiciFetch(url, { - method: 'GET', + method, headers: { accept: 'application/json', - authorization: `Bearer ${accessToken}`, + authorization: `Bearer ${options.accessToken}`, + ...(options.body !== undefined ? { 'content-type': 'application/json' } : {}), }, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), ...(this.#dispatcher ? { dispatcher: this.#dispatcher } : {}), }); } catch (error) { this.#logger?.warn('TrueFoundry ServiceFoundry server request failed', { url: url.href, + method, durationMs: Date.now() - startedAt, ...extractErrorLogFields(error), }); @@ -112,6 +291,7 @@ export class TrueFoundryServiceFoundryServerClient { } this.#logger?.info('TrueFoundry ServiceFoundry server request completed', { url: url.href, + method, status: response.status, durationMs: Date.now() - startedAt, }); @@ -120,12 +300,90 @@ export class TrueFoundryServiceFoundryServerClient { message: 'TrueFoundry ServiceFoundry server rejected the request', }); } + if (response.status === 404) { + throw new HTTPException(404, { + message: 'TrueFoundry ServiceFoundry resource not found', + }); + } if (!response.ok) { const detail = await readServiceFoundryErrorMessage(response); throw new HTTPException(424, { message: `TrueFoundry ServiceFoundry server request failed: ${detail ?? `HTTP ${String(response.status)}`}`, }); } - return response.json(); + if (response.status === 204) { + return undefined; + } + const text = await response.text(); + if (text.length === 0) { + return undefined; + } + return JSON.parse(text) as unknown; + } +} + +/** + * Substitute `{{mcpProxyBaseURL}}` in an SFY proxy URL template. + * TODO(mcp): callers must run this before dialing; see `SfyMcpServerSummary.proxyUrl`. + */ +export function resolveMcpProxyUrl(proxyUrl: string, gatewayBaseURL: string): string { + const base = gatewayBaseURL.replace(/\/+$/, ''); + if (!proxyUrl.includes(MCP_PROXY_BASE_URL_TEMPLATE)) { + return proxyUrl; + } + return proxyUrl.replaceAll(MCP_PROXY_BASE_URL_TEMPLATE, base); +} + +/** `tenant:mcp-server:name` → name; undefined when the FQN is not that shape. */ +function nameFromFqn(fqn: string | undefined): string | undefined { + if (fqn === undefined) { + return undefined; + } + const parts = fqn.split(':'); + if (parts.length < 3 || parts[1] !== 'mcp-server') { + return undefined; + } + return parts.slice(2).join(':') || undefined; +} + +function parseSfyMcpServerSummary(row: unknown): SfyMcpServerSummary | undefined { + if (!isRecord(row)) { + return undefined; + } + const id = readString(row['id']); + const tenantName = readString(row['tenantName']); + const proxyUrl = readString(row['proxyUrl']); + const manifest = isRecord(row['manifest']) ? row['manifest'] : {}; + const name = readString(row['name']) ?? readString(manifest['name']) ?? nameFromFqn(readString(row['fqn'])); + if (id === undefined || name === undefined || tenantName === undefined || proxyUrl === undefined) { + return undefined; + } + const description = readString(manifest['description']) ?? readString(row['description']) ?? name; + const auth = isRecord(manifest['auth_data']) + ? manifest['auth_data'] + : isRecord(manifest['auth']) + ? manifest['auth'] + : undefined; + const authType = auth !== undefined ? readString(auth['type']) : undefined; + const createdAt = readIsoTimestamp(row['createdAt']); + const updatedAt = readIsoTimestamp(row['updatedAt']); + if (createdAt === undefined || updatedAt === undefined) { + return undefined; + } + return { id, name, tenantName, proxyUrl, description, authType, createdAt, updatedAt }; +} + +function assertSfyMcpAuthResponse(payload: unknown): SfyMcpAuthorizeOrStatusResponse { + if (!isRecord(payload)) { + throw new HTTPException(502, { message: 'TrueFoundry MCP auth response was not an object' }); + } + const status = readString(payload['status']); + if (status === undefined) { + throw new HTTPException(502, { message: 'TrueFoundry MCP auth response missing status' }); } + const authorization_endpoint = readString(payload['authorization_endpoint']); + return { + status, + ...(authorization_endpoint === undefined ? {} : { authorization_endpoint }), + }; } diff --git a/packages/trueforge/src/truefoundry/mapMcpAuth.ts b/packages/trueforge/src/truefoundry/mapMcpAuth.ts new file mode 100644 index 000000000..a6bde5879 --- /dev/null +++ b/packages/trueforge/src/truefoundry/mapMcpAuth.ts @@ -0,0 +1,31 @@ +import type { McpAuthStatus } from '../schemas/mcpServer'; + +/** ServiceFoundry public MCP auth status values. */ +export type SfyMcpAuthStatusValue = 'authenticated' | 'authentication_required' | 'authentication_not_required'; + +export interface SfyMcpAuthorizeOrStatusResponse { + status: string; + authorization_endpoint?: string | undefined; +} + +/** + * Maps ServiceFoundry MCP auth status (and optional authorize URL) onto TrueForge + * {@link McpAuthStatus}. Unknown statuses fall through to `auth_required` without a URL. + */ +export function mapSfyMcpAuthStatus(response: SfyMcpAuthorizeOrStatusResponse): McpAuthStatus { + switch (response.status) { + case 'authenticated': + return { status: 'authenticated' }; + case 'authentication_not_required': + return { status: 'not_required' }; + case 'authentication_required': { + const url = response.authorization_endpoint; + if (url !== undefined && url.length > 0) { + return { status: 'auth_required', authorization_url: url }; + } + return { status: 'auth_required' }; + } + default: + return { status: 'auth_required' }; + } +} diff --git a/packages/trueforge/src/truefoundry/trueFoundryManaged.ts b/packages/trueforge/src/truefoundry/trueFoundryManaged.ts index 0899fa7e9..1b1d80a59 100644 --- a/packages/trueforge/src/truefoundry/trueFoundryManaged.ts +++ b/packages/trueforge/src/truefoundry/trueFoundryManaged.ts @@ -1,8 +1,14 @@ +import type { Context } from 'hono'; + import { RequestErrorResponseSchema } from '../schemas/errors'; export const TRUEFOUNDRY_MANAGED_STATUS = 424 as const; export const TRUEFOUNDRY_MANAGED_MESSAGE = 'This resource is managed by TrueFoundry'; +export function respondTrueFoundryManaged(c: Context) { + return c.json({ error: { message: TRUEFOUNDRY_MANAGED_MESSAGE } }, TRUEFOUNDRY_MANAGED_STATUS); +} + export const trueFoundryManagedResponse = { content: { 'application/json': { schema: RequestErrorResponseSchema } }, description: 'Resource is managed by TrueFoundry (`TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL` is set).', diff --git a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts index d340f3f7d..680a19e2b 100644 --- a/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpOAuth.test.ts @@ -6,6 +6,8 @@ import winston from 'winston'; import { createMcpOAuthRouter } from '../../../src/apis/mcpOAuth'; import { createMcpServersRouter, createSettingsMcpServersRouter } from '../../../src/apis/mcpServers'; import { LOCAL_USER_CONTEXT } from '../../../src/auth/identity'; +import { wrapLocalMcpServerStore } from '../../../src/db/LocalAuthMcpServerStore'; +import type { IMcpServerStore } from '../../../src/db/mcpServerStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; @@ -75,7 +77,7 @@ describe('MCP OAuth authorize + callback', () => { let settingsRouter: ReturnType; let mcpServersRouter: ReturnType; let oauthRouter: ReturnType; - let mcpServerStore: SqliteMcpServerStore; + let mcpServerStore: IMcpServerStore; let tokenStore: SqliteOAuthTokenStore; let withTransaction: (callback: (transaction: unknown) => Promise) => Promise; let logger: ReturnType; @@ -83,8 +85,11 @@ describe('MCP OAuth authorize + callback', () => { beforeAll(async () => { const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); - mcpServerStore = new SqliteMcpServerStore(db); tokenStore = new SqliteOAuthTokenStore(db); + mcpServerStore = wrapLocalMcpServerStore({ + store: new SqliteMcpServerStore(db), + tokenStore, + }); withTransaction = callback => db.transaction().execute(callback); logger = winston.createLogger({ silent: true }); settingsRouter = createSettingsMcpServersRouter({ diff --git a/packages/trueforge/tests/unit/apis/mcpServers.test.ts b/packages/trueforge/tests/unit/apis/mcpServers.test.ts index 725c73239..7ad9c70bb 100644 --- a/packages/trueforge/tests/unit/apis/mcpServers.test.ts +++ b/packages/trueforge/tests/unit/apis/mcpServers.test.ts @@ -7,6 +7,8 @@ import { McpCatalog } from '../../../src/catalog/McpCatalog'; import { ModelCatalog } from '../../../src/catalog/ModelCatalog'; import { SandboxCatalog } from '../../../src/catalog/SandboxCatalog'; import { SkillCatalog } from '../../../src/catalog/SkillCatalog'; +import { wrapLocalMcpServerStore } from '../../../src/db/LocalAuthMcpServerStore'; +import type { IMcpServerStore } from '../../../src/db/mcpServerStore'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; @@ -83,7 +85,7 @@ describe('mcp-servers routers', () => { let settingsRouter: ReturnType; let catalogRouter: ReturnType; let mcpServersRouter: ReturnType; - let mcpServerStore: SqliteMcpServerStore; + let mcpServerStore: IMcpServerStore; let tokenStore: SqliteOAuthTokenStore; let withTransaction: (callback: (transaction: unknown) => Promise) => Promise; let logger: ReturnType; @@ -97,8 +99,11 @@ describe('mcp-servers routers', () => { }) as typeof fetch; const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); - mcpServerStore = new SqliteMcpServerStore(db); tokenStore = new SqliteOAuthTokenStore(db); + mcpServerStore = wrapLocalMcpServerStore({ + store: new SqliteMcpServerStore(db), + tokenStore, + }); withTransaction = callback => db.transaction().execute(callback); logger = winston.createLogger({ silent: true }); settingsRouter = createSettingsMcpServersRouter({