DOC MCP-104 SCOPE Economics → Architecture PREREQ MCP 103 PROTOCOL 2026-07-28 READ ~60 min

Economics,
gateways & futures

MCP 103 made the server survive. This course asks what it costs, who should serve which request, and where the gateways, catalogues and model-backed tools people propose actually fit. One mechanism is implemented and tested; the rest is argued, and every chapter says which is which.

Host & client MCP server Model Product system
Chapter 00 · Groundwork

Where 103 left off

MCP 103 made the server survive. MCP 104 asks what it costs, who should serve which request, and where the gateways and model-backed tools people are proposing actually fit.

Start from the finished system in MCP 103. This repository adds one small file, src/economics.ts, about twenty-five lines, plus one tool and one endpoint. That file holds two functions and a table: a cost model, a router, and four providers for the router to choose between. Everything the rest of this course argues is tested against those, or is labelled as design where the repository has nothing to run.

Read this first

Most of MCP 104 is a set of design arguments, not code. One mechanism is implemented and tested (the router and cost model). Four chapters describe things this repository does not contain: a gateway, a tool catalogue, credential brokerage and a model inside a tool. Every chapter opens with a box saying which it is, and the frames in the design chapters are sketches, marked as such. Where a number comes from the repo’s lab it is a placeholder chosen by the author, not a measurement. Replace them with yours before drawing any conclusion.

ChapterTopicWhat the repo has
01The cost stackimplemented estimateCost()
02Tieringimplemented providers and route()
03Async economicspartly the batch provider here; the job table and worker are in 103
04Gatewaysdesign only explain_route exists
05Tool retrievaldesign a toy demo on the real tool list
06Semantic routingimplemented the semantic check in route()
07Auth brokeragedesign
08Model inside a tooldesign cost effect only
09Server compositionpartly nextHop(), used by one tool in 103
10Forecasthypotheses

What is inherited unchanged

The controls MCP 103 added come along untouched, and three of them matter to the economics that follow. The URL importer resolves a host once, checks the answer, and then connects to that address, so a hostile name cannot pass the check and be re-resolved to a private one. The /api/ops endpoint that reports usage and audit data answers only to an organisation administrator; anyone else gets FORBIDDEN. And a maintenance sweep, cleanupOperationalData() in src/db.ts, keeps the tables from growing without limit.

An operation key does not last forever

The sweep deletes operation records seven days after they were created, audit rows after thirty days, and rate-limit rows after one day. A retry with the same operation key inside the seven days is replayed. A retry after them is a new operation and the write runs again. The test an operation key stops replaying once the seven-day sweep has removed it checks both sides of that line. It matters here because a cheaper, slower route tempts you to queue work for longer: the longer a write can wait, the more the retry window has to be designed rather than assumed. MCP 103 Chapter 04 has the full story.

The four providers

The router has something to choose between because economics.ts declares four execution providers. They are data, not running services. Each states what it can do, how fast and how expensive it is, and what contract it obeys:

src/economics.tsproviders: the router’s whole world
export const providers: Provider[] = [
  {
    id: 'teamspace-standard-us',
    vendor: 'teamspace',
    capabilities: ['task.search', 'page.search'],
    semantics: 'teamspace-v1',
    writes: false,
    freshness: 'live',
    residency: 'us',
    mode: 'realtime',
    p95Ms: 450,
    costMicros: 20,
    available: true,
    trust: 'verified',
  },
  {
    id: 'teamspace-priority-us',
    vendor: 'teamspace',
    capabilities: ['task.search', 'page.search', 'task.write'],
    semantics: 'teamspace-v1',
    writes: true,
    freshness: 'live',
    residency: 'us',
    mode: 'realtime',
    p95Ms: 120,
    costMicros: 90,
    available: true,
    trust: 'verified',
  },
  {
    id: 'teamspace-batch-eu',
    vendor: 'teamspace',
    capabilities: ['page.search'],
    semantics: 'teamspace-v1',
    writes: false,
    freshness: 'cached',
    residency: 'eu',
    mode: 'task',
    p95Ms: 8000,
    costMicros: 4,
    available: true,
    trust: 'verified',
  },
  {
    id: 'lookalike-fast',
    vendor: 'other',
    capabilities: ['task.search'],
    semantics: 'other-v2',
    writes: false,
    freshness: 'live',
    residency: 'us',
    mode: 'realtime',
    p95Ms: 60,
    costMicros: 2,
    available: true,
    trust: 'verified',
  },
];
ProviderCan doWritesDataModep95CostContract
teamspace-standard-ustask + page searchnolive, USrealtime450 ms20teamspace-v1
teamspace-priority-ussearch + task writeyeslive, USrealtime120 ms90teamspace-v1
teamspace-batch-eupage search onlynocached, EUtask8000 ms4teamspace-v1
lookalike-fasttask searchnolive, USrealtime60 ms2other-v2

Cost is in micros: millionths of a currency unit per call. Notice that the fourth provider is the cheapest and the fastest. It is there to be a trap, and Chapter 06 shows why the router refuses it.

The new surface, and the old findings

The MCP surface gains one tool, explain_route. The HTTP API gains /api/economics, which returns the lab’s default experiment for an authenticated caller. The executable suite adds router, cost-model, and end-to-end checks for explain_route. Run it for the current count.

Representative runrun npm test for the current count
$ npm test
✔ an unreachable product API opens the circuit, and calls stop reaching it
✔ a definite refusal from a healthy dependency is not an outage
✔ tenant and team scope known identifiers
✔ viewer cannot mutate
✔ idempotency returns same result and rejects changed arguments
✔ optimistic version rejects stale write
✔ approval is actor and exact-operation bound and single use
✔ router chooses cheapest provider satisfying every hard constraint
✔ router does not substitute a semantically different tool
✔ write routing requires an explicitly write-capable provider
✔ slow task lane is selected only when requested and allowed
✔ cost model separates vendor infrastructure from client model cost
✔ operations data is admin-only and expired operational rows are removed
✔ the address check refuses internal space, including IPv4-mapped IPv6 and shared space
✔ URL import pins the validated address for the TLS connection
✔ a viewer cannot queue a job, and a member on the free plan gets PLAN_REQUIRED
✔ job tools use the same pipeline: a structured error and an audit row
✔ a successful job call is audited too
✔ the job tools now spend rate-limit permits, and throttling is audited as throttled
✔ explain_route goes through the pipeline: it is audited and returns structured content
✔ draft_release_note carries one deadline, budget and route through both internal hops
✔ the /mcp edge answers 401 with a WWW-Authenticate challenge, never a 500
✔ a refused write releases its key, so the same key can be used again
✔ publishing without an approval does not burn the operation key
✔ a failed transaction rolls back both the write and its claim
✔ a completed operation is replayed and its work is not run twice
✔ an operation key stops replaying once the seven-day sweep has removed it
✔ MCP discovery, tool call and resource read work end to end
✔ a reservation that is never settled expires and frees its hold
✔ a reservation is a ceiling: settlement never charges more than was reserved
✔ a worker runs a release-report job for its owner, scoped to the owner’s team
✔ a slow worker cannot complete a job that another worker has taken over
✔ a job that keeps failing is retried, then dead-lettered with its reason
✔ a job whose worker keeps crashing is dead-lettered instead of re-leased forever
✔ the token bucket is shared: two replicas draw from one allowance
✔ the token bucket refills over time
✔ one member cannot hold every slot their organisation has
✔ production refuses to start without its required settings, and says which is missing
✔ rate limits per actor and releases org concurrency
✔ budget reservation is atomic and reconciled
✔ jobs are scoped, leased and completed durably
✔ URL validation blocks private networks
✔ composite hop prevents cycles, excess depth and budget overrun
✔ a refused resource read is a structured not-found, the same as a page that does not exist
ℹ tests 44
ℹ pass 44
ℹ fail 0

src/production.ts is byte-for-byte the file from MCP 103, so everything that course built and fixed applies here unchanged: the shared token bucket, expiring budget holds, the job worker, the wired-in circuit breaker and the numeric address check. This course does not repeat it.

Chapter 01 · The economics

The cost stack

An MCP server is not free to run, and it is not where most of the money goes. Both statements are true, and the difference between them is the interesting part.

In the repo

estimateCost() and its lab are in src/economics.ts and tested. The numbers fed to it are placeholders.

When someone asks “what does it cost to run our MCP server?”, they usually picture one line: the compute for the wrapper. There are at least eight, and they scale differently:

  • Edge and wrapper compute: the process that receives the request, verifies the token and translates it.
  • Product API work: the actual query or business logic behind the tool. Usually the largest server-side line.
  • Storage and search, and outbound traffic.
  • Observability: logs, metrics and traces, which grow with call volume.
  • Abuse control: the limits, budgets and scanning of MCP 103.
  • Support: humans explaining why a call was refused.
  • A model, if the tool itself calls one (Chapter 08).

Then there is a second ledger that is not yours. The client’s model reads your tool descriptions, sends arguments and reads results, and bills its own operator for the tokens. The two ledgers are added together in the model below only so you can see their relative size.

src/economics.tsestimateCost: two separate ledgers
export function estimateCost(x: CostInput) {
  const vendorPerCall = x.wrapperMicros + x.backendMicros + x.vendorModelMicros;
  const vendorTotal = x.calls * vendorPerCall;
  const clientModel = Math.round(
    (x.clientInputTokens * x.clientMicrosPerMillionInput) / 1_000_000 +
      (x.clientOutputTokens * x.clientMicrosPerMillionOutput) / 1_000_000,
  );
  return {
    vendorPerCall,
    vendorTotal,
    clientModel,
    total: vendorTotal + clientModel,
    shares: {
      vendor: Number((vendorTotal / (vendorTotal + clientModel || 1)).toFixed(3)),
      client: Number((clientModel / (vendorTotal + clientModel || 1)).toFixed(3)),
    },
  };
}

Read it as three lines. vendorPerCall is wrapper plus backend plus any model inside the tool. vendorTotal multiplies by call volume. clientModel prices the tokens the caller’s model consumed. The function returns each, the sum, and the two shares.

Captured runcaptured 2026-09-20
$ npm run lab   (the "cost" part of the baseline)
{
  "vendorPerCall": 20,
  "vendorTotal": 200000,
  "clientModel": 9000000,
  "total": 9200000,
  "shares": {
    "vendor": 0.022,
    "client": 0.978
  }
}

Interactive · Who spends what

All money is in micros, millionths of a currency unit, as in src/economics.ts. A port of estimateCost(). The starting numbers are the ones the repo’s lab uses. They are the author’s placeholders, not measurements. Put in yours.

With the lab’s placeholders, ten thousand calls cost the vendor 200,000 micros and the client’s model 9,000,000, so the vendor side is about two percent of the total. If micros are millionths of a dollar that is twenty cents against nine dollars. The general shape is the point, not those figures: for a cheap read, the wrapper and the query are small next to the tokens spent deciding to make the call and reading the answer.

Then use the presets. A model inside the tool changes the picture at once, because now the vendor pays for inference on every call. Retries triple the calls multiplies every vendor line by three, and it is worth remembering that an agent can produce that multiplier without a human ever noticing: it retries, fans out, and re-reads. MCP 103’s limits and budgets are what stop that being your bill.

Units before comparisons

Never set a vendor micro-cost beside a client token price without a common unit and a workload volume. “Twenty micros per call” and “half a currency unit per million tokens” are not comparable until you multiply each by how many calls and tokens a real workflow uses. The function forces that by taking counts as inputs. Change one variable at a time.

Chapter 02 · The economics

Tiering

Should fast customers and slow customers get different servers? Almost never. They should get one server and different treatment behind it.

In the repo

The providers and route() are in src/economics.ts. Plan tiers themselves are not modelled: the repo routes by constraints on the operation, not by customer plan.

A common instinct is to publish a tasks-fast server for paying customers and a tasks-slow one for everyone else. It looks tidy and it causes real trouble. A model now sees two near-identical tools and must choose between them from their names, which it will do badly. Every client integration has to know which to connect to. The two drift apart. The cleaner design keeps one semantic surface and applies plan quotas, priority, freshness and feature entitlement after identity is known.

Separate servers are right when the trust domain or the product differs: a payments server and a tasks server, an EU-only deployment for data that cannot leave. They are wrong when the only difference is how fast you serve the same contract.

src/economics.tsroute(): hard constraints first, then the cheapest
export function route(operation: Operation, providers: Provider[]): Decision {
  const rejected: { id: string; reason: string }[] = [],
    eligible: Provider[] = [];
  for (const p of providers) {
    const reason =
      !p.available ? 'unavailable'
      : p.trust !== 'verified' ? 'unverified provider'
      : !p.capabilities.includes(operation.capability) ? 'capability mismatch'
      : p.semantics !== operation.semantics ? 'semantic contract mismatch'
      : operation.write && !p.writes ? 'write unsupported'
      : operation.freshness === 'live' && p.freshness !== 'live' ?
        'freshness mismatch'
      : operation.residency !== 'any' && p.residency !== operation.residency ?
        'residency mismatch'
      : operation.mode && p.mode !== operation.mode ? 'execution-mode mismatch'
      : p.p95Ms > operation.maxLatencyMs ? 'latency SLO exceeded'
      : p.costMicros > operation.maxCostMicros ? 'cost ceiling exceeded'
      : '';
    if (reason) rejected.push({ id: p.id, reason });
    else eligible.push(p);
  }
  eligible.sort(
    (a, b) =>
      a.costMicros - b.costMicros || a.p95Ms - b.p95Ms || a.id.localeCompare(b.id),
  );
  return eligible.length ?
      {
        selected: eligible[0],
        rejected,
        reason:
          'Cheapest provider satisfying the explicit semantic, policy, freshness, residency, latency and trust contract.',
      }
    : {
        rejected,
        reason:
          'No provider satisfies every hard constraint; the router refuses a silent downgrade.',
      };
}

The router works in two stages. First, every provider is tested against every constraint in a fixed order and the first failing one is recorded as the reason: available, verified, has the capability, same semantic contract, can write if a write is needed, fresh enough, right region, right mode, fast enough, cheap enough. Then the survivors are sorted by cost, then latency, then id, and the first wins. If there are no survivors, the answer is a refusal with reasons.

Interactive · Route an operation

Take a provider out of service:
Providers considered
ProviderSemanticsModep95CostResult

A port of route() and the four providers in src/economics.ts. Reasons are checked in the same order as the source, so the first failing constraint is the one named. Among providers that pass everything, the cheapest wins.

Try each preset, and read the table underneath, which names the first reason each provider was rejected. Three behaviours are worth seeing for yourself:

  • Standard is down falls over to priority. They share the teamspace-v1 contract, so the substitution is safe. It costs more (90 against 20) but fits the 100 ceiling.
  • Down, tight budget lowers the ceiling to 50, and now nothing qualifies. The router returns a refusal rather than quietly spending more than the caller allowed. Whether to raise the ceiling is a decision for a person or a policy, not for the router.
  • A write selects priority, because the standard provider does not write. Writes need a provider that says it can.
Where the plan fits

The plan tier is not in route() at all. It would arrive as an input: a free customer’s operations would carry a lower maxCostMicros and a higher maxLatencyMs, a priority customer’s the opposite. The router stays a pure function of constraints, which is what makes it testable, and policy decides which constraints a caller gets.

Chapter 03 · The economics

Async economics

A slow lane is not just a slower fast lane. It is a different way of doing the work, and it only pays when waiting saves real money.

Partly in the repo

The teamspace-batch-eu provider is in the router here, and the job table, leases and worker it stands for run in MCP 103. The break-even arithmetic below is plain calculation, not a repo function.

Two ideas get run together here and should be separated. Stateless means a request carries its own context, so any replica can serve it, which suits serverless and edge hosting. It says nothing about speed or waiting: a normal stateless tool call can still finish in ninety milliseconds. A slow lane is a lifecycle: accept the work, persist it, lease it to a worker, retry on failure, expose its status, expire the result. It is what MCP 103 Chapter 07 begins.

A slow lane earns its complexity only when waiting saves something material. The classic cases are GPU inference that batches many requests into one pass, large exports, and index rebuilds. It is a poor fit for a cheap database lookup: delaying a query that costs a few micros will not recover the cost of building and running a second service tier.

Captured runcaptured 2026-09-20 · from labs/economics.ts
$ route(page.search, stale-ok, EU, task mode, 10 s, ≤ 10 micros)
{
  "selected": "teamspace-batch-eu",
  "rejected": [
    {
      "id": "teamspace-standard-us",
      "reason": "residency mismatch"
    },
    {
      "id": "teamspace-priority-us",
      "reason": "residency mismatch"
    },
    {
      "id": "lookalike-fast",
      "reason": "capability mismatch"
    }
  ]
}

The lab asks for a stale-tolerant, EU-resident, asynchronous page search that can wait ten seconds. Only the batch provider qualifies: the two realtime providers are rejected on residency, and the lookalike on capability. It costs 4 micros against the standard provider’s 20, a saving of 80 percent per call. Now ask whether that is worth building:

Interactive · Is the slow lane worth it?

Plain arithmetic, not a repo function. The cost figures start as the router’s standard and batch providers; the monthly fixed cost is an invented number: the point is that it exists. Break-even volume is the fixed cost divided by the saving per call across the calls that can wait.

Lower the share that can wait, or the fixed cost’s tolerance, and the verdict flips quickly. With the default numbers, 200,000 calls a month save well under the invented fixed cost, so the lane does not pay. It starts to pay at millions of calls, or at a much larger per-call gap. That is the general shape of the argument for building a slow lane: volume times per-call saving must beat the cost of running two paths, and if the per-call gap is a few micros you need very high volume.

What the slow lane changes for callers

Everything about the contract: the answer is not in the response, it is a handle. The tool’s description has to say so, the host has to poll or be told, and results expire. Do not offer a slow lane as a hidden option behind an identical-looking tool. Make the mode explicit, as the router does (mode: "task").

Chapter 04 · Routing and gateways

Gateways

A gateway is a single place to enforce discovery, identity, policy, audit and quotas for many servers. It is also a single place for everything to go wrong at once.

Design chapter

There is no gateway in this repository. The only routing surface is the explain_route tool, which is real and shown below. The trace is a design sketch, labelled as such.

Once an organisation has ten MCP servers instead of one, the same problems appear ten times. Which servers may a host connect to? Whose credentials does each one use? Who is allowed to call what? Where is the audit trail? A gateway answers those once. It owns:

  • Discovery: a verified catalogue of servers, not a list someone pasted into a config file.
  • Client OAuth and the delegated identity that goes downstream.
  • Policy, audit and quotas across all servers, in one place.
  • Route evidence: a record of which provider handled a request and why.

It must not mint a more privileged downstream identity than the user has, and it must not hide which provider handled a write. A gateway that quietly acts as itself, with its own broad credentials, has turned every user into whatever the gateway can do.

Design sketch · One call through a gateway

  1. The host calls the gateway as the user

    One endpoint for many servers. The token is the user’s, issued for the gateway’s audience.

    sketch · not in the repo

    POST https://gateway.example/mcp
    Authorization: Bearer <user token, aud=gateway>
    Mcp-Method: tools/call
    Mcp-Name: search_tasks
  2. It checks who and what, before choosing where

    Verify the token. Apply policy for this user and this tool. Fail closed: if the policy source is unreachable, deny.

  3. It routes on hard constraints

    This is the part the repo does implement: route() filters providers by semantic contract, residency, freshness, latency and cost, then takes the cheapest survivor.

    the real explain_route answer · captured

    {
      "selected": {
        "id": "teamspace-standard-us",
        "vendor": "teamspace",
        "capabilities": [
          "task.search",
          "page.search"
        ],
        "semantics": "teamspace-v1",
        "writes": false,
        "freshness": "live",
        "residency": "us",
        "mode": "realtime",
        "p95Ms": 450,
        "costMicros": 20,
        "available": true,
        "trust": "verified"
      },
      "rejected": [
        {
          "id": "teamspace-batch-eu",
          "reason": "capability mismatch"
        },
        {
          "id": "lookalike-fast",
          "reason": "semantic contract mismatch"
        }
      ],
      "reason": "Cheapest provider satisfying the explicit semantic, policy, freshness, residency, latency and trust contract."
    }
  4. It calls the provider as the same user

    A fresh token for the provider’s audience, carrying the original subject, not the gateway’s own identity. Deadline and budget travel with it (MCP 103, nextHop()).

    sketch · not in the repo

    POST https://tasks.provider/mcp
    Authorization: Bearer <token, aud=provider, sub=alice>
    X-Route-Evidence: teamspace-standard-us
  5. The provider enforces its own rules

    The gateway is a second lock, not a replacement for the first. Tenant scope, role and approval still live in the product.

  6. The answer returns with the route recorded

    The gateway keeps which provider handled it and why, so a bad write can be traced to a decision.

The one real piece: explain_route

The repository exposes the router’s reasoning as a read-only tool. It explains a routing decision and performs nothing. Here are two real calls over HTTP, the accepted one and a refusal:

tools/call explain_route · captured

POST /mcp
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: explain_route

{
  "method": "tools/call",
  "params": {
    "name": "explain_route",
    "arguments": {
      "capability": "task.search",
      "semantics": "teamspace-v1",
      "write": false,
      "freshness": "live",
      "residency": "us",
      "maxLatencyMs": 500,
      "maxCostMicros": 100,
      "mode": "realtime"
    },
    "_meta": {
      "…": "same _meta envelope as the first request"
    }
  },
  "jsonrpc": "2.0",
  "id": 1
}

the same tool, an unknown contract · captured

POST /mcp
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: explain_route

{
  "method": "tools/call",
  "params": {
    "name": "explain_route",
    "arguments": {
      "capability": "task.search",
      "semantics": "unknown-product",
      "maxLatencyMs": 500,
      "maxCostMicros": 100
    },
    "_meta": {
      "…": "same _meta envelope as the first request"
    }
  },
  "jsonrpc": "2.0",
  "id": 2
}

the refusal, parsed · captured

{
  "rejected": [
    {
      "id": "teamspace-standard-us",
      "reason": "semantic contract mismatch"
    },
    {
      "id": "teamspace-priority-us",
      "reason": "semantic contract mismatch"
    },
    {
      "id": "teamspace-batch-eu",
      "reason": "capability mismatch"
    },
    {
      "id": "lookalike-fast",
      "reason": "semantic contract mismatch"
    }
  ],
  "reason": "No provider satisfies every hard constraint; the router refuses a silent downgrade."
}

The second answer is the useful one. Four providers were considered and none qualified: three on the semantic contract and one on capability. The reason says the router refuses a silent downgrade. It does not pick the next-best thing. That evidence is what makes a routing decision inspectable after the fact, which is the property a gateway most needs.

A single point of trust

A gateway concentrates risk. It becomes a high-value target and a failure boundary at once. Design its regional availability before optimising its latency. Make it fail closed for policy. Isolate one tenant’s cached data from another’s. Sign the catalogue it serves. And make its decisions explainable, as explain_route does, before making them fast.

explain_route goes through the same guarded() pipeline as every other tool (MCP 103, Chapter 00): it takes a rate-limit permit, writes an audit row carrying its trace id, and answers in the standard shape. A read-only tool still spends server capacity, and a record of who asked how a routing decision was made is worth having.

Chapter 05 · Routing and gateways

Tool retrieval

A host with a thousand tools cannot show a model a thousand tools. Retrieval picks a few. It is a filter for attention, never a lock.

Design chapter

There is no catalogue or retrieval in this repository. The demo below is a deliberately crude lexical toy, run over the real eleven tool descriptions from this server, to make the idea concrete.

Every tool description a host loads into a model’s context costs tokens on every turn and competes for the model’s attention. With a handful of tools that is fine. With a large catalogue (many servers, hundreds of tools) two things degrade: the context fills up, and selection gets worse because there are too many plausible choices. The remedy is retrieval: search a trusted catalogue by the task in hand, and expose only a small set of candidates.

Interactive · Choose the tools to show

The catalogue: eleven real tools, ranked by word overlap
toolscoreshown to the model?

A toy: words in the task are matched against each tool’s name and description. Real systems use embeddings and metadata filters, and the trap is the same. The last control is the point of the chapter: whatever retrieval hides, the server must still authorise.

Try the default task and a few of your own. Retrieval narrows eleven tools to three, and the readout shows how much of the description text the model no longer has to read. Then switch to the viewer and call create_task even though the host hid it.

What retrieval is, and is not

  • Narrowing, not authorising. Retrieval decides what the model sees. Authorisation decides what may happen. A hidden tool can still be named by a model or a caller, and the server must refuse it exactly as it would have if it were visible, which MCP 102 and 103 both build in.
  • The catalogue is a trust boundary. An entry needs a publisher identity, a semantic version, the scopes it needs, whether it has side effects, its data residency, a deprecation status and its provenance. Metadata a stranger’s server supplies about itself is input to be verified, not routing policy.
  • Descriptions are attack surface. Retrieval reads tool descriptions to rank them, so a description written to rank highly for everything is an attack. Rank on trusted metadata first and on description text second.
  • Filter by entitlement before ranking. Offering a tool the person can never use wastes a slot and invites a failed call.
The cost of getting it wrong

If retrieval hides the right tool, the model improvises with the wrong one. If it shows a dangerous tool to someone who should not have it and the server trusts the host to have filtered, you have moved the security boundary into a ranking function. Keep the server’s checks as if retrieval did not exist.

Chapter 06 · Routing and gateways

Semantic routing

Two tools can share a name and a price list and still disagree about what “search” means. Routing between them without checking is how data goes missing quietly.

In the repo

The semantic-contract check is the fourth constraint in route(), and three of the five new tests cover it.

Routing between models is comparatively easy, because most text models share an interface: text in, text out. If two models disagree slightly, the result is a somewhat different sentence. Routing between tools is not, because a tool has semantics. Two tools both called search_tasks may disagree on which tenants they search, whether archived items appear, how results are ranked, how fresh the index is, and whether the ids they return mean the same thing. Swap one for the other and nothing errors. The answers are simply wrong, and no one is told.

The lab includes a provider built to test exactly that: lookalike-fast. It advertises the same capability (task.search), it is cheaper (2 against 20 micros), and it is faster (60 against 450 ms). On price and latency alone it would win every time. It does not, because its contract is other-v2.

Interactive · The router with the contract as an input

Take a provider out of service:
Providers considered
ProviderSemanticsModep95CostResult

The same route() as Chapter 02. The operation states which semantic contract it needs, and the router never infers equivalence: it only matches. Watch lookalike-fast in each preset.

Read the presets in order. Needs teamspace-v1: the lookalike is rejected as a semantic contract mismatch, though it would have been cheapest. Needs other-v2: now it is the only provider that qualifies, and the Teamspace providers are the ones rejected. Needs a contract nobody offers: no provider is chosen at all. The router’s answer is a refusal, not the nearest match.

Captured runcaptured 2026-09-20
$ the real router: labs/economics.ts, "unsafeSwap"
{
  "rejected": [
    {
      "id": "teamspace-standard-us",
      "reason": "semantic contract mismatch"
    },
    {
      "id": "teamspace-priority-us",
      "reason": "semantic contract mismatch"
    },
    {
      "id": "teamspace-batch-eu",
      "reason": "capability mismatch"
    },
    {
      "id": "lookalike-fast",
      "reason": "semantic contract mismatch"
    }
  ],
  "reason": "No provider satisfies every hard constraint; the router refuses a silent downgrade."
}

the same refusal over MCP, from explain_route · captured

{
  "rejected": [
    {
      "id": "teamspace-standard-us",
      "reason": "semantic contract mismatch"
    },
    {
      "id": "teamspace-priority-us",
      "reason": "semantic contract mismatch"
    },
    {
      "id": "teamspace-batch-eu",
      "reason": "capability mismatch"
    },
    {
      "id": "lookalike-fast",
      "reason": "semantic contract mismatch"
    }
  ],
  "reason": "No provider satisfies every hard constraint; the router refuses a silent downgrade."
}
Captured runcaptured 2026-09-20
$ npm test   (the router and cost tests)
✔ router chooses cheapest provider satisfying every hard constraint
✔ router does not substitute a semantically different tool
✔ write routing requires an explicitly write-capable provider
✔ slow task lane is selected only when requested and allowed
✔ cost model separates vendor infrastructure from client model cost

Note what the router does not do: decide that two contracts are equivalent. That is a human decision, made when a provider is registered, recorded as an identical contract string, and it is the same decision a database team makes when they say two indexes are interchangeable. If you want other-v2 to satisfy teamspace-v1, declare it, test it, and say why. Do not let a routing layer infer it from a matching name.

AspectRouting between modelsRouting between tools
The interfaceLargely shared: text in, text outDifferent per tool: schemas, meanings, side effects
Wrong choice looks likeA somewhat different answerA confident answer over the wrong data, or a write to the wrong place
What must matchRoughly: quality and priceSemantics, permissions, side effects, freshness, residency and trust
Safe defaultFall back to a similar modelRefuse. Never silently substitute
Chapter 07 · Routing and gateways

Auth brokerage

A model gateway can hold one key for many endpoints. A tool gateway cannot, because every tool is somebody’s data and every call is somebody’s permission.

Design chapter

No brokerage is implemented here. The identity model to reason against is MCP 102 Chapter 07: audience-scoped tokens minted per call, never forwarded.

When a gateway sits between hosts and MCP servers it becomes a broker: it holds or obtains credentials on the user’s behalf. Model gateways do this happily, because many providers offer interchangeable text endpoints and one account key covers them all. Tool gateways must not copy that shape. Each tool guards a particular person’s access to a particular product, and the gateway’s own credentials are the wrong answer to “who is asking?”.

Interactive · Three ways a gateway can call downstream

wrong for user data

The gateway calls every server with its own service credential. It is simple, and now every downstream server sees the gateway as the caller. A user with no access to a page asks the gateway for it; the gateway has access, so it gets it. This is the confused deputy: an over-privileged intermediary lends its authority to whoever asks. Downstream audit shows one actor for every user.

the pattern to aim for

The gateway verifies the user’s token, then obtains a token for each downstream server that carries the original subject and is bound to that server’s audience. This is the same step MCP 102’s Downstream.call takes between the MCP server and the product APIs, applied one level up. The product’s own checks then answer for the real person. Downstream audit names the actual user.

necessary, and risky

Users often authorise a downstream product once, and the gateway keeps the resulting refresh token so later calls do not need a fresh consent. That is a store of long-lived credentials, and it needs care: encrypt it, separate tenants, bind each access token to its audience, request only the scopes a call needs, and log every consent change. Compromise of this vault is compromise of everyone’s access at once.

AspectModel gatewayTool gateway
What one credential coversMany interchangeable endpointsNothing broad. One audience, one user
Whose authority is usedThe account holder’sThe end user’s, delegated
Cost of a leakUnauthorised model spendUnauthorised access to real data and actions
Audit questionWhich key was used?Which person acted, on which tenant?

Rules that hold in every design

  • Carry the original subject in every downstream call. A gateway service identity alone is not enough for user data.
  • Bind tokens to an audience and refuse ones meant for other services (MCP 102 Chapter 07 has a simulator for this).
  • Minimise scopes per call, not per session.
  • Keep approvals in the host. A gateway that can approve on the user’s behalf has removed the consent it was meant to protect.
  • Log consent changes, not just calls.
One test

Take any call that goes through your gateway and ask: if the gateway’s own credentials were stolen tomorrow, what could an attacker read that the affected user could not? If the answer is “anything”, the gateway is acting as itself.

Chapter 08 · Composition and the future

A model inside a tool

Some tools should call a model. Most tools that do are hiding a second agent nobody asked for.

Design chapter

No tool in this repository calls a model. What is in the repo is the cost model, which the simulator below reuses to show what a server-owned model does to the vendor’s bill.

An MCP server can call a language model of its own inside a tool: to summarise a long document, extract fields, answer questions about a dataset. That is legitimate when synthesis is the product. A “summarise this contract” tool that calls a model is doing what it says, and the model is an implementation detail.

It is a mistake when the model is there to orchestrate: a tool that takes a goal and decides for itself which other tools to call. That is a second agent, in the server, invisible to the host. It compounds non-determinism (two models deciding), latency (a loop with no bound), and prompt-injection exposure (untrusted content now flows into a model the caller never saw). And it makes failures and spend nearly impossible for the host to explain.

Interactive · What a server-owned model does to the vendor bill

The same estimateCost() as Chapter 01. The Model inside the tool field is what the vendor pays per call for inference. The per-call figures for the two summary presets are illustrative placeholders, not prices of any real model.

With no model in the tool, the vendor share is a couple of percent. Add even a small model and the vendor pays for inference on every call, and the vendor share climbs from a rounding error to a large part of the total. This is the practical reason to be deliberate: a server-owned model turns a cheap read into a metered product, and you need to price it like one.

If the tool contains a model…It should…
Its job is synthesis (the product itself)Say so in the description, and return the model name, usage and cost where the caller needs them
It produces claims about dataReturn citations to the source rows, and the freshness of what it read
It can run long or expensiveEnforce a deadline and a token budget, and expose them as arguments the caller can lower
It reads untrusted textTreat that text as data, never as instructions, and label the output as model-generated
It would decide what to call nextNot exist. Return to the host and let it orchestrate, in view
The visibility test

After a call finishes, can the host tell how many model calls happened inside it, what they cost, what data they saw and why it stopped? If not, the model is hidden orchestration, and you will not be able to explain a bad answer or a surprising bill.

Chapter 09 · Composition and the future

Server composition

An MCP server that calls another MCP server adds a hop. Each hop must be paid for in latency and in ways to fail.

Partly in the repo

nextHop() is in src/production.ts and now has a caller: the composite draft_release_note tool in this server (MCP 103, Chapter 08). There is still no gateway or second server to compose with, so the cross-server case is design.

Composition is the pattern where a gateway, or a higher-level server, calls other MCP servers to fulfil one request. It is right for two cases: a gateway, whose whole job is to sit in the middle, and a stable high-level transaction, a workflow that always runs the same way, exposed as one tool. Each hop must carry the caller’s subject and scopes, a trace id, the shared deadline, the remaining budget and the route so far, and must cap depth and refuse cycles. That is what nextHop() checks, and MCP 103 Chapter 08 has a simulator for it:

Captured runcaptured 2026-09-20
$ nextHop() along a chain (from MCP 103)
b: ok budget=7 visited=a>b
c: ok budget=4 visited=a>b>c
a: RECURSION
d: BUDGET_EXCEEDED  (cost 5, budget 4)
e: ok budget=3 visited=a>b>c>e
f: ok budget=2 visited=a>b>c>e>f
g: HOP_LIMIT

What a check like that cannot do is make a hop free. Every hop multiplies two things you cannot budget away:

Interactive · What each hop costs

Serial chain of independent servers. Availability multiplies; the latency figure is the sum of the individual p95s, which overstates the p95 of the total but is the safe number to plan a timeout around. Plain arithmetic, not a repo function.

At three-nines per server, a chain of four (the caller’s server plus three hops) is available roughly 99.6 percent of the time, which is several hours of downtime a month where each part alone would have had under an hour. And its latency budget is four times one server’s. Neither figure appears on any hop’s dashboard.

  • Prefer a direct call inside a product-owned server. If your server owns the tasks and the knowledge base, call the product functions. Another MCP hop adds no independent boundary, only latency and a token exchange.
  • Use audience-specific tokens per hop, and keep approval at the trusted host for anything consequential.
  • Protocol uniformity is not a reason. That both ends speak MCP does not justify the extra hop.
  • Make the route visible in what you return, so a caller can see which servers touched their request.
Where composition earns its place

When the hop crosses a real boundary: a different organisation, a different trust domain, a product you do not own. Then the extra token exchange and the extra network call are the point, not a cost. Inside one product’s walls they are only overhead.

Chapter 10 · Composition and the future

A forecast, with triggers

Some of this is buildable today. Some is likely. Some is a hope. Say which, and say what would change your mind.

Design chapter

This chapter is a set of judgements, not results. The two things the repository can support are a router and a cost model. Everything below is framed as a hypothesis with something to measure.

Interactive · Current, likely, speculative

buildable now

  • Stateless requests. The 2026-07-28 protocol carries context on each request, so any replica serves it and serverless hosting is straightforward.
  • Durable task patterns. A jobs table with leases (MCP 103) and the protocol’s tasks/* methods for polling long work.
  • Plan entitlements applied after identity is known.
  • Gateways and tool-catalogue search as ordinary engineering: OAuth, policy, audit and retrieval over a verified catalogue.
  • Model-backed tools where synthesis is the product.

Evidence to hold yourself to: each of these works in production somewhere. None needs a new protocol feature.

probably, as agent traffic grows

  • Enterprise gateways as the normal way an organisation exposes and governs its servers.
  • Verified catalogues with signed publisher identity, so a host can decide whom to trust.
  • Per-tool metering, so cost attaches to a capability rather than to a whole server.
  • Priority classes for different customers on the same surface (Chapter 02).
  • Policy-aware retrieval that filters by entitlement before ranking (Chapter 05).
  • Route telemetry strong enough to answer “why did this go there?” (Chapter 04).

What would change my mind: if hosts standardise on connecting to a handful of servers directly, gateways matter less.

a hope, not a plan

  • Broad automatic substitution across competing products. It works for models. For tools, different semantics, delegated auth and side effects make it much harder (Chapter 06).
  • Many near-identical fast and slow servers chosen by a model. Discovery and selection get worse, not better (Chapter 02).

What would change my mind: a widely used way to declare and verify semantic equivalence between independent tools. Nothing here provides it.

Turn each into a measurement

A forecast you cannot check is an opinion. For each thing you might build, name the number that would tell you it has paid, and the number that would tell you it has not.

QuestionMeasureRead it as
Do we need a gateway?Number of servers a host must be configured with; time to onboard oneRising fast means a catalogue and one policy point are earning their keep
Is a slow lane worth it?Volume of calls that can wait, times per-call saving, against the lane’s fixed cost (Chapter 03)Below break-even, do not build it
Is tool retrieval helping?Selection error rate: wrong or missing tool, before and afterNo change means it is adding a moving part
Are we spending on the right thing?Cost per successful workflow, split vendor and client (Chapter 01)Per call hides retries and dead ends
Is a hop justified?p95 latency and availability of the whole chain (Chapter 09)Each hop must buy a real boundary
Is it working for people?Support incidents attributable to routing or refusalsA refusal that confuses is a cost too
The through-line of the series

MCP 101 drew a boundary. 102 built across it. 103 kept the building standing under load. 104 argues about what to put in front of it. The same rule held every time: a layer is allowed to help, and never to decide what only the layer beneath it can know. Retrieval helps and does not authorise. A gateway routes and does not impersonate. A router picks and refuses to guess.

Chapter 11 · Check yourself

Check yourself

Eight questions. The explanations do the teaching.

Interactive · Knowledge check

0 / 8

Missed one? Costs in 01, tiers in 02, slow lanes in 03, gateways in 04, retrieval in 05, semantic routing in 06, auth in 07, models in tools in 08, and chains in 09.

Continue the seriesReturn to the protocol foundation
Open MCP 101 →