@mcp-layer/rest
v1.2.3
Published
Fastify REST plugin that layers HTTP APIs on MCP servers with OpenAPI 3.1 docs, validation, and resilience.
Maintainers
Readme
@mcp-layer/rest
Fastify plugin for exposing MCP servers over REST.
mcpRest takes one or more MCP Session instances and registers HTTP routes that proxy MCP tools, prompts, and resources. A Session is the client-side handle you get from @mcp-layer/attach (in-process server) or @mcp-layer/connect (remote server). A Fastify “app” in this context is just a Fastify instance that owns routing, lifecycle hooks, and configuration.
Shared Runtime
@mcp-layer/rest now consumes shared adapter primitives from @mcp-layer/gateway for:
- runtime option normalization,
- catalog bootstrap and validator registration,
- manager/session request resolution,
- breaker-backed MCP execution,
- telemetry helpers and call context instrumentation.
This keeps REST behavior stable while ensuring GraphQL and REST adapters share one runtime backbone instead of duplicating business logic.
Table of Contents
- Installation
- Quick Start (In-Process Server)
- Connect From Discovered Config
- What Gets Exposed
- Plugin Options
- Configuration Example
- True Proxy Mode (Proxy Session Manager)
- Performance Tips
- Validation
- Error Handling
- Error Catalog
- Resilience
- Observability
- Composition with Fastify Ecosystem
- Runtime Error Reference
Installation
pnpm add @mcp-layer/rest
# or
npm install @mcp-layer/rest
# or
yarn add @mcp-layer/restQuick Start (In-Process Server)
import Fastify from 'fastify';
import { attach } from '@mcp-layer/attach';
import mcpRest from '@mcp-layer/rest';
import { createServer } from './mcp-server.js';
const server = createServer();
const session = await attach(server, 'primary');
const app = Fastify({ logger: true });
await app.register(mcpRest, { session });
await app.listen({ port: 3000 });This registers REST routes on the Fastify instance using the MCP catalog returned by the session.
You can also provide a precomputed catalog when the live session cannot be attached safely during plugin registration.
Connect From Discovered Config
This example uses @mcp-layer/config to discover local MCP server definitions and then connects using @mcp-layer/connect.
import Fastify from 'fastify';
import { load } from '@mcp-layer/config';
import { connect } from '@mcp-layer/connect';
import mcpRest from '@mcp-layer/rest';
const config = await load();
const entry = config.get('my-server');
if (!entry) {
throw new Error('Server "my-server" not found in config');
}
const session = await connect(config, entry.name);
const app = Fastify({ logger: true });
await app.register(mcpRest, { session });
await app.listen({ port: 3000 });See:
packages/attach/README.md(for in-process sessions)packages/connect/README.md(for stdio/remote sessions)packages/config/README.md(for configuration discovery)packages/openapi/README.md(for OpenAPI generation)
What Gets Exposed
For each session, the plugin registers routes under a versioned prefix (default: /v{major} or /v0). The list below shows the routes after the prefix is applied:
POST /{toolName}to execute a toolPOST /prompts/{promptName}to render a promptGET /{resourcePath}to read a resourceGET /resource-templatesto list resource templatesGET /{templatedPath}to read a dynamic resource template routeGET /openapi.jsonto serve the OpenAPI 3.1 document
/openapi.json is the standard OpenAPI endpoint. swagger.json is the legacy name and is not used here.
Resource templates are registered as dynamic HTTP routes. A template like template://note/{name} becomes GET /template/note/{name}, which expands {name} into a concrete URI before reading the resource. The template list is still available at GET /resource-templates for discovery and documentation tooling.
Plugin Options
Top-level options:
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| session | Session or Session[] | required unless using manager + catalog | MCP session(s) to expose over REST. |
| catalog | { server?, items? } | optional | Precomputed bootstrap catalog for route and OpenAPI registration. Use this when the live session should only be attached lazily at request time. |
| manager | { get(request) } | optional | Session manager used to resolve per-request MCP sessions (true proxy mode). Requires session or catalog for bootstrap metadata. |
| prefix | string or (version, serverInfo, sessionName) => string | derived | Route prefix. Use a function to create per-session routes. |
| validation | object | see below | Validation and schema safety controls. |
| resilience | object | see below | Circuit breaker configuration. |
| telemetry | object | see below | OpenTelemetry API integration. |
| errors | object | see below | Error response behavior. |
| exposeOpenAPI | boolean | true | Serve /openapi.json for each session prefix. |
Validation options:
| Option | Default | Description |
| --- | --- | --- |
| trustSchemas | auto | Trust in-process/stdio schemas by default, distrust remote Streamable HTTP schemas. |
| maxSchemaDepth | 10 | Prevents deeply nested schemas from consuming memory/CPU. |
| maxSchemaSize | 102400 bytes | Limits schema payload size to reduce abuse risk. |
| maxPatternLength | 1000 | Limits regex length to reduce ReDoS risk. |
| maxToolNameLength | 64 | Rejects tool names that are too long to be safe path segments. |
| maxTemplateParamLength | 200 | Caps template parameter length to prevent oversized URIs. |
Resilience options (backed by opossum):
| Option | Default | Description |
| --- | --- | --- |
| enabled | true | Enables circuit breaker protection. |
| timeout | 30000 | Maximum time (ms) before a request is treated as failed. |
| errorThresholdPercentage | 50 | Error rate that opens the breaker. |
| resetTimeout | 30000 | Time (ms) before moving to half-open. |
| volumeThreshold | 5 | Minimum volume before breaker starts tripping. |
When resilience is enabled, the breaker timeout is also passed through to MCP requests so the underlying client does not keep long-running request timers alive after a breaker timeout.
Telemetry options:
| Option | Default | Description |
| --- | --- | --- |
| enabled | false | Enables OpenTelemetry API instrumentation. |
| serviceName | mcp-layer-rest | Service name used for metrics and spans. |
| api | undefined | Optional OpenTelemetry API instance. If provided, telemetry is automatically enabled. |
Error options:
| Option | Default | Description |
| --- | --- | --- |
| exposeDetails | false | If true, Problem Details responses include upstream error messages. |
Configuration Example
This example shows a multi-session setup with a prefix strategy that keeps per-server routes isolated while tightening validation and circuit breaker thresholds. It demonstrates how the REST layer can enforce consistent safety limits even when multiple servers are attached.
await app.register(mcpRest, {
session,
prefix(version, info, name) {
return `/mcp/${name}/${version}`;
},
validation: {
trustSchemas: 'auto',
maxSchemaDepth: 8,
maxSchemaSize: 65536,
maxPatternLength: 512,
maxToolNameLength: 64,
maxTemplateParamLength: 120
},
resilience: {
enabled: true,
timeout: 20000,
errorThresholdPercentage: 40,
resetTimeout: 15000,
volumeThreshold: 3
},
telemetry: {
api: otelApi,
serviceName: 'my-mcp-rest'
},
errors: {
exposeDetails: false
},
exposeOpenAPI: true
});True Proxy Mode (Proxy Session Manager)
When you need per-request authentication or want to scale beyond a single MCP connection, register a session manager. The session manager derives an identity from the HTTP request (typically Authorization) and caches MCP sessions per identity.
import { createManager } from '@mcp-layer/manager';
import { connect } from '@mcp-layer/connect';
import { load } from '@mcp-layer/config';
const config = await load();
const entry = config.get('my-server');
if (!entry) {
throw new Error('Server not found.');
}
const manager = createManager({
max: 10,
ttl: 5 * 60 * 1000,
factory: async function factory(ctx) {
const token = ctx.identity.auth ? ctx.identity.auth.token : undefined;
return connect(config, entry.name, {
env: token ? { MCP_AUTH_TOKEN: token } : undefined
});
}
});
await app.register(mcpRest, {
session,
manager: manager
});Notes:
- The REST plugin uses the provided
sessionorcatalogto build routes and OpenAPI. The manager is used for per-request execution. - The session manager must return sessions that match the same MCP server surface as the bootstrap
sessionorcatalog.
When a live attach must happen after Fastify is ready, bootstrap from a catalog and keep the manager lazy:
await app.register(mcpRest, {
catalog,
manager: {
async get() {
return attach(app, 'primary');
}
}
});Performance Tips
- Scale REST horizontally (multiple worker processes) before increasing MCP session counts. In benchmarks, the REST process saturates first.
- Disable or tune validation/telemetry when load testing to measure raw throughput.
- Use stdio/remote MCP servers for realistic load tests; in-process sessions are excellent for correctness but do not model real transport contention.
- See Fastify’s benchmarking guidance and published results:
- https://fastify.dev/docs/v5.7.x/Guides/Benchmarking/
- https://fastify.dev/benchmarks/
Validation
The plugin validates tool and prompt inputs with Ajv when schemas are trusted. For untrusted schemas, safety checks are applied and validation is skipped if a schema fails the safety limits.
Trust policy:
autotrusts in-process/stdio sessions and distrusts remote Streamable HTTP sessionstruealways trustsfalsenever trusts (skip validation)
Hardening notes:
maxToolNameLengthandmaxTemplateParamLengthguard the HTTP surface area by preventing extremely long path segments.- Schema limits (
maxSchemaDepth,maxSchemaSize,maxPatternLength) reduce the risk of abuse from untrusted schemas. Increase them cautiously if your catalog uses deeply nested schemas.
Error Handling
All errors return RFC 9457 Problem Details. MCP/JSON-RPC errors are mapped to HTTP status codes and include mcpErrorCode for debugging.
Tool execution errors (isError: true) return HTTP 502 with a Problem Details body and a toolError extension that preserves the original MCP payload.
By default, detail is a generic message. Set errors.exposeDetails to true when you want the upstream error text included.
Error Catalog
The type field in Problem Details responses maps to the error names below.
Contents
- error-validation
- error-not-found
- error-parse
- error-auth
- error-invalid-params
- error-timeout
- error-circuit-open
- error-internal
- error-conflict
- error-tool
error-validation
When it happens: request payload failed schema validation or a request parameter exceeded the configured limits.
Resolution: confirm the payload matches the tool or prompt input schema. Validate against the catalog to see which fields are required, and check validation limits if you are sending very large payloads or long path segments.
error-not-found
When it happens: requested tool, prompt, or resource was not found by the underlying server.
Resolution: list the server catalog (listTools, listPrompts, listResources, listResourceTemplates) and ensure the requested name or URI exists. For templates, verify the generated HTTP route matches the template you expect.
error-parse
When it happens: malformed JSON or request body parsing failure.
Resolution: ensure the request body is valid JSON and the Content-Type is application/json. If you send an empty body, make sure the endpoint expects it.
error-auth
When it happens: missing or invalid authorization when manager requires auth.
Resolution: send a valid Authorization header (for example Bearer <token>) or configure manager with auth.mode: "optional" when auth is not required.
error-invalid-params
When it happens: parameters failed server-side validation beyond schema validation.
Resolution: inspect server-specific constraints. Some servers require values that are not expressible in JSON Schema alone (e.g., referential integrity or capability checks).
error-timeout
When it happens: an upstream call exceeded the configured timeout.
Resolution: increase resilience.timeout, reduce downstream work, or address upstream latency with caching or batching.
error-circuit-open
When it happens: the circuit breaker is open and rejecting traffic.
Resolution: reduce request volume, inspect upstream health, and wait for resetTimeout to elapse. You can also temporarily disable the breaker to debug, then re-enable it.
error-internal
When it happens: unexpected server-side failure in the REST layer.
Resolution: check server logs and enable errors.exposeDetails temporarily for debugging. If the error is reproducible, capture the request ID for faster triage.
error-conflict
When it happens: upstream resource state conflict.
Resolution: re-read the resource state and retry with updated parameters. If the server supports versioning or optimistic locking, include the expected version.
error-tool
When it happens: a tool returned isError: true.
Resolution: inspect the toolError payload in the response to determine which input or state caused the tool to fail. If the tool emits structured fields, surface those to the caller so they can correct inputs.
Resilience
Circuit breaker support prevents cascading failures and supports half-open recovery. When open, requests fail fast with HTTP 503 and a Problem Details payload.
await app.register(mcpRest, {
session,
resilience: {
enabled: true,
timeout: 15000,
errorThresholdPercentage: 30,
resetTimeout: 10000,
volumeThreshold: 2
}
});Observability
Telemetry is opt-in. When enabled, the plugin uses the OpenTelemetry API (not SDK) and exposes:
mcp.call.duration(Histogram)mcp.call.errors(Counter)rest.validation.errors(Counter)rest.circuit.state(ObservableGauge)
import * as otelApi from '@opentelemetry/api';
await app.register(mcpRest, {
session,
telemetry: {
api: otelApi,
serviceName: 'mcp-rest'
}
});Composition with Fastify Ecosystem
This plugin is designed to compose with standard Fastify plugins:
@fastify/under-pressurefor health checks@fastify/corsfor CORS headers@fastify/helmetfor security headers@fastify/rate-limitfor rate limiting
Runtime Error Reference
This section is written for high-pressure debugging moments. Each entry maps to concrete REST route guards or runtime option validation branches. Shared option validation entries are delegated to @mcp-layer/gateway via validateRuntimeOptions(...).
"{option}" must be a positive number.
Thrown from: requirePositiveNumber
This happens when numeric validation options are <= 0, NaN, or non-finite. The plugin validates limits like schema depth/size, name length, and resilience timers.
Step-by-step resolution:
- Identify which option name is shown in
{option}. - Trace that value from env/config to plugin registration.
- Coerce to number and enforce
> 0before passing options. - Add tests for invalid and valid values for that exact option.
const maxToolNameLength = Number(process.env.MCP_MAX_TOOL_LEN ?? 64);
if (!Number.isFinite(maxToolNameLength) || maxToolNameLength <= 0)
throw new Error('MCP_MAX_TOOL_LEN must be a positive number');
await fastify.register(restPlugin, {
session,
validation: { maxToolNameLength }
});Invalid template expression "{expression}" in template "{template}".
Thrown from: toFastifyPath
This happens when a template expression resolves to an unusable parameter name after normalization (for example malformed braces or empty expression content).
Step-by-step resolution:
- Inspect
{template}and the failing{expression}from your resource template catalog. - Ensure each placeholder has a concrete name (
{slug}), not empty or malformed tokens. - Correct template definitions at the MCP server layer before exposing them via REST.
- Add validation tests for malformed template expressions.
// Invalid: mcp://docs/{}
const template = 'mcp://docs/{slug}';
registerTemplate(template);Template expression "{expression}" is not supported.
Thrown from: toFastifyPath
This happens when URI templates use unsupported RFC 6570 operators (+, #, ., /, ?, &, *, or comma forms). REST route conversion accepts simple {name} placeholders only.
Step-by-step resolution:
- Find
{expression}in the failing template. - Remove operator syntax and use plain placeholders.
- Move query/path expansion behavior into handler logic instead of template operators.
- Add tests for unsupported operator forms and supported simple forms.
// Unsupported: mcp://docs/{+slug}
const template = 'mcp://docs/{slug}';
registerTemplate(template);Template parameter "{parameter}" must be URL-safe.
Thrown from: toFastifyPath
This happens when a template parameter name contains invalid identifier characters and cannot be converted to Fastify route params safely.
Step-by-step resolution:
- Review placeholder names in URI templates.
- Use only URL-safe parameter identifiers (
A-Z,a-z,0-9,.,_,-). - Keep human-readable labels outside parameter names.
- Add template validation tests for bad and good parameter names.
// Invalid: mcp://docs/{team name}
const template = 'mcp://docs/{team_name}';
registerTemplate(template);validation.trustSchemas must be "auto", true, or false.
Thrown from: trustMode
This happens when validation.trustSchemas is set to anything other than "auto", true, or false.
Step-by-step resolution:
- Inspect plugin options and find
validation.trustSchemas. - Replace stringified booleans (
"true") with actual booleans or"auto". - Keep one explicit trust mode per environment.
- Add config tests for each allowed trust mode.
await fastify.register(restPlugin, {
session,
validation: { trustSchemas: 'auto' }
});errors must be an object.
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when options.errors is provided as a non-object (for example boolean or string). The REST plugin expects an object like { exposeDetails: boolean }.
Step-by-step resolution:
- Inspect the shape of the
errorsoption at registration. - Replace primitive values with an options object.
- Configure only supported keys under
errors. - Add option-shape tests for invalid and valid
errorsconfig.
await fastify.register(restPlugin, {
session,
errors: { exposeDetails: false }
});manager does not support multiple sessions. Register multiple plugins instead.
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when manager is provided together with session as an array. Manager mode resolves sessions per request and only supports a single bootstrap session or a single bootstrap catalog.
Step-by-step resolution:
- If using
manager, pass a singlesessionobject or a singlecatalogobject. - If you need multiple static sessions, remove
managerand register multiple plugin instances. - Keep manager-backed and multi-session modes separate in architecture.
- Add tests for both registration modes.
await fastify.register(restPlugin, {
session: bootstrapSession,
manager
});manager must be an object with a get(request) function.
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when manager is not an object exposing get(request). The plugin calls manager.get to resolve request-scoped sessions.
Step-by-step resolution:
- Confirm
managervalue is an object. - Implement
async get(request)that returns aSession. - Optionally provide
close()for shutdown cleanup. - Add tests validating manager contract shape.
const manager = {
async get(request) {
return resolveSessionForRequest(request);
}
};prefix must be a string or function.
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when prefix is neither a string nor a function. REST version/prefix routing can only be configured with those two forms.
Step-by-step resolution:
- Use a string prefix (
/v1) for static routing. - Use a function
(version, info, name) => stringfor dynamic prefixes. - Remove unsupported prefix types from env/config injection.
- Add tests for both allowed forms.
await fastify.register(restPlugin, {
session,
prefix: function prefix(version, info, name) {
return `/${version}/${name}`;
}
});session or catalog is required when manager is provided (used for catalog bootstrap).
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when manager mode is enabled but neither a bootstrap session nor a bootstrap catalog is provided. The plugin still needs metadata to build routes and OpenAPI at startup.
Step-by-step resolution:
- Provide a bootstrap
sessionorcatalogalongsidemanager. - Ensure the bootstrap session is connected before plugin registration, or ensure the bootstrap catalog matches the manager-provided session surface.
- Keep manager responsible for per-request switching, not initial catalog extraction.
- Add startup tests for manager mode with and without bootstrap metadata.
await fastify.register(restPlugin, {
session: bootstrapSession,
manager
});await fastify.register(restPlugin, {
catalog,
manager
});catalog must be an object.
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when catalog is passed as a primitive, array, or serialized payload instead of the composed catalog object the REST plugin expects at bootstrap.
Step-by-step resolution:
- Pass the catalog root as a plain object.
- Do not pass stringified JSON or an
itemsarray by itself. - Ensure the catalog shape matches
serverplusitemsmetadata produced by extraction/composition. - Add registration tests that reject malformed catalog bootstrap values.
await fastify.register(restPlugin, {
catalog: {
server: {
info: { name: 'example-server', version: '1.0.0' }
},
items: []
},
manager
});session or manager option is required.
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when plugin options include neither session nor manager. REST cannot expose MCP endpoints without at least one session source.
Step-by-step resolution:
- Supply a connected
sessionfor static mode, ormanagerplus bootstrapsession/catalogfor dynamic mode. - Verify DI wiring does not drop these options before registration.
- Add startup assertions for required plugin options.
- Add tests that confirm registration fails fast without session sources.
await fastify.register(restPlugin, { session });validation must be an object.
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when validation is provided as a non-object. The plugin expects a validation config object with limit/trust fields.
Step-by-step resolution:
- Ensure
validationis an object literal. - Move validation-related keys under
validationinstead of top-level options. - Keep option values typed correctly (numbers/booleans/allowed strings).
- Add tests for malformed and valid validation option objects.
await fastify.register(restPlugin, {
session,
validation: {
trustSchemas: 'auto',
maxToolNameLength: 64
}
});normalizeError must be a function.
Thrown from: validateRuntimeOptions (via @mcp-layer/gateway)
This happens when normalizeError is provided as a non-function value. REST expects a callable custom normalizer when overriding default error mapping.
Step-by-step resolution:
- Ensure
normalizeErroris a function reference. - Keep the signature compatible with runtime usage (
error,instance,requestId,options). - Remove object/string placeholders from config loaders.
- Add tests validating custom normalization wiring.
await fastify.register(restPlugin, {
session,
normalizeError: function normalizeError(error, instance, requestId, options) {
return mapToProblem(error, instance, requestId, options);
}
});Tool name "{tool}" exceeds maximum length of {maxLength}.
Thrown from: validateSegmentName
This happens when an MCP tool name exceeds the configured maximum route segment length (validation.maxToolNameLength).
Step-by-step resolution:
- Check
{maxLength}from plugin validation settings. - Find tool names from
tools/listthat exceed that limit. - Shorten tool identifiers at MCP server registration time.
- Add tests for long-name rejection and acceptable-name registration.
const max = 64;
if (toolName.length > max)
throw new Error(`Tool name "${toolName}" exceeds ${max} chars`);
server.registerTool(toolName, meta, handler);Tool name "{tool}" must be URL-safe (letters, digits, ".", "_", "-").
Thrown from: validateSegmentName
This happens when a tool name contains characters that cannot be used safely in REST route path segments.
Step-by-step resolution:
- Validate tool names against
^[a-z0-9._-]+$(case-insensitive). - Remove spaces, slashes, colons, and query-style characters.
- Keep display labels in description fields, not in tool IDs.
- Add tests for invalid characters and sanitized names.
const toolName = rawName.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9._-]/g, '');
if (!toolName)
throw new Error('Tool name is empty after normalization');
server.registerTool(toolName, meta, handler);Tool name must be a non-empty string.
Thrown from: validateSegmentName
This happens when the tool name is missing or not a string. Route registration requires a non-empty string key for each tool.
Step-by-step resolution:
- Inspect tool definitions produced by your MCP server/catalog extraction.
- Ensure every tool has a string
name. - Reject/skip malformed tool entries before REST route registration.
- Add tests for missing-name and valid-name tool definitions.
if (typeof tool.name !== 'string' || tool.name.length === 0)
throw new Error('Each MCP tool must declare a non-empty name');
server.registerTool(tool.name, tool.meta, tool.handler);Tool name "{tool}" conflicts with reserved path. Reserved paths: {reservedPaths}
Thrown from: validateToolName
This happens when a tool name collides with reserved REST routes (prompts, resource-templates, openapi.json) or template-derived path segments.
Step-by-step resolution:
- Compare the failing name against
{reservedPaths}. - Rename tool identifiers to avoid route namespace conflicts.
- Re-run route registration after renaming and verify no overlap with template prefixes.
- Add tests that assert reserved names are rejected.
const blocked = new Set(['prompts', 'resource-templates', 'openapi.json']);
if (blocked.has(toolName))
throw new Error(`Tool name "${toolName}" is reserved by REST routes`);
server.registerTool(toolName, meta, handler);License
MIT
