Local emulators. Real API contracts. Part of Mockingbird.
Stateful, in-process emulator of a self-hosted Medplum server (v5.1.37) for test suites. It covers:
- FHIR R4 REST: create, read, update, delete, conditional writes, JSON Patch, versioning and
history, and search with the server's semantics (chained,
_has,_filter,_include/_revinclude,_sort,_total,_summary,_elements, offset and cursor paging). - Operations: batch and transaction bundles,
$validate,$expunge,Patient/$everything, GraphQL, and Binary storage with signed attachment URLs. - Auth: OAuth2 password login with PKCE, authorization code, client credentials, refresh tokens with rotation, and Basic auth, all as ES384 JWTs with a JWKS.
- Access control and admin: access policies, and the project admin API (invite, clients, members).
It is built on @medplum/core and the server's own FHIR router (@medplum/fhir-router, vendored),
so validation messages, search results and OperationOutcomes match the server's.
It needs no child process, database or native module. State lives in an in-memory
SQLite engine written in TypeScript, and crypto is WebCrypto. The ./server entry and the CLI are
the only Node-specific parts.
Parity is proven against the real thing. Every scenario in test/scenarios (32 scenarios, 481
exchanges: status, headers and body) and seeded random walks run against a self-hosted Medplum
v5.1.37 and the emulator, and they must agree exchange by exchange. The oracle's recording replays
in CI.
- Operation coverage: SUPPORT.md
- Medplum docs: https://www.medplum.com/docs · FHIR R4: https://hl7.org/fhir/R4/
Install
npm install -D @crvouga/mockingbird-service-medplumESM only. Requires Node >= 22 or Bun >= 1.2 (any runtime with WebCrypto and
DecompressionStream). @medplum/core is a dependency; bring your own
@medplum/core MedplumClient if you use the SDK.
Usage
In-process with the Medplum SDK
import { MedplumClient } from "@medplum/core"
import type { Patient } from "@medplum/fhirtypes"
import { DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, MedplumAPI } from "@crvouga/mockingbird-service-medplum"
const emulator = new MedplumAPI() // answers as http://localhost:8103/
const medplum = new MedplumClient({
baseUrl: "http://localhost:8103/",
fetch: (url: string, init?: RequestInit) => emulator.fetch(new Request(url, init)),
})
await medplum.startClientLogin(DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET)
const patient = await medplum.createResource<Patient>({
resourceType: "Patient",
name: [{ given: ["Ada"], family: "Lovelace" }],
})
const found = await medplum.searchResources("Patient", { name: "lovelace" })
console.log(found[0]?.id === patient.id) // true
await emulator.reset() // back to the seeded stateEvery MedplumAPI seeds what a fresh self-hosted server seeds, which is the super admin
(admin@example.com / medplum_admin) and the R4 base project. It also seeds a ready project,
Mockingbird (DEFAULT_PROJECT_ID), with a project-admin client application
(DEFAULT_CLIENT_ID / DEFAULT_CLIENT_SECRET). Pass project: false to seed only what the
server does.
mockingbird-medplum serve
npx mockingbird-medplum serve # http://127.0.0.1:8103
npx mockingbird-medplum serve --port 0 --client-id 0b9e4a5c-0000-4000-8000-00000000c1d1 --client-secret local
npx mockingbird-medplum serve --help| Flag | Default | Meaning |
|---|---|---|
--port <n> |
8103 |
Port to listen on (0 for any free port) |
--host <host> |
127.0.0.1 |
Interface to bind |
--base-url <url> |
the listening address | Public base URL the server answers as (fullUrls, Location, token issuer) |
--client-id <uuid> / --client-secret <secret> |
DEFAULT_CLIENT_ID / DEFAULT_CLIENT_SECRET |
The default project's client application |
--project-id <uuid> |
DEFAULT_PROJECT_ID |
The default project's id |
--super-admin-email <email> / --super-admin-password <password> |
admin@example.com / medplum_admin |
The seeded super admin |
--admin-key <key> |
open | Require x-mockingbird-admin-key on /__admin/* |
--log <pretty|json|off> |
pretty |
One line per request |
--config <file> |
— | Serve every service in a mockingbird.json (use "medplum" as the service name) |
It prints the listening address and the seeded client credentials. Point MedplumClient
(baseUrl: "http://127.0.0.1:8103/") or your backend's Medplum base URL at it.
createServer (Node)
import { MedplumClient } from "@medplum/core"
import { DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET } from "@crvouga/mockingbird-service-medplum"
import { createServer } from "@crvouga/mockingbird-service-medplum/server"
const server = await createServer() // any free port; the base URL is the listening address
const medplum = new MedplumClient({ baseUrl: `${server.url}/` })
await medplum.startClientLogin(DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET)
const patient = await medplum.createResource({ resourceType: "Patient", active: true })
console.log(patient.id) // "<uuid>"
await server.close()createServer(options) takes every createRuntime option plus port (default 0) and host
(default 127.0.0.1). The result has url, port, runtime and close().
createRuntime (any Fetch server)
createRuntime is the whole served emulator as a single runtime-neutral fetch(request). That
includes health, the /__admin/* control plane, namespaces, the clock, faults and the request
journal. Hand it to Bun.serve, a Worker or Deno, or call it directly:
import { createRuntime } from "@crvouga/mockingbird-service-medplum"
const medplum = createRuntime({ baseUrl: "https://medplum.test/" })
export default { fetch: (request: Request) => medplum.fetch(request) }Namespaces isolate data. A request picks one with x-mockingbird-namespace: <name>, with a
/__admin/ns/<name>/ base-URL prefix (new MedplumClient({ baseUrl: "http://localhost:8103/__admin/ns/worker-1/" })),
or through its client id (PUT /__admin/credentials {"credentials": {"<clientId>": "<namespace>"}}).
A client is recognized from Basic auth, from a bearer token's client_id, or from the
client_id field of a form-encoded token request.
Admin routes (beyond the standard contract)
| Route | Does |
|---|---|
GET /__admin/medplum |
The namespace's base URL, default project and client, and super admin |
PUT /__admin/medplum/clients/:id {"secret"?, "name"?, "projectId"?, "admin"?, "identityProvider"?} |
Create or replace a client application with a chosen id and secret; identityProvider ({"userInfoUrl", "useSubject"?, …}) enables token exchange |
POST /__admin/medplum/users {"email", "password", "firstName"?, "lastName"?, "profileType"?, "profileId"?, "profile"?, "admin"?, "projectId"?} |
Add a user with a password login and a membership; profileId pins the profile's id and profile adds fields to it (extension, birthDate, …) |
POST /__admin/medplum/token {"clientId"?} |
A bearer token for a client (default: the default client) |
POST /__admin/medplum/resources[?projectId=] |
Seed a resource, an array, or a Bundle's entries, keeping given ids |
GET /__admin/medplum/resources/:type |
Every current resource of a type, across projects |
GET /__admin/medplum/security-requests |
The UserSecurityRequests created by /auth/resetpassword, with the secret and the link the email would have carried |
POST /__admin/medplum/logins/revoke |
Revoke every login, so all issued tokens stop working |
The standard contract also serves POST /__admin/reset, snapshot and restore, the clock, faults,
GET /__admin/requests and metrics.
Fault presets
POST /__admin/faults {"preset": "<name>"}. Add count to limit how many requests it hits.
| Preset | Effect |
|---|---|
rate_limited |
Every FHIR request answers 429 with the server's quota OperationOutcome |
token_expired |
FHIR requests answer 401 as if the token expired |
server_error |
FHIR requests fail with the server's unhandled-error 500 |
token_server_error |
POST /oauth2/token fails with a 500 |
write_drop |
Creates and batches drop the connection |
slow_search |
Searches take 3 s |
Faithful details worth knowing
- Deleted resources answer 410 Gone, and their history ends with a delete entry.
- An update that changes nothing keeps its version.
- Resources are validated against the FHIR R4 and Medplum StructureDefinitions, with the server's messages.
- Transaction bundles are atomic only when the project has the
transaction-bundlesfeature, as on the server. Without it, entries commit one by one. A super admin can enable it by updating the Project'sfeaturesto include"transaction-bundles"(keep any existing features). With the flag, a failed entry returns one OperationOutcome and rolls back resources, history, and queued hooks. Requests sharing a namespace run serially to isolate rollback. - Search follows Postgres semantics. Name and address matching is by token prefix, sorts use C collation (missing values last ascending), and dates compare in UTC. Only the sort key orders results, so ties and unsorted pages have no guaranteed order on the server; don't rely on it.
:missingon lookup-table parameters (name,family,given,address…) matches nothing either way, as on the server.- Super admins get no refresh token from a password login, as on the server.
Token exchange (RFC 8693)
POST /oauth2/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange follows
exchangeExternalAuthToken in the server's oauth/token.ts (v5.1.37; the same code serves the
deprecated /auth/exchange). Give a client application an identityProvider (putClient({ id, identityProvider: { userInfoUrl } })) and post client_id, subject_token and
subject_token_type=urn:ietf:params:oauth:token-type:access_token:
- The emulator calls
identityProvider.userInfoUrlwithAuthorization: Bearer <subject_token>(a JSON orapplication/jwtanswer is read;userInfoMode: "gcip"posts{idToken}to the URL withuserInfoApiKey). PassexternalFetchto route that call without a network. - The user is the one in the client's project (else a project-less one) whose email is the
userinfo
email; withuseSubjectit is the membership whoseexternalIdis the userinfosub. The first membership signs in (or the one named bymembership_id). - The answer is the usual token response. The default scope is
openid offline_access, so a refresh token comes with it.
Errors are the server's: invalid_request with Invalid client, Invalid subject_token,
Invalid subject_token_type, Missing user info URL, or Failed to verify code - check your identity provider configuration (a 429 from the provider answers 429); an unknown user is the
User not found OperationOutcome. Not modelled: server-wide externalAuthProviders config, the
jwksUrl verification path, and the server's block on private userinfo addresses.
Password reset
POST /auth/resetpassword {email, projectId?, redirectUri?, sendEmail?} and POST /auth/setpassword {id, secret, password} follow auth/resetpassword.ts and auth/setpassword.ts (v5.1.37). A reset
answers all-OK for any well-formed email, and creates a UserSecurityRequest (type reset) for
the matching user: with projectId, in that project; without it, among project-less users, as on
the server. A domain with an external-auth DomainConfiguration is refused. setpassword answers
404 for an unknown request, and 400 for a used request, a verify-email request, a wrong secret,
or a password outside 8-72 characters; it marks the request used, sets emailVerified, and
revokes the user's logins.
No email is sent. A suite stands in for the mailbox with onSecurityRequest(request) (id, secret,
email and the <appBaseUrl>setpassword/<id>/<secret> link; not called for sendEmail: false),
api.securityRequests(), or GET /__admin/medplum/security-requests. Not modelled: reCAPTCHA
(the server enforces it only when a secret key is configured) and the breached-password lookup.
Rest-hook subscriptions
Active, project-scoped Subscription resources with channel.type: "rest-hook" deliver a POST
when a create, update, patch, or delete matches their FHIR criteria. Matching uses Medplum's
subscription evaluator, including supported-interaction and FHIRPath extensions. The first 1,000
active subscriptions per project are evaluated, as on the server.
Create and update bodies are the versioned resource, including Medplum metadata; delete bodies
are {} and include X-Medplum-Deleted-Resource. As in the pinned Medplum 5.1.37 worker, the body
is sent even when channel.payload is absent. Headers include Content-Type: application/fhir+json,
X-Medplum-Subscription, X-Medplum-Interaction, and channel.header entries. The
https://www.medplum.com/fhir/StructureDefinition/subscription-secret extension (or its legacy
https://www.medplum.com/fhir/StructureDefinition-subscriptionSecret URL) signs the exact body
with HMAC-SHA256 in X-Signature as lowercase hex.
Deliveries leave a transactional outbox only after the FHIR request commits; rolled-back changes
send nothing. Each delivery gets one attempt, with a 10-second timeout, before fetch() resolves.
The namespace lock is released before calling a hook, so receivers can call back into the API.
Hook errors do not fail the write or prevent delivery to other subscribers. Background retries,
subscription AuditEvents, and attachment URL rewriting in webhook payloads are not modelled.
The local oracle can be started with startOracle({ allowUnsafeOutbound: true }) for loopback
webhook receivers; its default outbound restrictions remain enabled otherwise. The regressions
in medplum.subscriptions.test.ts and medplum.transactions.test.ts run unchanged against it by
setting MOCKINGBIRD_MEDPLUM_ORACLE_URL.
Deliberately not modelled
These answer the server's 404 (or are stored but inert):
- terminology operations (
ValueSet/$expand,CodeSystem/$lookup, …) - bulk
$export - bots (
Bot/$execute) - subscription channels other than
rest-hook, server-scoped subscriptions, and webhook retries - email (password-reset and verification mail is replaced by
onSecurityRequest) - self-registration (
/auth/newuser) - the super-admin maintenance routes (
/admin/super/*)
The optional server features (BlobStorage, Redis, WebSocket subscriptions) are not
emulated.
API
| Export | Description |
|---|---|
MedplumAPI |
Class. new MedplumAPI(options?); fetch(request: Request): Promise<Response> is the Medplum server. |
createRuntime |
(options?: MedplumRuntimeOptions) => MedplumRuntime — the served emulator with the Mockingbird service contract. |
MEDPLUM_PRESETS |
The fault presets above, as Record<string, FaultPreset>. |
MEDPLUM_NAMESPACE |
"medplum" — the service name and default namespace. |
document |
The vendored OpenAPI contract (openapi.yaml). |
operationIds / supportedOperationIds |
Every operation id in the contract / the ones the emulator serves. |
DEFINITIONS_VERSION |
The @medplum/definitions release the embedded FHIR definitions come from. |
SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD |
"admin@example.com" / "medplum_admin" — the server's seeded super admin. |
SUPER_ADMIN_CLIENT_ID / SUPER_ADMIN_CLIENT_SECRET |
The super admin's client application (client_credentials as super admin). |
DEFAULT_PROJECT_ID |
The ready project's id. |
DEFAULT_CLIENT_ID / DEFAULT_CLIENT_SECRET |
The ready project's client application. |
DEFAULT_BASE_URL |
"http://localhost:8103/", Medplum's default base URL. |
createServer / serveTarget / DEFAULT_PORT |
From ./server (Node): serve over node:http; the CLI's flags; 8103. |
MedplumAPI members:
| Member | Description |
|---|---|
fetch(request) |
One request to the server. Only the path and query of request.url route it. |
ready() |
Resolves once the definitions are loaded and the namespace is seeded (fetch waits for it). |
reset() |
Drop everything and reseed. |
describe() |
{ baseUrl, project, superAdmin } — the seeded credentials. |
putClient({ id, secret?, name?, projectId?, admin?, identityProvider? }) |
Create or replace a client application. |
securityRequests() |
The UserSecurityRequests so far, with their secrets and links. |
addUser(fixture, projectId?) |
Add a user with a password login and a membership. |
accessToken(clientId?) |
A bearer token for a client (default: the default client). |
putResource(resource, projectId?) |
Write a resource as the system, keeping a given id. |
resources(type) |
Every current resource of a type, across projects. |
systemSearch(query) |
Search as the system (every project), e.g. "Patient?name=ada". |
revokeAllLogins() |
Revoke every login; returns how many. |
Options:
type MedplumAPIOptions = {
baseUrl?: string // public base URL, trailing slash; default "http://localhost:8103/"
now?: () => number // clock for meta.lastUpdated, token lifetimes; default Date.now
namespace?: string // storage namespace; default "medplum"
seed?: number | string // seeds generated ids and secrets; default the namespace
sqlite?: SqliteClient // default @crvouga/mockingbird-service-sqlite
superAdmin?: { email?, password?, clientId?, clientSecret? }
project?: false | { id?, name?, clientId?, clientSecret?, clientAdmin?, users?: MedplumUserFixture[] }
resources?: Resource[] // seeded as the system after the users; ids kept; meta.project defaults to the default project
appBaseUrl?: string // host of password-reset links; default "http://localhost:3000/"
onSecurityRequest?: (request: MedplumSecurityRequest) => void // the password-reset "email"
externalFetch?: (input: string, init?: RequestInit) => Promise<Response> // token-exchange userinfo calls; default fetch
maxSearchOffset?: number // largest _offset accepted; default unlimited, as on the server
}
type MedplumUserFixture = { email; password; firstName?; lastName?; profileType?: "Practitioner" | "Patient" | "RelatedPerson"; profileId?; profile?: Partial<Practitioner | Patient | RelatedPerson>; admin? }
type MedplumRuntimeOptions = Omit<MedplumAPIOptions, "now" | "namespace"> & { clock?; adminKey?; onLog?; journalSize? }Ids and secrets are deterministic per namespace and seed, so two emulators given the same requests return the same ids.
Development
For contributors to the mockingbird repo only.
bun run test # unit, SDK, auth, runtime, property and recorded-oracle tests
bun run portability # bundle for the browser platform and run it in workerd
bun run parity # boot a self-hosted Medplum and run every scenario and random walks live
bun run oracle:record # refresh test/fixtures/oracle-recording.json from the oracle
MOCKINGBIRD_MEDPLUM_ORACLE=1 bun test medplum.oracle.test.ts # the live comparison as a test
MOCKINGBIRD_MEDPLUM_ORACLE_URL=http://127.0.0.1:8103/ bun test medplum.oracle.test.tsThe oracle (oracle/, dev-only) is the real Medplum server built from the pinned tag
(MOCKINGBIRD_MEDPLUM_VERSION, default v5.1.37) into ~/.cache/mockingbird/medplum-server
(MEDPLUM_MOCK_CACHE_DIR). It runs on embedded Postgres and a redis-server on PATH. The
first boot clones and builds it, which takes several minutes. After changing a scenario, run
bun run oracle:record.
src/vendor/fhir-router is @medplum/fhir-router 5.1.37 (Apache-2.0), with the changes listed
in its README. bun run generate regenerates the operation table and the embedded definitions,
and generate:check fails when they are stale.
Part of mockingbird — agent integration guide: README · llms.txt · report an issue or request a feature.
Fleet public ingress
The Node fleet accepts publicOrigin plus optional publicPath for Medplum. Fixed
configuration controls emitted URLs; forwarding headers cannot select the public origin.
A namespace carried by /__admin/ns/<name> is retained in generated links. Management
routes remain private. Synthetic loopback acceptance: medplum.ingress.test.ts (#460).
| Surface | Public origin / prefix / namespace | Limits |
|---|---|---|
| OAuth discovery, token and JWKS | Supported | Existing emulator OAuth flows only; no production identity federation certification |
| FHIR create/read/search, fullUrl, Location and pagination | Supported | Existing project/access-policy enforcement applies |
| State replacement on changed private ports | Supported | Reacquire tokens after restore; public configuration must match |
| Storage and other hosted URLs | Configured base URL | Existing endpoint limitations apply; external hosting and DNS are caller-owned |
| Management routes and credentials | Private listener only | Never exposed through reference ingress |
