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.

index.server.ts view on GitHub
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.

server/mcp-server.ts view on GitHub
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();
    },
  };
}
paseo-plugin.json view on GitHub
{
  "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}
Without knowing the server name

"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" }] } } });