mcp-rune 0.107.0
Be star #1 Get started
SECTION IV · GUIDE 15 OF 49
Reading
8 min
Topic
architecture · core
Spec
v0.107.0
Source
04-tools/the-three-layers.md
On this page8 sections

The three layers

Chapter 3 ended with a Prompt that knows how to read a Model. This chapter — the foundation for everything in Tools and Apps — answers the next question: what does the code that actually runs see at runtime? The answer is three peer interfaces, injected per request, that every Tool, App, Prompt, and ApiExtension consumes.

You will never write a tool that imports ApiClient directly, or resolveDerivedFields, or extractEdgesFromRecord. The eslint config forbids it. What you write instead is this.dataLayer.find(...), this.modelLayer('book').resolveDerivedFields(...), this.analysisLayer('book').extractEdges(...). The three layers are how your tool reaches the rest of the framework.

The shape

Every BaseTool constructor receives the same ToolDependencies bag, and the three layers are three of its slots:

File: src/mcp/tools/base-tool.ts

src/tool-dependencies.ts
export interface ToolDependencies {
  /** Per-request backend I/O. Absent for tools with `requiresAuth = false`. */
  dataLayer?: DataLayer
  /** Per-model-bound, synchronous model-config reads. */
  modelLayer?: ModelLayerFactory
  /** Per-model-bound, per-request analysis projections. */
  analysisLayer?: AnalysisLayerFactory
  // …
}
/**
 * Types are a TypeScript-only artifact — no JS runtime equivalent.
 * The contract below is duck-typed at runtime.
 *
 * export interface ToolDependencies {
 *   /** Per-request backend I/O. Absent for tools with `requiresAuth = false`. */
 *   dataLayer?: DataLayer
 *   /** Per-model-bound, synchronous model-config reads. */
 *   modelLayer?: ModelLayerFactory
 *   /** Per-model-bound, per-request analysis projections. */
 *   analysisLayer?: AnalysisLayerFactory
 *   // …
 * }
 */

ToolRegistry constructs each layer per request, threads them in, and BaseTool exposes them as instance fields. From the tool’s point of view they are simply this.dataLayer, this.modelLayer, this.analysisLayer.

              ┌──────────────────────────┐
              │  Tool / App / Prompt     │  (projection layer — what you write)
              └────┬──────────┬────────┬─┘
                   │          │        │
       this.dataLayer  this.modelLayer  this.analysisLayer
       (per request)  (name) per call   (name) per call
                   │          │        │
        ┌──────────▼──┐  ┌────▼────┐  ┌▼─────────────┐
        │  DataLayer  │  │ Model-  │  │ Analysis-    │
        │  backend    │  │ Layer   │  │ Layer        │
        │  I/O        │  │ reads   │  │ projections  │
        └──────────┬──┘  └────┬────┘  └──┬───────────┘
                   │          │          │
                   ▼          ▼          ▼
                ┌──────────────────────────┐
                │      Model declaration   │
                └──────────────────────────┘

DataLayer — backend I/O

Lifetime: constructed per authenticated request. Carries the session’s access token (when applicable). The same instance threads through every tool invocation in that request.

Surface:

  • find / list / listNormalized / searchNormalized — read
  • create / update / delete — write
  • dispatch(method, url, payload?, params?) — raw HTTP for non-CRUD verbs and custom endpoints
  • lookupNormalized / groupSearchNormalized — cross-model search
  • buildPayload(model, modelConfig, attrs) — convention-aware payload assembly

Failures cross the seam as typed DataLayerError subclasses — RecordNotFoundError and ApiRequestError (with .status) — whose messages were already parsed through the failing model’s convention (ADR 0014). Tools never duck-type transport errors; formatError() just renders them.

In a tool:

src/layer.ts
override async execute(args: { id: string }): Promise<ToolResult> {
  const layer = this.requireDataLayer()
  const book = await layer.find('book', args.id)
  return this.formatResponse(book)
}
async execute(args) {
  const layer = this.requireDataLayer()
  const book = await layer.find('book', args.id)
  return this.formatResponse(book)
}

requireDataLayer() throws when the tool ran without auth — the form-strategy tools (get_prompt_guide, validate_form, get_form_summary) declare static requiresAuth = false, because they’re stateless and have no business hitting the backend.

What’s behind it: built-in implementations are createInMemoryDataLayer (the stub used in the quickstart), ModelService (the default HTTP adapter, wrapping ApiClient + EndpointResolver + convention), and SearchEnabledDataLayer (the wrapper that adds search/lookup methods on top of either base). ToolRegistry wraps every per-request DataLayer in SearchEnabledDataLayer automatically, so tools always see the *Normalized methods. Part II, chapter 6 covers them in detail.

ModelLayer — per-model model-config reads

Lifetime: synchronous, no I/O, cached per model class. Constructed via the ModelLayerFactorythis.modelLayer('book') returns a layer bound to the Book class for the duration of the call.

Surface:

  • resolveDerivedFields(records) → flattens derived: { from, field } declarations in place

Kind descriptors are not on this surface: kinds are shared vocabulary (ADR 0009) — read them directly via getKind / kindOptsFrom / resolveInputType from @mcp-rune/mcp-rune/models.

In a tool:

src/data.ts
override async execute(args: { model: string; record_id: string }) {
  const data = this.requireDataLayer()
  const layer = this.modelLayer?.(args.model)
  const record = await data.find(args.model, args.record_id)
  layer?.resolveDerivedFields([record])
  // …
}
async execute(args) {
  const data = this.requireDataLayer()
  const layer = this.modelLayer?.(args.model)
  const record = await data.find(args.model, args.record_id)
  layer?.resolveDerivedFields([record])
  // …
}

Why it exists: chapter 2’s Definition vs consumption showed that helpers that read a model live in src/mcp/model-layer/. ModelLayer is the public face of that folder. Projection-layer code must consume the interface, never import resolveDerivedFields or collectValidFieldNames directly.

AnalysisLayer — per-model analysis projections

Lifetime: per-model-bound and per-request, because its methods do I/O and need the authenticated DataLayer. this.analysisLayer('book') returns a layer bound to Book plus this request’s data layer.

Surface today:

  • extractEdges(record, options?) → graph edges derived from associations
  • buildEmbeddingText(record, options?) → text used by vector-embedding tools
  • walkHops(rootRecords, options) → async-generator BFS across declared associations, fetching destination records through this request’s DataLayer (cycle-safe, per-hop fan-out capped)

Designed to host summarize and buildStratifier in follow-up releases.

In a tool:

src/book.ts
override async execute(args: { id: string }) {
  const book = await this.requireDataLayer().find('book', args.id)
  const edges = this.analysisLayer?.('book').extractEdges(book) ?? []
  return this.formatResponse({ book, edges })
}
async execute(args) {
  const book = await this.requireDataLayer().find('book', args.id)
  const edges = this.analysisLayer?.('book').extractEdges(book) ?? []
  return this.formatResponse({ book, edges })
}

Why it exists: edge extraction and embedding-text assembly are analysis concerns that, like ModelLayer’s reads, were previously scattered helpers. Promoting them to a per-model layer makes them swappable per deployment and prevents projection-layer code from poking at the internals. The analysis_* tool family (covered in Part III) is the principal consumer.

Why domain knowledge isn’t a fourth layer

DomainRegistry is passed to tools as this.domainRegistry, but it does not follow the layer pattern and has no corresponding domain-layer/ folder. Two reasons:

  1. It is not a projection over per-request context. ModelLayer derives schema from model classes; AnalysisLayer builds embedding text from records. DomainRegistry is stateless and has no equivalent per-request state to project.

  2. Its storage backend may have no local config. A DataLayer or ModelLayer always projects local definitions. Remote DomainAdapter implementations (PGVector, Qdrant) retrieve items from a database — there is nothing local to project. Calling that a “layer” would leave a domain-layer/ folder empty downstream.

The storage backend is therefore called a DomainAdapter (see src/mcp/domain/adapters/), following the OAuth2 BaseTokenStoreAdapter pattern rather than the layer pattern.

The eslint guard

eslint.config.js declares a no-restricted-imports block scoped to src/mcp/apps/**, src/mcp/tools/**, src/mcp/prompts/**, and src/mcp/data-layer/api-extensions/**. From those folders, importing the model-layer helpers (resolveDerivedFields, collectValidFieldNames, derivePromptSchema) or the analysis-layer helpers fails the build. The adapter names (ApiClient, ModelService, SearchService, EndpointResolver) are AGENTS.md rules rather than lint errors. Kind helpers (getKind, kindOptsFrom, resolveInputType) are deliberately not restricted — kinds are shared vocabulary (ADR 0009).

If a method you need is missing from one of the three interfaces, the rule is extend the interface rather than reach past it. The interfaces are designed to grow; the projection layer is designed to stay narrow.

AGENTS.md at the repo root is the canonical source for these rules and lists every forbidden import.

When each layer is undefined

ToolDependencies declares all three as optional because not every tool needs all three:

  • requiresAuth = false tools (the form-strategy family: get_prompt_guide, validate_form, get_form_summary) get no dataLayer and no analysisLayer. They’re stateless and the framework wouldn’t have a token for them anyway. They get modelLayer because it’s synchronous and stateless — useful for derived-field flattening even at validate time.
  • CRUD tools get all three.
  • Bare unit tests that instantiate a tool with new MyTool({}) get none, which is by design — every dependency is constructor-injected, so test setup is explicit.

Inside a tool, this.requireDataLayer() is the idiomatic way to assert presence. For the other two, fall back to a clear error or no-op when absent.

What’s next

You now know what every tool actually sees at runtime. The next chapter, Polymorphic tools, looks at the nine bundled tools — six CRUD plus three form-strategy — what they are, how they use the three layers, and why so few tools are enough to serve every model you define.