This is a submission for the Sanity Challenge, Path One: Ship an Agent That Queries Real Content
What I Built
I'm a medical writer, and I built this project to explore how the clinical evidence requirements of the EU MDR can be modeled as structured content. In a clinical evaluation report, every claim needs supporting clinical evidence, and it is easy to lose track of which claims are still unsupported and which MDR requirements they touch. I have not worked on MDR submissions professionally: this is a learning project built from the public text of the regulation, and I built it with the help of an AI assistant.
The result is MDR Evidence Gap Agent: an agent that answers audit questions over a small dossier stored in Sanity, for example "Which claims have no supporting evidence, and which MDR requirements do they affect?"
Everything is synthetic. The device (VascuSeal, a fictional Class III vascular closure device), its claims and its studies are invented. The requirement entries are my own short paraphrases of the regulation, not the official text. This is a demo, not regulatory advice.
Why structure matters
A keyword search finds pages that mention things. It cannot find what is missing. In this project the gap is a claim with no linked evidence, which only exists as an empty reference list. The agent finds it by following references with GROQ, not by matching words.
Demo
Live app: https://mdr-evidence-gap-agent.vercel.app
- Ask the agent: type a question or click an example. You can see every tool call and every GROQ query the agent wrote on its own.
- Dossier: all claims, evidence and requirements read from Sanity through the same Context endpoint. Claims with no evidence are flagged, and each has a button that asks the agent about that gap.
- Coverage: a requirement by claim matrix that shows which requirements are at risk.
- About: how the content is structured, and the limits of the demo.
Note: the live demo runs on free quotas and on a Sanity trial that ends around October 19. It may become unavailable after that, depending on what the free plan includes. The sample run below shows what the agent returns.
Sample run. The agent called initial_context, then wrote its own queries. One of them:
*[_type == "claim"]{
_id, title, text,
"requirements": requirements[]->{ _id, title, annex, text },
"evidence": evidence[]->{ _id, title, studyType, summary },
"evidenceCount": count(evidence)
}
Its answer: two claims have no linked evidence.
- Five-year seal durability affects Article 61(1) (clinical evidence for conformity) and Annex XIV Part B (PMCF).
- Real-world PMCF confirmation affects Annex XIV Part B (PMCF) and Article 86 (PSUR).
These are the two gaps I planted in the dataset. The agent found both without being told where to look.
Code
This is the core agent loop. It connects to the Sanity Context MCP endpoint, hands the endpoint's tools to Gemini and loops until the model answers. The deployed app wraps the same loop in a streaming API route with a rate limit and retries when the model is busy, and keeps all keys on the server.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { GoogleGenAI } from "@google/genai";
const { CONTEXT_URL, CONTEXT_TOKEN, GEMINI_API_KEY } = process.env;
const question = process.argv.slice(2).join(" ") ||
"Which claims in the VascuSeal dossier have no supporting evidence, and which MDR requirements do they affect?";
const SYSTEM = [
"You are the MDR Evidence Gap Agent.",
"You audit a SYNTHETIC Class III device dossier (VascuSeal) stored in Sanity as three linked document types: requirement, claim and evidence.",
"Call initial_context first, then use groq_query to follow references between claims, requirements and evidence.",
"Only report what the linked documents show. Never declare a claim compliant. Requirement texts are paraphrased summaries, not the official MDR text.",
"Answer concisely in English and list each gap with the affected requirements.",
].join(" ");
const mcp = new Client({ name: "mdr-evidence-gap-agent", version: "1.0.0" });
await mcp.connect(
new StreamableHTTPClientTransport(new URL(CONTEXT_URL), {
requestInit: { headers: { Authorization: `Bearer ${CONTEXT_TOKEN}` } },
})
);
const { tools } = await mcp.listTools();
const functionDeclarations = tools.map((t) => {
const schema = { ...(t.inputSchema || { type: "object", properties: {} }) };
delete schema.$schema;
return { name: t.name, description: t.description || "", parametersJsonSchema: schema };
});
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const contents = [{ role: "user", parts: [{ text: question }] }];
for (let step = 0; step < 10; step++) {
const res = await ai.models.generateContent({
model: "gemini-3.1-flash-lite",
contents,
config: { systemInstruction: SYSTEM, tools: [{ functionDeclarations }] },
});
const calls = res.functionCalls || [];
if (calls.length === 0) { console.log(res.text); break; }
contents.push(res.candidates[0].content);
const responses = [];
for (const call of calls) {
const r = await mcp.callTool({ name: call.name, arguments: call.args || {} });
const out = (r.content || []).map((c) => c.text || "").join("\n").slice(0, 20000);
responses.push({ functionResponse: { name: call.name, response: { result: out } } });
}
contents.push({ role: "user", parts: responses });
}
await mcp.close();
How I Used Sanity
-
Schema: three document types defined in TypeScript in Sanity Studio:
requirement(title, annex, text),evidence(title, study type, summary) andclaim(title, text, and two arrays of references:requirementsandevidence). The links between them are what make the agent's questions answerable. -
Content: 6 requirement summaries, 4 pieces of synthetic evidence and 5 synthetic claims in the
productiondataset. Two claims deliberately have an emptyevidencearray. - Studio: deployed at https://mdr-evidence-gap.sanity.studio/ (requires a Sanity login), so the Context endpoint can read the deployed schema.
-
Sanity Context: an MCP endpoint created in the Context app, with a dataset source, a GROQ filter (
_type in ["requirement", "claim", "evidence"]) that limits what the agent can read, and custom instructions. The app connects with a read-only Context Viewer token kept on the server. -
What the agent does with the content: it calls
initial_contextto learn the schema, then runsgroq_queryto resolve references (->,count(),references()), and reports each claim with no evidence together with the requirements it is tied to. The Dossier and Coverage pages read the same content through the same endpoint.
Sanity Project Details
-
Project ID:
0hmb0qqg -
Dataset:
production - Studio: https://mdr-evidence-gap.sanity.studio/ (requires a Sanity login)
-
MCP endpoint name:
mdr-evidence-gap-agent
Limitations
- I built a small Knowledge Base from the text of four MDR provisions (Article 61, Annex XIV, Article 86, Annex I), but the agent does not read it yet: the endpoint behind this demo serves the dataset only.
- The dataset is small (15 documents) and fully synthetic.
- Requirement texts are short paraphrases, simplified, and were checked against public copies of the regulation with the help of an AI assistant. They are not legal text.
- The agent reports what the linked documents show. It does not judge compliance.
- The demo uses a free model quota, so questions are limited to 10 per visitor per hour, and the model may occasionally be busy. If you see an error, try again.
Top comments (0)