Transports
Four ways to serve the same catalog. The bundled HTTP route, a local stdio server, a server you mount yourself, and the workload boundary for a runtime that brings its own authorizer.
Pick one by answering a single question. How much is the server allowed to assume about who is calling?
| Transport | You call | Access mode | Built for |
|---|---|---|---|
| HTTP | nothing, mcpModule mounts it | always user | a remote agent with an OAuth token |
| stdio | startStdioServer(app, opts) | you must choose | a trusted process on the same machine |
| your own | createMcpServer(app, opts) | user, unless stdio | a transport the package does not bundle |
| workload | createWorkloadMcpServer(…) | always user | a remote runtime with its own authorizer |
user mode runs your .access() rules as the caller. system mode skips
them. Only stdio can ever be system.
HTTP
mcpModule contributes four route files on the key mcp, so the endpoint is
/api/mcp under the starters' base path.
| Method | What it does |
|---|---|
POST | Carries JSON-RPC messages. This is the real endpoint. |
GET | 405, with Allow: POST, OPTIONS. |
DELETE | 405, with Allow: POST, OPTIONS. |
OPTIONS | The CORS preflight. |
The transport is stateless. There is no session to resume and no SSE stream to
reconnect to. That is why GET and DELETE refuse. A request carrying
Mcp-Session-Id or Last-Event-ID gets 400.
It needs a principal first
The route checks identity before it dispatches anything. A first-party admin
session gives a user principal, a valid OAuth access token gives an oauth
one, and anything else gives none.
With no principal the answer is 401 plus a WWW-Authenticate header pointing
at your protected-resource metadata. An MCP client reads that, finds the
authorization server, and starts the flow. See MCP over OAuth
2.1.
Origin, host and size
| Check | Failure |
|---|---|
Origin header | 403 unless it matches the app URL or http.allowedOrigins |
Host header | 403 unless it matches the app URL or http.allowedHosts |
Content-Length over 1 MiB | 413 |
| Body over 1 MiB | 413 |
| More than 16 batched calls | 400 |
| Response over 4 MiB | 500 |
CORS replies allow POST, OPTIONS and the headers Content-Type,
Authorization, MCP-Protocol-Version and x-api-key. A preflight asking for
anything else is refused.
http.enableJsonResponse defaults to true. http.accessMode is on the type
and the route never reads it.
stdio
stdio claims no authority on its own. You have to say who the process is.
startStdioServer(app) with nothing else throws.
As a user. Pass a context that is already in user mode. Tools then run
under that user's access rules, exactly like HTTP.
import { startStdioServer } from "@questpie/mcp/stdio";
await startStdioServer(app, { ctx: userContext });As trusted maintenance. Opt in through the config, and the server runs as
system.
import { mcpConfig } from "@questpie/mcp";
export default mcpConfig({
stdio: { trustedMaintenance: true },
});Then pass no ctx, no request and no accessMode: "user". Combining
maintenance authority with request authority throws instead of picking one.
`system` skips your access rules, not the catalog
trustedMaintenance bypasses the collection and global .access() checks.
The catalog and its rules still apply, so an operation you never named in
config/mcp.ts stays unreachable. Only run it locally.
Your own transport
createMcpServer(app, options) returns an SDK McpServer you can connect to
anything.
| Option | Default | Notes |
|---|---|---|
transport | "http" | "http" or "stdio". A workload has its own factory. |
accessMode | "user" | Honoured for stdio only. Anything else is forced to user. |
ctx | none | The context every tool call runs under. |
request | none | Used to build a context when ctx is absent. |
config | none | An isolated config for this server alone. |
A config here builds its own snapshot. It never changes what the app-wide
OAuth catalog advertises, and it replaces entity entries by name rather than
merging into them. See Configuration.
Workload
A remote workload is not a user. It has no cookie, no token and no session, so
it brings its own authorizer instead. createWorkloadMcpServer takes no
request, no context and no access mode.
import { createWorkloadMcpServer } from "@questpie/mcp";
const server = await createWorkloadMcpServer(app, {
envelope, // opaque, passed straight to your authorizer
authorizer,
contextBinder,
});| Option | Required | What it does |
|---|---|---|
envelope | yes | Opaque value handed to the authorizer. Never interpreted. |
authorizer | yes | Returns a context and attribution, or null to deny. |
contextBinder | yes | Turns that opaque context into a real QUESTPIE context. |
config | no | An isolated config, same as above. |
audit | no | Receives an allow or deny event per phase. |
handoff | no | Wraps calls that declare a handoff capability. |
concurrencyKey | no | Shares one per-principal budget across boundaries. |
Discovery and every single call are authorized separately. The authorizer sees the envelope, the phase, and bounded facts about the tool: kind, name, operation, intent, capabilities and handoff. It sees no input and no output.
The bound context must be in user mode. A system context fails closed. A
workload can never reach the RBAC bypass.
A workload sees only tools that opt in
A tool is visible here only when it declares workload: { capabilities: [...] }.
Resources are never published to a workload at all. Anything you forget stays
hidden rather than open.
In-process port
When a trusted subsystem in the same process needs your custom tools, skip the client and server loop.
import { createWorkloadMcpToolPort } from "@questpie/mcp";
const tools = createWorkloadMcpToolPort(app, workloadOptions);
const released = await tools.listCustomTools({ signal });
const result = await tools.callCustomTool({
name: "messages.reply",
input: { body: "Hello" },
signal,
requestId: runId,
});The port carries custom tools only. Generated CRUD tools, route tools and resources are absent by construction. Everything else is the same: the release catalog, the authorizer, the context binder, input and output validation, the budgets and the error contract.
Access mode summary
| Mode | Reached by | Catalog and rules | .access() rules |
|---|---|---|---|
"user" | HTTP, workload, stdio with a user ctx | evaluated | evaluated |
"system" | stdio with trustedMaintenance | evaluated | skipped |