diff --git a/apps/e2e/demo-e2e-direct/e2e/direct-forward-headers.e2e.spec.ts b/apps/e2e/demo-e2e-direct/e2e/direct-forward-headers.e2e.spec.ts new file mode 100644 index 000000000..be5703e5f --- /dev/null +++ b/apps/e2e/demo-e2e-direct/e2e/direct-forward-headers.e2e.spec.ts @@ -0,0 +1,42 @@ +/** + * An in-process call keeps the request headers `http.forwardHeaders` names in `customHeaders`, as an HTTP + * request does (#1052): `createDirect()` and `create()` never listen, but they take `http.forwardHeaders`. + */ +import { App, create, FrontMcpInstance, LogLevel, Tool, ToolContext, type DirectMcpServer } from '@frontmcp/sdk'; + +@Tool({ name: 'show_headers', inputSchema: {} }) +class ShowHeadersTool extends ToolContext { + async execute() { + return this.context.metadata.customHeaders; + } +} + +@App({ id: 'help-desk', name: 'Help Desk', tools: [ShowHeadersTool] }) +class HelpDeskApp {} + +const info = { name: 'help-desk', version: '1.0.0' }; +const logging = { level: LogLevel.Off }; +const http = { forwardHeaders: ['x-acme-*'] }; +const metadata = { customHeaders: { 'x-acme-tenant': 'eu-1', 'x-frontmcp-desk': 'berlin', 'x-other': 'dropped' } }; + +async function headersOf(server: DirectMcpServer): Promise { + try { + return (await server.callTool('show_headers', {}, { metadata })).structuredContent; + } finally { + await server.dispose(); + } +} + +describe('http.forwardHeaders on in-process servers', () => { + it('keeps the named headers on a createDirect() call', async () => { + const server = await FrontMcpInstance.createDirect({ info, logging, apps: [HelpDeskApp], http }); + + expect(await headersOf(server)).toEqual({ 'x-acme-tenant': 'eu-1', 'x-frontmcp-desk': 'berlin' }); + }); + + it('keeps the named headers on a create() call', async () => { + const server = await create({ info, logging, tools: [ShowHeadersTool], http }); + + expect(await headersOf(server)).toEqual({ 'x-acme-tenant': 'eu-1', 'x-frontmcp-desk': 'berlin' }); + }); +}); diff --git a/libs/sdk/src/direct/__tests__/direct-server.forward-headers.spec.ts b/libs/sdk/src/direct/__tests__/direct-server.forward-headers.spec.ts new file mode 100644 index 000000000..417d2dcc0 --- /dev/null +++ b/libs/sdk/src/direct/__tests__/direct-server.forward-headers.spec.ts @@ -0,0 +1,86 @@ +import 'reflect-metadata'; + +import { App, LogLevel, Tool, ToolContext } from '../../common'; +import { FrontMcpInstance } from '../../front-mcp/front-mcp'; +import { type Scope } from '../../scope/scope.instance'; +import { create } from '../create'; +import { type DirectMcpServer } from '../direct.types'; + +@Tool({ name: 'show_headers', inputSchema: {} }) +class ShowHeadersTool extends ToolContext { + async execute() { + return this.context.metadata.customHeaders; + } +} + +@App({ id: 'help-desk', name: 'Help Desk', tools: [ShowHeadersTool] }) +class HelpDeskApp {} + +const info = { name: 'help-desk', version: '1.0.0' }; +const logging = { level: LogLevel.Off }; +const metadata = { + customHeaders: { 'x-acme-tenant': 'eu-1', 'x-frontmcp-desk': 'berlin', 'x-other': 'dropped' }, +}; + +async function headersOf(server: DirectMcpServer): Promise { + try { + return (await server.callTool('show_headers', {}, { metadata })).structuredContent; + } finally { + await server.dispose(); + } +} + +/** An in-process call keeps the headers `http.forwardHeaders` names, as an HTTP request does (#1052). */ +describe('http.forwardHeaders on in-process servers', () => { + it('keeps them on a createDirect() call', async () => { + const server = await FrontMcpInstance.createDirect({ + info, + logging, + apps: [HelpDeskApp], + http: { forwardHeaders: ['x-acme-*'] }, + }); + + expect(await headersOf(server)).toEqual({ 'x-acme-tenant': 'eu-1', 'x-frontmcp-desk': 'berlin' }); + }); + + it('keeps them on a create() call', async () => { + const server = await create({ info, logging, tools: [ShowHeadersTool], http: { forwardHeaders: ['x-acme-*'] } }); + + expect(await headersOf(server)).toEqual({ 'x-acme-tenant': 'eu-1', 'x-frontmcp-desk': 'berlin' }); + }); + + it('keeps only x-frontmcp-* headers without forwardHeaders', async () => { + const server = await FrontMcpInstance.createDirect({ info, logging, apps: [HelpDeskApp], http: { port: 4100 } }); + + expect(await headersOf(server)).toEqual({ 'x-frontmcp-desk': 'berlin' }); + }); + + it('takes only forwardHeaders from http, so a direct server stays without HTTP options', async () => { + const server = await FrontMcpInstance.createDirect({ + info, + logging, + apps: [HelpDeskApp], + http: { port: 4100, entryPath: '/desk', forwardHeaders: ['x-acme-*'] }, + }); + + try { + const { scope } = server as unknown as { scope: Scope }; + expect(scope.metadata.http?.forwardHeaders).toEqual(['x-acme-*']); + expect(scope.metadata.http?.port).not.toBe(4100); + expect(scope.entryPath).toBe(''); + } finally { + await server.dispose(); + } + }); + + it('refuses a forwardHeaders glob that matches a credential header', async () => { + await expect( + FrontMcpInstance.createDirect({ + info, + logging, + apps: [HelpDeskApp], + http: { forwardHeaders: ['authorization'] }, + }), + ).rejects.toThrow('http.forwardHeaders'); + }); +}); diff --git a/libs/sdk/src/direct/create.types.ts b/libs/sdk/src/direct/create.types.ts index 1752322c4..4201afc35 100644 --- a/libs/sdk/src/direct/create.types.ts +++ b/libs/sdk/src/direct/create.types.ts @@ -19,7 +19,7 @@ import type { WorkflowType, } from '../common/interfaces'; import type { FrontMcpConfigInput } from '../common/metadata'; -import type { AuthOptionsInput } from '../common/types'; +import type { AuthOptionsInput, HttpOptionsInput } from '../common/types'; import type { DirectServerOptions } from './direct.types'; /** The `@FrontMcp` options of a server whose apps share one endpoint, as `FrontMcpInstance.createDirect()` takes them. */ @@ -96,6 +96,12 @@ export interface CreateConfig extends Omit; + /** Job definitions for the app */ jobDefinitions?: JobType[]; diff --git a/libs/sdk/src/front-mcp/front-mcp.ts b/libs/sdk/src/front-mcp/front-mcp.ts index cc924c58e..4c7da141f 100644 --- a/libs/sdk/src/front-mcp/front-mcp.ts +++ b/libs/sdk/src/front-mcp/front-mcp.ts @@ -630,6 +630,9 @@ export class FrontMcpInstance implements FrontMcpInterface { * `options.workerEnv` sets the platform bindings every call reads as `this.workerEnv` unless the call * (`DirectCallOptions.workerEnv`) or client (`ConnectOptions.workerEnv`) passes its own (#706). * + * The server never listens, so of `http` it takes only `forwardHeaders`: a call's `metadata.customHeaders` keeps + * the headers it names, besides `x-frontmcp-*`, as an HTTP request does (#1052). + * * @example * ```typescript * import { FrontMcpInstance } from '@frontmcp/sdk'; @@ -658,8 +661,9 @@ export class FrontMcpInstance implements FrontMcpInterface { // Parse config through Zod to apply defaults, then disable HTTP server const parsedConfig = frontMcpMetadataSchema.parse({ ...config, - // Direct access never listens: no HTTP config and no HTTP host (a browser build has no Express) - http: undefined, + // Direct access never listens: no HTTP host (a browser build has no Express), so of `http` only the + // headers in-process calls keep apply (#1052) + http: config.http?.forwardHeaders ? { forwardHeaders: config.http.forwardHeaders } : undefined, serve: false, }); markDirectOnly(parsedConfig); diff --git a/libs/skills/catalog/frontmcp-config/references/configure-http.md b/libs/skills/catalog/frontmcp-config/references/configure-http.md index 788eeea5f..7d340a4f2 100644 --- a/libs/skills/catalog/frontmcp-config/references/configure-http.md +++ b/libs/skills/catalog/frontmcp-config/references/configure-http.md @@ -500,7 +500,7 @@ class WhoAmITool extends ToolContext { ``` - `authorization`, `cookie`, `set-cookie`, `proxy-*`, hop-by-hop headers (`connection`, `keep-alive`, `te`, `trailer`, `transfer-encoding`, `upgrade`), `content-length` (an outbound request's body has its own) and the client-address headers (`host`, `forwarded`, `x-forwarded-*`, `x-real-ip`, which a client could forge for an upstream that trusts this server) are never kept. A name or glob that matches one (`'authorization'`, `'proxy-*'`, `'x-forwarded-port'`, `'content-*'`, `'x-*'`, `'*'`) stops `@FrontMcp` at startup with `InvalidDecoratorMetadataError` (`@FrontMcp invalid metadata for "http"`), naming the header. Read the caller's identity from `this.auth` instead. -- A direct call to a `FrontMcpInstance.createDirect()` server built from the same config keeps the same headers from `DirectCallOptions.metadata.customHeaders`. `create()` takes no `http` option, so its calls keep `x-frontmcp-*` only. +- An in-process call keeps the same headers from `DirectCallOptions.metadata.customHeaders`: a `FrontMcpInstance.createDirect()` server built from the same config, and `create({ http: { forwardHeaders } })`, whose `http` takes only `forwardHeaders`. Both never listen, so they ignore the rest of `http`. - The debug request log still redacts credential-looking headers (`x-acme-api-key`, `x-acme-token`). - `this.fetch()` sends the kept headers upstream only to the origins `fetch.forwardCustomHeadersTo` lists; other origins get none of them. diff --git a/libs/skills/catalog/frontmcp-deployment/references/build-for-sdk.md b/libs/skills/catalog/frontmcp-deployment/references/build-for-sdk.md index 1d37eb5bb..b410d944a 100644 --- a/libs/skills/catalog/frontmcp-deployment/references/build-for-sdk.md +++ b/libs/skills/catalog/frontmcp-deployment/references/build-for-sdk.md @@ -142,8 +142,9 @@ create({ providers?: ProviderType[], adapters?: AdapterType[], - // Server-level: every other @FrontMcp option, passed on as it is (except http and splitByApp) + // Server-level: every other @FrontMcp option, passed on as it is (except splitByApp, and http beyond forwardHeaders) auth?: AuthOptionsInput, // the server's auth, as @FrontMcp({ auth }) + http?: { forwardHeaders?: string[] }, // request headers a call's metadata.customHeaders keeps, as @FrontMcp({ http }) redis?: RedisOptionsInput, transport?: TransportOptionsInput, logging?: LoggingOptionsInput, // without it: `warn`, or the level FRONTMCP_LOG_LEVEL names @@ -162,7 +163,7 @@ create({ A `create()` server builds one scope, the root scope, which holds the synthetic app (`standalone: false`) and applies `auth` as the server's auth. A server-scoped plugin (`@Plugin({ scope: 'server' })`) in `plugins` installs there. Up to 1.9.4 the synthetic app had a scope of its own and an empty root scope started too, so every `create()` server ran two task stores, two task runners and their timers, and a server-scoped plugin failed with `InvalidPluginScopeError`. -A `create()`, `createDirect()` or `connect()` server is reached in process only, so its scopes set up nothing that serves HTTP: no transport (`scope.transportService` is `undefined`), no `http:*` flows, custom `http.routes`, channel webhook routes or health checks (`scope.healthService` is `undefined`). Without `auth`, the scope's auth is public mode without the OAuth, discovery and session flows (`scope.auth.options.mode` is `'public'`); with `auth`, it is the auth of that mode with its flows, as before. To serve the same config over HTTP, use `FrontMcpInstance.bootstrap()`, `createHandler()` or `createFetchHandler()`. +A `create()`, `createDirect()` or `connect()` server is reached in process only, so its scopes set up nothing that serves HTTP: no transport (`scope.transportService` is `undefined`), no `http:*` flows, custom `http.routes`, channel webhook routes or health checks (`scope.healthService` is `undefined`). Of `http`, only `forwardHeaders` applies: a `create()` or `createDirect()` call's `DirectCallOptions.metadata.customHeaders` keeps the headers it names, as an HTTP request does. A `connect()` client's `callTool()` takes no per-call metadata. Without `auth`, the scope's auth is public mode without the OAuth, discovery and session flows (`scope.auth.options.mode` is `'public'`); with `auth`, it is the auth of that mode with its flows, as before. To serve the same config over HTTP, use `FrontMcpInstance.bootstrap()`, `createHandler()` or `createFetchHandler()`. ## Platform-Specific Connections