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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to `toolception` are listed here. Versions follow [semver](h

Releases before 0.7.0 are described in [GitHub Releases](https://github.com/code-rabi/toolception/releases).

## [Unreleased]

### Added

- `http.allowedOrigins` validates the `Origin` header on the `/mcp` endpoints, as the MCP spec requires. A request with an `Origin` outside the list gets `403`; requests without an `Origin` header are always allowed. When `cors` is on, only the listed origins get CORS access. Not set by default, so existing deployments behave as before.
- **Migration:** none needed. To turn it on, pass `http: { allowedOrigins: ["https://app.example.com"] }`.

## [0.7.0]

### Breaking
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,9 +962,14 @@ Notes

#### options.http (optional)

`{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean; customEndpoints?: CustomEndpointDefinition[] }`
`{ host?: string; port?: number; basePath?: string; cors?: boolean; logger?: boolean; allowedOrigins?: string[]; customEndpoints?: CustomEndpointDefinition[] }`

- Fastify transport configuration. Defaults: host `0.0.0.0`, port `3000`, basePath `/`, CORS enabled, logger disabled.
- `allowedOrigins`: Origins allowed to call `/mcp` (POST, GET, DELETE), e.g. `["https://app.example.com"]`. The MCP spec requires servers to validate `Origin` as a defence against DNS rebinding, so set this on any server that browsers could reach, and in particular on one bound to localhost.
- Not set (default): no check, as before. Set: a request whose `Origin` is not listed gets `403`.
- Requests with no `Origin` header (SDK, CLI and server-to-server clients) are always allowed, so non-browser clients keep working. `[]` allows only those.
- Entries are compared by scheme, host and port, case-insensitively. An entry that is not an origin (such as `"*"`) throws at startup. The request's `Origin` must be a bare origin as browsers send it, so a value with a path, userinfo, query or explicit default port is refused.
- With `cors` on, only the listed origins also get CORS access; the other endpoints (`/healthz`, `/tools`, custom endpoints) are not Origin-checked.
- `customEndpoints`: Optional array of custom HTTP endpoints to register alongside MCP protocol endpoints. See [Custom HTTP endpoints](#custom-http-endpoints) for details.

#### options.createServer (optional)
Expand Down
5 changes: 4 additions & 1 deletion src/http/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams,
## Key Components

- **FastifyTransport** (`FastifyTransport.ts`) — Main HTTP transport. Per-client bundles via ClientResourceCache, optional SessionContextResolver for context differentiation.
- **Origin validation** (`originValidation.ts`) — `normalizeAllowedOrigins()` + `createOriginGuard()`; each transport applies the guard as an `onRequest` hook on `/mcp` POST/GET/DELETE only, when `allowedOrigins` is set.
- **Custom Endpoints** (`customEndpoints.ts`, `endpointRegistration.ts`) — Type-safe endpoint definitions with Zod schemas. `defineEndpoint()` / `definePermissionAwareEndpoint()` helpers.

## Endpoints
Expand All @@ -25,7 +26,8 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams,
1. **`mcp-client-id` header required** — All `/mcp` endpoints reject without it (400). Custom endpoints auto-generate `anon-${UUID}` fallback instead.
2. **Session created on POST /mcp initialize** — `isInitializeRequest()` from MCP SDK detects first-contact requests. New session ID generated via `randomUUID()`, stored in `bundle.sessions` Map.
3. **Cache key uses `resolveSessionContext()`** — All three `/mcp` handlers (POST, GET, DELETE) must derive the cache key through the same `resolveSessionContext()` path. Using plain `clientId` causes cache misses when session context is active.
4. **Reserved paths cannot be overridden** — `/mcp`, `/healthz`, `/tools`, `/.well-known/mcp-config` are registered before custom endpoints.
4. **Origin check is opt-in and `/mcp`-only** — `allowedOrigins` unset means no check (backward compatible). When set: no `Origin` header → allowed (non-browser clients), listed → allowed, anything else (including `null`, which can never be listed) → 403 before the `mcp-client-id` check. Invalid entries throw at transport construction. Both transports must keep the same behaviour, and `cors` narrows to the same list.
5. **Reserved paths cannot be overridden** — `/mcp`, `/healthz`, `/tools`, `/.well-known/mcp-config` are registered before custom endpoints.

## Session Lifecycle

Expand Down Expand Up @@ -66,6 +68,7 @@ Provides Fastify-based HTTP transport for the MCP protocol. Handles SSE streams,
- Registering endpoints on reserved paths (throws)
- Sending MCP protocol requests without `mcp-client-id` header (returns 400)
- Blocking on SSE handlers (should be async)
- Adding a `*` wildcard to `allowedOrigins` (leave it unset to allow all; the guard rejects non-origin entries on purpose)

## Dependencies

Expand Down
21 changes: 19 additions & 2 deletions src/http/FastifyTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import type {
CustomEndpointDefinition,
} from "./http.types.js";
import { registerCustomEndpoints } from "./http.utils.js";
import {
createOriginGuard,
normalizeAllowedOrigins,
type OriginGuard,
} from "./originValidation.js";

const mcpClientIdSchema = z
.string({ message: "Missing required mcp-client-id header" })
Expand All @@ -34,8 +39,11 @@ export class FastifyTransport {
cors: boolean;
logger: boolean;
app?: FastifyInstance;
allowedOrigins?: string[];
customEndpoints?: CustomEndpointDefinition[];
};
/** Route options for the `/mcp` endpoints; carries the Origin check when configured. */
private readonly mcpRouteOptions: { onRequest?: OriginGuard };
private readonly defaultManager: DynamicToolManager;
private readonly createBundle: CreateBundleCallback;
private readonly sessionContextResolver?: SessionContextResolver;
Expand Down Expand Up @@ -74,8 +82,14 @@ export class FastifyTransport {
cors: options.cors ?? true,
logger: options.logger ?? false,
app: options.app,
allowedOrigins: options.allowedOrigins
? normalizeAllowedOrigins(options.allowedOrigins)
: undefined,
customEndpoints: options.customEndpoints,
};
this.mcpRouteOptions = this.options.allowedOrigins
? { onRequest: createOriginGuard(this.options.allowedOrigins) }
: {};
this.configSchema = configSchema;
}

Expand All @@ -95,6 +109,7 @@ export class FastifyTransport {
cors(value: boolean) { opts.cors = value; return builder; },
logger(value: boolean) { opts.logger = value; return builder; },
app(value: FastifyInstance) { opts.app = value; return builder; },
allowedOrigins(value: readonly string[]) { opts.allowedOrigins = value; return builder; },
customEndpoints(value: CustomEndpointDefinition[]) { opts.customEndpoints = value; return builder; },
configSchema(value: object) { _configSchema = value; return builder; },
sessionContextResolver(value: SessionContextResolver) { _sessionContextResolver = value; return builder; },
Expand All @@ -108,7 +123,7 @@ export class FastifyTransport {
if (this.app) return;
const app = this.options.app ?? Fastify({ logger: this.options.logger });
if (this.options.cors) {
await app.register(cors, { origin: true });
await app.register(cors, { origin: this.options.allowedOrigins ?? true });
}

const base = this.normalizeBasePath(this.options.basePath);
Expand Down Expand Up @@ -184,6 +199,7 @@ export class FastifyTransport {
private registerMcpPostEndpoint(app: FastifyInstance, base: string): void {
app.post(
`${base}/mcp`,
this.mcpRouteOptions,
async (req: FastifyRequest, reply: FastifyReply) => {
const parseResult = mcpClientIdSchema.safeParse(
req.headers["mcp-client-id"]
Expand Down Expand Up @@ -272,7 +288,7 @@ export class FastifyTransport {
* @param base - Base path for routes
*/
private registerMcpGetEndpoint(app: FastifyInstance, base: string): void {
app.get(`${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => {
app.get(`${base}/mcp`, this.mcpRouteOptions, async (req: FastifyRequest, reply: FastifyReply) => {
const clientIdHeader = (
req.headers["mcp-client-id"] as string | undefined
)?.trim();
Expand Down Expand Up @@ -310,6 +326,7 @@ export class FastifyTransport {
private registerMcpDeleteEndpoint(app: FastifyInstance, base: string): void {
app.delete(
`${base}/mcp`,
this.mcpRouteOptions,
async (req: FastifyRequest, reply: FastifyReply) => {
const clientIdHeader = (
req.headers["mcp-client-id"] as string | undefined
Expand Down
19 changes: 19 additions & 0 deletions src/http/http.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ export interface FastifyTransportOptions {
cors?: boolean;
logger?: boolean;
app?: FastifyInstance;
/**
* Origins allowed to call the `/mcp` endpoints (POST, GET, DELETE). The MCP
* spec requires servers to validate `Origin` so a malicious web page cannot
* reach a local server through DNS rebinding.
*
* - Not set (default): no check, as in earlier versions.
* - Set: a request whose `Origin` header is not listed gets `403`. Requests
* without an `Origin` header (CLI, SDK and server-to-server clients) are
* always allowed. `[]` therefore allows only clients that send no `Origin`.
*
* Entries are origins such as `"https://app.example.com"`, compared by
* scheme, host and port; case and default ports are normalized. Invalid
* entries throw when the transport is created. When `cors` is on, only
* these origins are also granted CORS access.
*
* Custom endpoints, `/healthz`, `/tools` and `/.well-known/mcp-config` are
* not covered.
*/
allowedOrigins?: readonly string[];
/**
* Optional custom HTTP endpoints to register alongside MCP protocol endpoints.
* Allows adding REST-like endpoints with Zod validation and type inference.
Expand Down
82 changes: 82 additions & 0 deletions src/http/originValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { FastifyReply, FastifyRequest } from "fastify";

/**
* Reduces an origin string to `scheme://host[:port]` in lower case, with
* default ports dropped. Built from `protocol` and `host` rather than
* `URL.origin`, which is `"null"` for non-http schemes, so entries like
* `vscode-webview://abc` can be listed. The host is lower-cased explicitly
* because `URL` keeps its case for non-http schemes.
*
* @param value - Raw origin, such as the `Origin` header or an allowlist entry
* @returns The canonical origin, or `undefined` when `value` is not an origin
*/
export function normalizeOrigin(value: string): string | undefined {
try {
const url = new URL(value);
if (!url.host) return undefined;
return `${url.protocol}//${url.host.toLowerCase()}`;
} catch {
return undefined;
}
}

/**
* Normalizes an allowlist, failing fast on entries that are not origins.
*
* @param allowedOrigins - Entries as configured by the caller
* @returns The canonical origins
* @throws When an entry cannot be parsed as an origin
*/
export function normalizeAllowedOrigins(
allowedOrigins: readonly string[]
): string[] {
return allowedOrigins.map((entry) => {
const normalized = normalizeOrigin(entry);
if (!normalized) {
throw new Error(
`Invalid allowedOrigins entry "${entry}": expected an origin such as "https://app.example.com"`
);
}
return normalized;
});
}

/** Fastify `onRequest` hook that answers `403` for a disallowed `Origin`. */
export type OriginGuard = (
req: FastifyRequest,
reply: FastifyReply
) => Promise<FastifyReply | undefined>;

/**
* Builds a Fastify `onRequest` hook that enforces the MCP spec's Origin check
* (protection against DNS rebinding) on the MCP endpoints.
*
* Requests without an `Origin` header are always allowed: they come from
* non-browser clients, which are not the threat this check addresses. A
* request that carries an `Origin` outside the allowlist gets `403`. The
* header must already be a bare origin (no path, userinfo, query or explicit
* default port), which is what browsers send; anything looser is refused.
* Letter case is ignored.
*
* @param allowedOrigins - Canonical origins from {@link normalizeAllowedOrigins}
* @returns The hook
*/
export function createOriginGuard(allowedOrigins: readonly string[]): OriginGuard {
const allowed = new Set(allowedOrigins);
return async function originGuard(
req: FastifyRequest,
reply: FastifyReply
): Promise<FastifyReply | undefined> {
const header = req.headers.origin;
if (header === undefined) return undefined;
const origin = normalizeOrigin(header);
if (origin && header.toLowerCase() === origin && allowed.has(origin)) {
return undefined;
}
return reply.code(403).send({
jsonrpc: "2.0",
error: { code: -32000, message: "Forbidden: Origin not allowed" },
id: null,
});
};
}
1 change: 1 addition & 0 deletions src/permissions/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Provides per-client access control for toolsets. Supports header-based and confi
2. **Header lookup is case-insensitive** — Per RFC 7230.
3. **Fail-secure resolution** — Invalid permissions return empty array, not error. Client gets no toolsets.
4. **All toolsets fail = throw** — If every requested toolset fails to enable, it's likely a config error. Partial success continues with warning.
5. **Same Origin check as FastifyTransport** — `allowedOrigins` guards `/mcp` POST/GET/DELETE via the shared `src/http/originValidation.ts`; keep the two transports in step.

## Resolution Priority (config source)

Expand Down
21 changes: 19 additions & 2 deletions src/permissions/PermissionAwareFastifyTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import type {
} from "./permissions.types.js";
import type { CustomEndpointDefinition } from "../http/http.types.js";
import { registerCustomEndpoints } from "../http/http.utils.js";
import {
createOriginGuard,
normalizeAllowedOrigins,
type OriginGuard,
} from "../http/originValidation.js";

const mcpClientIdSchema = z
.string({ message: "Missing required mcp-client-id header" })
Expand All @@ -33,8 +38,11 @@ export class PermissionAwareFastifyTransport {
cors: boolean;
logger: boolean;
app?: FastifyInstance;
allowedOrigins?: string[];
customEndpoints?: CustomEndpointDefinition[];
};
/** Route options for the `/mcp` endpoints; carries the Origin check when configured. */
readonly #mcpRouteOptions: { onRequest?: OriginGuard };
private readonly defaultManager: DynamicToolManager;
private readonly createPermissionAwareBundle: (
context: ClientRequestContext
Expand Down Expand Up @@ -73,8 +81,14 @@ export class PermissionAwareFastifyTransport {
cors: options.cors ?? true,
logger: options.logger ?? false,
app: options.app,
allowedOrigins: options.allowedOrigins
? normalizeAllowedOrigins(options.allowedOrigins)
: undefined,
customEndpoints: options.customEndpoints,
};
this.#mcpRouteOptions = this.options.allowedOrigins
? { onRequest: createOriginGuard(this.options.allowedOrigins) }
: {};
this.configSchema = configSchema;
}

Expand All @@ -92,6 +106,7 @@ export class PermissionAwareFastifyTransport {
cors(value: boolean) { opts.cors = value; return builder; },
logger(value: boolean) { opts.logger = value; return builder; },
app(value: FastifyInstance) { opts.app = value; return builder; },
allowedOrigins(value: readonly string[]) { opts.allowedOrigins = value; return builder; },
customEndpoints(value: CustomEndpointDefinition[]) { opts.customEndpoints = value; return builder; },
configSchema(value: object) { _configSchema = value; return builder; },
build() { return new PermissionAwareFastifyTransport(_defaultManager, _createPermissionAwareBundle, opts, _configSchema); },
Expand All @@ -103,7 +118,7 @@ export class PermissionAwareFastifyTransport {
if (this.app) return;
const app = this.options.app ?? Fastify({ logger: this.options.logger });
if (this.options.cors) {
await app.register(cors, { origin: true });
await app.register(cors, { origin: this.options.allowedOrigins ?? true });
}

const base = this.#normalizeBasePath(this.options.basePath);
Expand Down Expand Up @@ -270,6 +285,7 @@ export class PermissionAwareFastifyTransport {
#registerMcpPostEndpoint(app: FastifyInstance, base: string): void {
app.post(
`${base}/mcp`,
this.#mcpRouteOptions,
async (req: FastifyRequest, reply: FastifyReply) => {
// Validate mcp-client-id header
const parseResult = mcpClientIdSchema.safeParse(
Expand Down Expand Up @@ -376,7 +392,7 @@ export class PermissionAwareFastifyTransport {
* @param base - Base path for routes
*/
#registerMcpGetEndpoint(app: FastifyInstance, base: string): void {
app.get(`${base}/mcp`, async (req: FastifyRequest, reply: FastifyReply) => {
app.get(`${base}/mcp`, this.#mcpRouteOptions, async (req: FastifyRequest, reply: FastifyReply) => {
const clientIdHeader = (
req.headers["mcp-client-id"] as string | undefined
)?.trim();
Expand Down Expand Up @@ -413,6 +429,7 @@ export class PermissionAwareFastifyTransport {
#registerMcpDeleteEndpoint(app: FastifyInstance, base: string): void {
app.delete(
`${base}/mcp`,
this.#mcpRouteOptions,
async (req: FastifyRequest, reply: FastifyReply) => {
const clientIdHeader = (
req.headers["mcp-client-id"] as string | undefined
Expand Down
6 changes: 6 additions & 0 deletions src/permissions/permissions.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export interface PermissionAwareFastifyTransportOptions {
cors?: boolean;
logger?: boolean;
app?: import("fastify").FastifyInstance;
/**
* Origins allowed to call the `/mcp` endpoints. Not set means no check;
* requests without an `Origin` header are always allowed. See
* `FastifyTransportOptions.allowedOrigins`.
*/
allowedOrigins?: readonly string[];
/**
* Optional custom HTTP endpoints to register alongside MCP protocol endpoints.
* Allows adding REST-like endpoints with Zod validation and type inference.
Expand Down
3 changes: 3 additions & 0 deletions src/server/createMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ function buildTransport(
if (options.http?.app) {
builder.app(options.http.app);
}
if (options.http?.allowedOrigins) {
builder.allowedOrigins(options.http.allowedOrigins);
}
if (options.http?.customEndpoints) {
builder.customEndpoints(options.http.customEndpoints);
}
Expand Down
3 changes: 3 additions & 0 deletions src/server/createPermissionBasedMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ function buildPermissionTransport(
if (options.http?.app) {
builder.app(options.http.app);
}
if (options.http?.allowedOrigins) {
builder.allowedOrigins(options.http.allowedOrigins);
}
if (options.http?.customEndpoints) {
builder.customEndpoints(options.http.customEndpoints);
}
Expand Down
Loading
Loading