MCP Schema Reference
An MCP config (mcps/<key>.json) publishes a set of Stack9 capabilities to AI agents at
/api/mcp/<key>. This is the canonical reference for the file's shape and, more
importantly, for what each sourceType actually runs and what gates it — derived from
packages/stack9-sdk/src/schema/validators/v2/McpValidator.ts,
apps/core/src/controllers-core/McpEndpointController.ts and
apps/core/src/utils/mcpToolAuthorization.ts.
Every endpoint also exposes a built-in stack9_whoami tool that is not declared in the file;
see MCP Server.
Structure
{
"key": "pages_mcp",
"name": "Pages MCP",
"description": "Pages authoring tools",
"tools": [
{
"key": "getalldevelopers",
"name": "Get all developers",
"description": "Retrieves all developers.",
"sourceType": "query",
"inputSchema": {
"page": { "type": "number" },
"limit": { "type": "number", "optional": true }
}
},
{
"key": "pages_send_invite",
"name": "Send invite",
"sourceType": "automation",
"automationKey": "webhook_send_invite"
}
]
}
| Property | Required | Notes |
|---|---|---|
key | yes | Token. Must match the filename. |
name | yes | Server name the agent sees. |
description | no | |
tools | yes | At least one. |
module | no | Owning module, when shipped by one. |
Tool properties
| Property | Required | Notes |
|---|---|---|
key | yes | Token. The tool name the agent calls. |
name | yes | Tool title. |
description | no | Read by the model — say when to use the tool, not just what it is. |
sourceType | yes | query, automation or serverAction. |
inputSchema | no | Flat map of { type, description?, optional? }. Fields are required unless marked. Omit it on an automation tool to derive the schema from the action type's input — see below. |
automationKey | no | automation tools only (the validator forbids it on other source types). The automation to run. Defaults to key. |
Tool sourcing
sourceType: "query"
Runs the query library entry whose key is the tool key, through the query-library
service, with arguments partitioned into template variables, eq filters and search
terms. An argument that matches none of those is refused (unknown_arguments) rather
than silently dropped.
What gates it — the screen-query permission model the screen routes use
(app_screen_permissions). The query must be exposed by at least one screen (as its list or
detail query, a field's list query, or an entry in queries[]); the role that screen's
permission assigns to the query is compared with the caller's access level for the
screen's app, and the call is allowed if any exposing screen grants it. A query exposed
on no screen is refused (query_not_exposed). Declared screen queries are seeded at the
app's Admin role. Administrators bypass the check.
sourceType: "automation"
Runs one webhook automation:
- Which automation —
automationKey, or the tool key when the field is absent. That one key is the whole relationship: it selects the automation that runs and the permission row that gates it. The automation's ownentityKeyis passed to the run as trigger context; it does not select anything, and it does not need to match the tool key. - What gates it — the automation's
app_automation_permissionsrow: the row's required role versus the caller's access level for the app the row names.Publicis open to any authenticated caller. No row means denied — unlike the webhook route, which iterates candidates and treats a missing row as "not this one", an MCP tool names exactly one automation, so a missing row can only mean nobody granted it. Automation tools are not gated by any screen declaration; a screen never has to mention them. - A tool naming an automation that does not exist (or one that is not webhook-triggered)
is a configuration error. It is reported at boot, and the call itself fails with that
message — never a
nullresult that reads as a successful empty run.
sourceType: "serverAction"
Runs a server action: a typed, in-process command (defined as a server action class in an instance or module, or shipped by the framework) that is reachable only through a screen query that declares it:
{
"queries": [
{ "name": "grant_business_unit", "queryKey": "my_action_key", "serverAction": true }
]
}
- What runs — the tool key is the action key. It is traced to a screen whose
queries[]declares thatqueryKeywithserverAction: true, and dispatched through that screen query (runScreenQuerywith server actions allowed). The arguments become the query's variables, the action parses them against its owninputcontract (an invalid call is refused with422before the action runs), and the tool returns the action'sdata. A key declared on no screen fails withServer action '<key>' is not declared on any screen. - What gates it — exactly like a
querytool, but under the action key and everynamea declaring screen exposes it as, because screen permissions grant a server action by its declared name. An action declared on no screen is denied. - Where it is allowed — instance- and module-defined server actions are a DXP client
capability: they are permitted only when
stack9.config.jsonsetsdxp.modeto"client". Framework-owned actions shipped inapps/core(provider_plane_ping,dxp_provider_user_business_units,dxp_provider_user_business_unit_grant) are exempt. The rule is enforced at four points, all failing loudly:- defining the action (
ServerActionsReader); - at boot,
validateServerActionDeclarationsrefuses to start the container if any screen query or any MCP config tool withsourceType: "serverAction"names an action key that is not allowed on this instance, naming the offending MCP config and tool; - per request, the tool is refused with
server_action_not_permitted— a topology rule that no role, administrator included, can satisfy; - at dispatch,
runScreenQueryrefuses it again with403.
- defining the action (
A server action mutates state, runs in-process, can return show-once secret material and
leaves no automation runbook. Even on a DXP client, prefer a named query for reads and a
webhook automation for fire-and-forget work. See apps/core/src/server-actions/README.md
for the criteria.
Where a tool's input schema comes from
A tool advertises exactly one schema, and — for the derived forms — the artefact it advertises is the same one the call is validated against. In precedence order:
- The tool's own
inputSchema, when it declares one. Always wins, so no existing config changes what it advertises. Flat: one level, five types,objectbecomesadditionalProperties: true, no enums, nothing enforced beyond the field types. sourceType: "automation"— the entry action type'sinput. When the automation's first action's action type declares aninputzod object, the schema is derived from it, and the runparses the request body against that same object before any action executes. Nesting, enums, optionality and.strict()all survive; an invalid call is refused with422Invalid input for action "<actionTypeKey>": …and runs nothing — never a half-run. See Action Types → Declaring an input contract.sourceType: "serverAction"— the action'sinputcontract. The registered action's zodinputobject, kept faithful (required fields stay required), because dispatch parses the arguments against that same contract.sourceType: "query"— the generated model. Thestack9-modelsinput model for the query, advertised faithfully:{{template}}variables required, filters and search fields optional (models from an older generator marked every filter required — regenerate them), and each entity field'sdescriptioncarried through as the argument description.- Nothing to derive → an empty schema (no arguments advertised).
The contract belongs to the automation's entry action — actions[0]. Later
actions receive the previous action's next() output, not the caller's body, so
only the first action describes what a caller sends. An automation with only
conditionalActions has no entry action (which action runs first depends on the
body being validated) and is therefore not validated: give such a tool an
inputSchema, or front the automation with an unconditional entry action.
automationKey existsBefore it, the tool key had to equal the automation key and the automation's entityKey,
because dispatch matched on entityKey while the permission lookup used the tool key. Two
webhook automations sharing an entityKey both ran, and the last one's output won. Existing
configs are unaffected: an automation tool with no automationKey resolves to its own key,
which is exactly the automation those configs already ran.
Related
- Automations — trigger types, including
webhook. - Action Types — what an automation's actions receive,
including
requestId/correlationId. - Automation Schema Reference