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.

3
tools on day one
95
domains behind one query tool
12
tools a month later
500
rows max per call

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).

FOUR OF THE CHECKS A get_metrics CALL PASSES Call arrives from Claude Tenant named? or the only one Domain known? one of 95 Columns exist? in this domain Operand fits? date, number, bool no no no no yes 400 · which? options listed 404 · domain see list_domains 400 · column valid ones listed 400 · type needs YYYY-MM-DD Claude reads the message asks you, or calls again corrected Rows · groups · aggregates total_rows counts every match

"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:

You ask

Who has a licence they don't use?

Claude calls
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
Claude answers

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:

You ask

Show me Fabrikam HQ's sign-ins since yesterday.

Claude calls
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
Claude answers

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:

You ask

Which apps are the failed sign-ins in Fabrikam HQ coming from?

Claude calls
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
Claude answers

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.

The pattern
Few tools, each with a describe step in front. Refuse anything ambiguous, and write the refusal as the next instruction: which tenants exist, which columns are valid, what format a date needs. A 400 the model can act on costs one more call. A 200 with the wrong rows costs the answer.

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.

Sources
GraphPaaS

Ask your tenants a question and see the query it turned into.

Add the connector →

Newsletter

Get the next article by email

Practical M365 & Azure cost pieces like this one. No spam, unsubscribe anytime.