mcp-grounding
An MCP server that exposes a verify_place tool for verifying places via Google Places API, along with a rubric resource and classify_post prompt for grounding-based classification tasks.
README
mcp-grounding
The verify_place grounding tool from
camunda-trial, re-exposed as an
MCP server and measured against the same 91-post golden set, with the same
deterministic scorer, so the two protocols can be compared on numbers.
The server is the smaller half. The comparison is the point.
Headline: across three runs of the parity arm, the MCP path scores a mean of 87.9% against the Camunda connector's 86.8% — a 1.1-point gap inside a 2.2-point run-to-run spread. On this task, moving the tool between protocols did not change what the model got right. What it changed was smaller, reproducible, and more interesting than the accuracy number.
The numbers
Same rubric (hash-pinned identical), same golden set, same model
(claude-sonnet-5), same scorer — eval/score.mjs, copied byte-identical from
camunda-trial and never edited. No model anywhere in the judging loop.
| Camunda connector | MCP server (parity arm) | |
|---|---|---|
| Accuracy | 79/91 — 86.8% | mean 87.9%, range 86.8–89.0% (n=3) |
| In-rubric posts | 17/17 (100%) | 17/17 (100%) in all 3 runs |
| Held-out posts | 62/74 (83.8%) | 86.5% / 83.8% / 85.1% (runs 1–3) |
| Grounding discipline | 7/7 (100%) | 100% in every run |
Restraint (golden no_signal, zero calls) |
62/62 (100%) | 61/62 (98.4%) in all 3 runs |
| Errored rows | 0 | 0 |
| Output-format repairs needed | n/a (schema-enforced) | 0 of 91, every run |
Individual runs: 89.0%, 86.8%, 87.9%. Temperature is unset in both systems, so both are non-deterministic; a single run cannot distinguish a real difference from noise, which is why the parity arm was run three times. One of those runs reproduced Camunda's 79/91 exactly.
Read that as: no detectable difference in accuracy. The gap is smaller than the instrument's own resolution. Anyone reporting a single 89.0% run as "MCP beats Camunda by 2.2 points" would be reporting noise.
The confusion matrices are near-identical too. The 86.8% MCP run differs from the Camunda run by exactly one post traded between buckets.
The one reproducible difference
MCP calls the tool on a post Camunda never touched, in every single run, across every arm:
1ute6a4— "Has anyone visited Sedona, Arizona? Worth the trip?" — goldenno_signal
Camunda: no tool call, labelled no_signal, correct. MCP: verifies Sedona
(found=true) every time, and in 2 of 3 parity runs then labels it
rising_destinations — wrong.
Nothing in the prompt differs. Same rubric bytes, same model, same policy text. The difference is where the tool sits: in MCP the tool definition travels in the request payload on every turn, and that appears to make it marginally more reachable. One post out of 91 is not a law of nature, but 5 runs out of 5 is not noise either.
This is the shape of finding the project was built to catch — too small to move the headline number, invisible without a per-call trace, and a real behavioural difference between the two protocols.
What the tool description turned out to be worth
The build brief assumed the tool description would matter a great deal in MCP, because "in the Camunda version, the BPMN wiring decided when the tool fired."
That premise was wrong, and finding out was the useful part. The Camunda rubric
already contained a ## Tool policy: verify_place section — the system prompt
decided when the tool fired, not the BPMN. The connector's own description was
one sentence that Camunda Modeler read and no model ever did.
So the real question is not "does a description matter" but "does the spec survive being moved into one". Three arms, same instruction, only its location changing. Arm C is a negative control where the policy exists nowhere at all.
| arm | policy lives in | accuracy (n=91) | grounding | calls on non-rising posts |
|---|---|---|---|---|
| A parity | system prompt | 86.8–89.0% (n=3) | 100% | 1 (all 3 runs) |
| B MCP-native | tool description | 86.8% (n=1) | 100% | 2 |
| C control | nowhere | 85.7% (n=1) | 100% | 2 |
The last column counts every tool call on a post whose golden label is not
rising_destinations. It is deliberately broader than the scorer's own
restraint metric, which looks only at golden no_signal posts and so would not
have seen arm B verifying Viator on a competitor_mentions post.
Moving the policy changed nothing. On the 31-post day-2 subset, arms A and B agreed on every post — same label, same tool-call count, 31 for 31. At n=91 arm B lands inside arm A's range. The spec survives the move intact.
Removing the policy did not break grounding. It broke restraint. Arm C still
grounded every rising_destinations prediction it made. What degraded was the
"do not call it otherwise" half: spurious calls doubled.
That split says the two halves of the policy are held up by different things.
The obligation to ground has a backstop — the rubric's ## Output section still
asks for groundedPlaces "for each place verified with found=true", so the
requirement is implied by the output contract even with the policy deleted.
Restraint has no backstop, and it is the first thing to go.
The failure the brief predicted never appeared. Not once across five runs and
455 classifications did the model assign rising_destinations without verifying
first — including in the arm where no policy existed anywhere. On this rubric
the grounding obligation is over-determined.
Every description version and its measurement: docs/TOOL_DESCRIPTIONS.md.
Where the design decisions were
Error semantics: a place that doesn't exist is not an error
The contract carried over from the Java connector, and the reason the tests were written first:
| condition | Java connector | MCP server |
|---|---|---|
| place found | verdict, found=true |
CallToolResult, structuredContent.found=true |
| place not found | verdict, found=false |
CallToolResult, isError unset |
| auth / quota / transport | ConnectorException → incident |
JSON-RPC error → client rejects, row fails |
The distinction that matters is the last row. MCP offers two different "errors",
and the tempting one is wrong here. A tool result with isError: true is handed
back to the model as text — so an expired API key becomes something the model
can reason around, and it can still answer rising_destinations having verified
nothing. A JSON-RPC error never reaches the model at all; the client's
callTool() rejects and the eval records the row as errored, which is exactly
where a Camunda incident put it.
The SDK makes the right shape hard to reach. McpServer.registerTool()
catches every error a tool callback throws and rewrites it into
{ isError: true }, discarding error.code and error.data — only the message
survives. A protocol-level failure is unreachable from a tool callback. This
server is therefore built on the low-level Server class, where the error code
survives to the client. See FRICTION.md.
Asserted end-to-end over a real transport in
test/error-semantics.test.ts, including the
behavioural claim: an infrastructure failure never reaches the model as a tool
result.
Which primitive is each thing
Decided by a type signature rather than by taste:
verify_placeis a tool. It performs an action with a side effect — an outbound call spending someone's quota — and the model chooses when.- The rubric is a resource (
grounding://rubric/v3), not a prompt. MCP'sPromptMessagerole is"user" | "assistant"— there is no system role. The rubric is a system prompt; returning it as ausermessage would move 13,688 characters of instruction out of the system position and quietly change the experiment. A resource carries text with no role attached and lets the client place it. classify_postis a prompt. The hand-driving entry point for Claude Code. It carries the post, not the rubric — and why it cannot carry the rubric is the finding above.
The credential is not a tool input
The Java connector took apiKey as an input property bound to
{{secrets.GOOGLE_PLACES_API_KEY}}, so it appeared as a field on the element
template. Here it is server configuration read from the environment and is
absent from the tool schema — the model can neither see it nor be talked into
supplying one. A test asserts no credential appears in the published schema.
What "grounded" is actually worth
Worth stating plainly, because the project exists to avoid shipping plausible verifications nobody checked — and the verification layer has its own false positives.
Google Places Text Search is a search, not an existence check. It matches on substrings and business names:
| query | verdict |
|---|---|
Zzyzxqwv Fictional Republic |
found=true → Zzyzx, California, a real road |
Atlantis |
found=true → a shop in Berlin |
Wakanda |
found=false |
So found: true means "Places returned a best match for this query string",
not "this place exists".
This is not hypothetical. In arm B the model verified Viator — a tour
booking platform, not a place — and Google returned found=true.
The tool is left unchanged: tightening it would diverge from the Java connector and break the only thing making the two numbers comparable, and the golden set never exercises the false-positive path. But it bounds the claim. "Perfect tool discipline" means the tool was called, never that the place was real.
eval/live-check.ts reports these cases explicitly rather than hiding them.
Running it
npm install
cp .env.example .env
Fill in GOOGLE_PLACES_API_KEY (Places API New enabled) and
ANTHROPIC_API_KEY. .env is gitignored.
npm test
Day-1 check against the live API — confirms a nonexistent place returns a verdict, not an error:
node --env-file=.env eval/live-check.ts
A full 91-post run, then score it with the untouched scorer:
node --env-file=.env eval/run.ts --arm full --description v1-parity --label myrun
node eval/score.mjs eval/results/run-<timestamp>-myrun/results.jsonl
Compare finished runs (this calls score.mjs, it does not compute accuracy
itself):
node eval/summarise.mjs
Attaching to Claude Code
.mcp.json is committed and loads the key via --env-file, so no secret enters
a tracked file. Claude Code reads it at startup.
How it is put together
src/server/server.ts MCP server on the low-level Server class
src/server/tool-description.ts versioned descriptions, append-only
src/server/rubric.ts both arms, split from one source so they cannot drift
src/places/client.ts port of PlacesClient.java, same field mask, same codes
eval/run.ts MCP client + Messages API; tool def pulled from tools/list
eval/classify.ts the agent loop, extracted so it can be tested
eval/score.mjs copied byte-identical from camunda-trial. Never edit.
Dependencies: the MCP SDK and nothing else. Node ≥22 runs the TypeScript directly; there is no build step.
What the tests are for
34 tests, none of which need a network or an API key.
The three error-semantics cases were written before the tool existed. Beyond
those, the suite pins the things whose silent drift would invalidate the
comparison rather than break the build: the exact JSON tools/list puts on the
wire, the sha256 of the rubric against the string the Camunda BPMN actually sent,
that the tool policy appears exactly once per arm, that v2 lifts it verbatim
with no rewording, and that the result rows carry exactly the columns
score.mjs reads.
test/harness.test.ts drives the whole agent loop
against a stubbed Anthropic and a stubbed Places. Camunda's connector owned that
loop, so camunda-trial never had to test one; owning it means it can be wrong,
and a bug there would produce a plausible accuracy number with no other symptom.
Honest caveats
Held constant: rubric text (hash-pinned), golden set, scorer, model, max_tokens, temperature, user-prompt format, model-call cap.
Not eliminable, and load-bearing on how much the comparison is worth:
- Agent scaffolding. Camunda's AI Agent connector wraps the system prompt with framing not visible from outside. This runner sends the rubric raw. Some part of any difference could live here.
- Output enforcement. Camunda enforced a response JSON schema; the Messages API is prompted for JSON and parsed, with one repair turn allowed. In practice this cost nothing — 0 repairs across 455 classifications — but it is an asymmetry, and a second forced tool was rejected precisely because it would have polluted the tool-discipline measurement.
- n is small. 91 posts, one competitor_mentions, zero feature_gaps. Per-bucket figures for the rare classes carry no weight.
- Arms B and C are one run each. Their numbers sit inside or just below arm A's observed spread; treat the accuracy column for those rows as suggestive. The A≡B agreement is the stronger result, because it is post-for-post identity rather than a matching aggregate.
The friction log
FRICTION.md is a first-class deliverable, not a postscript. The
MCP TypeScript SDK is on 1.30.0, released ten days before this was built and
outside the assistant's training data. Every entry came from reading the
installed .d.ts rather than from recall, including the two that changed the
architecture and the one where a rubric that looked byte-identical was not.
Licence
Apache-2.0.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。