Skip to main content

Multi-Tool Agent Server

Description

This sample is the shape a real Instance MCP server — Stack9's implementation of the Model Context Protocol (MCP) — tends to converge on: a small, deliberately chosen set of tools that together let one agent do one job end to end. It combines all three schema strategies in a single config —

  1. a query tool with no inputSchema, relying on a generated input model,
  2. a query tool driven by template variables for a date range, and
  3. an automation tool with a mandatory inline schema for the write step.

It also shows the shape of the authorization surface: three tools, three different checks, all evaluated per call. A caller who is authorized for the read tools but not the write tool gets exactly two working tools out of three, with no configuration change.

Use Case

An internal "account health" assistant is asked "how did Acme track last quarter, and can you flag them for a check-in if revenue dropped?". The agent looks the account up, pulls the quarterly revenue series for a date range it chooses, decides whether the drop is material, and — only if it is — raises a follow-up task.

Key Features

  • Three tools, three schema strategies, one config
  • Generated-model derivation for a query whose input model already exists
  • Template-variable arguments for a date-ranged reporting query
  • One write action, isolated to a single automation with its own permission record
  • Per-tool authorization: read privileges for the queries, app role for the automation
  • One endpoint, one session, one credential for the whole toolset

JSON Definition

src/mcps/account_health.json

{
"key": "account_health",
"name": "Account Health Assistant",
"description": "Read account and revenue data, and raise follow-up tasks when an account needs attention.",
"tools": [
{
"key": "getaccounts",
"name": "getaccounts",
"description": "Look up accounts by name, ABN or owner. Returns id, name, owner, tier and lifecycle status. Use this first to resolve a numeric account id before calling any other tool.",
"sourceType": "query"
},
{
"key": "get_account_revenue_by_period",
"name": "get_account_revenue_by_period",
"description": "Monthly invoiced revenue for one account between two dates. Dates must be ISO-8601 (YYYY-MM-DD). Returns one row per month with month, invoiced_total and invoice_count.",
"sourceType": "query",
"inputSchema": {
"account_id": {
"type": "number",
"description": "Numeric Stack9 account id, from getaccounts"
},
"from_date": {
"type": "string",
"description": "Inclusive start date, YYYY-MM-DD"
},
"to_date": {
"type": "string",
"description": "Inclusive end date, YYYY-MM-DD"
}
}
},
{
"key": "raise_account_follow_up",
"name": "raise_account_follow_up",
"description": "Raise a follow-up task for the account owner. Only call this after confirming with revenue data that attention is warranted, and never more than once per account per conversation. Returns the created task id.",
"sourceType": "automation",
"inputSchema": {
"account_id": {
"type": "number",
"description": "Numeric Stack9 account id the task is about"
},
"reason": {
"type": "string",
"description": "Short explanation of why follow-up is needed, including the figures that triggered it"
},
"due_in_days": {
"type": "number",
"description": "Days from today the task is due. Defaults to 7.",
"optional": true
}
}
}
]
}

Tool 1 — generated-model derivation

getaccounts declares no inputSchema. Because it is a query tool, Stack9 PascalCases the key (getaccountsGetaccounts), looks for a generated GetaccountsPaginatedInput and then GetaccountsInput in the instance's generated models — the lookup is case-insensitive, so a generated GetAccountsPaginatedInput matches — and uses that model's fields as the tool schema, with every field made optional.

The Console shows a Generated model found badge on the tool card when this will happen, and hides the manual schema editor. If the badge reads No generated model, the tool would silently expose no arguments at all — add an inline schema instead.

note

Auto-derivation trades control for convenience. You get the query's real input shape for free, but you cannot write per-argument descriptions, and every argument becomes optional. For a tool an agent will call often, an inline schema usually produces better agent behaviour.

Tool 2 — template variables

get_account_revenue_by_period points at a query whose template contains {{account_id}}, {{from_date}} and {{to_date}} tokens. Because those names match template variables rather than filters or search fields, the arguments are passed through as query variables, which is what makes a date range possible:

{
"key": "get_account_revenue_by_period",
"name": "getAccountRevenueByPeriod",
"connector": "stack9_api",
"queryTemplate": {
"method": "post",
"path": "/invoice/search",
"bodyParams": "{\n \"$select\": [\"issued_at\", \"total\"],\n \"$where\": {\n \"account_id\": {{account_id}},\n \"issued_at\": { \"$gte\": \"{{from_date}}\", \"$lte\": \"{{to_date}}\" }\n }\n}"
},
"userParams": {}
}

The path is /invoice/search, so this tool authorizes against the invoice entity — not account. Each tool is authorized against the entity its own query reads.

warning

MCP arguments can only produce eq filters. Ranges, in, and comparisons must be expressed as template variables in the query, exactly as above. If you try to model a date range as two filters, both will be applied as equality and return nothing.

Tool 3 — the write action

raise_account_follow_up is a webhook automation. Its arguments become the automation body, and it is gated by the automation permission record for its app — with no record, the call fails closed with 403.

Note the tool description does prompt engineering: it tells the agent when the tool is appropriate and constrains how often to call it. Instance MCP does not enforce call budgets, so tools with side effects should state their own usage rules.

Authorization surface

ToolCheck applied on every callDenied when
getaccountsread privilege on the entity in the query path (account)Caller's security role lacks read on account
get_account_revenue_by_periodread privilege on invoiceCaller's security role lacks read on invoice
raise_account_follow_upAutomation permission for the automation's appNo permission record exists, or the caller's app role is insufficient

All three tools always appear in tools/list. Authorization happens at call time, so a partially-authorized caller discovers the boundary by being refused, not by seeing a shorter tool list.

note

This is worth telling agent authors: an agent may see a tool it cannot use. A good tool description plus graceful handling of a 403 error result is the intended pattern.

Connecting

{coreBaseUrl}/api/mcp/account_health

The client authenticates with a Stack9 session cookie or an Api-Key header, calls initialize once, keeps the returned mcp-session-id for the conversation, and tears the session down with DELETE when finished. Sessions expire after 30 minutes idle and are limited to 10 per user.

warning

Sessions are held in memory on the Core process that created them. If the instance runs more than one replica, the load balancer must route by mcp-session-id (sticky sessions) or clients will intermittently receive 400 Session not found.

Notes

  • Keep servers small and purposeful. One MCP server per agent job beats one server with every query in the instance: the agent has fewer irrelevant tools to reason about, and the blast radius of a misconfiguration is smaller.
  • Split reads from writes when it helps governance. Two configs — one read-only, one with actions — let you point a low-trust agent at the read-only endpoint and give only trusted principals the app role required for the write endpoint.
  • A module can ship an MCP config. Module-sourced configs are read-only in the Console: they cannot be updated, renamed, or deleted from an instance. Use that to distribute a curated, supported toolset.
  • Tools are built at initialize. Config changes reach a client on its next connection, not mid-session.
  • Test before you ship. The Console's Test tab on the MCP detail screen is a real MCP client against the same endpoint, so it exercises the same authorization path a live agent will hit.