@mcp-layer/manager
Reusable MCP session manager with identity-based session reuse, TTL expiration, and LRU eviction.
This package is transport-agnostic. It can be used with REST plugins, CLIs, job workers, or any runtime that needs controlled MCP session lifecycles.
Table of Contents
Installation
pnpm add @mcp-layer/manager
npm install @mcp-layer/manager
yarn add @mcp-layer/manager
What This Package Provides
@mcp-layer/manager solves session lifecycle concerns that are separate from any single framework:
- Identity derivation per incoming request/context.
- Session reuse for repeated identities.
- Time-based eviction (
ttl) for stale sessions.
- Capacity-based eviction (
max) using least-recently-used policy.
- Graceful shutdown via
close().
- Runtime visibility via
stats().
API Reference
createManager(options)
Creates a manager instance.
createManager(options: {
max?: number;
ttl?: number;
sharedKey?: string;
auth?: {
mode?: 'optional' | 'required' | 'disabled';
header?: string;
scheme?: 'bearer' | 'basic' | 'raw';
};
identify?: (request) =>
| string
| {
key: string;
auth?: { scheme?: 'bearer' | 'basic' | 'raw'; token?: string; header?: string };
shared?: boolean;
};
factory: (ctx: {
identity: {
key: string;
auth: { scheme: 'bearer' | 'basic' | 'raw'; token: string; header: string } | null;
shared: boolean;
};
request: FastifyRequest;
}) => Promise<Session>;
now?: () => number;
}) => {
get(request): Promise<Session>;
stats(): {
size: number;
max: number;
ttl: number;
evictions: number;
hits: number;
misses: number;
keys: string[];
};
close(): Promise<void>;
}
options fields
max | number | 10 | no | Maximum cached sessions. When exceeded, oldest LRU session is evicted and closed. |
ttl | number | 300000 | no | Idle timeout in milliseconds. Expired sessions are closed and recreated on next access. |
sharedKey | string | "shared" | no | Identity key used when auth is optional and missing (or disabled). |
auth.mode | 'optional' | 'required' | 'disabled' | 'optional' | no | Controls whether identity must come from auth headers. |
auth.header | string | 'authorization' | no | Header name used for auth parsing. Case-insensitive. |
auth.scheme | 'bearer' | 'basic' | 'raw' | 'bearer' | no | Header parsing strategy when identify is not provided. |
identify | function | undefined | no | Custom identity derivation. Overrides built-in auth parsing. |
factory | function | none | yes | Creates a Session for an identity. Must return @mcp-layer/session Session. |
now | function | Date.now | no | Clock source, mainly for deterministic tests. |
Manager methods
get | get(request) => Promise<Session> | Resolves identity, returns cached session when possible, otherwise creates and caches a new session. |
stats | stats() => { size, max, ttl, evictions, hits, misses, keys } | Returns in-memory pool statistics for observability and testing. |
close | close() => Promise<void> | Closes all tracked sessions and clears in-memory state. |
Error Behavior
Manager runtime errors are thrown as LayerError from @mcp-layer/error.
- Every error includes package + method source metadata.
- Every error includes a stable
reference id.
- Every error includes a generated
docs URL to this package README error section.
- Runtime references and full debugging playbooks are documented in Runtime Error Reference.
Identity Rules
When identify is not supplied, identity is derived from configured auth settings:
auth.mode = 'disabled': always uses sharedKey.
auth.mode = 'optional': uses auth header when present, otherwise sharedKey.
auth.mode = 'required': missing header raises a documented LayerError from identity.
Scheme handling:
bearer: expects Authorization: Bearer <token>.
basic: expects Authorization: Basic <base64>.
raw: takes the full header value as token.
Example: Default Auth Parsing
This example shows the default identity path (Authorization header) with per-identity session creation. This matters when multiple callers should not always share one MCP session.
Expected behavior: requests with the same bearer token reuse one session; a different token creates a different session.
import { createManager } from '@mcp-layer/manager';
import { connect } from '@mcp-layer/connect';
import { load } from '@mcp-layer/config';
const config = await load();
const manager = createManager({
max: 10,
ttl: 5 * 60 * 1000,
factory: async function factory(ctx) {
const entry = config.get('server-name');
if (!entry) {
throw new Error('Server not found.');
}
const token = ctx.identity.auth ? ctx.identity.auth.token : undefined;
return connect(config, entry.name, {
env: token ? { MCP_AUTH_TOKEN: token } : undefined
});
}
});
Example: Custom Identity Strategy
This example shows tenant-based routing without forcing header auth parsing. This matters when identity comes from app-level metadata instead of authorization headers.
Expected behavior: requests with the same tenant key share one session; requests without tenant fallback to shared identity.
const manager = createManager({
identify: function identify(request) {
const tenant = request.headers['x-tenant-id'];
if (!tenant || typeof tenant !== 'string') {
return 'shared';
}
return {
key: `tenant:${tenant}`,
shared: false
};
},
factory: async function factory(ctx) {
return connect(config, 'tenant-server');
}
});
Example: Integration with a Plugin
This example shows how to pass the manager into another package that resolves sessions per request.
Expected behavior: the host plugin calls manager.get(request) internally and reuses or creates sessions according to manager policy.
app.register(mcpRest, {
session,
manager
});
Shutdown
Call close() during process shutdown so cached sessions are closed cleanly.
await manager.close();
Runtime Error Reference
This section is written for high-pressure debugging moments. Each entry maps to a specific createManager(...) validation or identity-resolution branch.
factory must return a Session instance.
Thrown from: get
This happens when your factory(ctx) returns something other than @mcp-layer/session Session. Manager cache/storage and route integration require real Session instances.
Step-by-step resolution:
- Inspect the return value of
factory(ctx) and verify its constructor/type.
- Ensure the factory awaits
connect(...) or attach(...) rather than returning raw clients.
- Reject non-Session returns in your own factory wrapper.
- Add tests for one invalid factory return and one valid Session return.
Fix Example: return Session objects from manager factory
const manager = createManager({
factory: async function makeSession() {
return connect(config, 'local-dev');
}
});
const session = await manager.get(request);
Thrown from: identity
This happens when auth.mode is set to required and the configured auth header is missing from the incoming request.
Step-by-step resolution:
- Confirm manager auth config (
mode, header, scheme) used at runtime.
- Check upstream proxy/gateway forwarding for the authorization header.
- Ensure requests include the required header when manager auth is
required.
- Add tests for missing-header rejection and valid-header acceptance.
Fix Example: send required auth header for manager identity
await fastify.inject({
method: 'GET',
url: '/v1/tools/weather.get',
headers: { authorization: 'Bearer test-token' }
});
Thrown from: identity
This happens when manager auth scheme is configured as basic, but the request header is not formatted as Basic <base64>.
Step-by-step resolution:
- Verify manager auth config uses
scheme: "basic" intentionally.
- Check header format and ensure it starts with
Basic .
- Encode credentials as Base64 (
username:password) before sending.
- Add tests for incorrect scheme prefix and valid Basic header parsing.
Fix Example: send correctly formatted Basic auth header
const credentials = Buffer.from('user:pass').toString('base64');
await fastify.inject({
method: 'GET',
url: '/v1/tools/example',
headers: { authorization: `Basic ${credentials}` }
});
Thrown from: identity
This happens when manager auth scheme is bearer, but the header does not match Bearer <token>.
Step-by-step resolution:
- Confirm manager config uses
scheme: "bearer".
- Check clients/proxies are not rewriting the header prefix.
- Ensure token is sent as
Bearer <token> exactly.
- Add tests for malformed bearer headers and valid token headers.
Fix Example: send Bearer token with correct prefix
await fastify.inject({
method: 'GET',
url: '/v1/tools/example',
headers: { authorization: 'Bearer abc123' }
});
identify() must return a string or { key, auth } object.
Thrown from: identity
This happens when a custom identify(request) hook returns an unsupported shape (for example undefined, number, or object missing key).
Step-by-step resolution:
- Review your custom
identify implementation return type.
- Return either a string key or
{ key, auth?, shared? }.
- If supplying auth metadata, include
token under auth.
- Add tests for both supported return shapes.
Fix Example: implement identify with a supported return shape
const manager = createManager({
identify: function identify(request) {
const tenant = String(request.headers['x-tenant-id'] ?? 'shared');
return { key: `tenant:${tenant}`, shared: false };
},
factory: makeSession
});
max must be a positive number.
Thrown from: normalize
This happens when createManager receives max <= 0, NaN, or non-finite values. max controls cache capacity and must be a positive number.
Step-by-step resolution:
- Inspect the source of
max (env/config flags).
- Parse/coerce to number and validate positivity before manager creation.
- Set a sensible upper bound for your workload to avoid churn.
- Add tests for invalid (
0, -1, NaN) and valid values.
Fix Example: validate manager max before createManager
const max = Number(process.env.MCP_SESSION_MAX ?? 10);
if (!Number.isFinite(max) || max <= 0)
throw new Error('MCP_SESSION_MAX must be a positive number.');
const manager = createManager({ max, ttl: 300000, factory: makeSession });
Session manager options are required.
Thrown from: normalize
This happens when createManager(...) is called with undefined, null, or a non-object value. Manager initialization requires an options object.
Step-by-step resolution:
- Check the code path building manager options.
- Ensure options object construction does not short-circuit to
undefined.
- Add a local assertion before calling
createManager.
- Add tests for missing-options and valid-options initialization.
Fix Example: pass an explicit options object to createManager
const manager = createManager({
factory: makeSession,
max: 10,
ttl: 300000
});
Session manager requires a factory function.
Thrown from: normalize
This happens when manager options do not include a callable factory. Session creation is delegated entirely to this function.
Step-by-step resolution:
- Verify
factory exists and is a function.
- Ensure dependency injection/config wiring does not pass factory results instead of function references.
- Keep factory async and return
Session.
- Add tests that reject missing factory and accept valid factory functions.
Fix Example: pass a factory callback (not a precomputed value)
const manager = createManager({
factory: async function makeSession(ctx) {
return connect(config, ctx.identity.key);
}
});
ttl must be a positive number.
Thrown from: normalize
This happens when ttl is <= 0, NaN, or non-finite. Session entries use ttl for eviction; invalid values break expiration semantics.
Step-by-step resolution:
- Trace TTL input from environment/config to manager setup.
- Parse as number and enforce
ttl > 0.
- Choose a TTL aligned with upstream session cost and traffic patterns.
- Add tests for invalid TTL values and expected expiration behavior.
Fix Example: validate ttl before manager initialization
const ttl = Number(process.env.MCP_SESSION_TTL_MS ?? 300000);
if (!Number.isFinite(ttl) || ttl <= 0)
throw new Error('MCP_SESSION_TTL_MS must be a positive number.');
const manager = createManager({ factory: makeSession, max: 10, ttl });
License
MIT