Three tools not eighty
02 Nov 2026
Three tools, not eighty: designing MCP tools for real data
Our first sketch of the GraphPaaS connector for
Claude had one tool per report: get_licence_waste,
get_devices, get_signins, and so on down the list. At the time
that list was about 80 entries long. We shipped three tools instead on 2 October:
list_tenants, list_domains and get_metrics.
A month later, these are the field notes. The numbers come first, then what each one turned out to mean. Every exchange below was run against our demo organisation, which holds two synthetic tenants, Fabrikam HQ and Fabrikam Iberia.
95 domains, and the tool count didn't follow
Every tool a server exposes is a name, a description and a JSON schema that the client
loads into the model's context before the user types a word. Eighty tools would have
been eighty schemas, almost all of them irrelevant to any one question, and the count
would have grown with every domain we added. It did grow: list_domains
returns 95 names today.
Microsoft ran into the same wall with its own server. The
Azure MCP
Server concepts page notes that its all mode exposes 200+ tools and
"can exceed tool limits in some clients", cites the 128-tool limit per request in
Visual Studio Code Copilot, and recommends a consolidated mode that groups operations
into fewer tools named after what the user wants.
Our version of that is a lookup, a description and a query. list_domains
names what exists. describe_domain returns a domain's columns with their
types, a note on how to read the rows, how many snapshots are stored, and
whether each tenant has it (available, no_data or
unavailable with the reason). get_metrics reads it. A new
domain reaches Claude without a new tool. Besides describe_domain, the
eight tools added since are the things that aren't one table read: comparing two snapshots, browsing SharePoint folders live, the
licence and tenant advisors, two migration checks, Hostinger, and
sync_domain, the only one that isn't read-only (it queues a refresh).
"Ask the user which one they mean"
The first version picked a tenant when the call didn't name one. Our own QA in claude.ai caught it in the first days: a correct answer, about a tenant nobody had chosen. For an MSP with twenty clients that's the worst kind of wrong, because it reads exactly like the right answer.
Now, with more than one readable tenant and no tenant_id, the call is
refused, and the error is written for the model rather than for a log file:
Who has a licence they don't use?
get_metrics(domain="licence_waste", limit=5) → 400: you can read 2 tenants — ask the user which one they mean, as options they can pick from (one option per tenant plus 'All tenants' (tenant_id="all")), never by guessing
You can read two tenants: Fabrikam HQ (Demo) and Fabrikam Iberia (Demo). Which one, or all tenants?
The refusal carries the tenant names and ids, so Claude's next call is the right one.
"all" reads every tenant and tags each row with its tenant, which is what
the MSP
one-question ranking is built on. It also leaves demo tenants out once a real one
exists, so a sum across clients never adds synthetic rows to real ones.
"yesterday" matched every row
Dates travel as ISO text. Graph's own
signIn resource
reports createdDateTime as UTC in the form 2014-01-01T00:00:00Z,
and ISO strings sort correctly as text, so comparing them as text was the simple choice.
Then a model, mid-conversation, filtered on {"<": "yesterday"}. Digits
sort before letters, so every date is "less than" yesterday, and the call returned every
row with a 200. {">": "abc"} on a numeric column returned none. A
sum over a text column returned null. All three looked like
answers.
A 400 is better than any of them. The server now checks each operand against the types the column actually holds across all rows, and refuses what can't apply:
Show me Fabrikam HQ's sign-ins since yesterday.
get_metrics(domain="signins", tenant_id="ce48e256-…",
where={"when": {">=": "yesterday"}}, limit=5)
→ 400: '>=' on 'when' needs a YYYY-MM-DD date, got 'yesterday'.
get_metrics(domain="signins", tenant_id="ce48e256-…",
where={"when": {">=": "2026-10-30"}},
aggregate=["min:when", "max:when"])
→ 52 rows · min 2026-10-30T07:41:00Z · max 2026-10-31T19:23:00Z
52 sign-ins since 30 October, the first at 07:41 UTC and the latest on 31 October at 19:23 UTC.
An earlier round fixed equality: {"score": 1} used to match zero rows
against a stored 1.0, because values were compared as strings. Numbers now
compare as numbers.
35 failures, 6 lines back
The demo tenant's signins domain holds 420 rows: 385 successes and 35
failures, from one group_by call. Paging 420 rows into the conversation to
count them would spend most of the context on rows nobody reads, and a model counting by
hand miscounts. So get_metrics counts on the server:
Which apps are the failed sign-ins in Fabrikam HQ coming from?
get_metrics(domain="signins", tenant_id="ce48e256-…",
where={"status": {"!=": "success"}}, group_by="app", limit=5)
→ 35 rows · 6 groups: Azure Portal 12, Microsoft Teams 5, Power BI 5,
SharePoint Online 5, Microsoft 365 Portal 5
35 sign-ins failed, across 6 apps. The Azure portal has 12 of them; Teams, Power BI, SharePoint Online and the Microsoft 365 portal have 5 each.
The other half of this lesson was a cut nobody announced. One view returned 576 data
points, the 500-row ceiling trimmed it, and nothing in the response said so. Every cut
now says truncated, and total_rows always counts the matches
before paging, so the model knows when it's looking at part of the answer.
The tool list is frozen when the chat starts
We added two parameters to a tool, deployed, and the open conversation kept calling the old signature. A client reads the tool list when it connects and keeps it. Our server is stateless, so it has no channel to announce that the list changed. The rule we work by now: a changed signature means a new conversation in claude.ai, and if that isn't enough, disconnect and reconnect the connector. It also changed how we add things: a new optional parameter breaks nothing for an old chat, while a rename breaks every open one.
No picker, so the model asks
The tenant question begs for a dropdown. MCP has one in the spec, called elicitation:
the server pauses a tool call and asks the client to render a small form.
Microsoft's
Copilot Cowork documentation lists "disambiguate between options" as a use for it and
renders an enum as a dropdown. When we checked in October, claude.ai didn't
render elicitation forms, and a request-response server like ours can't open that
mid-call channel anyway. The same Microsoft page tells servers to check the client's
capabilities and "provide a conversational fallback when elicitation isn't available".
That fallback is our 400: the options go into the error, and the model turns them into a
question.
How the connector signs people in with their own Entra account, so Claude sees exactly what the dashboard shows them, is in the Entra login field notes.
- Azure MCP Server concepts — 200+ tools in all mode, client tool limits (128 in VS Code Copilot), consolidated mode.
- Collect structured input with elicitation forms in Copilot Cowork — elicitation for picking between options, and the conversational fallback.
- signIn resource type — createdDateTime as ISO 8601 UTC, the format the date check enforces.