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 (for example http://localhost:3000 in development).
  • mcpKey — the key of the MCP config. One endpoint per MCP server.

Transport: MCP Streamable HTTP. The route is registered for all HTTP methods; POST and DELETE are the methods with defined behaviour.

Clients should send:

POST /api/mcp/support_agent HTTP/1.1
Host: your-app.stack9.cloud
Content-Type: application/json
Accept: application/json, text/event-stream

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 sits inside the authenticated router. A request that does not resolve to an authenticated Stack9 principal is rejected with 401 Unauthorized before any MCP handling happens.

Two credential forms are relevant to MCP clients:

MethodHow to sendNotes
Session cookieThe Stack9 session JWT cookie, as used by the Console and frontendThe cookie name is api-token, or {prefix}-api-token when a session-cookie prefix is configured for the instance. The token issuer must match the instance.
API keyApi-Key: {key} request headerThe API key record is linked to a Stack9 user; the request inherits that user's security role (entity privileges) and user-group app roles.
warning

There is no MCP-specific credential — no OAuth flow, no per-server token, no consent screen. For a headless external agent an API key is the realistic option, but whether your MCP client can attach a Stack9 cookie or a custom Api-Key header to its HTTP transport is client-specific and not something Stack9 controls. Confirm your client supports custom headers on the MCP transport before designing around it. The Console Test tab works because it already holds a Console session.

Authorization is enforced again per tool call — see Per-tool authorization.

Session lifecycle

MCP Streamable HTTP is stateful. Sessions are held in memory on the Core process that created them.

1. Initialize

POST a JSON-RPC initialize request with no mcp-session-id header:

{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "my-agent", "version": "1.0" }
}
}

The server builds the tool set for the config, creates a transport, and returns the session id in the response header:

HTTP/1.1 200 OK
mcp-session-id: 5b1c2e2c-9c8f-4a1e-9c47-2b7e64f3f7a1

The server identifies itself with the config's name and version 1.0.0. Only tools are advertised — resources and prompts are always empty.

2. Subsequent calls

Send the mcp-session-id header on every following request:

POST /api/mcp/support_agent HTTP/1.1
mcp-session-id: 5b1c2e2c-9c8f-4a1e-9c47-2b7e64f3f7a1
Content-Type: application/json
Accept: application/json, text/event-stream
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "getcustomers",
"arguments": { "querySearch": "acme" }
}
}

3. Teardown

DELETE /api/mcp/support_agent HTTP/1.1
mcp-session-id: 5b1c2e2c-9c8f-4a1e-9c47-2b7e64f3f7a1

Teardown is idempotent and owner-scoped: it returns 200 whether or not the session existed, so a caller cannot probe for another user's session.

Session constraints

ConstraintValue
Idle expiry30 minutes (swept lazily on access)
Sessions per user10 (least-recently-used evicted)
Sessions per process1000 (least-recently-used evicted)
Owner bindingA session is bound to the user that created it; lookups from any other user are treated as not found
LocalityProcess-local. Multiple Core replicas require sticky routing on mcp-session-id
note

The tool set is built at initialize time. If you change the MCP config, existing sessions keep the tools they were built with — reconnect to pick up changes. The config file itself is re-read from disk on every request, so no restart is needed.

Endpoint behaviour matrix

RequestResponse
POST initialize, no session header200 + mcp-session-id response header
POST with a valid, owned mcp-session-idDispatched to the session (tools/list, tools/call, …)
POST with an unknown or another user's mcp-session-id400 {"error":"Session not found"}
POST non-initialize with no session header400 JSON-RPC error -32600Expected initialize request
DELETE with mcp-session-id200 (idempotent, owner-scoped)
DELETE with no mcp-session-id400 {"error":"Missing mcp-session-id"}
Any method with an unknown mcpKey404MCP '{mcpKey}' not found
Any request without valid credentials401 Unauthorized

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 during initialize.
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 (when sourceType is query) or the automation key (when sourceType is 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'yesDetermines the execution path and the authorization check.
inputSchemaS9McpToolInlineSchemanoInline argument schema. Overrides generated-model derivation when present.
note

key and name play different roles: key is the identifier the agent calls and the query/automation it resolves to, while name is only a display title. They cannot be decoupled — you cannot alias a query under a different tool name.

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. Generated model — otherwise, and only when sourceType is query: 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. If found, every field is forced optional and used as the tool schema.
  3. Empty — otherwise the tool declares no arguments.
warning

Step 2 never applies to automation tools. An automation tool without an inputSchema exposes zero arguments, so the agent can only invoke it with an empty body. Always declare an inline schema for automation tools.

Because the model lookup is name-based, all-lowercase query keys collapse into a single capitalised token (getalldevelopersGetalldevelopers). 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 aboveSilently ignored — no error

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. The automation runs with the webhook trigger type and the calling user's id (or null for a principal with no user id). The tool result is the last action's output from the runbook, or null when the last action produced none.

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 authorization

Tool calls bypass the route-level entity and webhook middleware, so equivalent checks run inside each tool callback on every call, against the credentials of that request.

query tools

ConditionResult
Query key does not exist403 — only entity-backed queries can be exposed via MCP
Query connector is not stack9_api (raw SQL, external connector)403 — same message
Query template path has no static first segment (missing, or a {{variable}})403 — same message
Entity resolved, but caller lacks the read privilege403You do not have read permission for '{entityKey}'
Entity resolved and caller has readQuery runs

The entity is the first non-empty segment of the query template path, so /customer/search resolves to the customer entity.

automation tools

ConditionResult
No permission record exists for the automation403 — fails closed
A record exists but the caller's role for the automation's app is insufficient403
The automation's required role is PublicAllowed
The caller's role is sufficientAllowed

Denials are returned as an MCP error result, not an HTTP error — the HTTP response for a tools/call is 200 with isError: true in the JSON-RPC result.

Error behaviour

SituationWhat the agent receives
Authorization denial, not-found, validation failureisError: true with the specific Stack9 message (these messages are intentionally safe to surface)
Any other failure (SQL error, driver fault, bug)isError: true with the generic text Tool '{key}' failed due to an internal error. The full error is logged server-side with the tool key
Transport-level problems (missing/foreign session, bad first request)HTTP 400 with a JSON body, as in the behaviour matrix
Unknown mcpKeyHTTP 404
Missing or invalid credentialsHTTP 401
note

Internal errors are deliberately opaque to the client to avoid leaking schema, query, or driver detail to an AI agent. Diagnose them from the Core logs using the tool key.

Console management API

The Console reads and writes MCP config files through a small management API on the Core instance. It is an internal 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.

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.