On this page8 sections
Customization: the default
DataLayeradapter. Swap the whole DataLayer viadataLayer:on the Registry, or extend just this one (custom convention, custom endpoint resolution) by subclassingModelService.
Model service
ModelService is the default DataLayer implementation — the adapter your server uses unless you swap something else in. It composes three smaller primitives (EndpointResolver, ApiClient, and a convention) into the polymorphic CRUD surface every Tool and App calls.
You don’t construct ModelService directly inside a tool — that would violate the layer discipline from chapter 4. You construct it in config.ts and pass it to the registry through the dataLayer: factory. Inside a tool, what you see is just this.dataLayer. This chapter is about the seam where ModelService is constructed and what each of its collaborators does.
The shape
DataLayer (interface)
▲
│ implements
│
┌────┴─────────────────────────────────────────┐
│ ModelService │
│ │
│ find/list/dispatch dispatches on action, │
│ resolves URL via EndpointResolver, │
│ shapes payload via Convention, │
│ sends HTTP via ApiClient │
│ │
└────┬──────────┬──────────────────┬───────────┘
│ │ │
▼ ▼ ▼
EndpointResolver Convention ApiClient
(URLs from (payload shape (HTTP verbs
model name + per model against
action) or server-wide) URLs)
ModelService itself is a thin orchestrator. The three pieces it composes are what you’d actually customize.
Errors. ModelService is also the seam’s single error-translation point: every request funnels through one internal _call helper that passes rejections to translateApiError, producing RecordNotFoundError for record-scoped 404s and ApiRequestError for everything else — with the response body parsed by the failing model’s convention and the original rejection preserved on cause. The full taxonomy is documented in the data layer chapter’s error contract.
When to use the default
The default ModelService is the right pick when:
- Your backend follows one of the bundled wire conventions (flat REST, JSON:API) — or you’ve written a custom convention against the convention seam.
- Each model maps to one endpoint, named conventionally (
Book→books). - Auth is bearer-token on the request and your
ApiClientknows how to attach it.
When any of those isn’t true, you have two options: extend ModelService via the seams below, or write your own DataLayer implementation entirely. The latter is one class with the methods listed in the data layer chapter; the former is the path covered in the rest of this guide.
EndpointResolver — URL composition
EndpointResolver answers “what URL backs find('book', 42)?” It does this via a layered resolution chain inspired by Ember Data’s adapter pattern:
- Per-action endpoint override declared on the model’s
static api.endpoints. - Standard CRUD route derived from
static api.endpoint(books→/books,/books/:id). - Compound-ID expansion for nested resources (
books/:book_id/chapters/:id). - Custom
pathForTypeif the deployer overrides the namespacing rule.
You rarely touch EndpointResolver directly. You touch it indirectly when you declare a model’s api.endpoint, when you wire namespace: on the registry, or when you declare endpoints: for non-CRUD actions. The API configuration guide covers the declarative surface.
ApiClient — HTTP verbs
ApiClient is the seam between ModelService and the actual HTTP library. The contract is small: get, post, patch, put, delete, each taking a URL and options, each returning a response with data and headers. The default ships axios under the hood.
ApiClient is its own seam because the right HTTP client is opinionated: some teams want fetch, some want an internal axios wrapper with custom retry/auth/telemetry, some want a typed contract layer like ts-rest. Naming the seam lets you swap without forking. Full coverage in API client.
Convention — wire shape
A convention answers “what does the payload look like on the wire?” Flat REST puts the model fields at the top level; JSON:API wraps them in data.attributes. Your custom convention can do anything in between.
As of v0.85.0, the default convention lives on DataLayer, not on BaseModel. You set it via the dataLayer factory’s defaultConvention: option (or implicitly through ToolRegistry / AppRegistry’s defaultConvention: which forwards into the factory). Per-model overrides still live where they always did — on static api.convention.
import { jsonApiConvention } from '@mcp-rune/mcp-rune/api-conventions'
// File: tasks/models/task.ts
export class Task extends BaseModel {
static override api = { endpoint: 'tasks', convention: jsonApiConvention }
// …
}import { jsonApiConvention } from '@mcp-rune/mcp-rune/api-conventions'
// File: tasks/models/task.ts
export class Task extends BaseModel {
static api = { endpoint: 'tasks', convention: jsonApiConvention }
}The full convention contract — serializePayload, parseResponse, association translation, envelope stripping — is covered in API convention.
Swapping ModelService out
For most deployments you keep ModelService and customize its three collaborators. For two cases you want to bypass it entirely:
- Tests / quickstart —
createInMemoryDataLayer({ fixtures })is aDataLayerimplementation that lives entirely in memory. NoModelService, noApiClient, no convention. The bookshelf quickstart uses it. - Wrapping a third-party client library — Warp Drive / Ember Data, Zodios, ts-rest, an internal company data layer. Wrap the library’s CRUD surface in something that satisfies the
DataLayerinterface and pass it to the registry. The projection layer doesn’t notice.
In both cases you skip ModelService and supply a different DataLayer directly. Same one-line swap in config.ts.
Adding search
ModelService covers CRUD. Search (free-text, filter pass-through, lookup, group search) is a separate concern bolted on by SearchEnabledDataLayer, which wraps any base DataLayer (in-memory or ModelService). Both registries apply the wrap automatically to every per-request DataLayer — configure it with searchGroups / defaultShaper on the registry config; the wrapper routes searchNormalized, lookupNormalized, groupSearchNormalized through SearchService. See data layer for the wiring.
What’s next
The next chapter, API client, goes one level deeper into the HTTP seam — when to write a custom client, what the contract looks like, and how to wire bearer tokens, retries, and telemetry without leaking those concerns above the seam.