Server hooks
word-tools has no UI. It runs a small MCP server, hands it to every new agent through before("agent.create"), and logs how each turn ended. The fake agent calls the injected tool, so the
whole path is testable without a real agent.
The plugin
A before hook gets the request and returns the request to use, or undefined to keep it. Spread the existing mcpServers: the user and other plugins add servers too. An on hook only observes, and runs on the daemon even
when no app is open.
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { startMcpServer } from "./server/mcp-server";
export default function contribute(server: PluginServerContext) {
const mcp = startMcpServer("word-tools", {
count_words: {
description: "Count the words in a text.",
inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
run: ({ text }) => `${String(text ?? "").split(/\s+/).filter(Boolean).length} words`,
},
});
// Every new agent gets the server as "words". Spread what is there: the user and other plugins add servers too.
const stopInjecting = server.before("agent.create", ({ request }) => {
const url = mcp.url();
if (!url) return undefined; // not listening yet: keep the request as it is
const words = { type: "http" as const, url };
return { ...request, config: { ...request.config, mcpServers: { ...request.config.mcpServers, words } } };
});
// Event hooks run on the daemon even with no app open. Output lands in `paseo plugin logs word-tools`.
const stopLogging = server.on("agent.turn_ended", (event) => {
const detail = event.outcome.kind === "failed" ? `: ${event.outcome.error.message}` : "";
console.log(`turn ${event.outcome.kind} for ${event.agent.provider} agent ${event.agent.id}${detail}`);
});
return () => {
stopLogging();
stopInjecting();
mcp.close();
};
}The MCP server is plain node:http, so the plugin has no runtime dependencies: Streamable HTTP with JSON responses,
enough for initialize, tools/list and tools/call. It listens on a free loopback port without authentication.
import { createServer } from "node:http";
export interface Tool {
description: string;
inputSchema: Record<string, unknown>;
run(args: Record<string, unknown>): string | Promise<string>;
}
/**
* A minimal MCP server over Streamable HTTP with JSON responses (initialize, ping, tools/list, tools/call) on a
* free loopback port. It has no authentication: anything on this machine can call it. `url()` is null until
* the server is listening.
*/
export function startMcpServer(name: string, tools: Record<string, Tool>) {
const http = createServer((request, response) => {
let body = "";
request.on("data", (chunk) => (body += chunk));
request.on("end", async () => {
let message: { id?: number | string; method?: string; params?: { name?: string; arguments?: Record<string, unknown> } };
try {
message = JSON.parse(body || "{}");
} catch {
response.writeHead(400).end();
return;
}
if (message.id === undefined) {
response.writeHead(202).end(); // a notification: no reply
return;
}
const reply = (payload: object) =>
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id: message.id, ...payload }));
switch (message.method) {
case "initialize":
return reply({ result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name, version: "0.1.0" } } });
case "ping":
return reply({ result: {} });
case "tools/list":
return reply({ result: { tools: Object.entries(tools).map(([tool, { description, inputSchema }]) => ({ name: tool, description, inputSchema })) } });
case "tools/call": {
const tool = tools[message.params?.name ?? ""];
if (!tool) return reply({ error: { code: -32602, message: `unknown tool: ${message.params?.name}` } });
try {
return reply({ result: { content: [{ type: "text", text: await tool.run(message.params?.arguments ?? {}) }] } });
} catch (error) {
return reply({ result: { isError: true, content: [{ type: "text", text: String(error) }] } });
}
}
default:
return reply({ error: { code: -32601, message: `method not found: ${message.method}` } });
}
});
});
http.listen(0, "127.0.0.1");
return {
url() {
const address = http.address();
return address && typeof address === "object" ? `http://127.0.0.1:${address.port}/mcp` : null;
},
close() {
http.close();
},
};
}{
"id": "word-tools",
"name": "Word tools",
"description": "Docs recipe: injects an MCP server into every new agent and logs how turns end.",
"requirements": { "paseo": ">=0.11.4" }
}Run it
mkdir -p /tmp/ws
npx ppd daemon up --fake-agent
npx ppd install path/to/recipes/server-hooks
npx ppd paseo -- run --provider fake-agent --cwd /tmp/ws '{"steps":[{"mcp":{"server":"words","tool":"count_words","arguments":{"text":"one two three"}}},{"say":"done"}]}'
npx ppd paseo -- logs <agent id>
npx ppd paseo -- plugin logs word-tools The agent called the tool on the server the plugin injected:
[User] {"steps":[{"mcp":{"server":"words","tool":"count_words","arguments":{"text":"one two three"}}},{"say":"done"}]}
[mcp__words__count_words] {"server":"words","tool":"count_words","arguments":{"text":"one two three"}}
done and the turn hook logged the outcome:
2026-10-18T08:20:44.812Z stdout turn completed for fake-agent agent 8769edf1-d1cf-47ed-a17e-e672d0140dbf The tool's result is in the canonical timeline (read with agentTimeline), as the tool call's output:
{"type":"tool_call","callId":"m-1-0-0","name":"mcp__words__count_words","detail":{"type":"unknown","input":{"server":"words","tool":"count_words","arguments":{"text":"one two three"}},"output":{"content":[{"type":"text","text":"3 words"}]}},"status":"completed","error":null} "server": "*" calls the tool on every MCP server in the session config, which shows what an agent actually
received. See MCP steps.
Test it
import { agentTimeline, runFakeAgentTurn } from "paseo-plugin-devkit";
const turn = runFakeAgentTurn([{ mcp: { server: "words", tool: "count_words", arguments: { text: "one two three" } } }], { cwd: workspace });
const [call] = agentTimeline(turn.agentId).filter((row) => row.type === "tool_call");
expect(call).toMatchObject({ status: "completed", detail: { output: { content: [{ type: "text", text: "3 words" }] } } });