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
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.
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
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
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);
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);
The add handler returns a + b for those params.
The router puts that value under the result key.
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
}
Read the handler with handlers[msg.method] and call it.
Return an object with jsonrpc, the echoed id, and result.
const handlers = { ping: () => ({ pong: true }) };
function route(msg) {
const result = handlers[msg.method](msg.params || {});
return { jsonrpc: '2.0', id: msg.id, result };
}
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 };
}
A missing id means msg.id is undefined.
Test for undefined specifically (===), so an id of 0 still counts as a request.
const handlers = { ping: () => ({ pong: true }) };
function route(msg) {
if (msg.id === undefined) {
return null;
}
const result = handlers[msg.method](msg.params || {});
return { jsonrpc: '2.0', id: msg.id, result };
}
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
}
Decide notification-ness once with msg.id === undefined.
Wrap the handler call in try/catch; missing handlers and thrown handlers each get their own error code.
Return null for notifications in every branch.
const handlers = {
ping: () => ({ pong: true }),
echo: ({ msg }) => ({ echoed: msg }),
boom: () => { throw new Error('kaboom'); }
};
function route(msg) {
const handler = handlers[msg.method];
const isNotification = msg.id === undefined;
if (typeof handler !== 'function') {
if (isNotification) return null;
return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } };
}
try {
const result = handler(msg.params || {});
if (isNotification) return null;
return { jsonrpc: '2.0', id: msg.id, result };
} catch (e) {
if (isNotification) return null;
return { jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: String(e && e.message || e) } };
}
}
A server receives a message with a method but no id. What must the router return?
A message without an id is a notification, and JSON-RPC forbids replying to one. The router processes it silently and returns nothing — no envelope, no error.
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 sameid, 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
nulland send nothing. - Next you will carry these messages over the two transports MCP actually uses: stdio and Streamable HTTP.