QUESTPIE

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.

View markdown

mcpConfig(...) takes one object. Every key is optional, and every one of them opens something up rather than locking it down.

KeyTypeWith no value
namestring"questpie"
versionstring"0.0.0"
crudMcpCrudConfigno collection or global
routesMcpRoutesConfigno route
resourcesMcpResourcesConfigno schema resource
httpMcpHttpConfigapp origin only
stdioMcpStdioConfigno trusted maintenance
executionMcpExecutionConfigthe shipped limits

name and version are what the client shows for the server itself.

crud

crud holds two maps and one number.

KeyTypeDefault
collectionsRecord<string, McpEntityPolicy>{}
globalsRecord<string, McpEntityPolicy>{}
maxLimitnumber100

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.

KeyTypeWhat it does
exposebooleanfalse hides the entity. An entry defaults to true.
operationsRecord<string, boolean | McpAccessRule>Which operations exist, and the rule on each.
requiredScopesstring | string[] | falseOAuth scopes for every operation on this entity.
operationScopesRecord<string, McpRequiredScopes>The same, per operation.
fields{ include?: string[]; exclude?: string[] }Column allow and deny lists.
descriptionstringReplaces the generated tool description.
workload{ capabilities: string[]; handoff?: string }Authority facts for a remote workload.
operationWorkloadsRecord<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

EntityOperationsKind
Collectionlist, count, getread
Collectioncreate, updatewrite
Collectiondeletedelete
Globalgetread
Globalupdatewrite
Routeexecuteinvoke

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.

  1. operationScopes[name]
  2. requiredScopes
  3. 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 },
}
MapPublished at
resources.collectionsquestpie://schema/collections and …/collections/{name}
resources.globalsquestpie://schema/globals and …/globals/{name}
resources.routesquestpie://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.

On this page