Skip to main content

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"
}
]
}
PropertyRequiredNotes
keyyesToken. Must match the filename.
nameyesServer name the agent sees.
descriptionno
toolsyesAt least one.
modulenoOwning module, when shipped by one.

Tool properties​

PropertyRequiredNotes
keyyesToken. The tool name the agent calls.
nameyesTool title.
descriptionnoRead by the model — say when to use the tool, not just what it is.
sourceTypeyesquery, automation or serverAction.
inputSchemanoFlat 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.
automationKeynoautomation 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 own entityKey is 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_permissions row: the row's required role versus the caller's access level for the app the row names. Public is 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 null result 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 that queryKey with serverAction: true, and dispatched through that screen query (runScreenQuery with server actions allowed). The arguments become the query's variables, the action parses them against its own input contract (an invalid call is refused with 422 before the action runs), and the tool returns the action's data. A key declared on no screen fails with Server action '<key>' is not declared on any screen.
  • What gates it — exactly like a query tool, but under the action key and every name a 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.json sets dxp.mode to "client". Framework-owned actions shipped in apps/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, validateServerActionDeclarations refuses to start the container if any screen query or any MCP config tool with sourceType: "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, runScreenQuery refuses it again with 403.
Server actions are a last resort

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:

  1. The tool's own inputSchema, when it declares one. Always wins, so no existing config changes what it advertises. Flat: one level, five types, object becomes additionalProperties: true, no enums, nothing enforced beyond the field types.
  2. sourceType: "automation" — the entry action type's input. When the automation's first action's action type declares an input zod object, the schema is derived from it, and the run parses the request body against that same object before any action executes. Nesting, enums, optionality and .strict() all survive; an invalid call is refused with 422 Invalid input for action "<actionTypeKey>": … and runs nothing — never a half-run. See Action Types → Declaring an input contract.
  3. sourceType: "serverAction" — the action's input contract. The registered action's zod input object, kept faithful (required fields stay required), because dispatch parses the arguments against that same contract.
  4. sourceType: "query" — the generated model. The stack9-models input 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's description carried through as the argument description.
  5. Nothing to derive → an empty schema (no arguments advertised).
Multi-action automations

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.

Why automationKey exists

Before 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.