Skip to content

MCP Data-Fetching Service

A platform-wide MCP capability that any N1 agent (built on Agno) calls to fetch user data from the database (api-backend) and use it anywhere — a single, governed, discovery-first data path that is domain- and use-case-agnostic.

Agentic data fetching today is bespoke and not platform-wide. Where data access exists, it is deterministic, not agent-driven: data is written to a static work directory and purpose-built Python scripts search through it. The agent doesn’t compose those searches — the scripts are fixed code. There is no shared, governed way for an agent anywhere on the platform to flexibly fetch user data from the DB.

This doesn’t scale across agents and is rigid: because retrieval is hard-coded in per-purpose scripts, an agent can’t ask the data different kinds of questions and get back relevant results — it is limited to what each script already anticipates. Every new agent or new kind of query means more bespoke script-writing.

The pressure is now twofold: new agents are launching, and Agno integration is being rolled out platform-wide. The goal is to simplify the whole setup so that standing up a new agent is just: write a .md, give it tools, register it with Agno — nothing more. (A later ambition, out of scope here, is letting end users author prompts themselves.)

Any N1 agent can fetch user data from the DB by calling one shared, platform-wide MCP data-fetching capability, and use that data wherever it needs to. Instead of bespoke Python scripts searching a static work directory, an agent asks the data questions through flexible queries and gets relevant results back, navigating the data through a guided, discovery-first interface.

Onboarding a new agent collapses to: write a .md, give it the MCP tools, register it with Agno — no new data-access plumbing per agent.

“Done looks like”: a generic test agent, using only the MCP’s tools and its own reasoning, produces correct outputs across a comprehensive scenario suite on staging (seeded data), validated through tuning loops on the tools — which is also the go/no-go gate for cutover.

  • A platform-wide MCP data-fetching capability any N1 agent can call to retrieve user data from the DB through flexible, discovery-first queries (ask-the-data, not fixed pre-written searches).
  • Reading from api-backend (the FastAPI system of record) as the data source — domain-agnostic; serving whatever user data lives there, independent of any particular use case. The MCP calls api-backend’s existing endpoints; adding new api-backend endpoints is in scope where the tool surface needs one (e.g. a count/exists footprint endpoint, or a conclusions-with-variants read) — these are still reads, and api-backend remains the system of record.
  • Full replacement of the legacy path: the static work directory and all bespoke Python search scripts are removed at cutover. No fallback retained, no parallel run.
  • A custom fetching agent used to exercise the capability across scenarios (scenarios defined later).
  • Building the identity / token-issuance infrastructure. The authorization model is OAuth 2.1: this service authenticates to api-backend with patient-scoped, time-limited, read-only access tokens. The registry of trusted internal services that pre-authorizes callers and issues those tokens is built separately (owned by Eyad) and is a hard dependency, not this service’s to build — the same registry also authorizes Pinax. This service consumes the tokens and adds no identity infrastructure of its own. This replaces the prior N1_API_KEY / header pattern and removes Oathkeeper from the path (see Security gate).
  • Custom Agno integration — we rely 100% on Agno’s native MCPTools/MultiMCPTools and align with it. We do not build our own Agno client.
  • Any particular consuming use case or domain logic — this is a general capability; consuming agents own their own behaviour and reach the data only through this service’s front door.
  • End-user-authored prompts (a later phase).

Build the capability as a dedicated FastMCP server that exposes user data as MCP tools (flexible “ask-the-data” queries, not fixed searches) and calls api-backend underneath, over authenticated Streamable HTTP. Agents consume it via Agno’s native MCPTools/MultiMCPTools. The legacy work directory and its scripts are removed at cutover. Onboarding a new agent = write a .md, attach the tools, register with Agno.

Architecture: one dedicated, governed door

Section titled “Architecture: one dedicated, governed door”

A dedicated FastMCP server gives full control over the tool layer (names, descriptions, typed schemas, shaped outputs) — where all of this service’s guidance lives — and keeps a clean seam: api-backend stays the system of record, this service is a thin read-only door in front of it.

Do not instead reflect api-backend’s REST routes as tools (FastAPI-MCP) or point Agno’s SQL context provider at the database (agno.context). Both hand the agent a broad, ungoverned path (raw routes or raw SQL) instead of the single governed entrance with a designed tool surface and server-side limits. The temptation is real because both are quick; the governed door is the point.

Keep it lean (the discipline that most first MCP servers get wrong). Teams consistently over-build their first server — reaching for heavyweight infrastructure and a dozen tools on day one when none of it is needed yet. This service deliberately does the opposite, on four concrete commitments:

  • Small tool count over a sprawling one. A handful of well-named tools, not one per table or per endpoint. Every tool must earn its place; the surface stays well under the ~20-tool point where model performance measurably degrades.
  • Progressive disclosure over exposing everything at once. The agent meets a small always-on surface and asks for detail (describe_data_type) only when it needs it, rather than being handed the whole schema up front. If the surface ever must grow, prefer FastMCP’s deferred/searchable tools over adding always-on tools.
  • Shaped, typed-text outputs over raw dicts. Tools return labelled, typed text — never raw nested JSON dumped from api-backend. Raw dicts are the truncation footgun: a deep structure gets clipped mid-object in the model’s context and the agent acts on half a record. Shaping the output is part of the contract, co-equal with the query surface.
  • No premature infrastructure. No caching beyond what’s defined, no search/index layer, no extra transport surface, until the simpler approach demonstrably breaks. Add the layer when the need is proven in testing, not in anticipation. The catalog is the load-bearing layer. What makes fetching reliable for a zero-context agent is the catalog — curated metadata giving each type/field meaning, valid values, and gotchas (see Core principles) — not the query plumbing. It is curated upstream and only surfaced by this service (through enum values, field descriptions, describe/list), never invented here; and it is full v1 coverage across all types from day one. Only the filter/link depth is thin for non-biomarker types (biomarker alone has rich sample_source/specialty filters today).

Design for first-attempt completeness. Design the tool so a generic agent queries completely on the first attempt — make bad queries unrepresentable (enum not free text, required not optional, server-side bound not hope-the-agent-scopes). Every incomplete query is a tool defect to eliminate, not something to excuse. Agno’s RetryAgentRun is only a backstop for what slips past our structured follow-ups, never the intended path.

Caching is integral, not an afterthought. With every agent on the platform reading through one door, caching is a core part of the product, designed in from v1 — not a later optimization. It exists to cut repeated reads against api-backend (the same patient’s data is hit many times within and across agent runs) and to keep the service responsive at platform scale. The hard constraint: freshness is a correctness property here. Cached clinical data that has gone stale is a wrong result (see Risks), so the cache must be bounded and invalidatable, not best-effort. What is safely cacheable differs by content: the catalog (type/field metadata, enum values) is near-static and cached freely; patient records are mutable and need short TTLs and/or explicit invalidation so a write upstream is never served stale. The cache layer and its per-content policy are settled at the design session against api-backend’s update patterns; what’s fixed here is that caching is in scope and freshness-bounded by design.

Before building any tool or control into the server, check whether Agno already provides it. Build only what is irreducibly server-side — the data contract to api-backend and protections that must hold regardless of caller; configure everything the framework owns rather than reimplement it. This is a review gate on every tool (and covers Agno’s 80+ prebuilt toolkits). Delegated, not built here:

  • Observability & audit — Agno’s OpenTelemetry tracing, run history, and audit logs cover “which agent called which tool when”; the server keeps only its own operational logging of api-backend calls.
  • Human-in-the-loop / approvals — Agno natively pauses runs for confirmation; if a read ever warrants it, that’s configured at the agent layer, not built here.
  • Future semantic retrieval — if the surface ever needs semantic search/reranking, the native path is Agno’s Knowledge system, not a bespoke index (this is what “don’t add an index until the simple approach breaks” resolves to).
  • Memory / state — hard boundary. This service is stateless: it reads and returns, retaining nothing; memory is the agent’s concern.
  • Not in play: A2A, chat interfaces, cron scheduling, and multi-framework runtime are agent-side concerns irrelevant to a read-only data-fetch server.
  • Charter: serve user data from the DB to any agent through one standardized, discovery-first MCP tool interface. Not responsible for any agent’s behaviour/prompts, any use case, or producing/owning data.
  • Door: the FastMCP server’s tool endpoints over authenticated Streamable HTTP — the single entrance, replacing the static work directory. Nothing reaches the data any other way.
  • Data ownership: this service reads; it owns no data. api-backend stays the system of record. The tool design (names, descriptions, enums, output shape, granularity) can’t be settled on paper — it’s discovered by running blind agents and tuning the tool in loops; the harness is built first. See Verification.

Core principles — this service is a librarian

Section titled “Core principles — this service is a librarian”

In one line: the agent is a visitor who knows what it’s looking for but not what’s on the shelves; this service is a librarian that knows the collection and helps the visitor reach the right content — nothing more. It does not decide what the visitor should want.

The model has three parts, kept strictly distinct:

  • Books — the data. Raw records. The agent never browses the shelves directly.
  • Catalog — curated metadata about the data. Per type/field: what it is, what its values mean, its gotchas. Authored upstream by the data owners (clinician-governed where clinical) — this service does not invent it — and access-method-agnostic (the same asset whether the data is reached by raw SQL or by these tools). It is not pre-digested answers; a record replaced by a summary (e.g. genetics conclusions) is a separate case, not a catalog card.
  • Librarian — this service. The intermediary that has internalized the catalog and does the finding. The catalog lives behind the tool — shaping its valid values, filters, and returns — never handed to the agent to interpret. The tool is the librarian: the agent expresses intent; the tool does the catalog-informed work. Cardinal rule — reports on the collection, never on intent. The only thing the librarian reports is the collection: what exists, how it’s organized, what a query matched. It never recommends, ranks by “relevance to your goal,” infers an unstated purpose, or says “you might also like.” Taste, strategy, and intent belong to the agent’s own prompt. This restraint is free in implementation — the tool holds no inference to do otherwise.

The librarian is deterministic code — there is no LLM inside this service. Everything later described as the librarian “orienting,” “narrowing,” or “deciding” is metaphor for rule-based routing and validation: the tool matches the agent’s arguments against the catalog, runs a bounded query, and returns either records or a follow-up triggered by a defined condition. The only non-deterministic actor is the calling agent, which chooses and fills the tool calls; once a call arrives, execution is deterministic and repeatable. (The only LLMs in this spec live in the test harness, never in the running service.) The mechanics of how the agent navigates — the orient/narrow/retrieve capabilities, entering at any rung, and the structured response contract — are defined in Agent guidance.

Agent guidance & discoverability (build the best tool; don’t do the agent’s job)

Section titled “Agent guidance & discoverability (build the best tool; don’t do the agent’s job)”

This is the engineering expression of the Core principles. Our job is to build the best possible tool — legible (the agent can tell what it does, returns, and how to call it) and returning a clean, usable result — without telling the agent what to do with the data. Strategy lives in the consuming agent’s own prompt, out of our scope; we never duplicate or hint at it.

A full retrieval runs orient → narrow → retrieve, but this is a map, not a pipeline: the agent enters at whatever rung its knowledge allows, steps repeat, and each is a separable capability — never one polymorphic fetch. Everything is patient-scoped first. The three capabilities (tools offer them; the agent chooses when — we impose no order):

  • Catalog / orient (list_data_types): “what kinds of data exist for this patient.”
  • Describe / narrow (describe_data_type): the catalog cards for a type — fields, valid filter values, units, gotchas, an example. The librarian informing, not a raw schema to reverse-engineer.
  • Query / retrieve: return the targeted result. An agent that doesn’t know the surface uses catalog/describe to find out; one that already knows (its prompt told it, or it has the exact id) goes straight to retrieve. Forcing a “get record b158” request through orientation is as much a failure as leaving a blind agent with no map.

We expose tools and their outputs, and that is the whole channel. Each tool is the MCP-standard trio name + description + input schema; Agno transports them and returns outputs. Each stays in its lane: definitions describe the tool (name, what it does/doesn’t, a precise typed schema) and never say when or in what order to use it; outputs describe the result (what came back, the filter/scope, matched-vs-returned counts, units/ranges/dates, stable IDs) and never suggest a next step.

The definitions are our artifact and the agent’s first exposure — what it sees before any call — so they’re the highest-leverage thing we control and get their own test tier (see Verification). Design rules:

  • Names: verb_object, clarity over brevity, type differences legible (a biomarker tool reads differently from a genetics-conclusions tool). Convention: snake_case verb_object (e.g. find_biomarkers); prefix consistently only if names must be namespaced across servers.
  • Descriptions: what it does and doesn’t do, in a sentence, with explicit formats and no hidden requirements.
  • Enums shape behaviour. A closed-set filter is a schema enum, never free text — showing the valid choices inline pulls even a careless agent into correct scoping and removes vocabulary-guessing. The single highest-leverage lever on query completeness.
  • Outputs are co-equal with the query surface — typed and labelled, never raw rows.
  • Posture lives in the name/description (e.g. a tool named for genetic conclusions signals “not raw variants”) — describing what the tool is, not how the agent should think.

The response contract (what the tool returns when a call doesn’t resolve)

Section titled “The response contract (what the tool returns when a call doesn’t resolve)”

When a call can’t be served as sent, the tool returns structured, factual feedback the agent acts on — it doesn’t decide for the agent or hold a dialogue. The agent now knows more (valid values, missing field, what matches) and chooses its next call; all deciding stays on the agent’s side. Rule-based:

  • Arguments resolve → return the records. “All the labs” isn’t ambiguous to the tool — it’s a query with one fewer filter, returned and capped by the server-side default bound. Breadth alone is never a reason to withhold.
  • An argument fails a check → return a follow-up carrying the facts to fix the call. This is our primary fallback: every tool always returns either content or actionable feedback, never a dead end or silent wrong answer. It sits above Agno’s RetryAgentRun (the layer below for anything that slips past). So the exchange is a genuine multi-turn conversation, but asymmetric — the agent reasons and decides; the tool is a deterministic decision tree mapping inputs to defined outputs.

Build-time alignment, not spec content. That decision tree — the branches, their order (checks before the query vs. after), and the exact shape each follow-up returns — is what the coding agent and the harness must align on first, since both implement and assert against it. It’s deliberately not drawn here (that’s pseudo-code, build work). The spec fixes what conditions exist and what each returns; the tree is pinned in code at build time.

The four follow-up kinds and their triggers:

  • clarify — a filter value isn’t in the catalog’s set (e.g. unknown specialty); returns the valid options.
  • narrow — the result count exceeds the threshold; returns the filters available to cut it down.
  • absent — zero records matched; returns the filter values that do match in this scope (so empty is never a bare list).
  • need-input — a required argument was omitted; returns which field is needed. A follow-up that fires when the tool could have served (value valid but phrased differently, threshold set too low) is a defect, not a success — same bar as first-attempt completeness. (Worked request→response examples of all four kinds, plus a resolved result and the genetics case, are in Worked examples.)

Presence-discovery (offered, not prescribed)

Section titled “Presence-discovery (offered, not prescribed)”

The tool can cheaply answer patient-scoped presence — for a filter/group, what the patient has (which biomarkers, the diagnosis list, whether a procedure exists) as a footprint, not full payloads. Strictly factual: absent means “your query matched nothing here,” never “this patient is missing X” (that would break the cardinal rule). We offer it; we don’t tell the agent to use it. Feasible in v1 on the existing paginated/filtered reads — a bounded patient-scoped query per type, orchestrated internally. (A lighter dedicated footprint primitive is in Future developments.)

Keep the always-on surface small (tool bloat degrades models past ~20 tools). If it must grow, prefer FastMCP’s deferred/searchable tools over exposing everything up front — but only when the simple surface demonstrably falls short. Concrete tool shapes come from the design session (in Verification); the navigation model is also recorded in Platform patterns.

patient is the subject key that scopes every query, not a content type you query for clinical content. All other types are content fetched in a patient’s scope.

Supported content types (v1): biomarker, diagnosis, procedures, genetics, medications/supplements (one type, split by a kind column), and records (raw source documents — PDFs and their metadata).

What every type supports today (uniform baseline): fetch by id, fetch by date, pagination, and a name search (api-backend exposes a search endpoint that matches on name only — not full-text or semantic), all scoped to a patient. This is the floor the MCP is built on. Name search is modest but is expected to be sufficient for v1; richer search is a backlog item, not a v1 commitment.

Per-type filter reality (advertised honestly via describe_data_type):

  • biomarker — uniquely structured: patient-owned Biomarkers group exact dated BiomarkerReadings and carry specialty/sample_source metadata, the only type with curated filters today. Value + unit + reference range + date travel on each reading.
  • medications/supplements — uniform baseline plus a kind discriminator (drug vs. supplement).
  • diagnosis, procedures, genetics — name search + the uniform baseline only; no curated clinical grouping yet. Bringing these to biomarker’s level is the top backlog item.
  • records — raw source documents (PDFs). Not clinical fields to filter; the tool returns document metadata + a fetch reference, a different contract from the queryable types (see tool list). The tool list (candidate v1). The organizing rule is one tool per distinct access contract, not one per table — which keeps the surface lean and type-aware at once, avoiding both the polymorphic query(type,…) god-tool and one-CRUD-tool-per-table sprawl. All tools are session-bound (no patient_id argument), return either content or a structured follow-up (the response contract), and expose closed sets as enums. Naming is verb_object, snake_case.

Catalog (orient / narrow):

  • list_data_types() — orient. No arguments. Returns the content types present for the session patient, each with a one-line description. Why: the high-level “what do you have?” rung; one tool covers all types (progressive disclosure — detail comes only on describe).

  • describe_data_type(type) — narrow. type is an enum. Returns that type’s catalog card: fields, the filter values that exist as concepts (the full enum sets), whether name search applies, units, gotchas, an example. Why: progressive disclosure incarnate — the agent pulls one type’s query surface only when it needs it, instead of every type’s schema up front. This is the catalog (definitional, all possible values), distinct from what the patient actually has — see discovery tools below. Discovery / metadata (patient-scoped — “what can I actually filter by for this patient”): These answer the question the catalog can’t: of all the filter values that exist, which are filled for this patient. They exist so the agent can scope correctly on the first attempt instead of guessing a value and getting a clarify or absent back — directly serving first-attempt completeness. Strictly factual (cardinal rule): they report which values are populated, never which are clinically expected, important, or “missing.”

  • list_populated_filters(type) (candidate, generalized) — for a data type, returns which of its advertised filters have data for this patient and the populated values within each. For biomarker that means the filled specialty and sample_source values on the patient’s Biomarkers; for medication, the kinds actually present. Why one generic tool: covers “sample sources this patient has,” “filled Biomarker metadata,” “which medication kinds,” etc. without one tool per filter (lean).

  • To be confirmed by the coding agent — three live options:

    • the single generic list_populated_filters(type) above (leanest);
    • specific named tools if testing shows they read more clearly to the agent — list_sample_sources(), list_biomarker_series(), list_medication_kinds();
    • fold it into describe_data_type — return, per filter, both the catalog values and which are filled for this patient (no new tool, one place to look).
    • The capability is settled (patient-populated filter values, factual); the shape is a build-time call, like the find_clinical_data granularity question. Retrieve (one tool per access contract):
  • find_biomarkers(specialty?, sample_source?, name?, grain?, date_range?, page?) — the curated-filter contract. specialty, sample_source, grain (latest|series) are enums; name is name search. Records carry value + unit + reference_range + date + id. Why: biomarker is the only type with curated clinical filters and the value bundle; it gets its own tool so those enums are legible and can’t be falsely implied on types that lack them.

  • find_clinical_data(type, name?, kind?, date_range?, page?) — one tool covering the clinical types that are searched the same way (type enum: diagnosis | procedure | medication): by name, date, and pagination, plus kind (drug|supplement) for medication. Why: grouping these is safe because their filters are identical — the agent is never surprised by what a type accepts (biomarker and genetics get their own tools precisely because they aren’t searched the same way). Open at build: testing may show one combined tool works best, or that splitting into separate per-type tools works better (decided at M3).

  • find_records(name?, date_range?, page?) — raw source documents (PDFs), a deliberately separate tool because their contract differs: there are no clinical fields to filter on. Returns document metadata + a fetch reference (e.g. id, title, date, type, a way to retrieve the PDF), not extracted clinical values. Why: a record is a document, not a queryable clinical entry — folding it into find_clinical_data would imply filters it doesn’t have and a content shape it isn’t.

  • find_genetic_conclusions(name?, page?) — the conclusion-as-data-point contract. Returns curated conclusion records (id + free-text conclusion) each with attached read-only variants. Why: genetics has its own tool because its contract differs fundamentally and the name/description must posture “conclusions, not raw variants.” There is deliberately no variant-query tool — the absent capability is the clinical-safety control (see Genetics special case).

  • get_data_by_id(id) — universal retrieve of a single item by stable id, any data type (a biomarker, diagnosis, genetic conclusion, or document). Why: the “I already know the exact item” rung — the agent that has an id skips orient/narrow entirely. First-class because reports cite every claim by stable id, so resolving an id back to its item must be a direct call. (One tool across all types — ids are unique — so the name says “data by id,” not “record,” which is a specific type.) Presence (offered, not prescribed):

  • check_presence(type, filter?) — factual footprint: for a type/filter, what the patient has (matched counts / identities), not full payloads. Why: offered as a cheap capability; strictly factual (“matched nothing here,” never “missing X”). v1 composes existing reads; a dedicated footprint primitive is backlog. Whether this is a standalone tool or folded into the find_* count behaviour is a build-time call. That is roughly eight to ten always-on tools depending on how the discovery and clinical-data groups consolidate — still well under the ~20-tool degradation point. The surface is a fixed set over a registry: each type registers its read path and advertised filters, so adding a filter or type later is a registry entry, not a new tool. Field-level detail (exact enum values, parameter names) is read from api-backend and confirmed at the design session; the contracts and seams above are settled here.

Search is name-only in v1 — defined, but deliberately modest. The agent can look up records by name through api-backend’s name-search endpoint; the MCP surfaces that as-is. It is not full-text, fuzzy-by-design, or semantic, and the spec does not pin match semantics (exact vs. partial) — that’s read off the endpoint at the design session. If name search proves insufficient in testing, richer search is a backlog item (Future developments), not a v1 redesign.

Data types are not interchangeable — why the surface is type-aware

Section titled “Data types are not interchangeable — why the surface is type-aware”

The types have genuinely different shapes, which is why the tool models each to its own access contract rather than flattening them into one uniform tool. This describes the data, not how an agent should use it — strategy stays in the agent’s prompt:

  • Diagnoses — small, categorical, low-cardinality. A short labeled set per patient.

  • Biomarkers — large, numeric, time-series, sparse and patient-variable. Each BiomarkerReading carries a unit, reference range, and date; its patient Biomarker carries specialty/sample_source metadata and makes a large set fetchable at the right grain (latest vs. series).

  • Procedures — low-volume, event-like, timestamped.

  • Medications/supplements — split by a kind discriminator (drug vs. supplement).

  • Records — raw source documents (PDFs), metadata + fetch reference, not queryable clinical fields. The tool surface mirrors these shapes; it never ranks the types or suggests an order to use them in. Which tool serves each type (the types are content the tools fetch, not tools themselves):

  • biomarker → find_biomarkers

  • diagnosis, procedures, medications/supplements → find_clinical_data(type)

  • genetics → find_genetic_conclusions

  • records → find_records

  • any type, by id → get_data_by_id(id)

Genetics — special case (conclusion-as-data-point)

Section titled “Genetics — special case (conclusion-as-data-point)”

Genetics is not raw data for the agent to analyze. The shape is a free-text conclusion tied to structured variants/alleles, and it’s served as a citable record (free text + stable ID) — fetched and cited verbatim, like any other record, not an interpretation surface. The tied variants/alleles ride along as read-only, non-queryable provenance (visible and citable, but there is no “query the variants” capability) so the agent can’t treat them as an analysis input. The tool’s name/description signal “conclusions, not raw genetics.” This is a clinical-accuracy measure: it removes a known error class (models over-interpreting raw genetics) by structurally preventing it — relevance and interpretation are owned upstream by the curation.

What the output format requires from the MCP (report-derived)

Section titled “What the output format requires from the MCP (report-derived)”

From the initial generated reports (early drafts, not canonical), two concrete requirements beyond what’s already stated:

  • Stable, citable record IDs on every record — the reports cite every clinical claim by per-record ID, so IDs are first-class, not optional.
  • A confirmed catalog source for biomarker. Global BiomarkerDefinitions and patient Biomarkers exist in api-backend — definitions provide the laboratory-test axes while patient Biomarkers back the real specialty/sample_source filter values in describe. They’re used to describe the collection and validate filters only, never to compute what a patient “should” have (presence stays factual). (Empty-results-are-factual and biomarker bundling are covered in the response contract and per-type reality above.)

Implementation contract (pin before building)

Section titled “Implementation contract (pin before building)”

Everything above is what to build and why. This section pins the mechanical contracts a build needs before the first file is written: settled decisions and facts, plus a few interface shapes to confirm at the design session.

The agent reaches the service over authenticated Streamable HTTP through Agno’s MCPTools — the standard MCP transport (HTTP, with streamed responses), not a raw socket. One agent run is one session against the server, and the contract for that session is:

  • Connect. Agno opens an authenticated MCP connection to the server’s tool endpoint and performs the MCP handshake (the agent discovers the tool list — names, descriptions, schemas). Authentication is OAuth 2.1: the connection carries a patient-scoped, time-limited, read-only access token issued to a registered trusted service (see Security gate). That same scoped token authorizes the server’s downstream reads against api-backend — there is no separate service credential. Exactly how the token reaches api-backend on each read (passed through from the session token, or obtained by token exchange) is confirmed at the design session.
  • Bind the patient — once, from the token. The patient is fixed at session establishment from the access token’s patient scope (a token claim), held as server-side session context, and never passed on calls. Because the scope rides in the signed token, the bound patient is established by the issuer, not asserted by the caller — the agent cannot widen or change it. Exactly how the patient scope is represented in the token is confirmed at the design session; what’s fixed is that it is set once, by the token, and the agent never restates it.
  • Scoped calls. Every tool call on that session is automatically scoped to the bound patient, server-side. Tools expose no patient_id argument, so the model cannot address another patient — the scope is a property of the token and the connection, not of any request the agent can shape.
  • Disconnect. The session ends with the run; session context (the bound patient) is discarded. The service holds no state between sessions — it is stateless across connections (memory is the agent’s concern, see Build vs. delegate). What the session guarantees: for the life of one connection, every call resolves against exactly one patient, established by the token at bind time and never thereafter trusted from a tool argument. Two agents (two sessions) are fully isolated; nothing in one session’s context leaks to another.

Failure modes the session must define: a call before the patient is bound (or with no valid token) → a default-deny error naming the missing bind/token, never a default or cross-patient read; a missing, expired, malformed, or insufficiently-scoped token → the call is rejected, never served from a fallback credential or a broader scope; a dropped or token-expired connection → the agent re-establishes a fresh session with a fresh token and re-binds (Agno does not auto-reconnect MCP connections — see Risks); the bound patient is immutable for the session (there is no “switch patient” call — a new patient is a new session, which means a new token).

Build-time check & fallback. This contract assumes the authenticated Streamable HTTP session can carry per-session context; confirm that at build. If it cannot, the patient scope is still taken from the token and validated server-side on every call; any patient_id the agent supplies is checked against the token’s scope and rejected if it does not match — never trusted as free input. Same guarantee (scope is the token’s, not the caller’s), weaker ergonomics (the id rides every call).

api-backend access — OAuth 2.1 via n1_api_client. The server reaches api-backend over HTTP/REST through the shared generated client n1_api_client (not in-process import, not hand-rolled HTTP). Authorization is OAuth 2.1: the client presents a patient-scoped, time-limited, read-only bearer token rather than the legacy N1_API_KEY in the N1-Api-Key header — that header, and every other custom auth header, is removed (see Security gate). The token originates from the trusted-services registry (Eyad’s, a hard dependency) that pre-authorizes registered internal services to request such tokens. There is still no per-patient credential for tools to thread through: the patient scope rides in the token, so tools take no auth arguments and the client is configured once for the OAuth flow.

Interface shapes to confirm at the design session

Section titled “Interface shapes to confirm at the design session”

Settled in shape, with specifics deferred to the session (they’re interfaces, not field-level detail):

  • Pagination & error model follow n1_api_client. Use whatever pagination shape and error type the generated client exposes rather than inventing one; the server’s cap/timeout wrapper and response mapping wrap the client’s conventions. Confirm the exact pagination style (offset/limit vs. cursor) and whether the client raises typed exceptions or returns typed error objects at the session.
  • Response envelope (fixed now, per-type payloads deferred). Every tool returns one of two shapes, as typed JSON text:
    • result: kind: “result”, the type, the scope/filter echoed back, matched_count, returned_count, and records[] — each record carrying a stable id (and, for biomarker, value + unit + reference range + date).
    • follow-up: kind: “clarify” | “narrow” | “absent” | “need-input”, a message, plus kind-specific fields — clarify → options[]; narrow → axes[]; absent → present_in_region[]; need-input → required[].
    • The per-type record fields are design-session work; the envelope is fixed so tools and the harness assert against the same shape.
  • Registry entry shape. What registering a type requires: type name, the n1_api_client read path, the advertised filters (each a name + enum values or “free”), projected fields, grain options (latest vs. series), and a role tag. Likely a typed Pydantic model per type so enums are validated — confirm at the session.
  • Runtime / infra (settled). A new repo; a separate service within existing infrastructure (service setup is reviewed at build-time and expected to be simple). Packaged as a container, pushed to ECR, deployed to Kubernetes via Helm — the standard N1 path. Dependencies are kept minimal and managed with uv: pyproject.toml holds declared intent ([project.dependencies], dev/test groups, constraints — human-edited), and uv.lock is the machine-generated, fully-resolved, universally-pinned source of truth, committed and never hand-edited. Config and secrets follow the existing pattern — secret-manager → pod env (now the OAuth client credential the service uses to obtain tokens from the registry, in place of the retired N1_API_KEY). No CI gate: tests are the harness the developing agent sets up and runs (Verification), not a pipeline this spec defines.

Worked examples (the contract, made concrete)

Section titled “Worked examples (the contract, made concrete)”

Two different things, often confused — keep them distinct. A tool is what the agent calls (find_biomarkers, get_data_by_id, …; the named list above). A response kind is what the tool returns — always one of two envelope shapes: a result, or one of the four follow-ups (clarify / narrow / absent / need-input). Any tool can return any kind: a single biomarker query might come back as a result, a clarify, a narrow, or an absent depending on the call. So the tool list and the response kinds are orthogonal — one axis is what you ask, the other is what you get.

The examples below illustrate the request→response contract — the envelope and the four follow-up kinds — using realistic clinical values. They are not the decision tree: which condition fires, and in what order, is build-time work (see the response contract). Requests carry no patient_id — the patient is session-bound (from the access token’s scope) and applied server-side. Field names below are illustrative; exact per-type fields are confirmed at the design session.

// request
{}
// response
{ "kind": "result", "type": "catalog",
"records": [
{ "type": "biomarker", "description": "Exact lab readings grouped into patient Biomarker series." },
{ "type": "diagnosis", "description": "Recorded diagnoses." },
{ "type": "medication", "description": "Medications and supplements, split by kind." },
{ "type": "genetics", "description": "Curated genetic conclusions with attached variants." }
] }

Resolve — a biomarker query that returns records

Section titled “Resolve — a biomarker query that returns records”
// request
{ "type": "biomarker", "specialty": "lipids", "grain": "latest" }
// response
{ "kind": "result", "type": "biomarker",
"scope": { "specialty": "lipids", "grain": "latest" },
"matched_count": 4, "returned_count": 4,
"records": [
{ "id": "bm_8f21", "name": "LDL cholesterol", "value": 143, "unit": "mg/dL", "reference_range": "<100", "date": "2026-04-18" },
{ "id": "bm_8f22", "name": "HDL cholesterol", "value": 51, "unit": "mg/dL", "reference_range": ">40", "date": "2026-04-18" },
{ "id": "bm_8f23", "name": "Triglycerides", "value": 210, "unit": "mg/dL", "reference_range": "<150", "date": "2026-04-18" },
{ "id": "bm_8f24", "name": "Total cholesterol", "value": 236, "unit": "mg/dL", "reference_range": "<200", "date": "2026-04-18" }
] }

clarify — an unknown filter value (returns the valid set; never guesses)

Section titled “clarify — an unknown filter value (returns the valid set; never guesses)”
// request
{ "type": "biomarker", "specialty": "cardiac-panel" }
// response
{ "kind": "clarify", "type": "biomarker",
"message": "Unknown specialty 'cardiac-panel'. Valid values for this patient:",
"field": "specialty",
"options": ["lipids", "metabolic", "hematology", "thyroid", "renal"] }

narrow — too many results (returns the axes available to cut by; does not pick one)

Section titled “narrow — too many results (returns the axes available to cut by; does not pick one)”
// request
{ "type": "biomarker" }
// response
{ "kind": "narrow", "type": "biomarker",
"message": "412 records match. Narrow by one of:",
"matched_count": 412,
"axes": [
{ "field": "specialty", "values": ["lipids", "metabolic", "hematology", "thyroid", "renal"] },
{ "field": "date_range" },
{ "field": "grain", "values": ["latest", "series"] }
] }

absent — zero matches (reports what does match in scope; never a bare empty list, never “missing”)

Section titled “absent — zero matches (reports what does match in scope; never a bare empty list, never “missing”)”
// request
{ "type": "procedure", "name": "colonoscopy" }
// response
{ "kind": "absent", "type": "procedure",
"message": "No procedure matched 'colonoscopy' for this patient.",
"present_in_region": ["echocardiogram", "chest x-ray", "skin biopsy"] }

need-input — a required argument omitted (says exactly what is needed)

Section titled “need-input — a required argument omitted (says exactly what is needed)”
// request
{ "type": "biomarker", "grain": "series" }
// response
{ "kind": "need-input", "type": "biomarker",
"message": "A 'series' grain needs a biomarker to trend. Provide one of: name, specialty.",
"required": ["name | specialty"] }

Genetics — conclusion as a citable record, variants attached read-only

Section titled “Genetics — conclusion as a citable record, variants attached read-only”
// request
{ "type": "genetics" }
// response
{ "kind": "result", "type": "genetics",
"matched_count": 1, "returned_count": 1,
"records": [
{ "id": "gx_b7c0",
"conclusion": "Intermediate CYP2C19 metabolizer; reduced clopidogrel activation expected.",
"variants": [
{ "rsid": "rs4244285", "allele": "CYP2C19*2", "genotype": "*1/*2" }
] } // attached as read-only provenance — there is no query path into variants
] }
  • Wrong / stale / mis-attributed results. Flexible querying can return the wrong, partial, or mis-attributed record, which may be used anywhere downstream. Staleness specifically is also a cache risk — the cache must be freshness-bounded and invalidatable so a write upstream is never served stale (see Design principles). (See Clinical-accuracy gate for the downstream-use angle.)
  • Unbounded / malformed queries. Two distinct concerns. (a) Unsafe content in the agent’s request (PII, prompt injection, jailbreak) → delegated to Agno guardrails (pre_hooks). (b) Malformed or oversized query shape (bad params, missing limit, full-table scan) is not “unsafe content,” so guardrails don’t cover it — it’s the server’s job: typed argument validation (FastMCP/Pydantic), read-only enforcement, and hard caps + pagination + timeouts on the api-backend query itself. Why source-side limits can’t be delegated: Agno’s max_results limits what the agent receives; only the server can stop api-backend from materializing a huge set before any data reaches Agno.
  • Auth dependency & token lifecycle. The OAuth 2.1 model depends on the trusted-services registry (Eyad’s) being live and this service being registered in it; until then the auth path is not complete (a cross-team sequencing dependency — see Open questions). At runtime, patient-scoped tokens are time-limited, so a token can expire mid-run — the service must reject the expired call cleanly (default-deny) and the agent re-establishes with a fresh token (Agno does not auto-reconnect — see Session contract), never falling back to a broader credential.
  • Big-bang cutover. Removal is immediate with no parallel run, so the test agent passing is the safety gate — the MCP path must be proven before the legacy path is deleted.
  • Tool-surface drift from api-backend. The server depends on api-backend schemas; an upstream change can silently break a tool. Agno does not auto-retry failed MCP connections, so failures surface to the agent.
  • api-backend read load. Every agent reads through one door (~40–70 ms + query cost per call); mitigated by the integral cache (Design principles), plus caps, pagination, and timeouts.
  • Poor tool ergonomics / raw output. A surface that doesn’t match how agents ask, or raw dicts that truncate; mitigated by the typed, shaped outputs in Agent guidance.
  • Prompt injection via fetched content. Free-text user data can carry instructions that hijack the calling agent — a documented attack class (a DB-via-MCP agent executing attacker-planted text in a support ticket). The lesson here: treat fetched free-text as a potential injection vector and delegate pre-screening to Agno guardrails.

We verify the service — the input→output exchange (did the tool orient, narrow, retrieve, and give clear, honest feedback) — never the agent’s downstream report, which is out of scope. The method is a loop: run a blind agent against scenarios, inspect the exchange, fix the tool, re-run. The agent is a test instrument, so it changes only when a change makes it a better diagnostic of the tool (e.g. a sharper mock prompt, a wider scenario) — never to compensate for a tool weakness. If a passing run depends on tuning the agent around a rough edge, that edge is a tool defect to fix, not an agent setting. The harness is built first and used throughout to find the best tool design, not bolted on at the end.

Two things are checked differently. Deterministic, must always hold (asserted mechanically; a failure is a hard defect): read-only access, result caps, pagination, timeouts, patient scoping, valid-enum rejection, token validation (a missing, expired, malformed, or insufficiently-scoped token is rejected, never served), stable IDs and the value+unit+range+date bundle, and the right follow-up firing on its defined condition. Best-effort, judged not asserted (scored by an LLM judge, tuned toward): whether the exchange was clear and honest — the agent understood what was available, wasn’t misled, wasn’t needlessly interrogated.

Scenarios are failure-first — empty results, ambiguous or unknown filter values, over-broad and out-of-scope requests, oversized/limit-omitted queries, blind “what do you have?” entry. The milestones drive these to zero by design.

Development: build and test together, in loops

Section titled “Development: build and test together, in loops”

Ordered milestones, each built and tested before the next; nothing is “done” on the first pass.

The shared test rig. Every milestone uses the same rig, pointed at a different thing:

  • A blind agent that knows only its mock report-writing prompt, not the data shape (an agent that already knew would hide a weak tool), run across a wide range of invented patient databases and mock prompts — from “what do you have?” to “get this exact record.” We know the right answer because we invented the patients.
  • Two scores per run: an automatic check of the trace (right tool/step taken, right follow-up fired) and an LLM-judge check of clarity and honesty. The specific judge model is chosen at build, then held fixed across the tuning loops so verdicts stay comparable run to run.
  • A questionnaire to the same agent afterward (was it clear what to ask, did you guess a value, did you get what you needed) — diagnostic only, never pass/fail, so it can’t be gamed.
  • A step replay so a failure traces to a specific tool weakness, not just “failed.”
  • Several models from the start — works on one but not another means it’s overfit.
  • A cheap data-free check and a full end-to-end check (below). Tool flaws driven to zero (the automatic check looks for these — each is designed out, not tolerated): an open-ended/unconstrained query; a guessed filter value (should be a fixed list); a missing required field uncaught; an unscoped request with no size limit; a result with no unit/range/date; no way to tell “nothing matched” from “more not fetched”; the wrong grain (a point where a series was needed, or the reverse); the wrong tool picked or filled wrong; any secret or token material in a result.

The tool definitions, tested on their own (the agent’s first exposure). The names, descriptions, and schemas are what the agent sees before any call, so they get a quick, data-free check run constantly while wording is tuned. Give a model only the definitions plus short intent prompts (clear ones, plus deliberately vague and out-of-scope ones); ask which tool it would call, with what arguments, and what it thinks the tool does. It should pick the right tool, fill only valid values (testing that closed lists are real enums), and describe the tool accurately; for vague or out-of-scope intents, “ask for clarification” or “none fit” is the right answer. Rewrite until this holds across several models. A definition that fails here can’t be rescued later, so it gates the end-to-end runs.

  • M1 — Build the test harness first. It’s the instrument every later milestone leans on, and a real piece of software: decide the patient-fixture format; run the loop against a mock api-backend backed by fixtures (cheap, deterministic — real staging is the separate final stage); define the trace schema (same envelope as the response contract); wire the LLM judge and questionnaire and pick the models/keys. Done when it runs a scenario end to end — trace, scores, questionnaire, replay — and its verdicts are stable and calibrated against deliberately good/bad planted exchanges.
  • M2 — One type, end to end (biomarkers). Expose just catalog + describe + a biomarker fetch; check (cheap definition-only first, then live) that a blind agent finds biomarkers without guessing, understood what was available, gets a complete result (value + unit + range + date), and never guesses a filter value. Done when that holds reliably.
  • M3 — Tool granularity. The role-aware shape is decided; build two or three granularities (how finely to split orient/narrow/retrieve) and keep the one where the agent makes the right moves most often with the fewest wasted calls and least confusion. Done when one clearly wins.
  • M4 — All types + safety limits. Turn on all six types with real filters; check each returns a correct, complete answer (genetics = citable conclusion with attached, non-queryable variants), and test the safety controls directly (not through the agent): oversized requests, and token rejection (missing, expired, or insufficiently-scoped). Done when every type is correct and the controls hold on their own.
  • M5 — The hard cases. Trigger empty / ambiguous / over-broad / out-of-scope deliberately and check the right follow-up fires each time (and that a merely-broad-but-answerable request is just served, not questioned). Any reliance on after-the-fact recovery is a tool flaw to fix. Done when the edges produce the right reply with no recovery needed.
  • M6 — Multi-model breadth. Run the full set across several models; the success rate must be high and consistent across them (one-model-only = overfit). Done when exchanges are good and consistent across models. After M6 the tool is proven on invented data; the real-data check and switch-over are the final stage.

A build-time working session, not a spec gate. The spec gives the outline (the data types, their roles, which have curated filters vs. generic search, the genetics case, the role-aware contract); the session reads the live detail from api-backend and turns it into a built surface. It: reads each type’s fields and real enum values (biomarker sample_source/specialty from patient Biomarkers, the meds kind values) to populate the schema enums and describe cards; confirms which existing endpoints back each type and which new read endpoints to add; tunes tool granularity and output shaping against harness output; settles what’s delegated to Agno; and confirms the OAuth token integration with api-backend (passthrough vs. token exchange, and the patient-scope claim shape) against the registry’s interface. The architecture, type outline, and v1 scope are already decided here — this is confirmation and build, not re-litigation.

Final verification: real data, then switch over

Section titled “Final verification: real data, then switch over”

The development loop runs on invented patients; this final stage confirms the tool on real data and gates the switch-over, using the same rig. Run a handful of real staging patients through the blind-agent test and confirm the service is as good as on invented data (staging carries PHI and inherits staging’s controls). Re-confirm the safety controls by calling the server directly (caps and token rejection). Confirm nothing still depends on the static work directory or old scripts.

Go / no-go for cutover (immediate, no parallel run): the full invented-patient set passes across models, the real-staging patients confirm it, and nothing depends on the legacy path. When all three hold, cut over and delete the old directory and scripts in the same move.

  • Any N1 agent can fetch user data through the MCP via Agno’s MCPTools/MultiMCPTools over authenticated Streamable HTTP — no bespoke per-agent fetch code.
  • Authorization is OAuth 2.1: the service accepts only patient-scoped, time-limited, read-only tokens issued by the trusted-services registry; the N1_API_KEY, all custom auth headers, and Oathkeeper are gone from the path; a missing, expired, or over-scoped token is denied (default-deny), never served from a fallback credential.
  • The librarian surface is live: list_data_types → describe_data_type → query/retrieve, enterable at any rung. Closed sets are enums; describe advertises each type’s real filters honestly (biomarker → sample_source/specialty; meds → kind; others → id/date/pagination/name only); every call returns content or a structured follow-up (clarify/narrow/absent/need-input), never a bare empty list.
  • All v1 content types are reachable in a patient scope (biomarker, diagnosis, procedures, genetics, medications/supplements, and records as source documents). Direct data only; derived values out of scope.
  • Onboarding a new agent is: write a .md, attach the tools, register with Agno — nothing more.
  • Verification passes per its own bar: the blind generic test agent gets effective exchanges across the full invented-patient × mock-prompt matrix and across multiple models, and the real-staging pass holds. (We evaluate the service, not the agent’s report.)
  • Server-side read-only access, result caps, pagination, timeouts, and token validation demonstrably hold.
  • The lean discipline holds: a small tool count (well under ~20), progressive disclosure (no whole-schema dump up front), shaped typed-text outputs (no raw dicts that truncate), and no premature infrastructure.
  • The static work directory and all bespoke Python search scripts are removed; no caller references them.
  • Every tool has passed the “is this already in Agno?” review gate.
  • The Platform patterns section is usable as the reference for the next MCP.

One cross-team dependency, no open design decisions. The OAuth 2.1 authorization model depends on the trusted-services registry (owned by Eyad) being live and this service being registered in it; that work is tracked separately and is a hard prerequisite for the auth path, not a decision open in this spec. The exact token integration with api-backend (passthrough vs. token exchange, and the shape of the patient-scope claim) is confirmed at the design session against the registry’s interface once available. Other items confirmed at the design session as normal build kickoff (not open decisions): n1_api_client’s exact pagination shape and error type (read off the generated client), and the service-setup review for deploying a new service in existing infrastructure. The patient-scoping decision is settled (bound once from the token’s scope, with a documented per-call fallback that still validates against the token if the session can’t carry context — see Implementation contract).

This service is use-case-agnostic and makes no clinical claims — it does not generate, interpret, or alter content; it returns existing user data unaltered from api-backend (the system of record). Because fetched data can be used anywhere downstream — potentially including clinical contexts — the accuracy risk it owns is retrieval correctness: returning the right data for the right subject. Mitigations: results sourced unaltered from api-backend; the test agent’s scenario set must include correctness/relevance checks (right record, right subject, no silent partial results); malformed/empty queries fail clearly rather than returning misleading data. Evidence of clinical claims is N/A — this service makes none; correctness of any downstream clinical use is owned by the consuming agent, not this service.

Genetics access-limiting. The one deliberate clinical-safety action: serving genetics as conclusion-as-data-point with no queryable raw-variant path (see Genetics special case) structurally removes a known error class — models over-interpreting raw genetics. Evidence N/A (no claim is made); the risk is reduced by design, not by review.

The service fetches user data, which may include PHI.

Authorization is OAuth 2.1, not an inherited API key. Access is granted by OAuth 2.1 patient-scoped, time-limited, read-only access tokens issued to registered services by the trusted-services registry (built separately, owned by Eyad). A service must be pre-authorized in that registry to obtain a token, and a token grants only read access to a single patient for a bounded window — least-privilege by construction. This replaces and retires the legacy N1_API_KEY / N1-Api-Key header and every other custom auth header, and removes Oathkeeper from the path entirely. The registry is a shared platform capability — the same mechanism authorizes Pinax — not infrastructure this service builds; this service consumes its tokens.

Identity is trusted from the token, enforced at the server. The patient scope rides in the signed token as the canonical, issuer-established identity; the server trusts it and never re-derives or widens it, and tools expose no patient_id the caller could assert (closing the IDOR / broken-access-control path, the highest-impact class). Read-only is enforced twice: the token grants only read scope, and the server enforces read-only regardless of caller (defense in depth). Enforcement is server-side, never client-side.

The service’s own contributions (the parts it owns): presenting and validating the OAuth token on every call (default-deny — a missing, expired, malformed, or insufficiently-scoped token is rejected, never served from a fallback credential or a broader scope); hard server-side result caps + pagination + query timeouts, so no query materializes an unbounded set; patient scoping on every query, taken from the token; reliance on Agno guardrails for PII/prompt-injection pre-screening on the agent side. De-identification is out of scope.

Secrets via the secret manager. The only secret this service holds is its OAuth client credential (secret-manager → pod env), used to obtain tokens from the registry — the retired N1_API_KEY is gone. No secret, credential, or token material ever appears in tool outputs (checked by the harness — one of the tool flaws driven to zero in Verification).

Fails secure: a malformed, unknown, or unauthorized request returns a follow-up or an auth error, never a broader result; an unscoped query is bounded by the server-side default, never an unbounded scan; an absent or invalid token denies access, never degrades to a default credential.

Platform patterns / conventions (reusable — “how we build MCPs at N1”)

Section titled “Platform patterns / conventions (reusable — “how we build MCPs at N1”)”

The decisions here are the canonical pattern for future N1 MCP servers:

  • Framework: a dedicated FastMCP server, not route-reflection.
  • Transport: Streamable HTTP, OAuth 2.1-authenticated.
  • Authorization: OAuth 2.1 with patient-scoped, time-limited, read-only tokens from the trusted-services registry; no per-service API keys, no custom auth headers, no Oathkeeper. Identity rides in the token and is enforced server-side; tools expose no caller-assertable scope argument.
  • Consumption: Agno-native MCPTools/MultiMCPTools only — no custom client.
  • Posture: a thin, read-only, governed door in front of the owning service; never reflect raw CRUD routes or raw SQL to agents.
  • Navigation: the librarian model — catalog held behind the tool; orient → narrow → retrieve as separable capabilities, enterable at any rung; a structured response contract (clarify/narrow/absent/need-input) as the primary fallback, never a dead end.
  • Channel: guidance lives only in tool definitions and outputs. Definitions describe the tool, outputs describe the result; posture lives in names/descriptions; neither tells the agent its strategy.
  • Tool surface: lightest viable; enums for closed sets; typed shaped outputs with stable IDs; optional scope with a server-side default bound.
  • Type-aware shaping: model each type to its data shape; don’t flatten distinct contracts into one tool.
  • Build-vs-delegate: prefer Agno-native; build only what is irreducibly server-side. “Is this already in Agno?” gates every tool. Worth promoting into blueprints as a standing convention once validated.

Future developments backlog (continuously tracked)

Section titled “Future developments backlog (continuously tracked)”

A living list of improvements, captured here so good ideas don’t expand v1 scope. Each item is tagged in-MCP (absorbed by the registry-driven surface) or outside-MCP (upstream in api-backend / a clinician-governed layer) with its prerequisite. Most enrichment is upstream — the MCP reads and routes, it does not invent clinical structure. Because the surface is registry-driven, in-MCP items stay additive: supply the structure, register it, and the existing tools absorb it without redesign.

In-MCP (additive to the existing surface):

  • Dedicated presence/footprint primitive. A single lightweight call returning per-type counts (and which filters/groups returned zero) without materializing payloads (vs. the v1 approach of composing existing paginated/filtered reads). Faster, fewer round-trips, lower context. Prereq: a count/exists read path per type.

  • Type-specific filters and clinical grouping for non-biomarker types. Bring diagnosis, procedures, and genetics up to biomarker’s level — curated filters/links and a pre-grouped clinical shape, not just the generic search/filter mechanics they have today. Prereq (outside): those fields/links/groupings set up on the data, as biomarker’s already are.

  • Richer search (beyond name-only). v1 search matches on name via the existing api-backend endpoint. If that proves insufficient, add full-text or semantic search (the semantic path is Agno’s Knowledge system, not a bespoke index). Prereq (outside): a richer search endpoint or indexed content.

  • Cross-type links. Connect related content across types within a patient (e.g. a diagnosis to supporting biomarkers/procedures). Prereq (outside): link/association structure in the data.

  • get_derived — computed/derived values. A separate tool serving clinical ratios and composite indices (deliberately separate from query as a clinical-safety signal: ask for the curated value, don’t compute). Prereq (outside): ratio/metric definitions owned and computed at api-backend / a governed layer. The MCP serves them; it does not compute clinical math.

  • Clinical groupings (panels / systems). Group content by clinical meaning rather than type. Prereq (outside): grouping membership encoded (clinician-owned).

  • Characterized trends. Upgrade time-series from raw points to characterized trends (direction/rate/baseline-relative). Prereq (outside): stored trend fields or a clinician-governed trend definition computed upstream.

  • Per-subject baseline & modifier context. Interpret values against the patient’s own baseline and modifiers, not just population ranges. Prereq (outside): baseline/context linkable. Outside-MCP (upstream data/structure work that unlocks the above):

  • Filter/link fields on non-biomarker types; cross-type associations; clinical grouping membership; stored or governed-definition trends; ratio/metric definitions and computation; per-subject baselines. These are api-backend / clinician-governed-layer changes, not MCP changes.