@mantlejs/mcp
Expose Mantle services as Model Context Protocol tools — an AI agent gets one tool per exposed service method, with input schemas derived from your TypeBox schemas and query capabilities, and every call runs the service’s full hook pipeline. There is no bypass: authentication, validation, and events apply to agents exactly as they do to REST callers.
Installation
Section titled “Installation”npm install @mantlejs/mcp@mantlejs/mantle is a peer dependency. For transport: "http", an HTTP transport (@mantlejs/express, @mantlejs/koa, or @mantlejs/http) must be configured first.
Concepts
Section titled “Concepts”Deny-by-default expose map
Section titled “Deny-by-default expose map”Nothing is exposed unless you say so. services is required and maps a service path to the methods that become tools:
mcp({ transport: "http", services: { articles: true, // every method registered in app.use() users: ["find", "get"], // read-only over MCP },});trueexposes all of the service’s registered methods — never more thanapp.use()registered.- An unknown path or method fails the boot (
listen()/startMcp()) with aBadRequestnaming it. services: "*"exposes every service registered aftermcp()— a deliberate, greppable escape hatch for prototyping.- With
events: true, only expose-map services get an event resource; hidden services never leak.
One tool per method
Section titled “One tool per method”Tools are generated from ServiceHandle.describe() and named {path}_{method} (users_find, blog_posts_create, custom methods included):
findtakes a structuredquery(where/limit/skip/sort/select). Thewhereoperator set is constrained to the adapter’sdescribe().capabilities.operators— an agent is never offered$ilikeagainst an adapter that rejects it.create/update/patchtakedatatyped by the schema attached inapp.use()(patchgets the all-optional variant); services without a schema still get tools with a generic object schema.get/removetakeid;update/removedescriptions carry destructive-operation notes.
Hook-pipeline dispatch
Section titled “Hook-pipeline dispatch”Every tool call goes through service.dispatch() — the same entry point the HTTP transports use — with params.provider = "mcp". Hooks can therefore apply agent-specific policy:
app.service("articles").hooks({ before: { remove: [ (ctx) => { if (ctx.params.provider === "mcp") throw new Forbidden("Agents may not delete articles"); return ctx; }, ], },});Errors come back as MCP tool errors carrying MantleError.toJSON() — name, message, code, data, and hint — so an agent can read what went wrong and adjust.
Result limits
Section titled “Result limits”find results are clamped: limit = min(requested ?? defaultLimit, maxLimit) (defaults 25 / 100, configurable via query). The clamp is advertised in the generated schema (maximum, default in the description), and a truncated page carries a note telling the agent to page with skip/limit and trim fields with select.
Authentication
Section titled “Authentication”- HTTP: a
Bearertoken on the request is verified with the configured auth engine (@mantlejs/auth); the resolved user lands onparams.userfor every call in the session, and theauthorizationheader is passed through soauthenticate("jwt")hooks work unchanged. No/invalid token → the session is anonymous and each service’s own auth hooks reject. The MCP layer makes no auth decisions of its own. - stdio: carries no HTTP request — pass
startMcp(app, { params: { headers: { authorization: "Bearer …" } } })to authenticate the session, otherwise it is anonymous.
Quick start
Section titled “Quick start”import { mantle, RepositoryService } from "@mantlejs/mantle";import { express } from "@mantlejs/express";import { mcp } from "@mantlejs/mcp";
const app = mantle() .configure(express()) .configure( mcp({ transport: "http", // mounts POST /mcp (path configurable) services: { users: ["find", "get"], articles: true }, events: true, // mantle://events/{path} resources }), );
app.use("users", new RepositoryService(new UserRepository(app)), { schema: userSchema });app.listen(3030);Point an MCP client at http://localhost:3030/mcp (streamable HTTP, JSON responses; SSE streaming is not supported — poll event resources instead).
// mcp.ts — separate entry point: build the app, don't listen()import { startMcp } from "@mantlejs/mcp";import { buildApp } from "./app.js";
const app = buildApp(); // configure(mcp({ transport: "stdio", services: { … } }))await startMcp(app);Custom (composite) tools
Section titled “Custom (composite) tools”Task-level tools beyond CRUD — chain service calls with the session’s identity; every inner dispatch runs the target’s hook pipeline:
mcp({ transport: "http", services: { articles: ["find", "get"] }, tools: [ { name: "publish_article", description: "Publish an article and record the activity.", inputSchema: { type: "object", required: ["articleId"], properties: { articleId: { type: "string" } } }, handler: async (args, { app, params }) => { const { articleId } = args as { articleId: string }; const article = await app.service("articles").dispatch("patch", { status: "published" }, articleId, params); await app.service("activity").dispatch("create", { type: "published", articleId }, undefined, params); return article; }, }, ],});A custom tool name colliding with a generated tool fails the boot.
Custom resources
Section titled “Custom resources”Read-only reference content an agent should load as context rather than query as a tool — schema docs, enum values, usage guides. read receives the same session context as tools, so inner dispatch() calls run the full hook pipeline:
mcp({ transport: "http", services: { users: ["find"] }, resources: [ { uri: "mantle://docs/usage", name: "API usage guide", mimeType: "text/markdown", read: async () => readFile("docs/agent-guide.md", "utf8"), }, ],});The mantle://events/ namespace is reserved for event resources; duplicate or reserved URIs fail at configure time. Custom resources have no update signal — subscribing is an accepted no-op (event resources keep live notifications on stdio).
Prompts
Section titled “Prompts”Prompt templates that MCP clients surface as user-invokable commands (e.g. slash commands). get may return a plain string (shorthand for a single user message) or the full { description?, messages } shape, and receives the session context:
mcp({ transport: "http", services: { articles: ["find"] }, prompts: [ { name: "triage_articles", description: "Review articles awaiting moderation.", arguments: [{ name: "focus", required: false }], get: async (args, { app, params }) => { const page = await app.service("articles").dispatch("find", undefined, undefined, { ...params, query: { status: "pending", $limit: 10 }, }); return `Review these pending articles${args["focus"] ? ` (focus: ${args["focus"]})` : ""}: ${JSON.stringify(page)}`; }, }, ],});The prompts capability is only declared when at least one prompt is defined; duplicate names fail at configure time.
Code mode
Section titled “Code mode”Instead of one tool per method, a server can give the agent a typed API it writes a short script against. Many service calls then run in one round trip, and intermediate results stay out of the model’s context. The sandbox lives in a separate package, @mantlejs/mcp-code (experimental), which plugs in through the codeMode option — mcp({ …, codeMode: codeMode() }). @mantlejs/mcp itself has no sandbox dependency.
mode |
Tools listed |
|---|---|
"tools" |
One generated tool per exposed method. The default when no codeMode provider is set, and today’s behavior. |
"code" |
The provider’s tools only; the generated per-method tools are suppressed. The default when codeMode is set. |
"both" |
Generated tools plus the provider’s tools. |
App-authored tools, resources, prompts, and event resources are registered in every mode. mode: "code" or "both" without a provider fails at configure time with a BadRequest.
A provider implements McpCodeModeProvider. Its build() runs once, when the expose map is resolved (at listen() for HTTP, at startMcp() for stdio):
interface McpCodeModeProvider { build(input: McpCodeModeInput): McpCodeModeSurface;}
interface McpCodeModeInput { app: MantleApplication; services: McpExposedService[]; // resolved expose map: { path, methods, descriptor }, never "*" query: { defaultLimit: number; maxLimit: number }; // effective find() guardrails}
interface McpCodeModeSurface { tools: McpToolDefinition[]; resources?: McpResourceDefinition[];}- Same rules as app-authored tools and resources. A provider tool whose name collides with any registered tool fails the boot, and so does a provider resource URI that duplicates another or uses
mantle://events/. In"code"mode the generated tools aren’t registered, so their names are free. - Same metadata and dispatch as tool mode.
describeServiceMethod(descriptor, method, query)andbuildQuerySchema(operators, limits)are exported — the exact helpers the generated tools are built from, so a provider’s typed API cannot drift from the tool schemas.createServiceMethodRunner(app, descriptor, method, query)is the runner every generated tool executes (find-limit clamp, structured-query translation, id checks); a provider dispatching through it behaves exactly like tool mode. - Per-session behavior lives in the handlers. The surface is built once and shared by every session. Tool handlers and resource
readfunctions receive the session’sMcpToolContext.paramson every call, which is where a provider narrows what a given agent sees. Enforcement still happens in the hook pipeline, because every innerdispatch()runs the target service’s hooks.
mcp(options: McpOptions): MantlePlugin
Section titled “mcp(options: McpOptions): MantlePlugin”| Option | Type | Default | Description |
|---|---|---|---|
services |
Record<string, string[] | true> | "*" |
— (required) | Deny-by-default expose map. |
transport |
"stdio" | "http" |
— (required) | "http" mounts on the app’s transport; "stdio" pairs with startMcp(). |
path |
string |
"/mcp" |
HTTP mount path. |
serverInfo |
{ name?, version? } |
{ name: "mantle", version: "0.0.0" } |
Identity reported during MCP initialization. |
events |
boolean |
false |
Event resources (mantle://events/{path}, last 50 events) for exposed services. |
tools |
McpToolDefinition[] |
[] |
App-authored composite tools. |
resources |
McpResourceDefinition[] |
[] |
App-authored read-only resources. |
prompts |
McpPromptDefinition[] |
[] |
App-authored prompt templates. |
query |
{ defaultLimit?, maxLimit? } |
{ defaultLimit: 25, maxLimit: 100 } |
find result guardrails. |
mode |
"tools" | "code" | "both" |
"code" with codeMode, else "tools" |
Tool surface — see Code mode. |
codeMode |
McpCodeModeProvider |
— | Code-mode provider (e.g. from @mantlejs/mcp-code). |
startMcp(app, options?): Promise<Server>
Section titled “startMcp(app, options?): Promise<Server>”Connects the configured server to stdio. options.params merges extra ServiceParams (e.g. an authorization header) into every call of the session.
There is deliberately no batch tool — agents issue parallel tool calls natively, and composite needs are served by tools.
Configure order
Section titled “Configure order”mcp() reads the "http:router" contract and (for "*") tracks registrations, so configure it after the HTTP transport and before registering services.