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. Every call is authorised against the screen-query permission of the screen that exposes the query, compared with the caller's access level for that screen's app — 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 connects over OAuth as a Stack9 user whose app role is granted the getcustomers screen query, discovers the tool, and calls it with a search term.
Key Features
- A query exposed by a screen (list, detail, field or
queries[]), so it has a screen-query permission - Read-only: no side effects, no writes
- Arguments derived from the query's
filtersandquerySearchFields - Per-request screen-query permission check against the caller's app role
- 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 — the call is refused with an unknown_arguments error listing the arguments the tool accepts, so the agent can correct itself rather than receive unfiltered results.
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): template variables required, filters and search fields optional, and entity field descriptions carried through. 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. - The query must be exposed by a screen. A query no screen exposes has no screen-query permission and is refused (
query_not_exposed) for everyone but administrators. - The caller still needs the permission. A caller whose app role does not meet the query's required role will not see the tool in
tools/list, and a direct call returns anAuthorization errorresult. Configuring a tool is not granting it. - Endpoint for this config:
{coreBaseUrl}/api/mcp/support_readonly.