On this page10 sections
Customization: swap via
dataLayer:onToolRegistry/AppRegistry. Default adapter isModelService(next chapter); anInMemoryDataLayerstub ships for tests. This is the deployer’s main backend seam.
Data layer
This chapter is the entry point to Part II. Chapter 4 introduced DataLayer as the per-request backend I/O interface every Tool and App consumes; this chapter covers what’s behind it, which built-in implementations ship, and how to swap one in.
DataLayer is the seam between mcp-rune’s projection layer (polymorphic CRUD tools, prompt strategies, schema-driven apps, domain workflows) and any concrete data backend. It declares the operations the projection layer needs; the default implementation is ModelService wrapping an ApiClient (covered in the next chapter), but you can swap in an in-memory stub, a fetch-only adapter, or a third-party library wrapper without changing tools, prompts, or apps.
v0.85.0: the default API convention moved from
BaseModelto theDataLayerfactory. ThedefaultConvention:option onToolRegistry/AppRegistry(and the underlyingdataLayerfactory) is now the canonical place to set the server-wide wire format. Per-modelstatic api.conventionoverrides still work exactly as before.
The seam exists for two reasons:
- The projection layer is what makes mcp-rune unique. The data layer overlaps heavily with mature client-side libraries (Warp Drive / Ember Data, Zodios, ts-rest, etc.). Naming the seam lets the ecosystem decide which adapter to use without forking the framework.
- The pre-v0.49 framework leaked
apiClientacross tools and apps. Closing the leak — and exposing a single typed surface — makes the boundary auditable.
At a glance, the seam and what sits above and below it:
The projection layer never imports ApiClient, SearchService, or a Convention directly — DataLayer is the only seam it crosses. Swap the adapter (in-memory stub, fetch-only, third-party wrapper) without touching anything above the line.
ModelLayeris the read-only peer seam for static model metadata — sync, no I/O, and unlikeDataLayerit is not swappable on the Registry. Tools and apps consume both:this.dataLayerfor I/O,this.modelLayer('book')for the static reads.
Table of Contents
- The Interface
- Error contract
- The Projection-Layer Rule
- The Default Adapter
- Swapping the Adapter
- Using DataLayer in a Custom Tool
- In-Memory Stub for Tests
- Writing Your Own Adapter
- Why Conventions Stay Below the Seam
The Interface
import type { DataLayer } from '@mcp-rune/mcp-rune/data-layer'
interface DataLayer {
// CRUD
create(model, attributes, options?)
find(model, recordId, options?)
list(model, filters?, pagination?, options?)
update(model, recordId, attributes, options?)
delete(model, recordId, options?)
// Normalized read surface — the projection layer consumes these for reads
listNormalized(model, filters?, pagination?, options?)
searchNormalized(model, query?, filters?, pagination?, options?)
lookupNormalized(model, query, options?)
groupSearchNormalized(group, query, options?)
// Escape hatches
dispatch(method, url, payload?, params?, options?)
buildPayload(model, modelConfig, attrs)
// Request validation — filters and nested resources are checked at the seam
validateFilters(model, filters)
normalizeFilters(model, filters)
validateNestedResource(parentModel, childResource)
readonly models: ModelsRegistry
readonly defaultConvention: BaseConvention
readonly endpointResolver: EndpointResolver // unstable; for custom-actions
}/**
* The seam between the projection layer (polymorphic CRUD tools, prompt
* strategies, schema-driven apps) and any concrete data backend. Reads
* flow through the four `*Normalized` methods; writes through CRUD.
*
* @typedef {Object} DataLayer
* @property {(model: string, attributes: Object, options?: Object) => Promise<Object>} create
* @property {(model: string, recordId: string, options?: Object) => Promise<Object>} find
* @property {(model: string, filters?: Object, pagination?: Object, options?: Object) => Promise<Object>} list
* @property {(model: string, recordId: string, attributes: Object, options?: Object) => Promise<Object>} update
* @property {(model: string, recordId: string, options?: Object) => Promise<Object>} delete
* @property {(model: string, filters?: Object, pagination?: Object, options?: Object) => Promise<NormalizedListResponse>} listNormalized
* @property {(model: string, query?: string, filters?: Object, pagination?: Object, options?: Object) => Promise<NormalizedListResponse>} searchNormalized
* @property {(model: string, query: string, options?: { perPage?: number }) => Promise<NormalizedListResponse>} lookupNormalized
* @property {(group: string, query: string, options?: { perPage?: number, models?: string[] }) => Promise<NormalizedListResponse>} groupSearchNormalized
* @property {(method: string, url: string, payload?: Object, params?: Object, options?: Object) => Promise<Object>} dispatch
* @property {(model: string, modelConfig: Object, attrs: Object) => Object} buildPayload
* @property {(model: string, filters?: Object) => FilterValidationResult} validateFilters
* @property {(model: string, filters?: Object) => (Object | undefined)} normalizeFilters
* @property {(parentModel: string, childResource: string) => NestedValidationResult} validateNestedResource
* @property {ModelsRegistry} models
* @property {BaseConvention} defaultConvention wire-format default resolved at construction
* @property {EndpointResolver} endpointResolver unstable; for custom-actions
*/Every CRUD method returns Promise<Record<string, unknown>>; the four *Normalized methods return Promise<NormalizedListResponse> ({ records, pagination }). Adapters are responsible for their own response normalization upstream of this boundary; the projection layer treats payloads as opaque.
The read surface splits by intent: listNormalized for “give me a page”, searchNormalized for “find records matching a text query and/or filters”, lookupNormalized for “single-model typeahead”, groupSearchNormalized for “multi-model typeahead across a configured group”. Base adapters without a search backend may delegate searchNormalized and lookupNormalized to listNormalized; groupSearchNormalized requires group config, so base adapters throw a clear error. The SearchEnabledDataLayer decorator is what actually implements text-search routing.
Filter validation also lives at the seam: validateFilters checks each filter key against the model’s declared filterable attributes (including enum values), normalizeFilters splits comma-separated enum strings, and validateNestedResource checks a nested-resource name against the parent model’s declared associations. Because these are interface members rather than adapter internals, every adapter — the default ModelService, the in-memory stub, or your own — enforces the same request validation the projection layer relies on.
Error contract
Backend failures cross the seam as a typed taxonomy (ADR 0014), so projection code branches on error class instead of duck-typing transport shapes:
DataLayerError— base class for every backend-failure error the seam throws; carries themodelthe operation failed on.RecordNotFoundError— a record lookup the backend answered with 404; carriesmodelandrecordId, with a message likeNo book found with id "9".ApiRequestError— any other backend failure; carries the HTTPstatusandmessagesalready parsed through the failing model’s convention (the server-wide default only applies when no model convention is declared).
import {
ApiRequestError,
DataLayerError,
RecordNotFoundError,
translateApiError
} from '@mcp-rune/mcp-rune/data-layer'import {
ApiRequestError,
DataLayerError,
RecordNotFoundError,
translateApiError
} from '@mcp-rune/mcp-rune/data-layer'Translation happens exactly once, at the adapter boundary: the default ModelService funnels every request through one internal _call helper that passes rejections to translateApiError, which maps a 404 on a record-scoped call to RecordNotFoundError and everything else to ApiRequestError — always preserving the original rejection on cause. Rejections without a response (network failures, timeouts, programming errors) pass through untouched. Above the seam, tools and apps never parse error bodies.
The Projection-Layer Rule
Apps, tools, prompts, and domain workflows consume only the
DataLayerinterface. They must never importSearchService,ApiClient, orModelServicedirectly. When the projection layer needs a capability the interface doesn’t expose, the right move is to extendDataLayerwith a method and implement it in adapters (or in a decorator likeSearchEnabledDataLayer) — not to reach around the seam.
This is the load-bearing contract that lets alternative adapters slot in. Three things hold the rule up:
- Both registries wrap the configured DataLayer factory output in
SearchEnabledDataLayer; app handlers see onlycontext.dataLayer, tools only the bound seam. There is nocontext.searchClient. Apps cannot violate the rule because the seam doesn’t expose it. BaseTool.requireDataLayer()is the only sanctioned way for tools to read the seam. It throws if the tool ran without authentication; it never hands back anApiClient.InMemoryDataLayerhas no HTTP transport and no search engine. Code paths that reach past the interface (e.g., importingModelServiceto coerce a method) fail loudly when run against the stub, surfacing the leak at test time rather than in production.
Why the rule pays off:
- Adapter interchangeability. The same projection-layer code runs against
ModelService(HTTP),InMemoryDataLayer(tests), and any future library-backed adapter (Zodios, fetch-only, GraphQL). - One auditable surface. “What can a tool ask the data layer to do?” is answered by reading one TypeScript file.
- Honest extensions. The pattern for extending the seam is documented and copy-paste-able — the
searchApiExtension is the worked example.
The Default Adapter
ModelService (@mcp-rune/mcp-rune/model-service) implements DataLayer by composing:
ApiClientfor HTTP transportEndpointResolverfor URL composition (per-action override → collection override → parent path → namespace → base)BaseConventionfor payload wrapping and association resolution (JSON:API, HAL, custom)
If you don’t configure anything, ToolRegistry and AppRegistry instantiate ModelService automatically and apply any ApiExtension mixins (custom-actions, etc.).
Adding search to the default adapter
Plain ModelService has no notion of search endpoints — it delegates searchNormalized and lookupNormalized to listNormalized and throws on groupSearchNormalized. The search ApiExtension ships a decorator that wraps any DataLayer and routes the three search-related methods through SearchService:
import { withSearchEnabledDataLayer } from '@mcp-rune/mcp-rune/api-extensions/search'
const base = new ModelService({ apiClient, models, namespace })
const dataLayer = withSearchEnabledDataLayer(base, { searchGroups, defaultShaper })import { withSearchEnabledDataLayer } from '@mcp-rune/mcp-rune/api-extensions/search'
const base = new ModelService({ apiClient, models, namespace })
const dataLayer = withSearchEnabledDataLayer(base, { searchGroups, defaultShaper })Both registries do this automatically — every app handler and every authenticated tool receives a dataLayer already wrapped, built from the searchGroups / defaultShaper options on their config. Calling withSearchEnabledDataLayer yourself is only needed when composing a DataLayer outside a registry (tests, custom hosting).
The decorator is a thin proxy: every CRUD method forwards to the base adapter; the three search methods route through SearchService.search / .lookup / .groupSearch. Apps cannot tell whether they’re talking to a raw ModelService or the search-enabled wrapper — they call dataLayer.searchNormalized either way.
Swapping the Adapter
Both ToolRegistry and AppRegistry accept a dataLayer factory option:
import { createInMemoryDataLayer } from '@mcp-rune/mcp-rune/data-layer'
const registry = new ToolRegistry({
toolClasses: { ...DATA_TOOL_CLASSES, custom_tool: MyTool },
models: MODEL_CLASSES,
createApiClient: (token) => createApiClient(token, { apiUrl }),
dataLayer: createInMemoryDataLayer({
fixtures: {
book: {
'1': { id: '1', title: 'Clean Code', author: 'Bob Martin' }
}
}
})
})import { createInMemoryDataLayer } from '@mcp-rune/mcp-rune/data-layer'
const registry = new ToolRegistry({
toolClasses: { ...DATA_TOOL_CLASSES, custom_tool: MyTool },
models: MODEL_CLASSES,
createApiClient: (token) => createApiClient(token, { apiUrl }),
dataLayer: createInMemoryDataLayer({
fixtures: {
book: {
1: { id: '1', title: 'Clean Code', author: 'Bob Martin' }
}
}
})
})The factory signature is:
type DataLayerFactory = (ctx: {
apiClient?: ApiClient
models: ModelsRegistry
namespace?: string
defaultConvention?: BaseConvention
logger?: ToolLogger
}) => DataLayer/**
* The factory ToolRegistry / AppRegistry call to build a DataLayer.
* `apiClient` is populated whenever `createApiClient` is configured;
* adapters that don't need HTTP can ignore it.
*
* @typedef {Object} DataLayerFactoryCtx
* @property {ApiClient} [apiClient]
* @property {ModelsRegistry} models
* @property {string} [namespace]
* @property {BaseConvention} [defaultConvention]
* @property {ToolLogger} [logger]
*
* @typedef {(ctx: DataLayerFactoryCtx) => DataLayer} DataLayerFactory
*/apiClient is passed by the registry whenever createApiClient is configured. Adapters that don’t need HTTP (in-memory stub, library-backed wrappers) can ignore it. defaultConvention carries the server-wide wire-format default set on the registry; the default ModelService factory consumes it, and custom factories may honor or ignore it (same contract as namespace).
Using DataLayer in a Custom Tool
import { BaseTool } from '@mcp-rune/mcp-rune/tools'
export class ArchiveProjectTool extends BaseTool {
// Inherits BaseTool's static requiresAuth = true, so the registry injects
// an authenticated DataLayer per invocation.
override get name() {
return 'archive_project'
}
override async execute({ project_id }: { project_id: string }) {
const dataLayer = this.requireDataLayer()
return dataLayer.dispatch('POST', `/projects/${project_id}/archive`)
}
}import { BaseTool } from '@mcp-rune/mcp-rune/tools'
export class ArchiveProjectTool extends BaseTool {
// Inherits BaseTool's static requiresAuth = true, so the registry injects
// an authenticated DataLayer per invocation.
get name() {
return 'archive_project'
}
async execute({ project_id }) {
const dataLayer = this.requireDataLayer()
return dataLayer.dispatch('POST', `/projects/${project_id}/archive`)
}
}requireDataLayer() returns the bound DataLayer and throws Error('Not authenticated. Please authenticate first.') if the tool ran without authentication. For typed CRUD, prefer the named methods over dispatch:
const book = await this.requireDataLayer().find('book', '42')
const books = await this.requireDataLayer().list(
'book',
{ status: 'unread' },
{ page: 1, perPage: 20 }
)const book = await this.requireDataLayer().find('book', '42')
const books = await this.requireDataLayer().list(
'book',
{ status: 'unread' },
{ page: 1, perPage: 20 }
)In-Memory Stub for Tests
InMemoryDataLayer (also exported from @mcp-rune/mcp-rune/data-layer) is the reference adapter for offline tool tests:
import { InMemoryDataLayer } from '@mcp-rune/mcp-rune/data-layer'
const dataLayer = new InMemoryDataLayer({
models: { book: { api: { endpoint: 'books' } } },
fixtures: { book: { '1': { id: '1', title: 'Clean Code' } } }
})
const tool = new FindRecordsTool({ dataLayer, models: dataLayer.models })
const result = await tool.execute({ model: 'book', record_id: '1' })import { InMemoryDataLayer } from '@mcp-rune/mcp-rune/data-layer'
const dataLayer = new InMemoryDataLayer({
models: { book: { api: { endpoint: 'books' } } },
fixtures: { book: { 1: { id: '1', title: 'Clean Code' } } }
})
const tool = new FindRecordsTool({ dataLayer, models: dataLayer.models })
const result = await tool.execute({ model: 'book', record_id: '1' })The stub is deliberately convention-free — it does not implement HAL _link decoration or any other API-specific shape. Use it to verify projection-layer behavior, not to mock a particular backend.
It also honors the seam’s error contract: find, update, and delete throw RecordNotFoundError for missing records, so error-handling paths behave the same under test as against the default adapter.
Writing Your Own Adapter
An adapter is any class or object that satisfies the DataLayer interface. Minimal example wrapping a fetch-based REST client:
import { jsonApiConvention } from '@mcp-rune/mcp-rune/data-layer'
import type { DataLayer, NormalizedListResponse } from '@mcp-rune/mcp-rune/data-layer'
import type { ModelsRegistry } from '@mcp-rune/mcp-rune/tools'
import { EndpointResolver } from '@mcp-rune/mcp-rune/model-service'
export class FetchDataLayer implements DataLayer {
readonly models: ModelsRegistry
readonly endpointResolver = new EndpointResolver()
readonly defaultConvention = jsonApiConvention
constructor(
public baseUrl: string,
models: ModelsRegistry
) {
this.models = models
}
async create(model, attributes) {
const endpoint = this.models[model]!.api.endpoint
const res = await fetch(`${this.baseUrl}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attributes)
})
return res.json()
}
// ... find / list / update / delete / dispatch / buildPayload,
// plus validateFilters / normalizeFilters / validateNestedResource
async listNormalized(model, filters, pagination): Promise<NormalizedListResponse> {
const raw = await this.list(model, filters, pagination)
return { records: raw.data ?? [], pagination: raw.meta ?? { page: 1, per_page: 20, total: 0 } }
}
// No text-search backend on this adapter — wrap with `withSearchEnabledDataLayer`
// if you want `dataLayer.searchNormalized` to honor a query.
async searchNormalized(model, _query, filters, pagination) {
return this.listNormalized(model, filters, pagination)
}
async lookupNormalized(model, _query, options) {
return this.listNormalized(model, undefined, { page: 1, perPage: options?.perPage ?? 10 })
}
async groupSearchNormalized(_group, _query, _options): Promise<NormalizedListResponse> {
throw new Error('Group search requires the search ApiExtension')
}
}import { jsonApiConvention } from '@mcp-rune/mcp-rune/data-layer'
import { EndpointResolver } from '@mcp-rune/mcp-rune/model-service'
export class FetchDataLayer {
endpointResolver = new EndpointResolver()
defaultConvention = jsonApiConvention
constructor(baseUrl, models) {
this.baseUrl = baseUrl
this.models = models
}
async create(model, attributes) {
const endpoint = this.models[model].api.endpoint
const res = await fetch(`${this.baseUrl}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attributes)
})
return res.json()
}
// ... find / list / update / delete / dispatch / buildPayload,
// plus validateFilters / normalizeFilters / validateNestedResource
async listNormalized(model, filters, pagination) {
const raw = await this.list(model, filters, pagination)
return { records: raw.data ?? [], pagination: raw.meta ?? { page: 1, per_page: 20, total: 0 } }
}
async searchNormalized(model, _query, filters, pagination) {
return this.listNormalized(model, filters, pagination)
}
async lookupNormalized(model, _query, options) {
return this.listNormalized(model, undefined, { page: 1, perPage: options?.perPage ?? 10 })
}
async groupSearchNormalized(_group, _query, _options) {
throw new Error('Group search requires the search ApiExtension')
}
}Custom adapters should throw the seam’s error taxonomy rather than leaking transport errors: raise RecordNotFoundError when a record-scoped call comes back empty, and ApiRequestError for other backend failures — adapters whose rejections carry an ApiClient-shaped response can reuse translateApiError instead of hand-rolling the mapping. The in-memory stub throws RecordNotFoundError too, so tests that swap adapters exercise the same contract your production adapter must honor.
Adapters that already speak HTTP can usually subclass ModelService and override the convention or endpoint-resolution behavior instead of reimplementing the whole interface. To add text search, compose with withSearchEnabledDataLayer rather than reimplementing the routing chain.
Why Conventions Stay Below the Seam
The BaseConvention interface (JsonApiConvention, HalConvention, custom) is not part of DataLayer. Conventions are consumed by:
- The default
ModelService.buildPayload(internal to the default adapter) - Prompt and app schema generators that read
modelConfig.api.conventionas static metadata for deriving form schemas and field documentation
A non-HTTP adapter (in-memory stub, GraphQL-backed adapter) has no use for HAL link generation but still needs to satisfy the projection layer’s static introspection. Keeping conventions out of the runtime seam preserves that asymmetry.