Skip to main content

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 filters and querySearchFields
  • Per-request screen-query permission check against the caller's app role
  • Explicit inline inputSchema so 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 argumentMatchesEffect at execution
namequerySearchFields entryFolded into the query's text search
statefilters[].keyApplied as an eq filter on state
statusfilters[].keyApplied as an eq filter on status
page{{page}} in the templatePassed as a template variable
limit{{limit}} in the templatePassed 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 from GetCustomersPaginatedInput (or GetCustomersInput): 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 description is 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 an Authorization error result. Configuring a tool is not granting it.
  • Endpoint for this config: {coreBaseUrl}/api/mcp/support_readonly.