An introduction to MCP Hooks!
Introduction
The Model Context Protocol (MCP) has quickly become a standard for bridging Large Language Models with external tools, APIs, and data sources. While the protocol itself handles structured JSON-RPC messaging under the hood, seeing a end-to-end integration come together using a local stdio process — a setup often referred to as an MCP Hook or integration — is remarkably straight-forward.
In this post, we’ll walk through building a custom MCP server in TypeScript, registering tools with schema validation, and wiring up a Node.js client to spawn, negotiate, and execute tool calls end-to-end.
TL;DR-What is an MCP Hook? An Architectural Definition
At its core, a Model Context Protocol (MCP) Hook is an integration pattern that bridges a Large Language Model (LLM) host with external execution environments. While LLMs excel at reasoning, they operate in isolation from live data and system state. MCP solves this by establishing a standardized protocol for tool discovery, context injection, and function calling.
The term MCP Hook specifically describes the runtime linkage and communication channel established between the client application (the MCP Host) and a background tool server (the MCP Server):
Protocol Framing: Rather than making remote network calls over HTTP/REST, local MCP hooks typically leverage low-latency process communication (such as standard I/O streams:
stdinandstdout).Deterministic Delegation: The client process spawns the tool server as a child process, negotiates capabilities via a structured JSON-RPC 2.0 handshake, and dynamically dispatches function calls based on the model’s intent.
Process Isolation: By decoupling tool execution into a separate runtime process, the hook ensures that file I/O, API calls, or hardware manipulations happen safely outside the primary host application thread.
In essence, an MCP Hook transforms an LLM from a passive text-generation engine into an active agent capable of deterministically inspecting time, querying databases, executing shell operations, or interacting with host resources.
Implementation-System Architecture & Lifecycle
**An MCP Hook **connection over standard input/output (stdio) operates through a parent-child process relationship:
Host/Client Initialization: The client process spawns the MCP server executable as a child process.
Protocol Handshake: Standard I/O streams (
stdin/stdout) frame JSON-RPC 2.0 requests to negotiate capacities and versions.Tool Discovery & Call: The client queries available capabilities (
listTools) and executes handlers (callTool).Graceful Teardown: Closing the transport terminates the child process safely.
Server Implementation: Schemas & Tool Handlers
The server uses @modelcontextprotocol/sdk alongside zod to enforce strict parameter schemas before any tool handler is invoked.
Tip: When using a stdio transport, never output logging or debugging messages to
console.logon the server. Doing so corrupts the stdout JSON-RPC message framing. Always redirect operational logs toconsole.error.
- Server Entry Point (index.ts);
#!/usr/bin/env node
/**
* mcp-hook-server — Demonstration MCP Server
*
* Exposes three tools:
* - echo : returns the input message back
* - current-time: returns the current server date/time in the requested timezone
* - random-joke : returns a random programming joke
*
* Transport: stdio (spawned by an MCP host such as Bob)
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// ─── Joke dataset ────────────────────────────────────────────────────────────
const JOKES = [
"Why do programmers prefer dark mode? Because light attracts bugs.",
"A SQL query walks into a bar, walks up to two tables and asks… 'Can I join you?'",
"How many programmers does it take to change a light bulb? None — that's a hardware problem.",
"Why do Java developers wear glasses? Because they don't C#.",
"There are 10 types of people in the world: those who understand binary and those who don't.",
"A byte walks into a bar looking pale. The barman asks: 'What's wrong?' It replies: 'I had a bit removed.'",
"Why was the developer unhappy at their job? They wanted arrays.",
"I would tell you a UDP joke, but you might not get it.",
];
// ─── Server setup ─────────────────────────────────────────────────────────────
const server = new McpServer({
name: "mcp-hook-server",
version: "0.1.0",
});
// ─── Tool: echo ──────────────────────────────────────────────────────────────
server.registerTool("echo", {
description: "Echoes back the provided message. Useful for verifying that the MCP hook is working end-to-end.",
inputSchema: z.object({
message: z.string().describe("The message to echo back"),
}),
}, async ({ message }) => {
return {
content: [
{
type: "text",
text: `[MCP echo] ${message}`,
},
],
};
});
// ─── Tool: current-time ───────────────────────────────────────────────────────
server.registerTool("current-time", {
description: "Returns the current date and time on the server.",
inputSchema: z.object({
timezone: z
.string()
.optional()
.describe("IANA timezone name (e.g. 'Europe/Paris'). Defaults to UTC."),
format: z
.enum(["iso", "human"])
.optional()
.describe("Output format: 'iso' (default) or 'human'-readable."),
}),
}, async ({ timezone, format }) => {
const tz = timezone ?? "UTC";
const fmt = format ?? "iso";
let result;
try {
const now = new Date();
if (fmt === "human") {
result = now.toLocaleString("en-US", {
timeZone: tz,
dateStyle: "full",
timeStyle: "long",
});
}
else {
result = now
.toLocaleString("sv-SE", { timeZone: tz })
.replace(" ", "T");
}
}
catch {
return {
content: [
{
type: "text",
text: `Unknown timezone: '${tz}'. Please use a valid IANA timezone name.`,
},
],
isError: true,
};
}
return {
content: [{ type: "text", text: result }],
};
});
// ─── Tool: random-joke ────────────────────────────────────────────────────────
server.registerTool("random-joke", {
description: "Returns a random programming or developer joke.",
inputSchema: z.object({}),
}, async () => {
const joke = JOKES[Math.floor(Math.random() * JOKES.length)];
return {
content: [{ type: "text", text: joke }],
};
});
// ─── Start ────────────────────────────────────────────────────────────────────
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("mcp-hook-server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in mcp-hook-server:", error);
process.exit(1);
});
Client Driver & Verification
To test the hook, the client process uses StdioClientTransport to target the compiled server binary (build/index.js), list the registered tools, and execute them.
- Client Script (index.js);
/**
* mcp-hook-client — Demonstration MCP Client
*
* Spawns mcp-hook-server via stdio transport, discovers all available tools,
* then calls each tool once to show an end-to-end MCP hook in action.
*
* Run: node src/index.js
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import path from "node:path";
import { fileURLToPath } from "node:url";
// ─── Resolve absolute path to the built server ────────────────────────────────
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SERVER_PATH = path.resolve(
__dirname,
"../../mcp-hook-server/build/index.js"
);
// ─── Helpers ──────────────────────────────────────────────────────────────────
function banner(title) {
const line = "─".repeat(60);
console.log(`\n${line}`);
console.log(` ${title}`);
console.log(`${line}`);
}
function printResult(toolName, result) {
const text = result?.content
?.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
console.log(`[${toolName}] →`, text ?? JSON.stringify(result));
}
// ─── Main demo ────────────────────────────────────────────────────────────────
async function main() {
banner("MCP Hook Demo — Client connecting to mcp-hook-server");
// 1. Create client
const client = new Client({
name: "mcp-hook-client",
version: "0.1.0",
});
// 2. Create stdio transport — spawns the server as a child process
const transport = new StdioClientTransport({
command: "node",
args: [SERVER_PATH],
});
// 3. Connect
console.log("\n[client] Connecting to server…");
await client.connect(transport);
console.log("[client] Connected ✓");
// 4. List available tools
const { tools } = await client.listTools();
console.log(
`\n[client] Server exposes ${tools.length} tool(s):`,
tools.map((t) => t.name).join(", ")
);
// ── Demo: echo ──────────────────────────────────────────────────────────────
banner("Tool demo: echo");
const echoResult = await client.callTool({
name: "echo",
arguments: { message: "Hello from the MCP client!" },
});
printResult("echo", echoResult);
// ── Demo: current-time (ISO, UTC) ────────────────────────────────────────────
banner("Tool demo: current-time (ISO / UTC)");
const timeIso = await client.callTool({
name: "current-time",
arguments: {},
});
printResult("current-time", timeIso);
// ── Demo: current-time (human, Paris) ────────────────────────────────────────
banner("Tool demo: current-time (human / Europe/Paris)");
const timeParis = await client.callTool({
name: "current-time",
arguments: { timezone: "Europe/Paris", format: "human" },
});
printResult("current-time", timeParis);
// ── Demo: random-joke ────────────────────────────────────────────────────────
banner("Tool demo: random-joke");
const joke = await client.callTool({ name: "random-joke", arguments: {} });
printResult("random-joke", joke);
// 5. Disconnect
await client.close();
console.log("\n[client] Disconnected. Demo complete ✓\n");
}
main().catch((err) => {
console.error("Demo client error:", err);
process.exit(1);
});
Overall Test Script
To test the implementation, we can run a small test script.
- Test Script (test.js);
/**
* mcp-hook-client — Unit Tests
*
* Spawns the real mcp-hook-server and exercises every tool.
* Exit code 0 = all passed. Exit code 1 = one or more failures.
*
* Run: node src/test.js
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SERVER_PATH = path.resolve(
__dirname,
"../../mcp-hook-server/build/index.js"
);
// ─── Tiny test harness ────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
function assert(condition, label) {
if (condition) {
console.log(` ✓ ${label}`);
passed++;
} else {
console.error(` ✗ ${label}`);
failed++;
}
}
function getText(result) {
return result?.content
?.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n") ?? "";
}
// ─── Tests ────────────────────────────────────────────────────────────────────
async function runTests(client) {
// ── listTools ────────────────────────────────────────────────────────────────
console.log("\n[suite] listTools");
const { tools } = await client.listTools();
const names = tools.map((t) => t.name);
assert(names.includes("echo"), "exposes 'echo' tool");
assert(names.includes("current-time"), "exposes 'current-time' tool");
assert(names.includes("random-joke"), "exposes 'random-joke' tool");
assert(tools.length === 3, "exposes exactly 3 tools");
// ── echo ─────────────────────────────────────────────────────────────────────
console.log("\n[suite] echo");
const echo1 = await client.callTool({
name: "echo",
arguments: { message: "test-payload" },
});
assert(
getText(echo1) === "[MCP echo] test-payload",
"echoes message with prefix"
);
assert(!echo1.isError, "echo returns no error flag");
const echoEmpty = await client.callTool({
name: "echo",
arguments: { message: "" },
});
assert(getText(echoEmpty) === "[MCP echo] ", "echoes empty string correctly");
// ── current-time ─────────────────────────────────────────────────────────────
console.log("\n[suite] current-time");
const timeUtc = await client.callTool({
name: "current-time",
arguments: {},
});
const utcText = getText(timeUtc);
assert(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(utcText), "UTC ISO format matches yyyy-mm-ddThh:mm");
assert(!timeUtc.isError, "current-time UTC returns no error flag");
const timeHuman = await client.callTool({
name: "current-time",
arguments: { timezone: "America/New_York", format: "human" },
});
assert(!timeHuman.isError, "human format (America/New_York) returns no error");
assert(getText(timeHuman).length > 0, "human format returns non-empty string");
const timeBad = await client.callTool({
name: "current-time",
arguments: { timezone: "Not/AReal_Zone" },
});
assert(timeBad.isError === true, "invalid timezone sets isError=true");
// ── random-joke ───────────────────────────────────────────────────────────────
console.log("\n[suite] random-joke");
const joke1 = await client.callTool({ name: "random-joke", arguments: {} });
assert(!joke1.isError, "random-joke returns no error flag");
assert(getText(joke1).length > 0, "random-joke returns non-empty text");
// Ensure it can return at least two different jokes across 20 calls (non-deterministic — might rarely fail)
const jokeSamples = new Set();
for (let i = 0; i < 20; i++) {
const r = await client.callTool({ name: "random-joke", arguments: {} });
jokeSamples.add(getText(r));
}
assert(jokeSamples.size > 1, "random-joke returns more than one unique joke across 20 calls");
}
// ─── Bootstrap ────────────────────────────────────────────────────────────────
async function main() {
console.log("=== mcp-hook unit tests ===");
const client = new Client({ name: "mcp-hook-test-client", version: "0.1.0" });
const transport = new StdioClientTransport({
command: "node",
args: [SERVER_PATH],
});
await client.connect(transport);
try {
await runTests(client);
} finally {
await client.close();
}
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
if (failed > 0) process.exit(1);
}
main().catch((err) => {
console.error("Test runner error:", err);
process.exit(1);
});
- Which provides a log file validating the whole logic of the implementation.
────────────────────────────────────────────────────────────
MCP Hook Demo — Client connecting to mcp-hook-server
────────────────────────────────────────────────────────────
[client] Connecting to server…
mcp-hook-server running on stdio
[client] Connected ✓
[client] Server exposes 3 tool(s): echo, current-time, random-joke
────────────────────────────────────────────────────────────
Tool demo: current-time (human / Europe/Paris)
────────────────────────────────────────────────────────────
[current-time] → Monday, August 10, 2026 at 9:49:46 AM GMT+2
[client] Disconnected. Demo complete ✓
Integration into MCP Hosts (e.g., Bob)
Once compiled, registering your new custom server inside an agent or workspace host like Bob requires registering the process entry in .bob/mcp.json:
{
"mcpServers": {
"mcp-hook-server": {
"command": "node",
"args": ["/absolute/path/to/mcp-hook-server/build/index.js"]
}
}
}
This makes echo, current-time, and random-joke directly available to the AI assistant as native tool primitives during chat sessions.
Conclusion: Implementing Your First MCP Hook
Implementing your first MCP Hook demonstrates how remarkably clean decoupled LLM integrations can be. By relying on lightweight stdio transports, process isolation, and standard schemas via zod, you keep your tool logic testable, language-agnostic, and completely independent of any specific client UI or host framework.
Building local MCP hooks gives you full control over tool security, deterministic execution, and process lifecycle management — laying a solid foundation for scaling up to complex agentic workflows.
Thanks for reading 🪝
Links
- Official Model Context Protocol Specification & Docs: modelcontextprotocol.io
- Official MCP TypeScript SDK: github.com/modelcontextprotocol/typescript-sdk
- Official Reference Servers Repository: github.com/modelcontextprotocol/servers
- civicteam/mcp-hooks: github.com/civicteam/mcp-hooks
- Zod Official Website & Documentation: zod.dev
- Zod GitHub Repository: github.com/colinhacks/zod
- IBM Bob: https://bob.ibm.com/





Top comments (0)