MCP Endpoint
Reference for the Stack9 Instance MCP runtime endpoint — Stack9's implementation of the Model Context Protocol (MCP) — and the config file that defines it. For the conceptual overview, see MCP Server.
Endpoint
{coreBaseUrl}/api/mcp/{mcpKey}
coreBaseUrl— the base URL of the Stack9 Core instance, taken fromSERVER_BASE_URL(for examplehttp://localhost:4444in development).mcpKey— thekeyof the MCP config. One endpoint per MCP server.
Transport: MCP Streamable HTTP, stateless (MCP revision 2026-07-28). The route is registered for all HTTP methods; JSON-RPC requests are sent with POST.
Clients should send:
POST /api/mcp/support_agent HTTP/1.1
Host: your-app.stack9.cloud
Authorization: Bearer {access_token}
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
The Accept header must permit both JSON and text/event-stream: the transport may frame a response as a single SSE data: line rather than a plain JSON body, and clients must handle both.
Authentication
The endpoint is an OAuth 2.1 protected resource, served behind its own resource-server gate rather than the generic authenticated router. A request that does not carry an acceptable credential is refused before any MCP handling happens, with a WWW-Authenticate challenge a client can discover from.
| Credential | Result |
|---|---|
Authorization: Bearer {token} — access token whose aud is this MCP resource ({coreBaseUrl}/api/mcp/{mcpKey}) and whose scope includes mcp:tools | Accepted; runs as the Stack9 user the token identifies |
Bearer token for another resource, or with no aud (including a Stack9 session JWT) | 401 invalid_token |
Audience-bound token missing mcp:tools | 403 insufficient_scope |
| Session cookie | Not read here; on its own treated as no credential — 401 |
| HTTP Basic | 401 invalid_token |
Api-Key: {key} header | Accepted when it resolves to an API-key user. Kept for development and QA; not advertised in metadata and not an OAuth mechanism |
| None | 401 |
The challenge has the form:
WWW-Authenticate: Bearer scope="mcp:tools", resource_metadata="{coreBaseUrl}/.well-known/oauth-protected-resource/api/mcp/{mcpKey}"
with error and error_description added when a token was presented but refused.
Discovery and token endpoints
All discovery documents are served from the host root, not under /api.
| Path | Purpose |
|---|---|
GET /.well-known/oauth-protected-resource/api/mcp/{mcpKey} | Protected Resource Metadata (RFC 9728): resource, authorization_servers, scopes_supported: ["mcp:tools"], bearer_methods_supported: ["header"] |
GET /.well-known/oauth-authorization-server | Authorisation server metadata (RFC 8414) |
GET /.well-known/openid-configuration | The same document, for clients that only know the OIDC discovery path |
/api/oauth/authorize | Authorisation endpoint (authorisation-code flow) |
/api/oauth/token | Token endpoint (authorization_code and refresh_token grants) |
| Property | Value |
|---|---|
| Issuer | SERVER_BASE_URL, normalised. If it is not a usable URL the MCP endpoint answers 503 |
| PKCE | S256 only. Mandatory for public and CIMD clients; optional for a pre-registered client holding a secret (enforced if sent) |
resource parameter | Required (RFC 8707), and must name an MCP endpoint under /api/mcp/ on this origin. A pre-registered confidential client may default it via default_oauth_resource |
| Scope | mcp:tools (the only supported scope; absent means mcp:tools) |
| Token endpoint auth | none, client_secret_basic, client_secret_post |
| Client registration | Client ID Metadata Documents, or pre-registered app_registration rows. No Dynamic Client Registration endpoint |
| Access token lifetime | 900 seconds (15 minutes); renew with the refresh grant |
| Authorisation response | Always carries iss (RFC 9207) |
For the step-by-step flow, confidential-client accommodations and DXP-client behaviour, see Connect Claude Code to a Stack9 MCP endpoint.
Authorisation is enforced again per tool — see Per-tool authorisation.
Request model
The endpoint is stateless. There is no initialize handshake to hold open and no mcp-session-id: every request stands alone and is authorised against its own credential. Clients speaking the 2025-era protocol are served through the MCP SDK's stateless legacy fallback.
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "getcustomers",
"arguments": { "name": "acme" }
}
}
The server identifies itself as {config name} [{instance} · {ENVIRONMENT_TYPE} · {host}], version 1.0.0. Only tools are advertised — resources and prompts are always empty.
tools/list returns only the tools this caller is authorised to run, plus the built-in stack9_whoami. Because the config and the tool set are rebuilt on every request, config changes apply to the next request with no reconnect or restart.
Endpoint behaviour matrix
| Request | Response |
|---|---|
POST tools/list with an accepted credential | 200 — the caller's authorised tools plus stack9_whoami |
POST tools/call with an accepted credential | 200; a refusal or failure is a JSON-RPC result with isError: true |
| No credential | 401 + WWW-Authenticate challenge |
Refused token (wrong audience, expired, unverifiable, no aud) | 401 invalid_token + challenge |
Token without mcp:tools | 403 insufficient_scope + challenge |
Unknown mcpKey, with an accepted credential | 404 — MCP '{mcpKey}' not found |
SERVER_BASE_URL unusable | 503 |
Config file schema
An MCP server is one JSON file at {instance}/src/mcps/{key}.json. Configs are also loaded from the framework's own mcps/ directory and from each installed module's mcps/ directory; duplicate keys de-duplicate with the first match winning.
S9McpConfig
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | Unique. Becomes the URL segment and the filename. Validated as a token: letters, digits, and underscores only. |
name | string | yes | The MCP server name reported to the client, suffixed with the instance label ({name} [{instance} · {environment} · {host}]). |
description | string | no | Human-facing description of the server. Not sent to the agent as tool metadata. |
tools | S9McpTool[] | yes | At least one tool is required. |
module | string | no | Set automatically when the config ships from a module. Makes the config read-only: update, rename, and delete are refused. Do not set this by hand in an instance config. |
S9McpTool
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | The query-library query key (query), the server action key (serverAction), or — unless automationKey is set — the automation key (automation). Also becomes the MCP tool name the agent calls. Validated as a token: letters, digits, and underscores only. |
name | string | yes | Becomes the MCP tool title (display label). The Console sets this equal to key. |
description | string | no | The MCP tool description. This is what the agent reads when deciding whether and how to call the tool — write it for the agent, not for a developer. |
sourceType | 'query' | 'automation' | 'serverAction' | yes | Determines the execution path and the authorisation check. See the MCP schema reference. |
inputSchema | S9McpToolInlineSchema | no | Inline argument schema. Overrides every derived schema when present. |
automationKey | string | no | automation tools only (forbidden on other source types). The automation to run and whose permission row gates it. Defaults to key. |
key and name play different roles: key is the identifier the agent calls and the query or server action it resolves to, while name is only a display title. For query and serverAction tools they cannot be decoupled — you cannot alias a query under a different tool name. An automation tool can name a different automation through automationKey.
S9McpToolInlineSchema
A map of argument name to field definition:
"inputSchema": {
"customer_id": { "type": "number", "description": "Stack9 customer id" },
"priority": { "type": "string", "description": "low | medium | high", "optional": true }
}
| Field | Type | Required | Notes |
|---|---|---|---|
type | 'string' | 'number' | 'boolean' | 'object' | 'array' | yes | See the type mapping below. |
description | string | no | Surfaced to the agent as the argument description. |
optional | boolean | no | When true the argument is optional. Defaults to required. |
Type mapping
| Declared type | Agent-facing schema |
|---|---|
string | string |
number | number |
boolean | boolean |
object | object with arbitrary keys and unvalidated values |
array | array with unvalidated items |
Anything not in the list above is treated as string.
object and array are untyped containers — there is no nested schema support. If an agent needs structured nested input, either flatten the arguments or accept an object and validate inside the automation.
Full example
{
"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. Defaults to medium.",
"optional": true
}
}
}
]
}
Configs are validated on write. A config that fails validation is rejected rather than persisted.
Tool derivation
Tool identity
| MCP field | Comes from |
|---|---|
name | tool.key |
title | tool.name |
description | tool.description |
inputSchema | resolved as below |
Input schema resolution
- Inline schema — if
tool.inputSchemais present, it is used. - Server action contract — for
serverActiontools, the registered action's zodinputcontract, with required fields kept required. - Entry action contract — for
automationtools, theinputzod object declared by the action type of the automation's first action, when there is one. The run validates the body against the same object. - Generated model — for
querytools: the tool key is PascalCased (split on_, each part capitalised) and looked up in the instance's generated models package as{Model}PaginatedInput, falling back to{Model}Input. The lookup is case-insensitive. The model is advertised faithfully:{{template}}variables required, filters and search fields optional (with a current model generator), and each field's entitydescriptioncarried through. - Empty — otherwise the tool declares no arguments.
An automation tool with no inputSchema and no entry-action contract exposes zero arguments, so the agent can only invoke it with an empty body. Declare an inline schema in that case.
Because the model lookup is name-based, all-lowercase query keys collapse into a single capitalised token (getalldevelopers → Getalldevelopers). The Console shows a Generated model found / No generated model badge per query tool so you can see which branch will be taken before saving.
Argument handling — query tools
Arguments are partitioned against the query definition:
| Argument name matches | Effect |
|---|---|
A {{variable}} token anywhere in the query template | Passed as a template variable |
A key in the query's filters[] | Applied as a filter with the eq operation. Values that are undefined, null, or "" are skipped |
An entry in the query's querySearchFields | Appended to a single space-joined search string |
| Nothing above | Refused — the call returns Argument error (unknown_arguments): '{key}' does not accept {names}. followed by the accepted template variables, filters and search fields |
Only the eq filter operation is reachable from tool arguments. Model anything else as a template variable in the query.
Argument handling — automation tools
The whole arguments object becomes the automation body. Exactly one automation runs — automationKey, or the tool key — with the webhook trigger type, the automation's own entityKey as trigger context, and the calling user's id. The tool result is the last action's output from the runbook, or null when the last action produced none. A tool naming a missing or non-webhook automation fails with that message.
Argument handling — serverAction tools
The action key is resolved to a screen query that declares it (serverAction: true), and the arguments are passed as that query's variables through the screen-query dispatch path. The action parses them against its input contract; the tool result is the action's data.
Result shape
{
"content": [
{ "type": "text", "text": "{\"data\":[{\"id\":1,\"name\":\"Acme Pty Ltd\"}],\"total\":1}" }
]
}
Results are always a single text block containing the JSON-serialised payload. Agents must parse content[0].text as JSON.
Per-tool authorisation
Each tool is authorised once per request against the verified identity, and the same decision filters tools/list and gates tools/call. Tool arguments are never an input to the decision. Administrators bypass the privilege checks below (but not the server-action topology rule).
query tools
Mirrors the screen-query permission check the screen routes use (app_screen_permissions).
| Condition | Result |
|---|---|
No screen exposes the query (as a list, detail or field query, or in queries[]) | Refused — query_not_exposed: No screen permission specified for {key} |
| Exposed, but the caller holds no access level for any owning app | Refused — no_app_role |
| Exposed, but the caller's access level is below the role every exposing screen requires | Refused — insufficient_app_role: You have no access to run {key} |
| Any exposing screen grants the query at or below the caller's access level | Query runs |
Matching of query keys is case-insensitive, and the most permissive exposing screen wins.
serverAction tools
| Condition | Result |
|---|---|
Instance-defined action on an instance whose dxp.mode is not client | Refused — server_action_not_permitted (no role can satisfy this) |
| Declared on no screen | Refused — query_not_exposed |
| Otherwise | Checked exactly like a query tool, under the action key and every name a declaring screen exposes it as |
automation tools
| Condition | Result |
|---|---|
No app_automation_permissions row for the automation | Refused — automation_not_exposed (fails closed) |
| A row exists but the caller holds no access level for its app | Refused — no_app_role |
| A row exists but the caller's access level is below its required role | Refused — insufficient_app_role |
The row's required role is Public | Allowed |
| The caller's access level is sufficient | Allowed |
Refusals are returned as an MCP error result, not an HTTP error: the tools/call response is 200 with isError: true and the text Authorization error ({code}): {reason}. In practice a refused tool is not listed by tools/list in the first place.
Error behaviour
| Situation | What the agent receives |
|---|---|
| Authorisation denial | isError: true — Authorization error ({code}): {reason} |
| Unknown query arguments | isError: true — Argument error (unknown_arguments): … |
| Any other failure during the tool run | isError: true — Error: {message}. The full error is also logged server-side with the MCP key, tool key and user id |
| Refused or missing credential | HTTP 401 / 403 with a WWW-Authenticate challenge |
Unknown mcpKey | HTTP 404 |
Unusable SERVER_BASE_URL | HTTP 503 |
The error message of an unexpected failure is passed to the agent as-is. Keep sensitive detail (connection strings, SQL, personal data) out of the messages your queries, hooks and action types throw.
Console management API
The Console reads and writes MCP config files through a small management API on the console server (webconsole), not the Core API. It is an internal, development-time admin surface used by the Console UI at /app-builder/mcps, not a public integration API, and it is not part of the MCP protocol. The console server also hosts the MCP handler used by the Console's Test tab, which runs only when NODE_ENV=development and executes tools as a fixed administrator.
| Method and path | Purpose |
|---|---|
GET /mcps | List all loaded configs with file path and timestamps |
GET /mcps/model-status?keys=k1&keys=k2 | Per-key boolean: does a generated input model exist for this query key |
GET /mcps/{key} | Fetch one config (404 if missing) |
POST /mcps | Create src/mcps/{key}.json (409 if the key exists) |
PUT /mcps/{key} | Validate and rewrite; renames the file when the key changes (409 on rename collision); refuses module-sourced configs |
DELETE /mcps/{key} | Delete the file; refuses module-sourced configs |
These endpoints write to src/mcps/, while a non-development runtime reads from the built dist/mcps/ output. Author locally, commit the JSON, and deploy — do not treat the management API as a production configuration channel.