Module 6 · The Model Context Protocol (MCP) ⏱ 18 min

Build a Working MCP Message Router

By the end of this lesson you will be able to:
  • Model a router as a dispatch table from JSON-RPC method name to handler function
  • Wrap a handler outcome in the correct result or error envelope, echoing the request id
  • Handle method-not-found, thrown handlers, and notifications to spec

You now have handlers for individual methods — tools/list, tools/call, and the rest. But a server does not know in advance which method a given message will carry. It needs a single entry point that takes any well-formed incoming message, finds the right handler, runs it, and wraps the outcome in the envelope a client expects. That entry point is the router, and once you have it, every transport — stdio on a laptop, Streamable HTTP on a remote host — can hand it the same parsed message and let it do the dispatch.

This lesson builds that router: a dispatch table, the result and error envelopes, and the one rule about notifications that quietly breaks naive implementations.

flowchart LR
  MSG["incoming message<br/>method + id + params"] --> LOOK["handlers[method]"]
  LOOK --> H1["handler runs"]
  H1 --> ENV["envelope to send"]
  style LOOK fill:#1d4ed8,color:#fff
  style ENV fill:#166534,color:#fff
The router reads the method, looks up its handler in a table, and wraps the outcome in an envelope.

A router is a dispatch table

At its core a router is a map from method name to handler function — a plain object whose keys are the JSON-RPC method strings and whose values are the functions that implement them. Given an incoming message, the router reads msg.method, looks it up in the table, and either calls the matching handler or reports that no such method exists. That lookup is the entire dispatch mechanism: there is no reflection, no class hierarchy, no framework. Keeping handlers as data in a table is what lets you register a new method by adding one line, and what lets the router treat every method uniformly.

Validate before you dispatch

A real router does one thing before consulting its table: it checks that the message is well-formed JSON-RPC at all. Every message must carry jsonrpc: '2.0' and a method string; a request adds an id, a notification omits it. Bytes that fail to parse are -32700 (parse error); a structurally wrong message — say, a method that is a number — is -32600 (invalid request). Only after that guard does the router look up the handler. Keeping validation separate from dispatch pays off: the same checks protect every method, and a handler never has to defend itself against a malformed envelope. For this lesson's exercises we assume well-formed input and focus on the dispatch itself.

Dispatch by method name, then wrap the result in a JSON-RPC envelope. Press Run.
const handlers = {
  greet: ({ name }) => 'hello ' + name,
  ping: () => 'pong'
};
function route(msg) {
  const handler = handlers[msg.method];
  if (typeof handler !== 'function') {
    return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } };
  }
  return { jsonrpc: '2.0', id: msg.id, result: handler(msg.params || {}) };
}

console.log(route({ jsonrpc: '2.0', id: 1, method: 'greet', params: { name: 'sam' } }).result);
console.log(route({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} }).result);

Wrapping the outcome in an envelope

A handler's return value is not, by itself, a valid response. The client sent a request carrying an id, and it needs the reply to carry that same id so it can pair them — especially when several requests are in flight at once. So the router wraps the handler's result as { jsonrpc: '2.0', id: msg.id, result }. When something goes wrong at the protocol level — the method does not exist, the handler threw unexpectedly — the router instead returns { jsonrpc: '2.0', id: msg.id, error: { code, message } }. The id always echoes back; only the final key differs: result on success, error on failure. A response carries exactly one of them, never both, never neither.

flowchart TD
  REQ["request with id"] --> OUT["handler outcome"]
  OUT -->|"returns a value"| RES["result envelope"]
  OUT -->|"throws or unknown"| ERR["error envelope"]
  style REQ fill:#1d4ed8,color:#fff
  style RES fill:#166534,color:#fff
  style ERR fill:#b91c1c,color:#fff
One fork decides the last key of the envelope: result on success, error on a protocol-level failure.
An unknown method yields a -32601 error envelope, not a crash. Press Run.
const handlers = { ping: () => 'pong' };
function route(msg) {
  const handler = handlers[msg.method];
  if (typeof handler !== 'function') {
    return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } };
  }
  return { jsonrpc: '2.0', id: msg.id, result: handler(msg.params || {}) };
}

const reply = route({ jsonrpc: '2.0', id: 9, method: 'nope', params: {} });
console.log(reply.error.code);
console.log(reply.error.message);

Method not found

When msg.method is not a key in the table, the router has no handler to call — but it still owes the client a response, because the message had an id. The spec reserves code -32601 for exactly this: the request was valid JSON-RPC, it simply names a method this server does not implement. Distinguish it from its neighbours: -32700 means the bytes were not even parseable JSON-RPC, and -32602 means the method exists but its parameters were wrong. Getting these codes right matters, because clients branch on them — a missing method is permanent, while bad parameters might succeed on a retry.

Requests answer; notifications do not

Every incoming message is one of two kinds. A request carries an id; the client expects a response, so the router returns an envelope with that same id. A notification carries no id at all — it is fire-and-forget, used for one-way signals like notifications/initialized. The spec is strict here: a router must never reply to a notification, because the client is not waiting for one and would have no way to match a reply. So the rule is simple to state and easy to get wrong: if id is absent, run the handler for its side effect (or skip it) and return null — nothing to send. The figure below shows the fork.

flowchart TD
  IN["incoming message"] --> ID["id present"]
  ID -->|"yes, it is a request"| REQ["respond with an envelope"]
  ID -->|"no, it is a notification"| NOT["return null, send nothing"]
  style IN fill:#1d4ed8,color:#fff
  style REQ fill:#166534,color:#fff
  style NOT fill:#b45309,color:#fff
The id decides everything: present means a request that needs a reply; absent means a notification that must get none.
A notification (no id) returns null; a request (with id) returns an envelope. Press Run.
const handlers = { ping: () => 'pong' };
function route(msg) {
  if (msg.id === undefined) return null;
  return { jsonrpc: '2.0', id: msg.id, result: handlers[msg.method](msg.params || {}) };
}

console.log(route({ jsonrpc: '2.0', method: 'ping', params: {} }));
console.log(route({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }).result);
Exercise

What does this print? The router looks up add, runs it, and wraps the return value as result.

const handlers = { add: ({ a, b }) => a + b };
function route(msg) {
  return { jsonrpc: '2.0', id: msg.id, result: handlers[msg.method](msg.params) };
}
console.log(route({ jsonrpc: '2.0', id: 5, method: 'add', params: { a: 2, b: 3 } }).result);
Exercise

Write route(msg) that looks up msg.method in handlers, calls it with msg.params, and returns the JSON-RPC success envelope { jsonrpc: '2.0', id: msg.id, result }. Assume the method always exists and the message is a request (it has an id).

const handlers = { ping: () => ({ pong: true }) };
function route(msg) {
  // call handlers[msg.method](msg.params) and wrap the result
}
Exercise

This route replies to every message, even notifications. But a notification (a message with no id) must get NO response. Fix route so that when msg.id is absent it returns null instead of an envelope. (Watch out: an id of 0 is a valid request id, not a missing one.)

const handlers = { ping: () => ({ pong: true }) };
function route(msg) {
  const result = handlers[msg.method](msg.params || {});
  return { jsonrpc: '2.0', id: msg.id, result };
}
Exercise

Write the full route(msg). If msg.id is absent it is a notification: return null. Otherwise look up handlers[msg.method]: if there is no such handler, return an error envelope with code -32601; if the handler throws, return an error envelope with code -32603; on success return { jsonrpc: '2.0', id: msg.id, result }. Pass msg.params (or {} if absent) to the handler.

const handlers = {
  ping: () => ({ pong: true }),
  echo: ({ msg }) => ({ echoed: msg }),
  boom: () => { throw new Error('kaboom'); }
};
function route(msg) {
  // notification -> null; unknown -> -32601; throw -> -32603; else result envelope
}
Exercise

A server receives a message with a method but no id. What must the router return?

Recap

  • A router is a dispatch table from JSON-RPC method name to handler function — a lookup, not reflection.
  • On success it returns { jsonrpc: '2.0', id, result }; on a protocol fault it returns { jsonrpc: '2.0', id, error: { code, message } } — the same id, only the last key differs.
  • An unknown method is -32601 (method not found). A thrown handler becomes a -32603 error envelope — at the router boundary, faults are errors, unlike tool-level isError.
  • A message with no id is a notification: the router must return null and send nothing.
  • Next you will carry these messages over the two transports MCP actually uses: stdio and Streamable HTTP.

Checkpoint quiz

A request arrives for method 'tools/call', but the server has no handler registered for it. What does a spec-correct router return?

Why does the router wrap a handler's return value in { jsonrpc, id, result } instead of returning it directly?

Go deeper — technical resources