Skip to content

Commit 8fbf1f9

Browse files
committed
docs: close pre-v1 doc gaps
- README spec-coverage: flip Phase 6 follow-up to ✅ (it shipped in v0.49.0); add MCP streamable HTTP row for v0.50.0. - README invariants section: drop the stale "follow-up phase" line and replace with the actual generator-wired story. - README MCP section: replace the "stdio today, HTTP planned" claim with both transports, plus an httpTransport() example. - New apps/docs/docs/guides/mcp.md covering both transports, auth strategies, write gating, token economy, and audit. Added to the Guides sidebar. - apps/docs validation guide: new "FHIRPath invariants" subsection documenting the auto-wiring landed in v0.49.0. - apps/docs roadmap rewritten — most prior bullets shipped; new copy points at V1_PLAN.md and lists the explicit v1-vs-v2 split. No code changes. Tests/lint/typecheck unchanged.
1 parent 4c62608 commit 8fbf1f9

5 files changed

Lines changed: 264 additions & 22 deletions

File tree

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ fhir-dsl is audited against the FHIR architectural overview (https://build.fhir.
226226
| MCP server generation — auth strategies || Bearer / backend-services (signed JWT via SMART v2) / patient-launch (refresh-token flow), all lazy-loaded (Phase 8.4, v0.48.0). |
227227
| MCP server generation — write gating + token economy || Per-resource-type allowlists, dryRun, confirmWrites, default `_count`/`_summary`, response-byte cap (Phases 8.5+8.7, v0.44.0+v0.45.0). |
228228
| MCP server generation — generator + CLI integration || `fhir-gen generate --mcp <out>` emits a server scaffold; `fhir-gen mcp <baseUrl>` launches one inline (Phases 8.8+8.9, v0.46.0+v0.47.0). |
229-
| Phase 6 follow-up — invariants in emitted validators || Compile generator-time invariants into the emitted Standard Schema validators. |
229+
| MCP server generation — streamable HTTP transport || `httpTransport()` accepts JSON-RPC over POST with optional CORS, auth hook, body cap, and external-server mounting (Phase 8 streamable HTTP, v0.50.0). |
230+
| Phase 6 follow-up — invariants in emitted validators || `--validator` automatically wires `validateInvariants` via `s.refine` (native) / `.superRefine` (zod); opt out with `--no-invariants` (v0.49.0). |
230231

231232
Drift between this table and the code is caught by `pnpm audit:export-surface` — every PR that changes the public surface must refresh `.surface-snapshot.json`.
232233

@@ -292,7 +293,7 @@ const result = inv.check(patient); // { passed: true | false | "indet
292293
const oo = validateInvariants(patient, [inv]); // { resourceType: "OperationOutcome", issue: [...] }
293294
```
294295

295-
Generator wiring (so the emitted Standard Schema validators run invariants automatically) is a follow-up phase.
296+
Generator wiring is automatic when `--validator` is used: every emitted resource and backbone schema with `ElementDefinition.constraint[*]` is wrapped in `s.refine(...)` (native) or `.superRefine(...)` (zod) that calls `validateInvariants` after structural validation succeeds. Opt out with `fhir-gen generate --validator native --no-invariants`. Generated projects need `@fhir-dsl/fhirpath` as a runtime dependency. (Phase 6 follow-up, v0.49.0.)
296297

297298
## MCP Server (Model Context Protocol)
298299

@@ -314,6 +315,20 @@ const server = createServer({
314315
await server.listen(stdioTransport());
315316
```
316317

318+
Or expose the same server over HTTP for hosted deployments:
319+
320+
```ts
321+
import { createServer, httpTransport } from "@fhir-dsl/mcp";
322+
323+
const transport = httpTransport({
324+
port: 8080,
325+
cors: true,
326+
authenticate: (req) => req.headers.authorization === `Bearer ${process.env.MCP_TOKEN}`,
327+
});
328+
await server.listen(transport);
329+
console.log(`MCP listening on ${transport.url()}`);
330+
```
331+
317332
Capabilities:
318333

319334
- **~10 generic verbs** typed by `resourceType` discriminated union: `read`, `vread`, `search`, `history`, `create`, `update`, `patch`, `delete`, `operation`, `capabilities`
@@ -322,7 +337,7 @@ Capabilities:
322337
- **Pluggable `AuditSink`**`JsonLogAuditSink`, `MemoryAuditSink`, `NullAuditSink` ship by default
323338
- **Token economy guards**`defaultSearchCount` (default 20), `defaultReadSummary`, and a `maxResponseBytes` cap (default 64KB) that swaps oversize bodies for a `too-costly` OperationOutcome (the audit retains the original)
324339
- **MCP resources**`fhir://<ResourceType>/{id}` URIs read via `resources/read` (and `_history/<versionId>` for vread)
325-
- **Stdio transport today**; Streamable HTTP is planned
340+
- **Two transports**`stdioTransport()` for CLI MCP clients, `httpTransport()` for hosted deployments (POST JSON-RPC; optional CORS, auth hook, body cap; mounts onto a caller-owned `http.Server` if you already have one)
326341

327342
### Generate a server alongside the typed client
328343

apps/docs/docs/guides/mcp.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
---
2+
id: mcp
3+
title: MCP Server (LLM bridge)
4+
sidebar_label: MCP Server
5+
---
6+
7+
# MCP Server (LLM bridge)
8+
9+
`@fhir-dsl/mcp` exposes a FHIR endpoint as a [Model Context Protocol](https://modelcontextprotocol.io/) tool surface, so an LLM agent can `read`, `search`, `vread`, `history`, and (opt-in) `create`/`update`/`delete` against a real FHIR server with full audit + auth.
10+
11+
One server === one upstream FHIR endpoint, scoped to one IG (the IG pin lives at generate time; the runtime just receives the resource-types list).
12+
13+
## Install
14+
15+
```bash
16+
npm install @fhir-dsl/mcp
17+
# Auth strategies that need it:
18+
npm install @fhir-dsl/smart jose # required only for backend-services / patient-launch auth
19+
```
20+
21+
`@fhir-dsl/smart` and `jose` are optional peer dependencies — bearer-token-only deployments never load them.
22+
23+
## Minimal example
24+
25+
```ts
26+
import { createServer, stdioTransport } from "@fhir-dsl/mcp";
27+
28+
const server = createServer({
29+
name: "us-core-mcp",
30+
version: "1.0.0",
31+
baseUrl: "https://hapi.fhir.org/baseR4",
32+
resourceTypes: ["Patient", "Observation", "Encounter"],
33+
auth: { kind: "bearer", token: process.env.FHIR_TOKEN! },
34+
});
35+
36+
await server.listen(stdioTransport());
37+
```
38+
39+
That's a fully functional MCP server: read-only, audited to stderr, ready to plug into Claude Desktop, Cursor, or any other MCP client.
40+
41+
## Transports
42+
43+
### `stdioTransport()`
44+
45+
Newline-delimited JSON over stdin/stdout. Default for CLI MCP clients (Claude Desktop, the `claude` CLI, etc.) which spawn the server as a child process.
46+
47+
```ts
48+
import { stdioTransport } from "@fhir-dsl/mcp";
49+
await server.listen(stdioTransport());
50+
```
51+
52+
Options: `input` and `output` streams (defaults to `process.stdin` / `process.stdout`). The transport idles indefinitely; the host process kills it on shutdown.
53+
54+
### `httpTransport()`
55+
56+
Streamable HTTP transport for hosted deployments — POST a JSON-RPC body to a single endpoint, get a `Content-Type: application/json` response. Spec: [Streamable HTTP](https://modelcontextprotocol.io/docs/concepts/transports#streamable-http).
57+
58+
```ts
59+
import { createServer, httpTransport } from "@fhir-dsl/mcp";
60+
61+
const transport = httpTransport({
62+
port: 8080,
63+
cors: true,
64+
authenticate: (req) =>
65+
req.headers.authorization === `Bearer ${process.env.MCP_TOKEN}`,
66+
});
67+
68+
await server.listen(transport);
69+
console.log(`MCP listening on ${transport.url()}`);
70+
```
71+
72+
Options:
73+
74+
| Option | Default | What it does |
75+
|---|---|---|
76+
| `port` | `0` (ephemeral) | TCP port. Read the actual port back via `transport.url()` after `start()`. |
77+
| `host` | `127.0.0.1` | Bind host. |
78+
| `path` | `/mcp` | Endpoint path. |
79+
| `cors` | `false` | Adds `Access-Control-Allow-Origin: *` and a preflight handler. |
80+
| `authenticate` | none | `(req) => boolean \| Promise<boolean>`. Return `false` to short-circuit with `401`. |
81+
| `maxRequestBytes` | `1 << 20` (1 MiB) | Hard cap on request body size; over-cap requests get `413`. |
82+
| `server` | none | Pre-built `http.Server` to mount onto, instead of starting a new one. Useful when MCP shares a process with an unrelated HTTP service. |
83+
84+
Calling `transport.url()` before `start()` throws; call it afterwards to get the resolved URL (with the actual port if you passed `0`).
85+
86+
#### Mounting onto an existing server
87+
88+
```ts
89+
import { createServer as createHttpServer } from "node:http";
90+
import { httpTransport } from "@fhir-dsl/mcp";
91+
92+
const httpServer = createHttpServer((req, res) => {
93+
// Your existing routes go here. The transport only handles `/mcp`.
94+
});
95+
httpServer.listen(8080);
96+
97+
const transport = httpTransport({ server: httpServer, path: "/mcp" });
98+
await server.listen(transport);
99+
```
100+
101+
When `options.server` is provided, the transport never calls `listen()` or `close()` — the caller owns the lifecycle.
102+
103+
#### Currently out of scope (planned for v1)
104+
105+
- GET `/mcp` opening an SSE stream for server-initiated notifications.
106+
- `text/event-stream` responses for streaming tool output.
107+
- Batched JSON-RPC arrays.
108+
109+
The dispatcher today only emits single synchronous responses, so SSE has no producer yet. Tracked in `V1_PLAN.md` Theme 1.1.
110+
111+
## Auth strategies
112+
113+
Three pinned variants behind one `AuthStrategy` interface. Every strategy resolves an outbound HTTP header set per request, so token refresh, JWT resigning, and zero-config bearer all coexist.
114+
115+
```ts
116+
// Bearer (dev / static tokens):
117+
auth: { kind: "bearer", token: process.env.FHIR_TOKEN! }
118+
119+
// SMART v2 backend-services (signed JWT — RS384 or ES384):
120+
auth: {
121+
kind: "backend-services",
122+
issuer: "https://hapi.example.org/fhir",
123+
clientId: "my-bot",
124+
privateKey: process.env.JWT_PRIVATE_KEY!,
125+
scope: "system/*.read",
126+
}
127+
128+
// SMART v2 patient launch (refresh-token flow with auto-rotation):
129+
auth: {
130+
kind: "patient-launch",
131+
issuer: "https://hapi.example.org/fhir",
132+
clientId: "my-spa",
133+
refreshToken: session.refreshToken,
134+
scope: "patient/*.read",
135+
}
136+
```
137+
138+
`@fhir-dsl/smart` is loaded lazily, so bearer-only servers never pay the `jose` cost.
139+
140+
## Write gating
141+
142+
Writes are off by default. Enable them surgically:
143+
144+
```ts
145+
createServer({
146+
// ... base config
147+
writes: ["create", "update"], // which verbs to expose
148+
writeResourceTypes: ["Observation"], // narrow further to specific resources
149+
confirmWrites: true, // require {confirm: true} per call
150+
dryRun: false, // set true to short-circuit with synthetic OperationOutcome
151+
});
152+
```
153+
154+
A typical safe setup for an LLM that should only ever create observations: `writes: ["create"], writeResourceTypes: ["Observation"], confirmWrites: true`.
155+
156+
## Token economy
157+
158+
Defaults that prevent an LLM from reading 50 MB of bundle and burning your context:
159+
160+
| Option | Default | What it does |
161+
|---|---|---|
162+
| `defaultSearchCount` | `20` | Default `_count` when the LLM omits one. `0` disables. |
163+
| `defaultReadSummary` | none | Default `_summary` for read verbs (`text`, `data`, `count`, etc.). |
164+
| `maxResponseBytes` | `64 * 1024` | Hard cap on JSON response bytes. Oversized bodies are swapped for a `too-costly` `OperationOutcome`; the audit retains the original. `0` disables. |
165+
166+
## Audit
167+
168+
Every verb call routes through an `AuditSink` regardless of outcome. Three implementations ship:
169+
170+
- `JsonLogAuditSink` (default) — structured JSON to stderr.
171+
- `MemoryAuditSink` — keep events in memory; useful for tests and integration smoke.
172+
- `NullAuditSink` — drop events; for performance benchmarks.
173+
174+
```ts
175+
import { MemoryAuditSink, createServer } from "@fhir-dsl/mcp";
176+
177+
const audit = new MemoryAuditSink();
178+
const server = createServer({ /* ... */, audit });
179+
180+
// later in tests:
181+
expect(audit.events.map((e) => e.call.verb)).toContain("read");
182+
```
183+
184+
A custom sink is just an object with `record(event: AuditEvent): void | Promise<void>` — drop-in for Splunk, Loki, OTLP, or a `FhirAuditEventSink` writing `AuditEvent` resources back to the upstream.
185+
186+
## Generating a server alongside the typed client
187+
188+
```bash
189+
fhir-gen generate --version r4 --ig hl7.fhir.us.core@6.1.0 \
190+
--out ./src/fhir --mcp ./mcp-server
191+
```
192+
193+
`./mcp-server/` gets a `server.ts` shim, `mcp.config.json` seeded with the IG's resource types, and a README. Launch it with `FHIR_BASE_URL=… node mcp-server/server.ts`.
194+
195+
## Or run inline (no generated types)
196+
197+
```bash
198+
fhir-gen mcp https://hapi.fhir.org/baseR4 \
199+
--resources Patient,Observation \
200+
--writes create --confirm-writes \
201+
--auth-bearer-env FHIR_TOKEN
202+
```

apps/docs/docs/guides/validation.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ Bindings on `Coding` and `CodeableConcept` emit inline objects that narrow the `
7676

7777
`minItems: 1` is enforced for required arrays. Required scalar fields fail validation when missing.
7878

79+
### FHIRPath invariants
80+
81+
Every `ElementDefinition.constraint[*]` from the spec or your IG -- root-level (`dom-3`, `dom-6`, etc.) and backbone-level (`pat-1` on `Patient.contact`, `obs-7` on `Observation`, ...) -- is wired into the emitted schema automatically. Each schema with constraints is wrapped in `s.refine(...)` (native) or `.superRefine(...)` (zod) that calls `validateInvariants` from `@fhir-dsl/fhirpath` after structural validation succeeds. Errors surface as Standard Schema issues; `severity: "warning"` constraints are filtered out so they don't fail validation but remain reported by `validateInvariants` directly.
82+
83+
```ts
84+
// A Patient.contact with no name/telecom/address/organization fails pat-1.
85+
const result = await PatientSchema["~standard"].validate({
86+
resourceType: "Patient",
87+
contact: [{}], // <-- violates pat-1
88+
});
89+
// result.issues[0].message → "pat-1: SHALL at least contain a contact's details ..."
90+
```
91+
92+
Generated projects need `@fhir-dsl/fhirpath` as a runtime dep. Opt out with `fhir-gen generate --validator native --no-invariants` if you don't want the dependency.
93+
7994
### Profiles
8095

8196
When you pass `--ig hl7.fhir.us.core@6.1.0` alongside `--validator`, the generator also emits `schemas/profiles/<slug>.schema.ts`, one per profile, each extending the base resource schema with the profile's tighter cardinality and bindings.

apps/docs/docs/roadmap.md

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,42 @@ sidebar_label: Roadmap
66

77
# Roadmap
88

9-
Upcoming features on the fhir-dsl roadmap. Items land in approximate priority
10-
order; nothing here is guaranteed until it ships.
9+
The 0.x line is feature-complete against the original FHIR-compliance plan — every Phase 0–8 row is shipped as of v0.50.0. The next milestone is **v1.0.0**, which is framed as a stability commitment (API freeze + semver discipline) rather than a feature ship.
1110

12-
## FHIR Operations
11+
The full plan lives in [`V1_PLAN.md`](https://github.com/awbx/fhir-dsl/blob/main/V1_PLAN.md). Highlights below.
1312

14-
- **`$everything`** — Patient/Encounter everything operations
15-
- **`$validate`** — Resource validation against profiles
16-
- **Custom operations** — Type-safe builder for arbitrary FHIR operations
13+
## Towards v1.0.0
1714

18-
## Developer Experience
15+
### Cleanups landing before the freeze
1916

20-
- **Middleware/interceptors** — Hook into request/response pipeline for logging, retries, metrics
21-
- **History** — Resource and type-level history queries
22-
- **Capabilities** — Typed access to CapabilityStatement for feature detection
17+
- **Streamable HTTP — finish the spec.** Today's `httpTransport()` only handles POST → single JSON response. SSE on GET (for server-initiated notifications), `text/event-stream` responses, and batched JSON-RPC arrays land before v1 so the framing isn't observable later.
18+
- **Per-property invariants.** Phase 6 follow-up (v0.49.0) wires invariants on root + backbone elements; deeper-level constraints flow into the same `s.refine()` machinery.
2319

24-
## Code Generation
20+
### Borrowing from the atomic-ehr ecosystem
2521

26-
- **Watch mode** — Re-generate types when StructureDefinitions change
27-
- **Custom profiles** — Generate types from your own StructureDefinitions
28-
- **Incremental generation** — Only regenerate changed resources
29-
- **Extension support** — First-class typed extensions
22+
- **UCUM integration**`@atomic-ehr/ucum` plugged into FHIRPath quantity arithmetic and the `code-value-quantity` composite-search normalizer. Closes a real correctness gap (`5 'kg'` vs `5000 'g'` are silently unequal today).
23+
- **`@atomic-ehr/fhir-canonical-manager`** — replaces the generator's roll-your-own tgz/registry handling.
3024

31-
## Ecosystem
25+
### One open feature ask
3226

33-
- **React hooks**`useFhirSearch`, `useFhirRead` for React applications
34-
- **Adapter packages** — Pre-built adapters for popular FHIR servers (HAPI, Azure Health Data Services, Google Cloud Healthcare API)
27+
- **FHIRPath `setValue()` / `createPatch()`** ([#50](https://github.com/awbx/fhir-dsl/issues/50)) — write through a typed FHIRPath builder back to a resource (or emit a JSON Patch), creating intermediate nodes per `where()` predicates.
28+
29+
### Stability scaffolding
30+
31+
- Deprecation pass with `@deprecated` tags + console warnings.
32+
- Performance baseline (generator <30s on R4 + US Core; 1k-resource Bundle <100ms parse+validate; FHIRPath 10k iters <500ms).
33+
- Documentation parity between README, generated TSDoc, and this docs site.
34+
- Hand-written v1.0.0 changelog entry.
35+
36+
## Out of scope for v1, in scope for v2
37+
38+
Documented up front so they don't bleed scope:
39+
40+
- **React adapter** (`@fhir-dsl/react`). Useful, but the query builder API must freeze first.
41+
- **Server adapter packages** for HAPI, Azure Health Data Services, Google Cloud Healthcare API. Same reason.
42+
- **Generator watch / incremental modes**. Convenient, not a stability blocker.
43+
- **Middleware/interceptor pipeline** on the runtime executor. Real feature, but adding it changes the request flow — better as a v2 design pass than a pre-freeze rush.
3544

3645
## Community
3746

38-
Suggestions, feature requests, and contributions are welcome. Open an issue on GitHub to propose new features or discuss architectural changes.
47+
Suggestions, feature requests, and bug reports are welcome. Open an issue on GitHub to propose new features or discuss architectural changes.

apps/docs/sidebars.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const sidebars = {
4747
'guides/terminology',
4848
'guides/validation',
4949
'guides/smart',
50+
'guides/mcp',
5051
'guides/fhirpath-and-queries',
5152
],
5253
},

0 commit comments

Comments
 (0)