On this page9 sections
Customization: pass your own
ApiClientviacreateApiClient:onToolRegistry/AppRegistry. The default is the bundled axios-based client. Sits belowDataLayer; projection-layer code never sees it directly.
API client
ApiClient is the universal CRUD HTTP contract ModelService (covered earlier in this chapter) depends on. It sits one layer below the DataLayer seam — projection-layer code never imports it directly; only ModelService does. DataLayer is “data operations against models”; ApiClient is “HTTP verbs against URLs.” Most deployers never need to write their own — they pass the bundled axios-based client. You write a custom one when:
- Your backend uses a transport that isn’t HTTP (gRPC bridge, message queue).
- You need request-level instrumentation that wrappers can’t reach (per-tenant header injection, request signing, mTLS).
- You want a fast in-process stub for integration tests without spinning a real server.
- You need streaming/chunked responses that the default client doesn’t expose.
This guide covers writing one, plugging it in, and testing it.
The lifecycle is per-request — never reuse a client across users:
The factory is the only contract the framework cares about, and the two registries invoke it with slightly different shapes: ToolRegistry calls it as createApiClient(token), while AppRegistry calls it as createApiClient(token, { apiUrl }). The implementation can be axios, fetch, gRPC bridge, or an in-memory stub for tests. The framework calls this factory once per request, so the token stays bound to a single user’s call and never bleeds between concurrent sessions.
Table of Contents
- The Interface
- The
createApiClientFactory - Where It Plugs In
- Worked Example: Fetch-Based Client
- Composing with
OAuthService - In-Memory Stub for Tests
SearchApiClient: The Read-Only Subset- Don’t Reach Past the Interface
The Interface
import type { ApiClient, RequestOptions } from '@mcp-rune/mcp-rune/core'
interface RequestOptions {
userId?: string
[key: string]: unknown
}
interface ApiClient {
baseUrl?: string
get(
url: string,
params?: Record<string, unknown>,
options?: RequestOptions
): Promise<Record<string, unknown>>
post(
url: string,
data?: Record<string, unknown>,
options?: RequestOptions
): Promise<Record<string, unknown>>
put(
url: string,
data?: Record<string, unknown>,
options?: RequestOptions
): Promise<Record<string, unknown>>
patch(
url: string,
data?: Record<string, unknown>,
options?: RequestOptions
): Promise<Record<string, unknown>>
delete(url: string, options?: RequestOptions): Promise<Record<string, unknown>>
}/**
* Optional per-request context. Tools receive a fresh client per request;
* `userId` is populated by mcp-rune when impersonating during an OAuth
* flow. Additional keys are passed through to your transport.
*
* @typedef {Object} RequestOptions
* @property {string} [userId]
*/
/**
* Universal CRUD HTTP contract. Every authenticated tool, every MCP App,
* and ModelService itself depend on a JS object exposing these methods.
* Implementations should throw on non-2xx responses.
*
* @typedef {Object} ApiClient
* @property {string} [baseUrl]
* @property {(url: string, params?: Object, options?: RequestOptions) => Promise<Object>} get
* @property {(url: string, data?: Object, options?: RequestOptions) => Promise<Object>} post
* @property {(url: string, data?: Object, options?: RequestOptions) => Promise<Object>} put
* @property {(url: string, data?: Object, options?: RequestOptions) => Promise<Object>} patch
* @property {(url: string, options?: RequestOptions) => Promise<Object>} delete
*/Five methods, all returning Promise<Record<string, unknown>>. The framework treats payloads as opaque — response normalization is the convention’s job, not the client’s.
Errors, though, have a declared contract: ApiClientHttpError (import it from @mcp-rune/mcp-rune/core). A client that fails an HTTP call must throw an Error carrying an optional response property shaped { status?: number; data?: unknown }. That shape is what ModelService translates at the adapter boundary into the typed DataLayer taxonomy (ADR 0014): a 404 on a record-scoped call becomes RecordNotFoundError, and every other HTTP failure becomes ApiRequestError with the body parsed through the failing model’s convention — both classes, plus translateApiError itself, export from @mcp-rune/mcp-rune/data-layer. An error with no response (a network failure, a timeout, or a client that throws some other shape) forfeits typed errors, and does so visibly: it surfaces to projection code as-is rather than as a typed domain error.
RequestOptions is intentionally open. mcp-rune populates userId when impersonating a user (OAuth flow); custom apps can pass any other keys and your client can pluck what it needs.
The createApiClient Factory
The framework never holds onto a single ApiClient instance. Tools that require authentication receive a fresh one per request, produced by a factory you provide:
import type { ApiClientFactory } from '@mcp-rune/mcp-rune/tools'
type ApiClientFactory = (token: string) => ApiClient/**
* A factory the framework calls per request to produce a fresh ApiClient.
* The token argument is the OAuth access token (or whatever your
* `getAccessToken` callback returns).
*
* @typedef {(token: string) => ApiClient} ApiClientFactory
*/The token is the OAuth access token (when OAuth is enabled) or whatever scheme your getAccessToken callback returns. Your factory is responsible for injecting auth headers, the base URL, and any per-request transport setup.
Where It Plugs In
ToolRegistry and AppRegistry both accept the factory:
import { ToolRegistry, DATA_TOOL_CLASSES } from '@mcp-rune/mcp-rune/tools'
const toolRegistry = new ToolRegistry({
toolClasses: DATA_TOOL_CLASSES,
models: MODEL_CLASSES,
createApiClient: (token) => myCustomClient(token, { apiUrl })
})import { ToolRegistry, DATA_TOOL_CLASSES } from '@mcp-rune/mcp-rune/tools'
const toolRegistry = new ToolRegistry({
toolClasses: DATA_TOOL_CLASSES,
models: MODEL_CLASSES,
createApiClient: (token) => myCustomClient(token, { apiUrl })
})import { AppRegistry } from '@mcp-rune/mcp-rune/apps'
const appRegistry = new AppRegistry(apps, {
apiUrl,
createApiClient: (token) => myCustomClient(token, { apiUrl })
})import { AppRegistry } from '@mcp-rune/mcp-rune/apps'
const appRegistry = new AppRegistry(apps, {
apiUrl,
createApiClient: (token) => myCustomClient(token, { apiUrl })
})Same factory, both registries. Tools call the factory when they receive a tool invocation; apps call it when they handle their tool invocation.
If you’re using createDefaultAppRegistry, pass createApiClient as one of its options — it threads through to the AppRegistry it assembles.
Worked Example: Fetch-Based Client
A minimal fetch implementation with bearer-token auth and a tenant header:
import type { ApiClient, ApiClientHttpError, RequestOptions } from '@mcp-rune/mcp-rune/core'
interface FetchClientOpts {
apiUrl: string
tenant?: string
}
export function createFetchClient(token: string, opts: FetchClientOpts): ApiClient {
const baseUrl = opts.apiUrl.replace(/\/$/, '')
function headers(options?: RequestOptions): HeadersInit {
const h: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
if (opts.tenant) h['X-Tenant'] = opts.tenant
if (options?.userId) h['X-Impersonate-User'] = String(options.userId)
return h
}
function buildQuery(params?: Record<string, unknown>): string {
if (!params || Object.keys(params).length === 0) return ''
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue
qs.set(k, String(v))
}
const s = qs.toString()
return s ? `?${s}` : ''
}
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions
): Promise<T> {
const url = `${baseUrl}${path.startsWith('/') ? path : '/' + path}`
const res = await fetch(url, {
method,
headers: headers(options),
body: body === undefined ? undefined : JSON.stringify(body)
})
if (!res.ok) {
const data = await res.json().catch(() => undefined)
const err = new Error(
`${method} ${url} failed: ${res.status} ${res.statusText}`
) as ApiClientHttpError
err.response = { status: res.status, data }
throw err
}
if (res.status === 204) return {} as T
return res.json() as Promise<T>
}
return {
baseUrl,
get: (url, params, options) =>
request('GET', `${url}${buildQuery(params)}`, undefined, options),
post: (url, data, options) => request('POST', url, data, options),
put: (url, data, options) => request('PUT', url, data, options),
patch: (url, data, options) => request('PATCH', url, data, options),
delete: (url, options) => request('DELETE', url, undefined, options)
}
}export function createFetchClient(token, opts) {
const baseUrl = opts.apiUrl.replace(/\/$/, '')
function headers(options) {
const h = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
if (opts.tenant) h['X-Tenant'] = opts.tenant
if (options?.userId) h['X-Impersonate-User'] = String(options.userId)
return h
}
function buildQuery(params) {
if (!params || Object.keys(params).length === 0) return ''
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue
qs.set(k, String(v))
}
const s = qs.toString()
return s ? `?${s}` : ''
}
async function request(method, path, body, options) {
const url = `${baseUrl}${path.startsWith('/') ? path : '/' + path}`
const res = await fetch(url, {
method,
headers: headers(options),
body: body === undefined ? undefined : JSON.stringify(body)
})
if (!res.ok) {
const data = await res.json().catch(() => undefined)
// ApiClientHttpError contract: { response?: { status?, data? } }
const err = new Error(`${method} ${url} failed: ${res.status} ${res.statusText}`)
err.response = { status: res.status, data }
throw err
}
if (res.status === 204) return {}
return res.json()
}
return {
baseUrl,
get: (url, params, options) =>
request('GET', `${url}${buildQuery(params)}`, undefined, options),
post: (url, data, options) => request('POST', url, data, options),
put: (url, data, options) => request('PUT', url, data, options),
patch: (url, data, options) => request('PATCH', url, data, options),
delete: (url, options) => request('DELETE', url, undefined, options)
}
}Wire it up:
// your-server/config.ts
import { createFetchClient } from './api-client.js'
export const toolRegistry = new ToolRegistry({
toolClasses: DATA_TOOL_CLASSES,
models: MODEL_CLASSES,
createApiClient: (token) =>
createFetchClient(token, {
apiUrl: process.env.API_URL!,
tenant: process.env.TENANT_ID
})
})// your-server/config.ts
import { createFetchClient } from './api-client.js'
export const toolRegistry = new ToolRegistry({
toolClasses: DATA_TOOL_CLASSES,
models: MODEL_CLASSES,
createApiClient: (token) =>
createFetchClient(token, {
apiUrl: process.env.API_URL,
tenant: process.env.TENANT_ID
})
})The factory closes over apiUrl and tenant at startup; the token rotates per request.
Composing with OAuthService
When OAuth is enabled, the token comes from the OAuthService — you don’t fetch it yourself in the factory. HttpServer hands your mcp.createServer factory a per-session getAccessToken callback; pass it straight through to ToolRegistry.registerTools:
import { HttpServer } from '@mcp-rune/mcp-rune/server'
import { OAuthService } from '@mcp-rune/mcp-rune/oauth2'
const oauth = new OAuthService({
authServerUrl: process.env.AUTH_SERVER_URL!,
clientId: process.env.CLIENT_ID!,
clientSecret: process.env.CLIENT_SECRET!,
redirectUri: 'https://example.com/oauth/callback',
scopes: 'mcp:read mcp:write',
resourceUri: 'https://api.example.com',
isProduction: true
})
const httpServer = new HttpServer({
port: 3000,
oauth,
mcp: {
name: 'bookshelf',
createServer: ({ sessionId, transport, getAccessToken }) => {
const mcpServer = createMcpServer(/* … */)
toolRegistry.registerTools(mcpServer, { getAccessToken })
return mcpServer
}
}
})import { HttpServer } from '@mcp-rune/mcp-rune/server'
import { OAuthService } from '@mcp-rune/mcp-rune/oauth2'
const oauth = new OAuthService({
authServerUrl: process.env.AUTH_SERVER_URL,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
redirectUri: 'https://example.com/oauth/callback',
scopes: 'mcp:read mcp:write',
resourceUri: 'https://api.example.com',
isProduction: true
})
const httpServer = new HttpServer({
port: 3000,
oauth,
mcp: {
name: 'bookshelf',
createServer: ({ sessionId, transport, getAccessToken }) => {
const mcpServer = createMcpServer(/* … */)
toolRegistry.registerTools(mcpServer, { getAccessToken })
return mcpServer
}
}
})registerTools calls getAccessToken once per tool invocation, then invokes createApiClient(token). Your factory stays simple — it doesn’t know OAuth exists.
For requests that need impersonation, take user_id from the tool’s arguments and pass it as RequestOptions.userId — the DataLayer threads it through to your client:
// Inside a custom tool's execute(args):
const { record_id, user_id } = args
await this.requireDataLayer().find('book', record_id, user_id ? { userId: user_id } : undefined)// Inside a custom tool's execute(args):
const { record_id, user_id } = args
await this.requireDataLayer().find('book', record_id, user_id ? { userId: user_id } : undefined)Your client picks userId out of options and sets the impersonation header. The token still belongs to the application — userId just tells the API which user to act as.
In-Memory Stub for Tests
For integration tests, you usually want to bypass HTTP entirely. Provide a stub client backed by a Map:
// __tests__/helpers/in-memory-client.ts
import type { ApiClient, ApiClientHttpError } from '@mcp-rune/mcp-rune/core'
export function createInMemoryClient(seed: Record<string, unknown[]> = {}): ApiClient {
const store = new Map<string, Map<string, Record<string, unknown>>>()
for (const [collection, records] of Object.entries(seed)) {
const bucket = new Map()
for (const r of records) bucket.set(String(r.id), r as Record<string, unknown>)
store.set(collection, bucket)
}
function bucket(url: string): Map<string, Record<string, unknown>> {
const segments = url.replace(/^\/+|\/+$/g, '').split('/')
const collection = segments[0]!
if (!store.has(collection)) store.set(collection, new Map())
return store.get(collection)!
}
return {
async get(url) {
const segments = url.replace(/^\/+|\/+$/g, '').split('/')
if (segments.length === 1) {
return { data: Array.from(bucket(url).values()) }
}
const record = bucket(url).get(segments[1]!)
if (!record) {
const err = new Error(`Not found: ${url}`) as ApiClientHttpError
err.response = { status: 404 }
throw err
}
return { data: record }
},
async post(url, data) {
const id = String(bucket(url).size + 1)
const record = { ...data, id }
bucket(url).set(id, record)
return { data: record }
},
async put(url, data) {
const segments = url.replace(/^\/+|\/+$/g, '').split('/')
const id = segments[1]!
bucket(url).set(id, { ...(data ?? {}), id })
return { data: bucket(url).get(id)! }
},
patch(url, data) {
return this.put(url, data)
},
async delete(url) {
const segments = url.replace(/^\/+|\/+$/g, '').split('/')
bucket(url).delete(segments[1]!)
return {}
}
}
}export function createInMemoryClient(seed = {}) {
const store = new Map()
for (const [collection, records] of Object.entries(seed)) {
const bucket = new Map()
for (const r of records) bucket.set(String(r.id), r)
store.set(collection, bucket)
}
function bucket(url) {
const segments = url.replace(/^\/+|\/+$/g, '').split('/')
const collection = segments[0]
if (!store.has(collection)) store.set(collection, new Map())
return store.get(collection)
}
return {
async get(url) {
const segments = url.replace(/^\/+|\/+$/g, '').split('/')
if (segments.length === 1) {
return { data: Array.from(bucket(url).values()) }
}
const record = bucket(url).get(segments[1])
if (!record) {
// ApiClientHttpError contract: { response?: { status?, data? } }
const err = new Error(`Not found: ${url}`)
err.response = { status: 404 }
throw err
}
return { data: record }
},
async post(url, data) {
const id = String(bucket(url).size + 1)
const record = { ...data, id }
bucket(url).set(id, record)
return { data: record }
},
async put(url, data) {
const segments = url.replace(/^\/+|\/+$/g, '').split('/')
const id = segments[1]
bucket(url).set(id, { ...(data ?? {}), id })
return { data: bucket(url).get(id) }
},
patch(url, data) {
return this.put(url, data)
},
async delete(url) {
const segments = url.replace(/^\/+|\/+$/g, '').split('/')
bucket(url).delete(segments[1])
return {}
}
}
}Use it in tests:
const apiClient = createInMemoryClient({
books: [{ id: '1', title: 'Clean Code' }]
})
const toolRegistry = new ToolRegistry({
toolClasses: DATA_TOOL_CLASSES,
models: MODEL_CLASSES,
createApiClient: () => apiClient
})const apiClient = createInMemoryClient({
books: [{ id: '1', title: 'Clean Code' }]
})
const toolRegistry = new ToolRegistry({
toolClasses: DATA_TOOL_CLASSES,
models: MODEL_CLASSES,
createApiClient: () => apiClient
})The err.response = { status: 404 } line is what keeps the stub inside the ApiClientHttpError contract, so translateApiError still turns a missing record into RecordNotFoundError in your tests instead of surfacing a raw Error. For tests that assert on the error taxonomy, though, stubbing at the higher DataLayer boundary is usually easier (no wire format to mock) — see the In-Memory DataLayer section instead; it bypasses both ApiClient and the convention.
SearchApiClient: The Read-Only Subset
The SearchService and other read-only consumers depend on a narrower interface:
import type { SearchApiClient } from '@mcp-rune/mcp-rune/core'
type SearchApiClient = Pick<ApiClient, 'get' | 'post'>/**
* Read-only subset of ApiClient: only `get` and `post` are required.
* SearchService accepts this narrower contract so a search-only adapter
* doesn't have to implement `put` / `patch` / `delete`.
*
* @typedef {Pick<ApiClient, 'get' | 'post'>} SearchApiClient
*/If you’re writing a client that only ever serves search (e.g. an Elasticsearch bridge that exposes nothing else), implement SearchApiClient and use a separate full ApiClient for CRUD. The narrow type makes the seam auditable.
Don’t Reach Past the Interface
Tools and apps receive an ApiClient. They should not import a concrete client class, sniff for axios, or call baseUrl to construct URLs themselves — the EndpointResolver owns URL composition. The whole point of the ApiClient seam is that every consumer is interchangeable.
If you find yourself needing behavior the interface doesn’t expose, the answer is almost always one of:
- Add the concern to your client’s implementation of an existing method (auth header injection, request logging — these stay invisible to callers).
- Move the concern into the convention (response normalization, association resolution).
- Add it to an ApiExtension (new tools,
ModelServicemixins).
Extending the ApiClient interface itself — adding a sixth method — is a framework-level decision, not a deployer-level one.
Related guides:
- DataLayer Guide — the layer above
ApiClientthat adds model awareness. - Custom API Convention — response normalization and payload wrapping live here, not in the client.
- Model service — how
ModelServicecomposesApiClient+EndpointResolver+ convention. - OAuth 2.0 Discovery Flow — where the token in
createApiClient(token)comes from.