Skip to main content

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:

ToolSource typeWhat the agent can do
getstudentsqueryRead student records, with filters and free-text search
create_support_ticketautomationTrigger 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/call from both curl and an MCP client

Time to complete: 30-45 minutes

Prerequisites

  • A running Stack9 instance (yarn dev or your deployed instance)
  • At least one entity-backed query in src/query-library/ (connector stack9_api)
  • At least one automation with triggerType: "webhook" — see Building Workflows
  • Permission to create an api_key record 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:

  1. Configs are files, not database rows. They live in src/mcps/*.json and travel with your instance source, exactly like queries and screens. The Console authoring UI reads and writes those same files.
  2. Nothing is exposed implicitly. A query or automation is only reachable over MCP if you list it as a tool.
  3. 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 queryBecomesExample
{{...}} tokens in queryTemplateTemplate variablespage, limit
filters[].keyEquality filtersis_active
querySearchFieldsFree-text searchname

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.

Raw SQL and external connectors cannot be exposed

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:

FieldTypeRequiredNotes
keystringyesUnique. Becomes the URL segment and the filename.
namestringyesServer display name reported to the MCP client.
descriptionstringnoHuman description.
toolsarrayyesAt least one tool.
modulestringnoPresent only when the config ships from a module — makes it read-only.

And for each S9McpTool:

FieldTypeRequiredNotes
keystringyesThe query-library key or automation key. Also the MCP tool name the agent calls.
namestringyesThe MCP tool title. Conventionally the same as key.
descriptionstringnoShown to the agent — this is your prompt surface. Write it carefully.
sourceTypequery or automationyesDetermines the execution path and the authorization model.
inputSchemaobjectnoInline 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.

Descriptions are prompts

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).

Console MCP list screen at /app-builder/mcps, showing the table of MCP servers with key, name and description columns and a Create button
  1. Click Create and fill in Key, Name, and Description in the Create new MCP server drawer.
Console Create new MCP server drawer with Key, Name and Description fields filled in for my_assistant
  1. 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 is webhook.
Console MCP authoring screen, Configuration tab, showing a tool card with the grouped source select open and the Queries and Webhook Automations option groups visible
  1. 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.
Close-up of a Console MCP tool card showing the No generated model badge and the inline input schema editor with field name, type, description and optional columns
  1. Save. The Console writes src/mcps/my_assistant.json.
Module-sourced configs are read-only

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:

  1. Inline inputSchema wins. Converted directly into the tool's argument shape.
  2. 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 generated stack9-models package. Every field is made optional.
  3. 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 matchesWhere it goes
A {{var}} token in the query templatevars
A filters[].keyAn applied filter with operation eq (blank values skipped)
An entry in querySearchFieldsJoined into a single querySearch string
NothingIgnored

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:

FieldWhat to set
NameSomething traceable, e.g. support-assistant-agent
UserRequired. The agent inherits this user's privileges.
Allowed IP addressesOptional comma-separated allow-list
KeyLeave blank — generated on save
Instance settings screen showing the API Keys list with a newly created key named support-assistant-agent and its associated user

The user you attach is the whole authorization story, because each tool call is re-authorized in-process:

  • Query tools require read privilege on the derived entity (std_student in our example) via the user's security role.
  • Automation tools are evaluated against app_automation_permissions for the automation's app and role. Only a result of allowed proceeds; both no-permission and denied return 403. Automations whose role is Public are always allowed.
Create a dedicated, least-privilege user for the agent

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-id belonging 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.

Console MCP detail page with the actions dropdown open showing Get MCP URL, and the MCP URL modal displaying the full endpoint with a copy button

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.

Console MCP detail page Test tab showing the connected tool list, a parameter form for the getstudents tool, and the JSON result panel

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"
}
}
}
}
Verify header injection with your specific client

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

Roadmap and operational notes
  • Only tools are exposed. The MCP resources and prompts collections are empty today.
  • Sessions are in-memory and process-local. Running more than one core replica requires sticky routing on the mcp-session-id header 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