Inspiration
A voice assistant cannot show you a citation.
On a screen you can glance at a source, notice a hedge, see a greyed-out "low confidence" note. In speech all of that disappears. A grounded answer and a confident guess arrive in the same voice, at the same volume, with the same certainty. The listener has no way to tell them apart.
That gap is where the damage happens. Not when an assistant says "I don't know", but when it says something plausible about your tenancy agreement, your boiler warranty, or your insurance excess, and you act on it.
So we built the opposite: a server whose refusals are as loud and as specific as its answers.
What it does
Grounded Answers is a self-hosted MCP server that answers household questions from the household's own documents, and refuses when it cannot.
Every reply carries a verdict:
- ANSWERED with the exact line and the document it came from
- REFUSED because the documents were searched and nothing supports an answer
- CANNOT_CONCLUDE because the check could not be run at all
That third verdict is the one most tools are missing. A lookup whose index timed out usually returns the same empty result as a lookup that ran fine and found nothing, so the caller cannot tell them apart and fills the gap with a guess. An empty result from a check that never ran is not evidence of absence.
Four tools over MCP:
askreturns the full verdict envelope with the search trailsayreturns one sentence meant to be read aloud verbatimsourceslists what the server can see, so a person can tell "not true" from "not loaded"why_refusedexplains a refusal in full: every document searched and how many lines in each one touched the question
The server also ships the instructions that tell the model a refusal is a result to report, not a gap to repair. An honest REFUSED is wasted if the assistant reading it decides to be helpful and answers from its own knowledge.
How we built it
A Python MCP server on the official SDK, served over Streamable HTTP, negotiating protocol 2025-11-25.
The included demo page is itself a Model Context Protocol client, written in plain browser JavaScript. It performs the real handshake against the running server: initialize, notifications/initialized, then tools/call. Nothing is mocked and there is no second code path, so the page cannot display something the protocol would not produce.
The interface argues the product rather than describing it. "What you hear" is the spoken line exactly as a listener receives it. "What a listener cannot see" sits underneath: every document searched and how many lines in each one matched. That second panel is precisely what voice can never give you.
Transport runs in JSON response mode rather than SSE. Event streams are buffered by nginx by default and buffered and timed out by Cloudflare, so a streaming transport is the most likely thing to fail in the one place it cannot be debugged.
Challenges we ran into
A test that could not fail. Our verification read protocolVersion where
the SDK exposes protocol_version, got nothing, fell back to the string "?",
and then passed the version gate, because in Python "?" > "2025-11-25" is
true. The single hardest requirement of the track was being confirmed by a value
that meant "I could not read it". It now parses as a date with no fallback.
A matcher that could not answer short questions. "How much is the rent" reduced to one search term after noise stripping while the threshold demanded two, and "responsibility" never matched "responsible" because comparison was exact. Caught by asserting both directions: every test case declares whether it must answer or must refuse, so a wrong refusal fails as loudly as a wrong answer.
Making an extraction real. When the verdict envelope was pulled out into its own package, we checked the dependency both ways: breaking the library turns this entry's test suite red, and reverting turns it green. An extraction that leaves an inline copy behind means the tests guard nothing.
Accomplishments that we're proud of
The refusal path is tested as carefully as the answer path. A server that can only answer has not been shown to refuse, and refusing is the entire product.
Our verification plants defects on purpose and confirms each one turns the suite red before trusting any green result.
What we learned
The dangerous failure is not a crash. A crash is visible. The dangerous failure is a plausible answer about the wrong document, delivered in a confident voice, to someone who cannot see where it came from.
What's next
Calibrating the match threshold against real documents rather than a fixed ratio, honest extraction progress instead of fixed waypoints, and DOCX support while continuing to refuse legacy .doc rather than guessing at it.
Open Source Challenge
Alongside this entry we published mcp-refusal, a separate dependency-free package that gives any MCP server the three-verdict envelope described above.
What it does: provides ANSWERED, REFUSED and CANNOT_CONCLUDE as first class results, a decorator that turns a thrown exception into an honest CANNOT_CONCLUDE rather than a protocol error, and the instruction text that stops a model papering over a refusal.
How it works: one invariant is enforced rather than documented. A REFUSED verdict must rest on at least one check that actually ran, or construction raises. Without that, a failed lookup quietly becomes a "no".
Why it matters: every agent framework today lets a tool succeed or throw. Neither says "I did my job and found nothing I can stand behind." That sentence is what a person needs before acting on what an assistant told them.
11 tests, plus a plant harness that breaks the library five different ways and confirms the suite catches each one.
GitHub username: yamminrus
Product Feedback
Which developer tools, APIs and SDKs did you use, and for what?
- Model Context Protocol Python SDK (
mcp2.x) — the server itself.MCPServerwith the@mcp.tooldecorator for the four tools, andstreamable_http_app()mounted under Starlette so the demo page and the MCP endpoint are served from one process. - MCP SDK client (
streamable_http_client+ClientSession) — not for the product, for the proof. The test harness drives our own server the way a real client would, over the wire, instead of calling the Python functions directly. - Starlette / Uvicorn — the ASGI host, plus one extra route for the browser demo.
- Streamable HTTP transport, protocol revision 2025-11-25.
What worked well
Mounting MCP inside an existing ASGI app was the best part. streamable_http_app() returns an ordinary Starlette app, so adding our own route next to /mcp took one line and no shimming. Tool registration from type hints is clean: the JSON schema that reaches the client is generated from the Python signature, and it was correct every time without us hand-writing one.
The decision to expose json_response=True on the Streamable HTTP app deserves specific credit. It is the difference between this deploying and not deploying.
What needs work
The 1.x to 2.x rename has no migration note we could find. Almost every tutorial and answer online is still 1.x.
FastMCPis nowMCPServer;streamablehttp_clientis nowstreamable_http_client. Both fail as a plainImportError, which reads like "you installed it wrong" rather than "this moved." A one-page rename table in the docs would have saved an hour.Result models moved from camelCase to snake_case, and this one is dangerous.
InitializeResult.protocolVersionbecame.protocol_version. Reading the old name does not raise — it returnsNone. Our verification code didgetattr(init, "protocolVersion", "?")and compared the result against the expected revision."?" > "2025-11-25"isTruein Python, so our protocol check passed while reading nothing at all. We caught it, but only because we went looking for checks that could not fail. A deprecation shim that warns on the camelCase attribute, rather than silently returningNone, turns a silent false pass into a visible one.SSE is the default, and SSE is the thing most likely to break in production. The default Streamable HTTP app streams responses. Behind nginx with
proxy_buffering on— the default — and behind a CDN, an event stream buffers and the client hangs with no error, in the one environment where you cannot attach a debugger.json_response=Truefixes it completely. We would make that trade-off louder in the deployment docs, because the failure it prevents looks like a network problem rather than a transport choice.
Onboarding: zero to hello world
Fast once the rename was behind us — roughly ten minutes to a server answering tools/list. The slow part was not the SDK, it was trusting it. Our first "it works" was a server talking to itself in-process. Getting to a real hello world, where the official client opens a session over HTTP and reads the negotiated protocol version back, took longer, and it is the only version we would call done.
Would you build with these again?
Yes. The protocol is the right shape for this problem. What we wanted to build is a server that can say "I checked, and I have nothing I can stand behind" — and MCP's structured tool results carry that envelope to the model without us inventing a side channel for it. The transport got out of the way, which is the highest compliment an integration layer gets.
Built With
- alexa
- javascript
- mcp
- model-context-protocol
- pytest
- python
- starlette
- streamable-http
- uvicorn
Log in or sign up for Devpost to join the conversation.