Configuration
config/mcp.ts is the whole catalog. It names every collection, global, route and resource an agent may reach, and the rule and OAuth scope on each one.
mcpConfig(...) takes one object. Every key is optional, and every one of them
opens something up rather than locking it down.
| Key | Type | With no value |
|---|---|---|
name | string | "questpie" |
version | string | "0.0.0" |
crud | McpCrudConfig | no collection or global |
routes | McpRoutesConfig | no route |
resources | McpResourcesConfig | no schema resource |
http | McpHttpConfig | app origin only |
stdio | McpStdioConfig | no trusted maintenance |
execution | McpExecutionConfig | the shipped limits |
name and version are what the client shows for the server itself.
crud
crud holds two maps and one number.
| Key | Type | Default |
|---|---|---|
collections | Record<string, McpEntityPolicy> | {} |
globals | Record<string, McpEntityPolicy> | {} |
maxLimit | number | 100 |
maxLimit is the ceiling the list tool clamps its limit to. A caller who
asks for more gets maxLimit rows.
A key that names nothing in your app is ignored. A collection you never name has no tools.
The entity policy
Every entry in collections, globals and routes is the same shape.
| Key | Type | What it does |
|---|---|---|
expose | boolean | false hides the entity. An entry defaults to true. |
operations | Record<string, boolean | McpAccessRule> | Which operations exist, and the rule on each. |
requiredScopes | string | string[] | false | OAuth scopes for every operation on this entity. |
operationScopes | Record<string, McpRequiredScopes> | The same, per operation. |
fields | { include?: string[]; exclude?: string[] } | Column allow and deny lists. |
description | string | Replaces the generated tool description. |
workload | { capabilities: string[]; handoff?: string } | Authority facts for a remote workload. |
operationWorkloads | Record<string, …> | The same, per operation. |
Two shorthands exist. posts: false hides the collection. posts: true sets
expose and nothing else. It names no operation, so it still produces no
tools. Only operations creates a tool.
`expose: true` is not enough on its own
The catalog keeps an operation when its value is present and is not false.
An entity with an empty operations map drops out entirely. Write the
operation names you want.
Operation names
| Entity | Operations | Kind |
|---|---|---|
| Collection | list, count, get | read |
| Collection | create, update | write |
| Collection | delete | delete |
| Global | get | read |
| Global | update | write |
| Route | execute | invoke |
The kind sets the tool's MCP annotations and its default OAuth scope. Reads get
readOnlyHint: true. delete gets destructiveHint: true. update and
delete get idempotentHint: true. A route tool carries only the annotations
you wrote in meta.mcp.annotations.
Rules on an operation
An operation value is true, false, or a function. The function receives one
argument.
interface McpAccessRuleContext {
transport: "http" | "stdio" | "workload";
accessMode: "user" | "system";
session?: RequestContext["session"];
scopes?: string[]; // only an OAuth caller carries these
ctx: AppContext & Partial<RequestContext>;
}Return true to allow. The entity's own .access() rules still run after it,
unless the caller is in system mode. See Access
control.
Scopes
An OAuth caller must hold the scope an operation requires. The requirement resolves in this order, and the first one that is set wins.
operationScopes[name]requiredScopes- the default
<resource>:<name>:<verb>
So collections.posts.list defaults to collections:posts:read, and
globals.siteSettings.update to globals:siteSettings:write. A route defaults
to routes:<key>:invoke. Setting any level to false requires no scope at
all.
A coarse <resource>:read or <resource>:write umbrella also satisfies a
granular scope with the same resource and verb. There is no umbrella for
delete or invoke. A caller who is not an OAuth caller holds no scopes and
skips this gate. The whole flow is on MCP over OAuth
2.1.
Fields
fields filters the columns a tool exposes. It applies to the tool's input
schema, its results, and the entity's schema resource.
posts: {
operations: { list: true, get: true },
fields: { exclude: ["internalNotes", "authorEmail"] },
}include is an allow list and exclude is a deny list. A column named in both
is dropped. Leave include unset and your own fields stay, plus id,
createdAt, updatedAt, deletedAt and _status. Set include and only the
columns you listed survive, those five included.
This is the way to hide a column
A CRUD tool mirrors your columns. Trusting the model not to ask for a secret
is not protection. Name the column in fields.exclude and it is gone from
arguments, results and schemas.
routes
routes.routes is a map keyed by route key. The key is the file path under
routes/ without the extension or method suffix, so
routes/reports/generate.post.ts is reports/generate and
routes/reports/[id].get.ts is reports/[id].
routes: {
routes: {
"reports/generate": { operations: { execute: true } },
"reports/[id]": { operations: { execute: true } },
},
}A route needs two opt-ins. meta.mcp.expose === true on the route file, and an
execute operation here. Miss either one and there is no tool. Only JSON
routes qualify, because a raw route has no schema to advertise. See
Metadata.
The tool is named meta.mcp.name when you set one. Otherwise it is routes.
plus the key with slashes, colons and brackets turned into dots, so
reports/[id] becomes routes.reports.id.
Two tools cannot share a name
A custom tool and a route that resolve to the same name make the server throw
Duplicate MCP tool name when it starts. Rename one of them.
resources
Resources publish JSON schemas an agent can read without calling a tool. They are opt-in per entity, by name.
resources: {
collections: { posts: true },
globals: { siteSettings: true },
routes: { "reports/generate": true },
}| Map | Published at |
|---|---|
resources.collections | questpie://schema/collections and …/collections/{name} |
resources.globals | questpie://schema/globals and …/globals/{name} |
resources.routes | questpie://schema/routes and …/routes/{key} |
A collection resource needs one of list, count or get released, and a
global resource needs get. Every read runs the same rule, scope and access
gates the tools do. The server advertises the resources capability only when at
least one is published.
http, stdio and execution
These three tune the server rather than the catalog. http and stdio are on
Transports, and execution is on Limits and
errors.
One field is worth naming here. http.accessMode exists on the type, and the
bundled route never reads it. HTTP always runs as user.
Two configs at once
createMcpServer(app, { config }) takes a config of its own. It does not
deep-merge entity entries. An entry it names replaces your file's entry for
that name outright, and names it leaves alone survive. That server also gets
its own snapshot, so it never changes what OAuth advertises.
TypeScript
import type {
McpConfig,
McpCrudConfig,
McpRoutesConfig,
McpResourcesConfig,
McpEntityPolicy,
McpAccessRule,
McpAccessRuleContext,
McpRequiredScopes,
McpTransportKind, // "http" | "stdio" | "workload"
McpAccessMode, // "user" | "system"
} from "@questpie/mcp";mcpConfig(...) returns its argument unchanged, so your default export keeps
the exact type you wrote.
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.
Custom tools
mcpTool() gives an agent one operation of your own, with a Zod input and the full app context. It runs the same authorization gates the generated CRUD tools run.