mcp-rune 0.107.0
Be star #1 Get started
SECTION VI · GUIDE 25 OF 49
Reading
7 min
Topic
adapter · http
Spec
v0.107.0
Source
06-the-three-layers-up-close/api-client.md
On this page9 sections

Customization: pass your own ApiClient via createApiClient: on ToolRegistry / AppRegistry. The default is the bundled axios-based client. Sits below DataLayer; 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:

PER-REQUEST CLIENT LIFECYCLEIncoming MCPtool requestOAuthService(per session)sessionIdgetValidAccessToken(sessionId)→ tokenauto-refresh · 5 min expiry buffercreateApiClient(token, { apiUrl })→ ApiClient instancelifetime = ONE requestTool / App handlerapiClient.get / post / …

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

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

src/api-client-factory.ts
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:

src/registries/tool-registry.ts
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 })
})
src/registries/app-registry.ts
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:

your-server/api-client.ts
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:

src/registries/tool-registry.ts
// 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:

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

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

src/clients/create-in-memory-client.ts
// __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:

src/clients/api-client.ts
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:

src/clients/search-api-client.ts
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:

  1. Add the concern to your client’s implementation of an existing method (auth header injection, request logging — these stay invisible to callers).
  2. Move the concern into the convention (response normalization, association resolution).
  3. Add it to an ApiExtension (new tools, ModelService mixins).

Extending the ApiClient interface itself — adding a sixth method — is a framework-level decision, not a deployer-level one.


Related guides: