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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/e2e/demo-e2e-direct/e2e/direct-forward-headers.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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' });
});
});
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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');
});
});
8 changes: 7 additions & 1 deletion libs/sdk/src/direct/create.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -96,6 +96,12 @@ export interface CreateConfig extends Omit<ServerConfigInput, CreateOwnedServerO
/** Authentication configuration for the server (`@FrontMcp({ auth })`) */
auth?: AuthOptionsInput;

/**
* The request headers an in-process call keeps in `customHeaders`, besides `x-frontmcp-*`, as `@FrontMcp({ http })`
* does for an HTTP request. An in-process server has no HTTP host, so it takes no other `http` option.
*/
http?: Pick<HttpOptionsInput, 'forwardHeaders'>;

/** Job definitions for the app */
jobDefinitions?: JobType[];

Expand Down
8 changes: 6 additions & 2 deletions libs/sdk/src/front-mcp/front-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading