On this page10 sections
API configuration
This guide is the reference for the static api block on a BaseModel — endpoint, convention, namespace, readOnly, per-action overrides, and compound IDs — plus the custom-actions extension that layers non-CRUD verbs on top of it. It pairs with three sibling guides in this chapter — Model service routes against the api block, API client ships the requests, and API convention decides the wire format. Read this one when you’re ready to point a model at a real backend.
Custom actions (non-CRUD verbs) moved to the
custom-actionsApiExtension in v0.44.0. Sections in this guide that referenceapi.actions,ActionDefinition, orModelActionTooldescribe behavior that now lives in the extension. The configuration shape is unchanged — only the registration site moved. RegistercustomActionsExtension()onToolRegistryand declare actions onstatic extensions['custom-actions']via thecustomActionsConfig()helper.The
search_recordsandget_filters_guideMCP tools moved to thesearchApiExtension in v0.45.0. RegistersearchExtension()onToolRegistryto expose them.
SearchServiceand all search-related types moved to the search extension in v0.47.0 (@mcp-rune/mcp-rune/api-extensions/search); import from there.Per-model search config moved from
static searchonBaseModeltoextensions['search']viasearchConfig({...})helper in v0.48.0. Identical structure tocustomActionsConfig. See the migration diff in the v0.48.0 CHANGELOG.
Table of Contents
- Overview
- ApiConfig Reference
- ActionDefinition Reference
- EndpointResolver
- ModelService
- ModelActionTool
- Convention Integration
- Compound IDs and Nested Resources
- Examples
Overview
EndpointResolver walks a layered chain to produce the URL for any (model, action) pair. First match wins:
Later steps only fire when the prior one declined to provide a path. Record operations (find, update, delete) swap the parent-path step for a compound-ID check: a recordId containing / is used as the full path. Custom actions (publish, archive) never consult api.endpoints — the custom-actions extension resolves the action’s own path template, prepends the model’s base endpoint unless the record ID is compound, and applies the namespace.
Every model declares a static api configuration that describes how it maps to a REST API. Custom actions are not part of static api — they are declared under static extensions['custom-actions'] via the customActionsConfig() helper:
import { customActionsConfig } from '@mcp-rune/mcp-rune/api-extensions/custom-actions'
import { jsonApiConvention } from '@mcp-rune/mcp-rune/api-conventions'
import { type ApiConfig, BaseModel } from '@mcp-rune/mcp-rune/models'
class Book extends BaseModel {
static api: ApiConfig = {
endpoint: 'books',
convention: jsonApiConvention,
namespace: 'api/v1',
endpoints: { create: 'books/draft' }
}
static extensions = {
'custom-actions': customActionsConfig({
actions: { publish: { path: ':id/publish' } }
})
}
}import { customActionsConfig } from '@mcp-rune/mcp-rune/api-extensions/custom-actions'
import { jsonApiConvention } from '@mcp-rune/mcp-rune/api-conventions'
import { BaseModel } from '@mcp-rune/mcp-rune/models'
class Book extends BaseModel {
static api = {
endpoint: 'books',
convention: jsonApiConvention,
namespace: 'api/v1',
endpoints: { create: 'books/draft' }
}
static extensions = {
'custom-actions': customActionsConfig({
actions: { publish: { path: ':id/publish' } }
})
}
}The extension itself is registered once, on the registry — this enables the model_action MCP tool and the ModelService.action() mixin method. Every custom-action example in this guide assumes this wiring:
import { customActionsExtension } from '@mcp-rune/mcp-rune/api-extensions/custom-actions'
import { DATA_TOOL_CLASSES, ToolRegistry } from '@mcp-rune/mcp-rune/tools'
const registry = new ToolRegistry({
toolClasses: DATA_TOOL_CLASSES,
models: { book: Book },
createApiClient,
apiExtensions: {
'custom-actions': customActionsExtension()
}
})import { customActionsExtension } from '@mcp-rune/mcp-rune/api-extensions/custom-actions'
import { DATA_TOOL_CLASSES, ToolRegistry } from '@mcp-rune/mcp-rune/tools'
const registry = new ToolRegistry({
toolClasses: DATA_TOOL_CLASSES,
models: { book: Book },
createApiClient,
apiExtensions: {
'custom-actions': customActionsExtension()
}
})This configuration is consumed by:
- EndpointResolver — builds URLs from model config + action context
- ModelService — orchestrates CRUD and custom actions through the resolver + convention + ApiClient pipeline
- ModelActionTool — MCP tool surface (registered by the
custom-actionsextension) that exposes custom actions to LLMs - Convention — formats request payloads and normalizes responses
ApiConfig Reference
interface ApiConfig {
/** Base API path for this model (e.g., 'books'). */
endpoint: string
convention?: BaseConvention
readOnly?: boolean
/** Parent model name(s) for nested resources. */
parent?: string | string[]
/** Whether the model has a standalone (non-nested) endpoint. Default: true. */
standalone?: boolean
/** API namespace prefix (e.g., 'api/v1'). Overrides server-wide default. */
namespace?: string
/** Per-action endpoint overrides for non-standard API paths. */
endpoints?: EndpointOverrides
}/**
* The full shape of `static api` on a model. `endpoint` is required;
* every other field is optional — see the per-field sections below for
* resolution rules and defaults.
*
* @typedef {Object} ApiConfig
* @property {string} endpoint
* @property {BaseConvention} [convention]
* @property {boolean} [readOnly]
* @property {string | string[]} [parent]
* @property {boolean} [standalone]
* @property {string} [namespace]
* @property {EndpointOverrides} [endpoints]
*/endpoint
Type: string — Required
The base API path for this model. Used by EndpointResolver.pathForType() as the default path segment.
static api = { endpoint: 'books' }
// → GET /books, POST /books, PATCH /books/:id, DELETE /books/:idapi = { endpoint: 'books' }
// → GET /books, POST /books, PATCH /books/:id, DELETE /books/:idconvention
Type: BaseConvention — Optional
Fallback chain: model api.convention → registry defaultConvention → jsonApiConvention.
Controls how request payloads are built and responses are normalized. The convention determines:
- How attributes are wrapped for create/update (
buildRequestPayload) - How association values are transformed (
resolveAssociationValues) - How list responses are extracted and paginated (
normalizeListResponse)
static api = { endpoint: 'books', convention: jsonApiConvention }
// create payload: { "book": { "title": "Test" } }
static api = { endpoint: 'books', convention: flatRestConvention }
// create payload: { "title": "Test" }api = { endpoint: 'books', convention: jsonApiConvention }
// create payload: { "book": { "title": "Test" } }
api = { endpoint: 'books', convention: flatRestConvention }
// create payload: { "title": "Test" }flatRestConvention is a custom convention — the framework only ships jsonApiConvention. See the flat REST worked example for its full implementation.
readOnly
Type: boolean — Optional (defaults to false)
When true, ModelService blocks write operations (create, update, delete) on this model with a ModelReadOnlyError. Custom actions are not blocked by readOnly — the custom-actions mixin only checks that the model exists, so read-only models can still expose actions.
static api = { endpoint: 'reports', readOnly: true }
// create/update/delete → throws ModelReadOnlyError
// action('export', { recordId: '1' }) → allowedapi = { endpoint: 'reports', readOnly: true }
// create/update/delete → throws ModelReadOnlyError
// action('export', { recordId: '1' }) → allowedparent / standalone
Type: parent: string | string[], standalone: boolean — Optional
Configure nested resource relationships.
parent— names the parent model(s) this resource is nested understandalone: false— this model has no standalone endpoint; aparentPathis required for collection operations
class Asset extends BaseModel {
static api = {
endpoint: 'assets',
parent: 'title',
standalone: false
}
}
// List: requires parentPath → GET /titles/42/assets
// Find: uses compound ID → GET /titles/42/assets/7
// Create: requires parentPath → POST /titles/42/assetsclass Asset extends BaseModel {
static api = {
endpoint: 'assets',
parent: 'title',
standalone: false
}
}
// List: requires parentPath → GET /titles/42/assets
// Find: uses compound ID → GET /titles/42/assets/7
// Create: requires parentPath → POST /titles/42/assetsWhen standalone is false and no parentPath is provided, EndpointResolver throws MissingParentError.
Multiple parents are supported:
static api = {
endpoint: 'schedulings',
parent: ['title', 'title_group'],
standalone: false
}api = {
endpoint: 'schedulings',
parent: ['title', 'title_group'],
standalone: false
}namespace
Type: string — Optional
Per-model API namespace prefix. Overrides the server-wide namespace configured on EndpointResolver.
// Server-wide namespace: 'api/v1'
static api = { endpoint: 'books', namespace: 'api/v2' }
// → api/v2/books (model-level overrides server-wide)api = { endpoint: 'books', namespace: 'api/v2' }
// → api/v2/books (model-level overrides server-wide)endpoints (CRUD overrides)
Type: EndpointOverrides — Optional
Per-action endpoint overrides for APIs with non-standard CRUD paths.
interface EndpointOverrides {
collection?: string // list + create (unless overridden)
record?: string // find + update + delete (unless overridden), :id substituted
create?: string // create only — highest priority for collection ops
update?: string // update only — highest priority for record ops, :id substituted
delete?: string // delete only — highest priority for record ops, :id substituted
}/**
* Per-action endpoint overrides for APIs with non-standard CRUD paths.
* Resolution priority (highest first): per-action override → `collection`
* / `record` → default. Explicit overrides bypass namespace.
*
* @typedef {Object} EndpointOverrides
* @property {string} [collection] list + create (unless overridden)
* @property {string} [record] find + update + delete (unless overridden), `:id` substituted
* @property {string} [create] create only — highest priority for collection ops
* @property {string} [update] update only — highest priority for record ops, `:id` substituted
* @property {string} [delete] delete only — highest priority for record ops, `:id` substituted
*/Resolution priority (highest first):
| Action | Resolution Order |
|---|---|
| list | endpoints.collection → default |
| create | endpoints.create → endpoints.collection → default |
| find | endpoints.record → default |
| update | endpoints.update → endpoints.record → default |
| delete | endpoints.delete → endpoints.record → default |
static api = {
endpoint: 'books',
endpoints: {
collection: 'catalogue/book-items',
record: 'catalogue/book-items/:id',
create: 'books/draft',
update: 'books/:id/revise',
delete: 'books/:id/archive'
}
}
// list → catalogue/book-items
// create → books/draft (per-action > collection)
// find → catalogue/book-items/123
// update → books/123/revise (per-action > record)
// delete → books/123/archive (per-action > record)api = {
endpoint: 'books',
endpoints: {
collection: 'catalogue/book-items',
record: 'catalogue/book-items/:id',
create: 'books/draft',
update: 'books/:id/revise',
delete: 'books/:id/archive'
}
}
// list → catalogue/book-items
// create → books/draft (per-action > collection)
// find → catalogue/book-items/123
// update → books/123/revise (per-action > record)
// delete → books/123/archive (per-action > record)Note: Explicit overrides bypass namespace — they are treated as full paths.
Custom actions (extension)
Type: CustomActionsConfig ({ actions: Record<string, ActionDefinition> }) — Opt-in, declared under static extensions['custom-actions'], not on ApiConfig
Custom actions beyond CRUD. Each key under actions is the action name, each value defines the HTTP method, URL path template, and behavior options. See ActionDefinition Reference and the registry wiring in Overview.
static extensions = {
'custom-actions': customActionsConfig({
actions: {
publish: { path: ':id/publish', description: 'Publish a draft book' },
archive: { path: ':id/archive', method: 'PATCH' },
export: { path: ':id/export', method: 'GET' },
approve_chapter: { path: ':id/chapters/:chapter_id/approve' },
bulk_publish: { path: 'bulk-publish', recordLevel: false, rawPayload: true }
}
})
}static extensions = {
'custom-actions': customActionsConfig({
actions: {
publish: { path: ':id/publish', description: 'Publish a draft book' },
archive: { path: ':id/archive', method: 'PATCH' },
export: { path: ':id/export', method: 'GET' },
approve_chapter: { path: ':id/chapters/:chapter_id/approve' },
bulk_publish: { path: 'bulk-publish', recordLevel: false, rawPayload: true }
}
})
}ActionDefinition Reference
ActionDefinition is exported from @mcp-rune/mcp-rune/api-extensions/custom-actions.
interface ActionDefinition {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
path: string
recordLevel?: boolean
description?: string
rawPayload?: boolean
}/**
* A custom action declared under `extensions['custom-actions']` via
* `customActionsConfig()`. The path supports `:id` and arbitrary
* `:param` substitution.
*
* @typedef {Object} ActionDefinition
* @property {'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'} [method]
* @property {string} path
* @property {boolean} [recordLevel]
* @property {string} [description]
* @property {boolean} [rawPayload]
*/method
Type: string — Optional (defaults to 'POST')
The HTTP method used for this action. Any standard method is supported.
path
Type: string — Required
URL path template with Rails-style named parameters. Relative paths are resolved against the model’s base endpoint by the extension’s ActionResolver.
Supports two kinds of placeholders:
:id— substituted fromrecordId(the primary record parameter):param_name— substituted frompathParams(additional named parameters)
// Single record action
path: ':id/publish'
// Nested action with extra parameter
path: ':id/chapters/:chapter_id/approve'
// Collection action with parameters
path: 'reports/:report_type/:year/generate'
// Simple collection action
path: 'bulk-publish'// Single record action
path: ':id/publish'
// Nested action with extra parameter
path: ':id/chapters/:chapter_id/approve'
// Collection action with parameters
path: 'reports/:report_type/:year/generate'
// Simple collection action
path: 'bulk-publish'Path Parameter Substitution
ActionResolver.resolveAction() substitutes path parameters in this order:
:idis replaced withrecordId(if present in both path and context)- All remaining
:param_nameplaceholders are replaced frompathParams - If any placeholders remain unresolved, an error is thrown
// recordId='42', pathParams={ chapter_id: '5' }
':id/chapters/:chapter_id/approve' → 'books/42/chapters/5/approve'
// No recordId, pathParams={ report_type: 'sales', year: '2026' }
'reports/:report_type/:year/generate' → 'books/reports/sales/2026/generate'
// recordId='42', no pathParams with :chapter_id
':id/chapters/:chapter_id/approve' → Error: "Unresolved path parameters in action 'approve_chapter' on 'book': :chapter_id. Provide values via recordId or pathParams."// recordId='42', pathParams={ chapter_id: '5' }
':id/chapters/:chapter_id/approve'
'books/42/chapters/5/approve'
// No recordId, pathParams={ report_type: 'sales', year: '2026' }
'reports/:report_type/:year/generate'
'books/reports/sales/2026/generate'
// recordId='42', no pathParams with :chapter_id
':id/chapters/:chapter_id/approve'
Error: "Unresolved path parameters in action 'approve_chapter' on 'book': :chapter_id. Provide values via recordId or pathParams."Compound IDs: When recordId contains / (e.g., 'titles/42/assets/7'), it is treated as a compound ID. After :id substitution, the base endpoint is not prepended — the compound ID already encodes the full resource hierarchy.
// recordId='titles/42/assets/7', path=':id/publish'
// → 'titles/42/assets/7/publish' (no base prepend)// recordId='titles/42/assets/7', path=':id/publish'
// → 'titles/42/assets/7/publish' (no base prepend)recordLevel
Type: boolean — Optional (defaults to true)
Indicates whether this action operates on a specific record. Used for documentation and tooling hints. Does not affect resolution — :id substitution only happens when recordId is actually provided.
description
Type: string — Optional
Human-readable description. Included in the model_action tool description so LLMs understand what each action does.
rawPayload
Type: boolean — Optional (defaults to false)
When true, ModelService.action() sends attributes as-is without convention wrapping. Useful for actions that accept a non-standard payload format.
// rawPayload: false (default) → convention wraps payload
// POST /books/42/publish with { "book": { "publish_date": "2026-01-01" } }
// rawPayload: true → attributes sent directly
// POST /books/bulk-publish with { "ids": [1, 2, 3] }// rawPayload: false (default) → convention wraps payload
// POST /books/42/publish with { "book": { "publish_date": "2026-01-01" } }
// rawPayload: true → attributes sent directly
// POST /books/bulk-publish with { "ids": [1, 2, 3] }EndpointResolver
EndpointResolver consolidates URL building into a single class with layered resolution chains.
CRUD Resolution Chain
Collection operations (list, create):
- Per-action override (
endpoints.createfor create) - Collection override (
endpoints.collection) - Parent path (explicit
parentPathfor nested collections) - Namespace +
pathForType(default endpoint)
Record operations (find, update, delete):
- Per-action override (
endpoints.update,endpoints.delete) with:idsubstitution - Record override (
endpoints.record) with:idsubstitution - Compound ID (if
recordIdcontains/) — used as full path - Namespace +
pathForType+/recordId
Action Resolution Chain
Custom-action URLs are resolved by ActionResolver (from the custom-actions extension), which composes pathForType() and applyNamespace() from EndpointResolver. Its resolveAction() method:
- Look up the
ActionDefinitionfrom the model’sextensions['custom-actions']slice - Substitute
:idwithrecordId - Substitute remaining
:param_namefrompathParams - Validate no unresolved placeholders remain
- Compound ID → skip base prepend; Simple/collection → prepend
pathForType - Apply namespace
Returns { url: string, method: string }.
Namespace Resolution
Effective namespace: model-level > server-wide > none.
import { ActionResolver } from '@mcp-rune/mcp-rune/api-extensions/custom-actions'
import { EndpointResolver } from '@mcp-rune/mcp-rune/model-service'
const resolver = new EndpointResolver({ namespace: 'api/v1' })
// Server-wide:
resolver.resolveCollection({ model: 'book', modelConfig }) // → 'api/v1/books'
// Model override:
// modelConfig.api.namespace = 'api/v2'
resolver.resolveCollection({ model: 'book', modelConfig }) // → 'api/v2/books'
// Actions also respect namespace — ActionResolver composes the resolver:
new ActionResolver(resolver).resolveAction({
model: 'book',
modelConfig,
action: 'publish',
recordId: '42'
})
// → { url: 'api/v1/books/42/publish', method: 'POST' }import { ActionResolver } from '@mcp-rune/mcp-rune/api-extensions/custom-actions'
import { EndpointResolver } from '@mcp-rune/mcp-rune/model-service'
const resolver = new EndpointResolver({ namespace: 'api/v1' })
// Server-wide:
resolver.resolveCollection({ model: 'book', modelConfig }) // → 'api/v1/books'
// Model override:
// modelConfig.api.namespace = 'api/v2'
resolver.resolveCollection({ model: 'book', modelConfig }) // → 'api/v2/books'
// Actions also respect namespace — ActionResolver composes the resolver:
new ActionResolver(resolver).resolveAction({
model: 'book',
modelConfig,
action: 'publish',
recordId: '42'
})
// → { url: 'api/v1/books/42/publish', method: 'POST' }Note: CRUD endpoint overrides bypass namespace (they are treated as full paths). Action paths do apply namespace after base prepending.
Custom pathForType
Override in a subclass for APIs with different naming conventions:
class DasherizedResolver extends EndpointResolver {
override pathForType(model: string): string {
return model.replace(/_/g, '-') + 's'
}
}class DasherizedResolver extends EndpointResolver {
pathForType(model) {
return model.replace(/_/g, '-') + 's'
}
}ModelService
ModelService composes EndpointResolver + Convention + ApiClient. It is the single orchestrator for all data operations — both CRUD and custom actions.
CRUD Operations
await modelService.create('book', { title: 'Test', author: 'Author' })
await modelService.find('book', '123')
await modelService.list('book', { status: 'active' }, { page: 2, perPage: 10 })
await modelService.update('book', '123', { title: 'Updated' })
await modelService.delete('book', '123')
// Nested resources:
await modelService.create('asset', { name: 'HD' }, { parentPath: 'titles/42/assets' })
await modelService.find('asset', 'titles/42/assets/7') // compound ID
// User impersonation:
await modelService.create('book', attrs, { userId: 'user-123' })await modelService.create('book', { title: 'Test', author: 'Author' })
await modelService.find('book', '123')
await modelService.list('book', { status: 'active' }, { page: 2, perPage: 10 })
await modelService.update('book', '123', { title: 'Updated' })
await modelService.delete('book', '123')
// Nested resources:
await modelService.create('asset', { name: 'HD' }, { parentPath: 'titles/42/assets' })
await modelService.find('asset', 'titles/42/assets/7') // compound ID
// User impersonation:
await modelService.create('book', attrs, { userId: 'user-123' })Custom Actions
action() is contributed by the custom-actions extension’s ModelService mixin — register the extension as shown in Overview before calling it.
// Simple record action (POST)
await modelService.action('book', 'publish', { recordId: '42' })
// → POST books/42/publish
// Record action with payload (convention-wrapped)
await modelService.action('book', 'archive', {
recordId: '42',
attributes: { reason: 'outdated' }
})
// → PATCH books/42/archive with { "book": { "reason": "outdated" } }
// GET action with query params
await modelService.action('book', 'export', {
recordId: '42',
params: { format: 'pdf' }
})
// → GET books/42/export?format=pdf
// Multi-param action (Rails-style)
await modelService.action('book', 'approve_chapter', {
recordId: '42',
pathParams: { chapter_id: '5' }
})
// → POST books/42/chapters/5/approve
// Collection-level action with raw payload
await modelService.action('book', 'bulk_publish', {
attributes: { ids: [1, 2, 3] }
})
// → POST books/bulk-publish with { ids: [1, 2, 3] }
// Compound ID (nested resource action)
await modelService.action('asset', 'publish', {
recordId: 'titles/42/assets/7'
})
// → POST titles/42/assets/7/publish
// With user impersonation
await modelService.action('book', 'publish', {
recordId: '42',
requestOptions: { userId: 'u1' }
})// Simple record action (POST)
await modelService.action('book', 'publish', { recordId: '42' })
// → POST books/42/publish
// Record action with payload (convention-wrapped)
await modelService.action('book', 'archive', {
recordId: '42',
attributes: { reason: 'outdated' }
})
// → PATCH books/42/archive with { "book": { "reason": "outdated" } }
// GET action with query params
await modelService.action('book', 'export', {
recordId: '42',
params: { format: 'pdf' }
})
// → GET books/42/export?format=pdf
// Multi-param action (Rails-style)
await modelService.action('book', 'approve_chapter', {
recordId: '42',
pathParams: { chapter_id: '5' }
})
// → POST books/42/chapters/5/approve
// Collection-level action with raw payload
await modelService.action('book', 'bulk_publish', {
attributes: { ids: [1, 2, 3] }
})
// → POST books/bulk-publish with { ids: [1, 2, 3] }
// Compound ID (nested resource action)
await modelService.action('asset', 'publish', {
recordId: 'titles/42/assets/7'
})
// → POST titles/42/assets/7/publish
// With user impersonation
await modelService.action('book', 'publish', {
recordId: '42',
requestOptions: { userId: 'u1' }
})Domain Errors
| Error | When | Properties |
|---|---|---|
UnknownModelError | Model name not in registry | availableModels: string[] |
ModelReadOnlyError | Write CRUD on read-only model | — |
MissingRequiredFieldsError | Create missing required attrs | missingFields: string[] |
MissingParentError | Nested-only model without parentPath | model, childEndpoint, parentModels: string[] |
DataLayerError | Base class for every backend-failure error the seam throws | model?: string |
RecordNotFoundError | Record-scoped call the backend answered with 404 | recordId?: string (extends DataLayerError) |
ApiRequestError | Any other backend failure with an HTTP response | status?: number, messages: string[] (extends DataLayerError) |
UnknownActionError* | Action not declared on model | — |
The typed backend-failure taxonomy — DataLayerError, RecordNotFoundError, ApiRequestError, and the translateApiError helper — is exported from @mcp-rune/mcp-rune/data-layer.
* UnknownActionError is owned by the custom-actions extension (@mcp-rune/mcp-rune/api-extensions/custom-actions), not by ModelService.
Note: ModelReadOnlyError only applies to CRUD write operations (create, update, delete). The custom-actions mixin only validates that the model exists and never checks readOnly, so read-only models can still expose custom actions (e.g., GET export).
ModelActionTool
The model_action MCP tool exposes custom actions to LLMs.
Input schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
model | enum | Yes | Model name (only models with actions) |
action | string | Yes | Action name as declared on the model |
record_id | string | No | Record ID (supports compound IDs) |
attributes | object | No | Payload attributes |
path_params | Record<string, string> | No | Named path parameters |
params | object | No | Query parameters (for GET actions) |
user_id | string | No | User ID for impersonation |
The tool description dynamically includes a summary of all available actions per model, showing action names, HTTP methods, and descriptions for LLM discoverability.
Convention Integration
ModelService uses the model’s convention for payload building in both CRUD and custom actions:
- CRUD —
create()andupdate()always wrap payloads viaconvention.buildRequestPayload(model, attrs) - Custom actions —
action()wraps by default; setrawPayload: trueto skip wrapping
The convention also handles:
- Association values — transforms
_idfields to convention-specific formats (e.g.,_linkfor HAL) - Response normalization —
normalizeListResponse()extracts records and pagination - Error parsing —
parseErrorResponse()extracts structured error messages from HTTP error responses
Error Parsing
Each convention knows its API’s error response shape, but parsing happens exactly once — at the adapter boundary, not in the tool layer. ModelService wraps every ApiClient call in _call, which routes transport failures through translateApiError using the failing model’s convention (api.convention, falling back to the service’s defaultConvention). A record-scoped 404 becomes RecordNotFoundError; any other HTTP failure becomes ApiRequestError with messages already parsed through the convention. BaseTool.formatError() then just renders error.message and logs ApiRequestError.status — it never touches the convention. See ADR 0014 for the full reasoning.
The convention method receives an ErrorResponse object ({ status?, data? }) and returns a flat string[] of error messages:
import type { ErrorResponse } from '@mcp-rune/mcp-rune/api-conventions'
// Base implementation: strings pass through, other primitives are
// stringified, objects are JSON-dumped
parseErrorResponse(response: ErrorResponse): string[] {
const data = response.data
if (data === undefined || data === null) return []
if (typeof data === 'string') return [data]
if (typeof data !== 'object') return [String(data)]
return [JSON.stringify(data, null, 2)]
}// Base implementation: strings pass through, other primitives are
// stringified, objects are JSON-dumped
parseErrorResponse(response) {
const data = response.data
if (data === undefined || data === null) return []
if (typeof data === 'string') return [data]
if (typeof data !== 'object') return [String(data)]
return [JSON.stringify(data, null, 2)]
}JSON API convention handles Rails error shapes:
| API Response Shape | Parsed Output |
|---|---|
{ error: "Not found" } | ["Not found"] |
{ errors: { title: ["can't be blank"] } } | ["title: can't be blank"] |
{ errors: ["msg1", "msg2"] } | ["msg1", "msg2"] |
Custom conventions should override to handle their API’s specific error envelope. For example, a HAL convention might extract errors from _embedded.errors or a different structure.
ApiRequestError’s constructor joins the parsed messages with semicolons and appends the HTTP status inline (falling back to Unknown error when the convention returned nothing):
title: can't be blank; status: is not included in the list (422)
This format is optimized for LLM consumption: isError: true already signals the error, so no “Error:” prefix or “Status:” label is needed.
Compound IDs and Nested Resources
Nested resources are handled through compound IDs and parentPath:
// Model configuration
class Asset extends BaseModel {
static api = {
endpoint: 'assets',
parent: 'title',
standalone: false
}
static extensions = {
'custom-actions': customActionsConfig({
actions: {
publish: { path: ':id/publish' }
}
})
}
}// Model configuration
class Asset extends BaseModel {
static api = {
endpoint: 'assets',
parent: 'title',
standalone: false
}
static extensions = {
'custom-actions': customActionsConfig({
actions: {
publish: { path: ':id/publish' }
}
})
}
}Collection operations use parentPath:
await modelService.list('asset', {}, {}, { parentPath: 'titles/42/assets' })
await modelService.create('asset', attrs, { parentPath: 'titles/42/assets' })await modelService.list('asset', {}, {}, { parentPath: 'titles/42/assets' })
await modelService.create('asset', attrs, { parentPath: 'titles/42/assets' })Record operations use compound IDs:
await modelService.find('asset', 'titles/42/assets/7')
await modelService.update('asset', 'titles/42/assets/7', attrs)
await modelService.delete('asset', 'titles/42/assets/7')await modelService.find('asset', 'titles/42/assets/7')
await modelService.update('asset', 'titles/42/assets/7', attrs)
await modelService.delete('asset', 'titles/42/assets/7')Custom actions on nested resources:
await modelService.action('asset', 'publish', { recordId: 'titles/42/assets/7' })
// → POST titles/42/assets/7/publish (compound ID — no base prepend)await modelService.action('asset', 'publish', { recordId: 'titles/42/assets/7' })
// → POST titles/42/assets/7/publish (compound ID — no base prepend)The compound-id module provides utilities:
import { buildCompoundId, buildCollectionPath, parseId } from '@mcp-rune/mcp-rune/model-service'
buildCompoundId('titles', '42', 'assets', '7') // → 'titles/42/assets/7'
buildCollectionPath('titles', '42', 'assets') // → 'titles/42/assets'
parseId('titles/42/assets/7', 'assets') // → { isCompound: true, leafId: '7', ... }import { buildCompoundId, buildCollectionPath, parseId } from '@mcp-rune/mcp-rune/model-service'
buildCompoundId('titles', '42', 'assets', '7') // → 'titles/42/assets/7'
buildCollectionPath('titles', '42', 'assets') // → 'titles/42/assets'
parseId('titles/42/assets/7', 'assets') // → { isCompound: true, leafId: '7', ... }Examples
Standard REST Model
class Book extends BaseModel {
static api = { endpoint: 'books' }
}
// list → GET /books
// create → POST /books
// find → GET /books/123
// update → PATCH /books/123
// delete → DELETE /books/123class Book extends BaseModel {
static api = { endpoint: 'books' }
}
// list → GET /books
// create → POST /books
// find → GET /books/123
// update → PATCH /books/123
// delete → DELETE /books/123Non-Standard CRUD Paths
class Book extends BaseModel {
static api = {
endpoint: 'books',
endpoints: {
collection: 'catalogue/book-items',
create: 'books/draft',
update: 'books/:id/revise',
delete: 'books/:id/archive'
}
}
}
// list → GET /catalogue/book-items
// create → POST /books/draft
// find → GET /catalogue/book-items/123
// update → PATCH /books/123/revise
// delete → DELETE /books/123/archiveclass Book extends BaseModel {
static api = {
endpoint: 'books',
endpoints: {
collection: 'catalogue/book-items',
create: 'books/draft',
update: 'books/:id/revise',
delete: 'books/:id/archive'
}
}
}
// list → GET /catalogue/book-items
// create → POST /books/draft
// find → GET /catalogue/book-items/123
// update → PATCH /books/123/revise
// delete → DELETE /books/123/archiveCustom Actions (Publish, Archive, Export)
class Book extends BaseModel {
static api = {
endpoint: 'books',
convention: jsonApiConvention
}
static extensions = {
'custom-actions': customActionsConfig({
actions: {
publish: { path: ':id/publish', description: 'Publish a draft book' },
archive: { path: ':id/archive', method: 'PATCH', description: 'Archive a book' },
export: { path: ':id/export', method: 'GET', description: 'Export book data' }
}
})
}
}
await modelService.action('book', 'publish', { recordId: '42' })
// → POST /books/42/publish
await modelService.action('book', 'archive', {
recordId: '42',
attributes: { reason: 'outdated' }
})
// → PATCH /books/42/archive { "book": { "reason": "outdated" } }
await modelService.action('book', 'export', {
recordId: '42',
params: { format: 'pdf' }
})
// → GET /books/42/export?format=pdfclass Book extends BaseModel {
static api = {
endpoint: 'books',
convention: jsonApiConvention
}
static extensions = {
'custom-actions': customActionsConfig({
actions: {
publish: { path: ':id/publish', description: 'Publish a draft book' },
archive: { path: ':id/archive', method: 'PATCH', description: 'Archive a book' },
export: { path: ':id/export', method: 'GET', description: 'Export book data' }
}
})
}
}
await modelService.action('book', 'publish', { recordId: '42' })
// → POST /books/42/publish
await modelService.action('book', 'archive', {
recordId: '42',
attributes: { reason: 'outdated' }
})
// → PATCH /books/42/archive { "book": { "reason": "outdated" } }
await modelService.action('book', 'export', {
recordId: '42',
params: { format: 'pdf' }
})
// → GET /books/42/export?format=pdfMulti-Param Actions (Rails-Style)
class Book extends BaseModel {
static api = { endpoint: 'books' }
static extensions = {
'custom-actions': customActionsConfig({
actions: {
approve_chapter: {
path: ':id/chapters/:chapter_id/approve',
description: 'Approve a specific chapter'
},
generate_report: {
path: 'reports/:report_type/:year/generate',
method: 'GET',
recordLevel: false,
description: 'Generate a report'
}
}
})
}
}
await modelService.action('book', 'approve_chapter', {
recordId: '42',
pathParams: { chapter_id: '5' }
})
// → POST /books/42/chapters/5/approve
await modelService.action('book', 'generate_report', {
pathParams: { report_type: 'sales', year: '2026' }
})
// → GET /books/reports/sales/2026/generateclass Book extends BaseModel {
static api = { endpoint: 'books' }
static extensions = {
'custom-actions': customActionsConfig({
actions: {
approve_chapter: {
path: ':id/chapters/:chapter_id/approve',
description: 'Approve a specific chapter'
},
generate_report: {
path: 'reports/:report_type/:year/generate',
method: 'GET',
recordLevel: false,
description: 'Generate a report'
}
}
})
}
}
await modelService.action('book', 'approve_chapter', {
recordId: '42',
pathParams: { chapter_id: '5' }
})
// → POST /books/42/chapters/5/approve
await modelService.action('book', 'generate_report', {
pathParams: { report_type: 'sales', year: '2026' }
})
// → GET /books/reports/sales/2026/generateNested-Only Model with Custom Actions
class Asset extends BaseModel {
static api = {
endpoint: 'assets',
parent: 'title',
standalone: false
}
static extensions = {
'custom-actions': customActionsConfig({
actions: {
publish: { path: ':id/publish', description: 'Publish an asset' },
transcode: { path: ':id/transcode', method: 'POST', description: 'Start transcoding' }
}
})
}
}
// CRUD uses compound IDs / parentPath:
await modelService.find('asset', 'titles/42/assets/7')
await modelService.list('asset', {}, {}, { parentPath: 'titles/42/assets' })
// Actions use compound IDs:
await modelService.action('asset', 'publish', { recordId: 'titles/42/assets/7' })
// → POST /titles/42/assets/7/publish
await modelService.action('asset', 'transcode', {
recordId: 'titles/42/assets/7',
attributes: { format: 'h265', resolution: '4k' }
})
// → POST /titles/42/assets/7/transcode { "asset": { "format": "h265", "resolution": "4k" } }class Asset extends BaseModel {
static api = {
endpoint: 'assets',
parent: 'title',
standalone: false
}
static extensions = {
'custom-actions': customActionsConfig({
actions: {
publish: { path: ':id/publish', description: 'Publish an asset' },
transcode: { path: ':id/transcode', method: 'POST', description: 'Start transcoding' }
}
})
}
}
// CRUD uses compound IDs / parentPath:
await modelService.find('asset', 'titles/42/assets/7')
await modelService.list('asset', {}, {}, { parentPath: 'titles/42/assets' })
// Actions use compound IDs:
await modelService.action('asset', 'publish', { recordId: 'titles/42/assets/7' })
// → POST /titles/42/assets/7/publish
await modelService.action('asset', 'transcode', {
recordId: 'titles/42/assets/7',
attributes: { format: 'h265', resolution: '4k' }
})
// → POST /titles/42/assets/7/transcode { "asset": { "format": "h265", "resolution": "4k" } }Read-Only Model with GET Actions
class Report extends BaseModel {
static api = {
endpoint: 'reports',
readOnly: true
}
static extensions = {
'custom-actions': customActionsConfig({
actions: {
download: { path: ':id/download', method: 'GET', description: 'Download report' },
preview: { path: ':id/preview', method: 'GET', description: 'Preview report' }
}
})
}
}
// CRUD writes blocked:
await modelService.create('report', {}) // → throws ModelReadOnlyError
// Custom GET actions allowed:
await modelService.action('report', 'download', {
recordId: '42',
params: { format: 'csv' }
})
// → GET /reports/42/download?format=csvclass Report extends BaseModel {
static api = {
endpoint: 'reports',
readOnly: true
}
static extensions = {
'custom-actions': customActionsConfig({
actions: {
download: { path: ':id/download', method: 'GET', description: 'Download report' },
preview: { path: ':id/preview', method: 'GET', description: 'Preview report' }
}
})
}
}
// CRUD writes blocked:
await modelService.create('report', {}) // → throws ModelReadOnlyError
// Custom GET actions allowed:
await modelService.action('report', 'download', {
recordId: '42',
params: { format: 'csv' }
})
// → GET /reports/42/download?format=csvCollection-Level Action with Raw Payload
class Book extends BaseModel {
static api = { endpoint: 'books' }
static extensions = {
'custom-actions': customActionsConfig({
actions: {
bulk_publish: {
path: 'bulk-publish',
recordLevel: false,
rawPayload: true,
description: 'Publish multiple books at once'
}
}
})
}
}
await modelService.action('book', 'bulk_publish', {
attributes: { ids: [1, 2, 3], publish_date: '2026-01-01' }
})
// → POST /books/bulk-publish { "ids": [1, 2, 3], "publish_date": "2026-01-01" }class Book extends BaseModel {
static api = { endpoint: 'books' }
static extensions = {
'custom-actions': customActionsConfig({
actions: {
bulk_publish: {
path: 'bulk-publish',
recordLevel: false,
rawPayload: true,
description: 'Publish multiple books at once'
}
}
})
}
}
await modelService.action('book', 'bulk_publish', {
attributes: { ids: [1, 2, 3], publish_date: '2026-01-01' }
})
// → POST /books/bulk-publish { "ids": [1, 2, 3], "publish_date": "2026-01-01" }