Exposing MCP Tools
Your Stack9 instance already models data as entities, reads as query-library queries, and actions as automations. Instance MCP turns a chosen subset of those into Model Context Protocol tools, served from one secured endpoint that any MCP-compatible AI agent can call.
This guide takes you from "I have a query and an automation" to "an AI agent successfully called both of them" — without writing any integration code.
What You'll Build
An MCP server called my_assistant, exposing two tools:
| Tool | Source type | What the agent can do |
|---|---|---|
getstudents | query | Read student records, with filters and free-text search |
create_support_ticket | automation | Trigger a webhook automation that creates a ticket |
By the end you will have:
- A config file at
src/mcps/my_assistant.json(or the same config authored in the Console) - A working endpoint at
{coreBaseUrl}/api/mcp/my_assistant - A Stack9 API key scoped to a user with exactly the privileges the agent needs
- A successful
tools/callfrom bothcurland an MCP client
Time to complete: 30-45 minutes
Prerequisites
- A running Stack9 instance (
yarn devor your deployed instance) - At least one entity-backed query in
src/query-library/(connectorstack9_api) - At least one automation with
triggerType: "webhook"— see Building Workflows - Permission to create an
api_keyrecord in your instance - Conceptual background: MCP Server
How It Works
An MCP server in Stack9 is one JSON config (S9McpConfig) containing a list of tools (S9McpTool). Each tool points at exactly one existing artifact:
src/mcps/my_assistant.json
├── tool "getstudents" sourceType: query → src/query-library/getstudents.json
└── tool "create_support_ticket" sourceType: automation → src/automations/create_support_ticket.json
Three things are worth internalising before you start:
- Configs are files, not database rows. They live in
src/mcps/*.jsonand travel with your instance source, exactly like queries and screens. The Console authoring UI reads and writes those same files. - Nothing is exposed implicitly. A query or automation is only reachable over MCP if you list it as a tool.
- Every call is authorized twice — once at the endpoint (valid Stack9 credentials) and once per tool (entity read privilege for query tools, automation permission for automation tools). Both fail closed.
Step 1: Pick a Query to Expose
Only entity-backed queries can be exposed — that means connector: "stack9_api", where the query template's path starts with an entity key. Here is src/query-library/getstudents.json:
{
"key": "getstudents",
"name": "getStudents",
"description": "Retrieves all students",
"connector": "stack9_api",
"queryTemplate": {
"method": "post",
"path": "/std_student/search",
"bodyParams": "{\n \"$where\": { \"_is_deleted\": false },\n \"$select\": [\"id\", \"name\", \"dob\", \"is_active\"]\n}",
"queryParams": {
"page": "{{page}}",
"limit": "{{limit}}"
}
},
"querySearchFields": ["name"],
"filters": [
{
"name": "Is Active",
"key": "is_active",
"typeQueryFilter": "compare",
"field": "is_active",
"typeFilter": "BooleanValue",
"sequence": 1
}
]
}
Three parts of this file decide what arguments the tool will accept:
| Part of the query | Becomes | Example |
|---|---|---|
{{...}} tokens in queryTemplate | Template variables | page, limit |
filters[].key | Equality filters | is_active |
querySearchFields | Free-text search | name |
The entity key is derived from the first segment of path — here std_student. Remember it: the caller will need read privilege on that entity.
stack9_db (raw SQL) queries and queries against external connectors have no derivable entity, so Stack9 has nothing to authorize them against. Listing one as a tool is allowed, but the call fails closed with a 403: "only entity-backed queries can be exposed via MCP". Wrap the data in an entity-backed query first.
Step 2: Pick a Webhook Automation
Automation tools are the "action" side. The automation must use the webhook trigger type — that is the only trigger type Instance MCP can invoke:
{
"key": "create_support_ticket",
"name": "Create Support Ticket",
"app": "crm",
"triggerType": "webhook",
"triggerParams": {
"method": "post",
"path": "/create-support-ticket"
},
"actions": [
{
"key": "create_ticket",
"name": "Create Ticket",
"actionTypeKey": "create_entity",
"params": {
"entity": "support_ticket",
"data": {
"subject": "{{trigger.body.subject}}",
"body": "{{trigger.body.body}}",
"priority": "{{trigger.body.priority}}"
}
}
}
]
}
When the agent calls the tool, the whole arguments object is passed straight through as the automation body — so {{trigger.body.subject}} resolves to the agent's subject argument. The tool returns the output of the automation's last action.
Step 3: Author the MCP Config File
Create src/mcps/my_assistant.json. The filename must match the key:
{
"key": "my_assistant",
"name": "My Assistant",
"description": "Read student data and raise support tickets",
"tools": [
{
"key": "getstudents",
"name": "getstudents",
"description": "Search students. Supports is_active filter and name search. Page starts at 0.",
"sourceType": "query"
},
{
"key": "create_support_ticket",
"name": "create_support_ticket",
"description": "Raise a support ticket on behalf of a student",
"sourceType": "automation",
"inputSchema": {
"subject": { "type": "string", "description": "Short summary of the issue" },
"body": { "type": "string", "description": "Full description" },
"priority": {
"type": "string",
"description": "One of: low, normal, high",
"optional": true
}
}
}
]
}
Field reference for S9McpConfig:
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | Unique. Becomes the URL segment and the filename. |
name | string | yes | Server display name reported to the MCP client. |
description | string | no | Human description. |
tools | array | yes | At least one tool. |
module | string | no | Present only when the config ships from a module — makes it read-only. |
And for each S9McpTool:
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | The query-library key or automation key. Also the MCP tool name the agent calls. |
name | string | yes | The MCP tool title. Conventionally the same as key. |
description | string | no | Shown to the agent — this is your prompt surface. Write it carefully. |
sourceType | query or automation | yes | Determines the execution path and the authorization model. |
inputSchema | object | no | Inline schema. Overrides auto-derivation when present. |
Each inline schema field takes type (string, number, boolean, object, array), an optional description, and an optional optional flag.
The agent chooses which tool to call based on description alone. "Retrieves all students. Page starts at 0, not 1." prevents a whole class of failed calls. Treat these strings as part of your prompt engineering, not as documentation.
MCP configs are re-read from disk on every request to the endpoint, so a new or edited file is picked up on the next call — no restart required. Because the Console writes the same files, the two authoring paths in this guide are interchangeable.
Step 4: (Alternative) Author in the Console
The Console writes the same files, so pick whichever path suits you. Navigate to App Builder → MCPs (/app-builder/mcps).
- Click Create and fill in Key, Name, and Description in the Create new MCP server drawer.
- Open the new server's detail page (
/app-builder/mcps/detail/my_assistant). On the Configuration tab, add a tool and choose its source from the grouped select — the Queries group or the Webhook Automations group. The automation list only contains automations whose trigger type iswebhook.
- Watch the badge on each tool card. Generated model found means Stack9 can derive the tool's input schema automatically and the manual schema editor is hidden. No generated model means you should define the schema inline.
- Save. The Console writes
src/mcps/my_assistant.json.
An MCP config that ships from an installed module carries a module field. The Console cannot rename or delete it — attempts return "MCP from module cannot be changed". Copy it into your instance under a new key if you need to change it.
Step 5: Understand How Arguments Are Resolved
The tool's input schema is resolved in this order:
- Inline
inputSchemawins. Converted directly into the tool's argument shape. - Auto-derived from generated models (query tools only). Stack9 PascalCases the tool key and looks for
{Model}PaginatedInput, then{Model}Input, in your instance's generatedstack9-modelspackage. Every field is made optional. - Empty — the tool takes no arguments.
Run yarn generate-models if you expect auto-derivation and the Console shows No generated model.
At call time, a query tool partitions the incoming arguments against the query definition:
| Argument matches | Where it goes |
|---|---|
A {{var}} token in the query template | vars |
A filters[].key | An applied filter with operation eq (blank values skipped) |
An entry in querySearchFields | Joined into a single querySearch string |
| Nothing | Ignored |
An automation tool skips all of that: the entire arguments object becomes the automation body.
Step 6: Secure the Endpoint
There is no MCP-specific token. The endpoint sits behind the standard Stack9 authorized middleware, so a caller must present either a valid JWT session cookie or a Stack9 API key in the Api-Key header. Anonymous access is impossible.
For a headless agent, an API key is the practical choice. Create an api_key record in your instance:
| Field | What to set |
|---|---|
Name | Something traceable, e.g. support-assistant-agent |
User | Required. The agent inherits this user's privileges. |
Allowed IP addresses | Optional comma-separated allow-list |
Key | Leave blank — generated on save |
The user you attach is the whole authorization story, because each tool call is re-authorized in-process:
- Query tools require
readprivilege on the derived entity (std_studentin our example) via the user's security role. - Automation tools are evaluated against
app_automation_permissionsfor the automation's app and role. Only a result ofallowedproceeds; bothno-permissionanddeniedreturn 403. Automations whose role isPublicare always allowed.
Do not point an agent's API key at an administrator. Administrators bypass entity privilege checks entirely, so every entity-backed query in your instance becomes readable the moment it is listed as a tool. Create a user whose security role grants read only on the entities the agent genuinely needs, and whose user groups grant only the automations it should be able to trigger.
Everything else fails closed by design:
- A tool whose query is not entity-backed → 403.
- A tool whose automation has no permission record → 403.
- An unexpected server-side error → the agent receives only "Tool 'x' failed due to an internal error", while the detail is logged server-side. Schema and driver internals never leak to the agent.
- An
mcp-session-idbelonging to another user → treated as Session not found.
Step 7: Get the MCP URL
The endpoint is always {coreBaseUrl}/api/mcp/{mcpKey} — for our example, https://core.example.com/api/mcp/my_assistant.
In the Console, use the … dropdown on the MCP detail page and choose Get MCP URL to copy it.
Step 8: Test a Tool Call with curl
The transport is MCP Streamable HTTP, which is stateful: initialize once, then send the returned session id on every subsequent request. Set up your shell first:
export CORE_BASE_URL="https://core.example.com"
export STACK9_API_KEY="your-api-key"
export MCP_URL="$CORE_BASE_URL/api/mcp/my_assistant"
8a. Initialize and capture the session id
curl -i -X POST "$MCP_URL" \
-H "Api-Key: $STACK9_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "curl", "version": "1.0.0" }
}
}'
The response headers include the session id, and the body may be SSE-framed (data: {...}) — that is expected:
HTTP/1.1 200 OK
mcp-session-id: 8f2c7d64-0f4a-4a2f-9a1e-5f7c0b9e12ab
content-type: text/event-stream
export MCP_SESSION="8f2c7d64-0f4a-4a2f-9a1e-5f7c0b9e12ab"
Then complete the handshake:
curl -X POST "$MCP_URL" \
-H "Api-Key: $STACK9_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $MCP_SESSION" \
-d '{ "jsonrpc": "2.0", "method": "notifications/initialized" }'
8b. List the tools
curl -X POST "$MCP_URL" \
-H "Api-Key: $STACK9_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $MCP_SESSION" \
-d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }'
You should see both tools, with the titles and descriptions from your config, and the resolved input schemas.
8c. Call the query tool
curl -X POST "$MCP_URL" \
-H "Api-Key: $STACK9_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $MCP_SESSION" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "getstudents",
"arguments": { "page": 0, "limit": 5, "is_active": true, "name": "smith" }
}
}'
Results are returned as a single text block containing JSON:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "{\"total\":1,\"totalPages\":1,\"results\":[{\"id\":42,\"name\":\"Jane Smith\",\"is_active\":true}]}"
}
]
}
}
8d. Call the automation tool
curl -X POST "$MCP_URL" \
-H "Api-Key: $STACK9_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $MCP_SESSION" \
-d '{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "create_support_ticket",
"arguments": {
"subject": "Cannot access portal",
"body": "Student reports a 403 on login since this morning.",
"priority": "high"
}
}
}'
8e. Tear the session down
curl -X DELETE "$MCP_URL" \
-H "Api-Key: $STACK9_API_KEY" \
-H "mcp-session-id: $MCP_SESSION"
DELETE is idempotent and owner-scoped: it returns 200 even for an unknown session id, so nobody can probe for valid sessions. Sessions also expire after 30 minutes idle, with a cap of 10 per user and 1000 globally.
Or use the built-in Test tab
The Console's Test tab on the MCP detail page is a real MCP client that performs exactly the flow above from your browser session — connect, list tools, fill in parameters, run, and read the JSON result or error. It is the fastest way to confirm a config before wiring up an external agent.
Step 9: Connect an MCP Client
Point your client at the endpoint using the Streamable HTTP transport and make sure the Stack9 credential reaches the server on every request.
If your client supports custom headers on remote MCP servers, add Api-Key: your-api-key and you are done. If it does not, run a local bridge that injects the header. For example, with a Claude-style client config file:
{
"mcpServers": {
"stack9-my-assistant": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://core.example.com/api/mcp/my_assistant",
"--header",
"Api-Key:${STACK9_API_KEY}"
],
"env": {
"STACK9_API_KEY": "your-api-key"
}
}
}
}
Stack9 authenticates with a cookie or the Api-Key header — there is no OAuth flow and no MCP-specific token. Whether a given third-party MCP client can attach that header directly, or needs a bridge process like the one above, depends entirely on the client and its version. Test the connection in a non-production instance first, and treat the API key as a production secret: keep it out of version control and rotate it if a client stores it in plaintext config.
Once connected, ask the agent something that requires the data, for example "How many active students match 'smith'?". The agent should call getstudents and answer from the result.
Current Limitations
- Only
toolsare exposed. The MCPresourcesandpromptscollections are empty today. - Sessions are in-memory and process-local. Running more than one core replica requires sticky routing on the
mcp-session-idheader at the load balancer. There is no shared session store. - No MCP-specific rate limiting. The only limits are the session caps and the 30-minute idle expiry.
Troubleshooting
401 Unauthorized
The request carried no valid credential. Check that the Api-Key header name is exactly Api-Key, that the key exists and is not soft-deleted, that it has a User assigned, and that the caller's IP is within Allowed IP addresses if you set that field.
404 MCP 'my_assistant' not found
The config was not found on disk. Confirm the file is at src/mcps/my_assistant.json, that its key matches both the filename and the URL segment, and — in a deployed instance — that the file was included in the build you shipped. Duplicate keys are de-duplicated on load and the first file wins, so also check whether an installed module ships the same key.
400 Expected initialize request
You sent a non-initialize request without an mcp-session-id header. Initialize first, then reuse the returned session id.
400 Session not found
The session expired (30 minutes idle), the core process restarted, the request landed on a different replica, or the session belongs to a different user. Re-initialize.
403 only entity-backed queries can be exposed via MCP
The query's connector is not stack9_api, or its path's first segment is missing or is itself a template variable. Rewrite it as an entity-backed query with a literal entity key in the path.
403 You do not have read permission for 'x'
The API key's user lacks read on that entity. Grant it on the user's security role — deliberately, one entity at a time.
403 You do not have permission to run 'x'
No app_automation_permissions record matched, or the user's role for the automation's app is insufficient. Grant the automation permission for the user's group, or mark the automation's access level Public if it is genuinely safe to expose.
The tool takes no arguments when you expected some
Auto-derivation found no generated model. Run yarn generate-models, or define an inline inputSchema. Remember the key-to-model convention: the tool key is PascalCased by splitting on _ only, so an all-lowercase key like getalldevelopers looks for Getalldevelopers{,Paginated}Input (matched case-insensitively).
Arguments are silently ignored
An argument that matches no template variable, no filter key, and no search field is dropped for query tools. Cross-check your inline schema field names against the query definition.
Next Steps
- MCP Server concepts — the full architecture, session model, and security reference
- Building Workflows — build the webhook automations you expose as action tools
- Custom Queries — write the entity-backed queries you expose as read tools
- Executing Queries — filters, search fields, and template variables in depth