Medplum

@crvouga/mockingbird-service-medplum

Stateful emulator of the self-hosted Medplum server (FHIR R4 REST, OAuth2 and admin API), proven at parity with a real self-hosted Medplum used as the oracle.

Parity: FHIR R4, OAuth, and adminHealth & labs54 of 54 operationsIn-process fetchNode servermockingbird-medplum
$
npm install -D @crvouga/mockingbird-service-medplum

Playground

Requests go to the real emulator running in this tab. State persists across requests, and the journal shows what it received. A green dot marks operations whose sample succeeds as-is; others need ids from earlier responses.

Loads the real @crvouga/mockingbird-service-medplum into this tab on first send
http://localhost:8103

Send a request to see the emulator's response.

Operations

54 of 54 operations in the vendored contract are emulated.

MethodPathOperationStatus
GET/GetRoot
GET/healthcheckGetHealthcheck
GET/robots.txtGetRobots
GET/.well-known/jwks.jsonGetJwks
GET/.well-known/openid-configurationGetOpenIdConfiguration
GET/.well-known/smart-configurationGetSmartConfiguration
POST/auth/loginPostAuthLogin
POST/auth/profilePostAuthProfile
POST/auth/resetpasswordPostAuthResetPassword
POST/auth/setpasswordPostAuthSetPassword
GET/auth/meGetAuthMe
POST/oauth2/tokenPostOauth2Token
GET/oauth2/userinfoGetOauth2UserInfo
POST/oauth2/userinfoPostOauth2UserInfo
GET/oauth2/logoutGetOauth2Logout
POST/oauth2/logoutPostOauth2Logout
GET/admin/projects/{projectId}GetAdminProject
POST/admin/projects/{projectId}/clientPostAdminProjectClient
POST/admin/projects/{projectId}/invitePostAdminProjectInvite
POST/admin/projects/{projectId}/settingsPostAdminProjectSettings
POST/admin/projects/{projectId}/secretsPostAdminProjectSecrets
POST/admin/projects/{projectId}/sitesPostAdminProjectSites
GET/admin/projects/{projectId}/members/{membershipId}GetAdminProjectMember
POST/admin/projects/{projectId}/members/{membershipId}PostAdminProjectMember
DELETE/admin/projects/{projectId}/members/{membershipId}DeleteAdminProjectMember
GET/storage/{binaryId}/{versionId}GetStorageBinary
GET/fhir/R4FhirSearchSystem
POST/fhir/R4FhirBatch
GET/fhir/R4/metadataFhirCapabilities
POST/fhir/R4/$graphqlFhirGraphql
POST/fhir/R4/BinaryFhirBinaryUpload
GET/fhir/R4/Binary/{id}FhirBinaryRead
PUT/fhir/R4/Binary/{id}FhirBinaryReplace
GET/fhir/R4/PatientSearchPatient
POST/fhir/R4/PatientCreatePatient
GET/fhir/R4/Patient/{id}ReadPatient
PUT/fhir/R4/Patient/{id}UpdatePatient
DELETE/fhir/R4/Patient/{id}DeletePatient
GET/fhir/R4/Patient/{id}/_historyReadPatientHistory
GET/fhir/R4/Patient/{id}/$everythingPatientEverything
GET/fhir/R4/{resourceType}FhirSearch
POST/fhir/R4/{resourceType}FhirCreate
PUT/fhir/R4/{resourceType}FhirConditionalUpdate
PATCH/fhir/R4/{resourceType}FhirConditionalPatch
DELETE/fhir/R4/{resourceType}FhirConditionalDelete
POST/fhir/R4/{resourceType}/_searchFhirSearchPost
POST/fhir/R4/{resourceType}/$validateFhirValidate
GET/fhir/R4/{resourceType}/{id}FhirRead
PUT/fhir/R4/{resourceType}/{id}FhirUpdate
PATCH/fhir/R4/{resourceType}/{id}FhirPatch
DELETE/fhir/R4/{resourceType}/{id}FhirDelete
GET/fhir/R4/{resourceType}/{id}/_historyFhirHistory
GET/fhir/R4/{resourceType}/{id}/_history/{versionId}FhirVersionRead
POST/fhir/R4/{resourceType}/{id}/$expungeFhirExpunge

Documentation

The package README, the same file that ships in the npm tarball.Edit on GitHub

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.

Install

Shell
npm install -D @crvouga/mockingbird-service-medplum

ESM 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

TypeScript
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 state

Every 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

Shell
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)

TypeScript
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:

TypeScript
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-bundles feature, as on the server. Without it, entries commit one by one. A super admin can enable it by updating the Project's features to 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.
  • :missing on 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:

  1. The emulator calls identityProvider.userInfoUrl with Authorization: Bearer <subject_token> (a JSON or application/jwt answer is read; userInfoMode: "gcip" posts {idToken} to the URL with userInfoApiKey). Pass externalFetch to route that call without a network.
  2. The user is the one in the client's project (else a project-less one) whose email is the userinfo email; with useSubject it is the membership whose externalId is the userinfo sub. The first membership signs in (or the one named by membership_id).
  3. 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:

text
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.

Shell
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.ts

The 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
↑↓ to navigate↵ to open