mcp-rune 0.107.0
Be star #1 Get started
SECTION VI · GUIDE 24 OF 49
Reading
12 min
Topic
models · http
Spec
v0.107.0
Source
06-the-three-layers-up-close/api-configuration.md
On this page10 sections

API configuration

This guide is the reference for the static api block on a BaseModelendpoint, 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-actions ApiExtension in v0.44.0. Sections in this guide that reference api.actions, ActionDefinition, or ModelActionTool describe behavior that now lives in the extension. The configuration shape is unchanged — only the registration site moved. Register customActionsExtension() on ToolRegistry and declare actions on static extensions['custom-actions'] via the customActionsConfig() helper.

The search_records and get_filters_guide MCP tools moved to the search ApiExtension in v0.45.0. Register searchExtension() on ToolRegistry to expose them.

SearchService and 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 search on BaseModel to extensions['search'] via searchConfig({...}) helper in v0.48.0. Identical structure to customActionsConfig. See the migration diff in the v0.48.0 CHANGELOG.

Table of Contents


Overview

EndpointResolver walks a layered chain to produce the URL for any (model, action) pair. First match wins:

ENDPOINTRESOLVER · FIVE-STEP CHAINResolve endpoint for (model, action)Per-action override?api.endpoints[<action>]yesendpoints[<action>]noCollection override?api.endpointyesapi.endpointnoParent path?api.parentyesparent.endpoint + ownnoNamespace?api.namespaceyesnamespace + defaultnoDefaultpluralize(model name)"book" → /books

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:

src/models/book.ts
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:

src/server.ts
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-actions extension) that exposes custom actions to LLMs
  • Convention — formats request payloads and normalizes responses

ApiConfig Reference

src/config/api-config.ts
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: stringRequired

The base API path for this model. Used by EndpointResolver.pathForType() as the default path segment.

examples/api-config-guide-02.ts
static api = { endpoint: 'books' }
// → GET /books, POST /books, PATCH /books/:id, DELETE /books/:id
api = { endpoint: 'books' }
// → GET /books, POST /books, PATCH /books/:id, DELETE /books/:id

convention

Type: BaseConventionOptional

Fallback chain: model api.convention → registry defaultConventionjsonApiConvention.

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)
examples/api-config-guide-03.ts
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: booleanOptional (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.

examples/api-config-guide-04.ts
static api = { endpoint: 'reports', readOnly: true }
// create/update/delete → throws ModelReadOnlyError
// action('export', { recordId: '1' }) → allowed
api = { endpoint: 'reports', readOnly: true }
// create/update/delete → throws ModelReadOnlyError
// action('export', { recordId: '1' }) → allowed

parent / standalone

Type: parent: string | string[], standalone: booleanOptional

Configure nested resource relationships.

  • parent — names the parent model(s) this resource is nested under
  • standalone: false — this model has no standalone endpoint; a parentPath is required for collection operations
src/asset.ts
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/assets
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/assets

When standalone is false and no parentPath is provided, EndpointResolver throws MissingParentError.

Multiple parents are supported:

examples/api-config-guide-06.ts
static api = {
  endpoint: 'schedulings',
  parent: ['title', 'title_group'],
  standalone: false
}
api = {
  endpoint: 'schedulings',
  parent: ['title', 'title_group'],
  standalone: false
}

namespace

Type: stringOptional

Per-model API namespace prefix. Overrides the server-wide namespace configured on EndpointResolver.

examples/api-config-guide-07.ts
// 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: EndpointOverridesOptional

Per-action endpoint overrides for APIs with non-standard CRUD paths.

src/endpoint-overrides.ts
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):

ActionResolution Order
listendpoints.collection → default
createendpoints.createendpoints.collection → default
findendpoints.record → default
updateendpoints.updateendpoints.record → default
deleteendpoints.deleteendpoints.record → default
examples/api-config-guide-09.ts
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.

examples/api-config-guide-10.ts
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.

src/action-definition.ts
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: stringOptional (defaults to 'POST')

The HTTP method used for this action. Any standard method is supported.

path

Type: stringRequired

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 from recordId (the primary record parameter)
  • :param_name — substituted from pathParams (additional named parameters)
examples/api-config-guide-12.ts
// 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:

  1. :id is replaced with recordId (if present in both path and context)
  2. All remaining :param_name placeholders are replaced from pathParams
  3. If any placeholders remain unresolved, an error is thrown
examples/api-config-guide-13.ts
// 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.

examples/api-config-guide-14.ts
// 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: booleanOptional (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: stringOptional

Human-readable description. Included in the model_action tool description so LLMs understand what each action does.

rawPayload

Type: booleanOptional (defaults to false)

When true, ModelService.action() sends attributes as-is without convention wrapping. Useful for actions that accept a non-standard payload format.

examples/api-config-guide-15.ts
// 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):

  1. Per-action override (endpoints.create for create)
  2. Collection override (endpoints.collection)
  3. Parent path (explicit parentPath for nested collections)
  4. Namespace + pathForType (default endpoint)

Record operations (find, update, delete):

  1. Per-action override (endpoints.update, endpoints.delete) with :id substitution
  2. Record override (endpoints.record) with :id substitution
  3. Compound ID (if recordId contains /) — used as full path
  4. 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:

  1. Look up the ActionDefinition from the model’s extensions['custom-actions'] slice
  2. Substitute :id with recordId
  3. Substitute remaining :param_name from pathParams
  4. Validate no unresolved placeholders remain
  5. Compound ID → skip base prepend; Simple/collection → prepend pathForType
  6. Apply namespace

Returns { url: string, method: string }.

Namespace Resolution

Effective namespace: model-level > server-wide > none.

src/resolver.ts
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:

src/dasherized-resolver.ts
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

examples/api-config-guide-18.ts
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.

examples/api-config-guide-19.ts
// 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

ErrorWhenProperties
UnknownModelErrorModel name not in registryavailableModels: string[]
ModelReadOnlyErrorWrite CRUD on read-only model
MissingRequiredFieldsErrorCreate missing required attrsmissingFields: string[]
MissingParentErrorNested-only model without parentPathmodel, childEndpoint, parentModels: string[]
DataLayerErrorBase class for every backend-failure error the seam throwsmodel?: string
RecordNotFoundErrorRecord-scoped call the backend answered with 404recordId?: string (extends DataLayerError)
ApiRequestErrorAny other backend failure with an HTTP responsestatus?: 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:

ParameterTypeRequiredDescription
modelenumYesModel name (only models with actions)
actionstringYesAction name as declared on the model
record_idstringNoRecord ID (supports compound IDs)
attributesobjectNoPayload attributes
path_paramsRecord<string, string>NoNamed path parameters
paramsobjectNoQuery parameters (for GET actions)
user_idstringNoUser 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:

  1. CRUDcreate() and update() always wrap payloads via convention.buildRequestPayload(model, attrs)
  2. Custom actionsaction() wraps by default; set rawPayload: true to skip wrapping

The convention also handles:

  • Association values — transforms _id fields to convention-specific formats (e.g., _link for HAL)
  • Response normalizationnormalizeListResponse() extracts records and pagination
  • Error parsingparseErrorResponse() 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:

src/data.ts
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 ShapeParsed 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:

src/asset.ts
// 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:

examples/api-config-guide-22.ts
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:

examples/api-config-guide-23.ts
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:

examples/api-config-guide-24.ts
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:

examples/api-config-guide-25.ts
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

src/book.ts
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/123
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/123

Non-Standard CRUD Paths

src/book.ts
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/archive
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/archive

Custom Actions (Publish, Archive, Export)

src/book.ts
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=pdf
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=pdf

Multi-Param Actions (Rails-Style)

src/book.ts
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/generate
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/generate

Nested-Only Model with Custom Actions

src/asset.ts
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

src/report.ts
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=csv
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=csv

Collection-Level Action with Raw Payload

src/book.ts
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" }