MCP Server
Instance MCP lets a Stack9 instance publish a subset of its existing configuration — query-library queries and webhook automations — as Model Context Protocol tools, served from an authenticated endpoint on the instance itself.
An MCP-compatible AI agent that can authenticate to your Stack9 instance can then discover those tools, call them, read your data, and trigger your automations — without any bespoke integration code on either side.
AI agent / MCP client Stack9 Core instance
┌──────────────────────┐ ┌──────────────────────────────────────┐
│ initialize │ POST │ /api/mcp/{mcpKey} │
│ tools/list │ ─────────► │ ├─ authenticated router │
│ tools/call │ │ ├─ reads src/mcps/{mcpKey}.json │
│ │ ◄───────── │ ├─ builds one MCP tool per entry │
└──────────────────────┘ JSON / │ └─ per-call authorization │
SSE │ ├─ query → entity read priv │
│ └─ automation → app perm │
└──────────────────────────────────────┘
Why Instance MCP exists
Teams building on Stack9 have already modelled their data as entities, their reads as query-library queries, and their business logic as automations. Historically, making that available to an AI agent meant writing and maintaining a separate integration layer: an HTTP wrapper, a tool schema, and a second copy of your permission logic.
Instance MCP removes that layer:
- ✅ Reuses existing config — a tool is just a pointer to a query key or an automation key.
- ✅ Reuses existing permissions — every call is re-authorized against the same entity privileges and automation permissions your REST API enforces.
- ✅ Auto-derives input schemas — for query tools with a generated input model, the agent-facing schema comes from the model.
- ✅ Authored in the Console or in files — both paths write the same JSON.
- ✅ Fails closed — anything the server cannot positively authorize is refused.
Instance MCP is a Stack9 Core feature. It is independent of DXP client/provider mode and does not require it.
Core concepts
| Concept | What it is |
|---|---|
| MCP server | One named collection of tools, defined by a single JSON config (S9McpConfig). Each MCP server gets its own endpoint URL. |
| Tool | One callable capability exposed to the agent (S9McpTool). Backed by either a query or an automation. |
| Source type | query (read data) or automation (perform an action). Determines both the execution path and the authorization check. |
| MCP endpoint URL | {coreBaseUrl}/api/mcp/{mcpKey} — one URL per MCP server. |
| Session | MCP Streamable HTTP is stateful. A client calls initialize once, receives an mcp-session-id, and sends it on every subsequent request. |
Source types
query tools are read-only. They run a query-library query through the query service. Only entity-backed stack9_api queries can actually execute — see Security model.
automation tools are the action side. They run a Stack9 automation with the webhook trigger type, passing the tool arguments as the automation body.
"Webhook automation" here means a Stack9 automation whose triggerType is webhook. It is unrelated to the DXP Webhooks feature (outbound HTTP callbacks).
Architecture
Configs are files, not database rows
An MCP server is a JSON file:
{instance}/src/mcps/{mcpKey}.json
This matters operationally: MCP configs travel with your instance source, exactly like queries, screens, and automations. They are reviewed in pull requests, versioned in git, and deployed with your build — not edited in a production database.
At request time the server aggregates MCP configs from three locations, in this order:
- Core built-ins — the framework's own
mcps/directory. - Your instance —
src/mcps/in development, the compileddist/mcps/output otherwise. - Installed modules — each module's
mcps/directory.
Duplicate key values are de-duplicated on load and the first match wins, following the order above.
Configs sourced from a module carry a module field and are read-only: the writer refuses to update, rename, or delete them, so a module can ship a curated MCP server that an instance cannot silently mutate.
The Console writes to src/mcps/, but a non-development runtime reads the built dist/mcps/ output. Treat Console authoring as a development-time workflow: author locally, commit the JSON, and deploy. Editing an MCP config against a deployed instance is not a substitute for a release.
Request lifecycle
- A
POSTarrives at/api/mcp/{mcpKey}and passes the standard authenticated-router middleware chain. - The handler loads the MCP config fresh from disk on every request, so config changes are picked up by the next
initializewithout a restart. - Unknown
mcpKey→404. - If the request has no
mcp-session-id, it must be a JSON-RPCinitialize. The server builds one MCP tool per config entry, creates a Streamable HTTP transport, and returns a new session id in themcp-session-idresponse header. - Subsequent requests carrying that
mcp-session-idare dispatched to the live session (tools/list,tools/call, …). - Each
tools/callis authorized at call time, inside the tool callback, against the credentials of that request.
Because tools are registered once per session but invoked across many concurrent requests, the server binds each tool callback to its own request context (services, authenticated user, database transaction) rather than a shared slot. Concurrent tool calls do not leak each other's context.
Authoring in the Console
The Console exposes MCP servers under App Builder → MCP Servers (/app-builder/mcps).
The list screen shows each MCP server's key, name, description, number of tools, and last-updated timestamp, with actions to open the detail screen or delete the server. Create new opens a drawer that captures the key, name, description, and at least one tool.
Opening a server takes you to the detail screen (/app-builder/mcps/detail/{mcpKey}), which has two tabs plus a Get MCP URL action in the overflow menu.
Configuration tab
Left column: the server's key, name, and description. Right column: a repeatable list of tool cards. Each card:
- Query / Automation — one grouped select with a Queries group and a Webhook Automations group. The automation list is filtered to automations whose trigger type is
webhook; everything else is not selectable. Internally the selection is stored asquery:{key}orautomation:{key}. - Schema badge — a live Generated model found / No generated model badge for query tools, resolved from the instance's generated models. When a generated model exists, the manual schema editor is hidden because the model is used instead.
- Description — pre-filled from the selected query's description and editable. This is the text the agent sees when deciding whether to call the tool, so it is worth writing deliberately.
- Input Schema — shown when there is no generated model. Add fields with a name, a type (
string,number,boolean,object,array), an optional description, and an Optional checkbox.
At least one tool is required.
Test tab
The Test tab is a real MCP client running in the browser against the same endpoint: it performs initialize, lists the tools the server actually exposes, lets you fill in each tool's arguments, and shows the raw JSON result or error. Because it authenticates as your Console session, it exercises the same authorization path a real agent would — including permission denials.
Get MCP URL
Displays and copies the endpoint for this server: {coreBaseUrl}/api/mcp/{mcpKey}.
Authoring in files
The equivalent of the Console form is a plain JSON file. A minimal two-tool server:
{
"key": "support_agent",
"name": "Support Agent Tools",
"description": "Read-only customer lookups plus ticket creation for the support assistant.",
"tools": [
{
"key": "getcustomers",
"name": "getcustomers",
"description": "Search customers by name or email. Returns a paginated list.",
"sourceType": "query"
},
{
"key": "create_support_ticket",
"name": "create_support_ticket",
"description": "Create a support ticket for an existing customer.",
"sourceType": "automation",
"inputSchema": {
"customer_id": { "type": "number", "description": "Stack9 customer id" },
"subject": { "type": "string", "description": "One-line summary" },
"body": { "type": "string", "description": "Full description" },
"priority": {
"type": "string",
"description": "low | medium | high",
"optional": true
}
}
}
]
}
For the complete field-by-field reference, see the MCP API reference.
See also the worked examples in Samples → MCP Servers.
Tool input schemas
A tool's agent-facing input schema is resolved in three steps:
- Inline schema wins. If the tool declares
inputSchema, it is used verbatim. - Generated model (query tools only). With no inline schema, the server PascalCases the tool key and looks for a generated
{Model}PaginatedInputexport, then{Model}Input, in the instance's generated models package. If found, every field is made optional and used as the tool schema. - Empty schema. Otherwise the tool takes no arguments.
Step 2 applies to query tools only. An automation tool with no inputSchema exposes no arguments at all to the agent — always declare an inline schema for automation tools.
Naming convention for auto-derivation
The key-to-model mapping is convention-driven: the tool key is split on _ and each part is capitalised. An all-lowercase query key such as getalldevelopers becomes the single token Getalldevelopers. Lookups are case-insensitive, so this works in practice against a generated GetAllDevelopersPaginatedInput, but the mapping is by name, not by a registry. If the badge in the Console says No generated model for a query you expect to have one, declare an inline schema rather than fighting the convention.
How arguments are executed
Query tools
Incoming arguments are partitioned against the query definition:
| Argument matches | Becomes |
|---|---|
A {{variable}} token in the query template | A template variable |
A key in the query's filters[] | An applied filter with the eq operation (empty, null, and "" values are skipped) |
A key in the query's querySearchFields | Part of a single space-joined search string |
| Nothing above | Silently ignored |
The query then runs through the query-library service with those variables, filters, and search string.
Filters are applied with equality only. Range, in, and comparison operators are not reachable through MCP arguments today — model those as template variables in the query itself if an agent needs them.
Automation tools
The entire arguments object is passed straight through as the automation body, with the webhook trigger type and the calling user's id. The tool returns the last action's output from the automation runbook.
Result shape
Both paths return the result as a JSON string inside a single MCP text content block. Agents parse the text as JSON.
Security model
The endpoint is not a public surface. Three layers apply.
1. Endpoint authentication — who can reach it
The MCP route is mounted inside the authenticated router, behind the same middleware chain as the rest of /api. The request must resolve to an authenticated Stack9 principal, or it is rejected with 401 Unauthorized. In practice there are two credential forms relevant to MCP:
- Session cookie — the Stack9 session JWT cookie (the same one the Console uses). The token's issuer must match the instance.
- API key — the
Api-Keyrequest header. An API key row is linked to a Stack9 user, so the key inherits that user's security role (entity privileges) and user-group app roles.
There is no anonymous MCP access and no MCP-specific token, OAuth flow, or consent screen. Instance MCP reuses the platform's existing authentication.
Credential provisioning for headless external agents is not a solved story yet. The endpoint accepts a Stack9 session cookie or a Stack9 API key, and an API key is the realistic option for a headless agent. But how a key is minted and scoped for an agent, and whether a given third-party MCP client can attach a Stack9 cookie or a custom Api-Key header to its transport at all, is client-specific and not something Stack9 provides today. Verify your client can send the credential before you plan around it. The Console Test tab works because it already holds a Console session.
2. Per-tool authorization — what an authenticated caller may do
Authentication only gets a caller to the endpoint. MCP tool calls bypass the route-level entity and webhook middleware, so the controller re-applies the equivalent checks inside every tool callback, on every call:
Query tools
- The entity is derived from the query definition: only
stack9_apiqueries are entity-backed, and the entity key is the first non-empty segment of the query template path (/{entityKey}/...). - If no entity can be derived — a raw SQL (
stack9_db) query, an external-connector query, a path whose first segment is a template variable, or a query key that does not exist — the call is refused with403: only entity-backed queries can be exposed via MCP. - Otherwise the caller must hold the
readprivilege on that entity, or403.
Automation tools
- The call is evaluated against the same automation-permission model the webhook route uses, backed by
app_automation_permissions. - Only an explicit allowed result proceeds. Both "no permission record exists" and "role insufficient" fail closed with
403. Automations whose required role isPublicare allowed. - The automation runs as the calling user.
Configuring a tool does not grant access to it. A tool the caller is not authorized for still appears in tools/list but fails with 403 on call. Conversely, exposing a tool never widens permissions: a caller can only reach data and actions they could already reach through the REST API.
3. Error sanitization and session isolation
- Stack9's own intentional errors (authorization denials, not-found, validation) are surfaced to the agent as an error result with their message, because those messages are safe by design.
- Any other error is logged server-side and returned to the agent as a generic
Tool '{key}' failed due to an internal error, so schema names, SQL, and driver internals never reach the client. - Sessions are bound to the user that created them. A lookup or delete from any other user is treated as not found, so a leaked or guessed
mcp-session-idcannot be driven or torn down across users.DELETEreturns200even for an unknown or foreign session id, so existence cannot be probed.
Current limitations and operational notes
| Area | Today |
|---|---|
| MCP surface | Only tools are exposed. resources and prompts are empty. |
| Sources | Only entity-backed stack9_api queries and webhook automations. Raw SQL and external-connector queries are refused. |
| Sessions | In-memory and process-local. Running more than one Core replica requires sticky routing on mcp-session-id at the load balancer; there is no shared or Redis-backed session store. |
| Session limits | 30-minute idle expiry, 10 sessions per user, 1000 globally, least-recently-used eviction. |
| Rate limiting | No MCP-specific rate limiting. The session caps above are the only MCP-level bound. |
| Filters | Equality only when driven from tool arguments. |
| Notifications / streaming | Responses may be SSE-framed by the transport, but there is no long-running or streaming tool pattern. |
Related
- MCP API reference — endpoint, config schema, and error behaviour
- Samples → MCP Servers — complete example configs
- Query Library — authoring the queries behind
querytools - Automations — authoring the webhook automations behind
automationtools - Apps — where automation permissions come from