Skip to main content

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 from SERVER_BASE_URL (for example http://localhost:4444 in development).
  • mcpKey — the key of 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.

CredentialResult
Authorization: Bearer {token} — access token whose aud is this MCP resource ({coreBaseUrl}/api/mcp/{mcpKey}) and whose scope includes mcp:toolsAccepted; 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:tools403 insufficient_scope
Session cookieNot read here; on its own treated as no credential — 401
HTTP Basic401 invalid_token
Api-Key: {key} headerAccepted when it resolves to an API-key user. Kept for development and QA; not advertised in metadata and not an OAuth mechanism
None401

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.

PathPurpose
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-serverAuthorisation server metadata (RFC 8414)
GET /.well-known/openid-configurationThe same document, for clients that only know the OIDC discovery path
/api/oauth/authorizeAuthorisation endpoint (authorisation-code flow)
/api/oauth/tokenToken endpoint (authorization_code and refresh_token grants)
PropertyValue
IssuerSERVER_BASE_URL, normalised. If it is not a usable URL the MCP endpoint answers 503
PKCES256 only. Mandatory for public and CIMD clients; optional for a pre-registered client holding a secret (enforced if sent)
resource parameterRequired (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
Scopemcp:tools (the only supported scope; absent means mcp:tools)
Token endpoint authnone, client_secret_basic, client_secret_post
Client registrationClient ID Metadata Documents, or pre-registered app_registration rows. No Dynamic Client Registration endpoint
Access token lifetime900 seconds (15 minutes); renew with the refresh grant
Authorisation responseAlways 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​

RequestResponse
POST tools/list with an accepted credential200 — the caller's authorised tools plus stack9_whoami
POST tools/call with an accepted credential200; a refusal or failure is a JSON-RPC result with isError: true
No credential401 + WWW-Authenticate challenge
Refused token (wrong audience, expired, unverifiable, no aud)401 invalid_token + challenge
Token without mcp:tools403 insufficient_scope + challenge
Unknown mcpKey, with an accepted credential404 — MCP '{mcpKey}' not found
SERVER_BASE_URL unusable503

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​

FieldTypeRequiredNotes
keystringyesUnique. Becomes the URL segment and the filename. Validated as a token: letters, digits, and underscores only.
namestringyesThe MCP server name reported to the client, suffixed with the instance label ({name} [{instance} · {environment} · {host}]).
descriptionstringnoHuman-facing description of the server. Not sent to the agent as tool metadata.
toolsS9McpTool[]yesAt least one tool is required.
modulestringnoSet 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​

FieldTypeRequiredNotes
keystringyesThe 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.
namestringyesBecomes the MCP tool title (display label). The Console sets this equal to key.
descriptionstringnoThe 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'yesDetermines the execution path and the authorisation check. See the MCP schema reference.
inputSchemaS9McpToolInlineSchemanoInline argument schema. Overrides every derived schema when present.
automationKeystringnoautomation tools only (forbidden on other source types). The automation to run and whose permission row gates it. Defaults to key.
note

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 }
}
FieldTypeRequiredNotes
type'string' | 'number' | 'boolean' | 'object' | 'array'yesSee the type mapping below.
descriptionstringnoSurfaced to the agent as the argument description.
optionalbooleannoWhen true the argument is optional. Defaults to required.

Type mapping

Declared typeAgent-facing schema
stringstring
numbernumber
booleanboolean
objectobject with arbitrary keys and unvalidated values
arrayarray with unvalidated items

Anything not in the list above is treated as string.

note

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 fieldComes from
nametool.key
titletool.name
descriptiontool.description
inputSchemaresolved as below

Input schema resolution​

  1. Inline schema — if tool.inputSchema is present, it is used.
  2. Server action contract — for serverAction tools, the registered action's zod input contract, with required fields kept required.
  3. Entry action contract — for automation tools, the input zod 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.
  4. Generated model — for query tools: 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 entity description carried through.
  5. Empty — otherwise the tool declares no arguments.
warning

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 matchesEffect
A {{variable}} token anywhere in the query templatePassed 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 querySearchFieldsAppended to a single space-joined search string
Nothing aboveRefused — 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).

ConditionResult
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 appRefused — no_app_role
Exposed, but the caller's access level is below the role every exposing screen requiresRefused — insufficient_app_role: You have no access to run {key}
Any exposing screen grants the query at or below the caller's access levelQuery runs

Matching of query keys is case-insensitive, and the most permissive exposing screen wins.

serverAction tools​

ConditionResult
Instance-defined action on an instance whose dxp.mode is not clientRefused — server_action_not_permitted (no role can satisfy this)
Declared on no screenRefused — query_not_exposed
OtherwiseChecked exactly like a query tool, under the action key and every name a declaring screen exposes it as

automation tools​

ConditionResult
No app_automation_permissions row for the automationRefused — automation_not_exposed (fails closed)
A row exists but the caller holds no access level for its appRefused — no_app_role
A row exists but the caller's access level is below its required roleRefused — insufficient_app_role
The row's required role is PublicAllowed
The caller's access level is sufficientAllowed

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​

SituationWhat the agent receives
Authorisation denialisError: true — Authorization error ({code}): {reason}
Unknown query argumentsisError: true — Argument error (unknown_arguments): …
Any other failure during the tool runisError: true — Error: {message}. The full error is also logged server-side with the MCP key, tool key and user id
Refused or missing credentialHTTP 401 / 403 with a WWW-Authenticate challenge
Unknown mcpKeyHTTP 404
Unusable SERVER_BASE_URLHTTP 503
warning

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 pathPurpose
GET /mcpsList all loaded configs with file path and timestamps
GET /mcps/model-status?keys=k1&keys=k2Per-key boolean: does a generated input model exist for this query key
GET /mcps/{key}Fetch one config (404 if missing)
POST /mcpsCreate 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
warning

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.