Query-Backed Read Tool
Description
This sample exposes a single query-library query as a read-only Model Context Protocol (MCP) tool, so an AI agent can look customers up by name, email, state, or status. It is the smallest useful Instance MCP config and the pattern most teams start with.
The tool's arguments are not invented for MCP — they are the query's own filters and search fields. At call time Stack9 partitions the arguments the agent sends against the query definition: names that match a filter key become equality filters, names that match a search field are folded into the query's text search, and names that match a {{variable}} token in the template become template variables. Because the query is backed by the customer entity, every call is re-authorized against the caller's read privilege on customer — the tool cannot become a permission bypass.
Use Case
A support assistant needs to answer questions like "is Acme Pty Ltd still an active customer, and which state are they in?" without a human opening the CRM. The agent authenticates as a Stack9 principal with read access to customer, discovers the getcustomers tool, and calls it with a search term.
Key Features
- Entity-backed
stack9_apiquery — the only query type MCP will execute - Read-only: no side effects, no writes
- Arguments derived from the query's
filtersandquerySearchFields - Per-call
readprivilege check on thecustomerentity - Explicit inline
inputSchemaso the agent sees exactly the arguments you intend
The query it points at
src/queries/getcustomers.json — the query already exists for a Console list view; MCP reuses it as-is.
{
"key": "getcustomers",
"name": "getCustomers",
"description": "Search customers by name or email, optionally filtered by state and status.",
"connector": "stack9_api",
"queryTemplate": {
"method": "post",
"path": "/customer/search",
"bodyParams": "{\n \"$select\": [\"id\", \"name\", \"email\", \"state\", \"status\"],\n \"$sort\": { \"name\": \"asc\" }\n}",
"queryParams": {
"page": "{{page}}",
"limit": "{{limit}}"
}
},
"filters": [
{
"name": "State",
"key": "state",
"typeQueryFilter": "compare",
"field": "state",
"typeFilter": "StringCompareValue",
"useSubquery": false,
"sequence": 1
},
{
"name": "Status",
"key": "status",
"typeQueryFilter": "compare",
"field": "status",
"typeFilter": "StringCompareValue",
"useSubquery": false,
"sequence": 2
}
],
"querySearchFields": ["name", "email"],
"userParams": {}
}
The path is /customer/search, so the first non-empty segment — customer — is the entity MCP authorizes against.
JSON Definition
src/mcps/support_readonly.json
{
"key": "support_readonly",
"name": "Support Read-Only Tools",
"description": "Read-only customer lookups for the support assistant. No write access.",
"tools": [
{
"key": "getcustomers",
"name": "getcustomers",
"description": "Search customers by name or email. Optionally filter by Australian state (e.g. NSW, VIC) and status (active, churned, prospect). Returns a paginated list of id, name, email, state and status. Page numbering starts at 0.",
"sourceType": "query",
"inputSchema": {
"name": {
"type": "string",
"description": "Free-text search across customer name and email",
"optional": true
},
"state": {
"type": "string",
"description": "Exact state code filter, e.g. NSW",
"optional": true
},
"status": {
"type": "string",
"description": "Exact status filter: active, churned or prospect",
"optional": true
},
"page": {
"type": "number",
"description": "Zero-based page index",
"optional": true
},
"limit": {
"type": "number",
"description": "Page size, default 20",
"optional": true
}
}
}
]
}
How the arguments map
| Agent argument | Matches | Effect at execution |
|---|---|---|
name | querySearchFields entry | Folded into the query's text search |
state | filters[].key | Applied as an eq filter on state |
status | filters[].key | Applied as an eq filter on status |
page | {{page}} in the template | Passed as a template variable |
limit | {{limit}} in the template | Passed as a template variable |
So a call of:
{
"name": "getcustomers",
"arguments": { "name": "acme", "state": "NSW", "limit": 5 }
}
runs the query with a text search of acme, an eq filter of state = NSW, and limit = 5.
If the agent invents an argument that matches nothing — say sort_by — it is silently ignored. Nothing errors, the query simply runs without it.
Result
{
"content": [
{
"type": "text",
"text": "{\"data\":[{\"id\":42,\"name\":\"Acme Pty Ltd\",\"email\":\"ops@acme.example\",\"state\":\"NSW\",\"status\":\"active\"}],\"total\":1}"
}
]
}
The agent parses content[0].text as JSON.
Notes
- Could you omit
inputSchema? Yes, if a generated input model exists for the query key — Stack9 will derive the schema fromGetCustomersPaginatedInput(orGetCustomersInput) and mark every field optional. The Console shows a Generated model found badge when this will happen. The inline schema above is preferred here because it lets you write agent-facing descriptions per argument, which materially improves how well the agent calls the tool. - Descriptions are prompt engineering. The tool
descriptionis what the agent reads to decide whether to call the tool and what to pass. Spell out enumerations, units, and zero-based indexing, exactly as above. - Filters are equality only when driven from MCP arguments. A "customers created in the last 30 days" tool needs a
{{from_date}}template variable in the query, not a filter. stack9_dband external-connector queries are refused with403at call time, even if you configure them. Only entity-backedstack9_apiqueries can execute through MCP.- The caller still needs the privilege. A caller without
readoncustomerwill see the tool intools/listbut get a403error result on call. Listing a tool is not granting it. - Endpoint for this config:
{coreBaseUrl}/api/mcp/support_readonly.