Guide

Moving a Package Over

Take a package that declares its tools four times and bring it down to one definition. Step by step with the traps marked

You have a package with an MCP server, an AI SDK file, a Pi extension and an OMP extension. Each declares the same tools again, with its own schema, its own descriptions and its own idea of an error. Here's how to get to one file. Your executors stay exactly as they are, this is only about the layer on top.

1. One schema, from the right Type

Put the schemas in one module and build them with Type from @agntn/tools. Take the types (Static, TObject) from there too. Close every object with properties. Swap every union of literals for Type.Enum.

If the old MCP or AI SDK surface used Zod, compare the semantics, not just the shape. Zod's .trim().min(1) rejects a string of spaces and trims the value. A TypeBox minLength: 1 accepts spaces. Decide which one you meant, write it down once, and let the executor do the trimming.

2. The definitions

A tools.ts with one defineTool per tool. Descriptions come from the most complete surface you had. Usually that's MCP, because it had nothing else to say it with. The Pi snippet and guidelines move into snippet and guidelines. execute loads the executors lazily, see Defining tools.

Pick one text for the model and use it everywhere. If MCP answered with raw JSON and Pi with a formatted summary, one of them was wrong for somebody. The formatted text plus the data in details works for every host.

3. The surfaces

tssrc/mcp.ts
import type { Server } from "@modelcontextprotocol/server";
import { createMcpServer as createToolServer } from "@agntn/tools/mcp";
import { tools } from "./tools.ts";

export function createMcpServer(): Server {
  return createToolServer({ name: "mypackage", version }, tools);
}
tssrc/ai.ts
import { toAiTool, type AiToolOutput } from "@agntn/tools/ai";
import type { Tool } from "ai";

export const searchTool: Tool<SearchParams, AiToolOutput<SearchDetails>> = toAiTool(searchDefinition);

Annotate exported AI SDK tools with your own parameter and details types. Inferred, the type points into the bundled TypeBox and your declaration build can't name it.

The Pi extension becomes one registerPiTools(pi, tools) call. The OMP one is registerOmpTools(pi, tools, { Text }), with Text imported from @oh-my-pi/pi-coding-agent in the extension file itself. Status line summaries go into renderers with describeCall and describeResult. A result preview of your own goes in as renderResult.

4. The traps, in the order they bit

  • MCP SDK v2. @modelcontextprotocol/sdk becomes @modelcontextprotocol/server, the stdio transport comes from @modelcontextprotocol/server/stdio and the test client from @modelcontextprotocol/client. The dependency list shrinks a lot on the way.
  • A text field in details. The AI SDK output is { ...details, text }, so a details object with its own text would get overwritten. The adapter throws instead. Rename it (slice works).
  • /tui in the OMP extension. Compiled OMP gives an extension only the package root. An import from @oh-my-pi/pi-coding-agent/tui stops the whole extension from loading. The adapter draws the status line with the host theme, so you don't need it.
  • A test double for OMP's TypeBox. Standalone omptype's Type.Unsafe hands back one shared object for every call. Patch safeParse on it and every tool validates against whichever schema registered last. Give each document its own callable.
  • Providers that register on import. If your MCP server used to import the providers, the lazy executor now does it on the first call. A test that looks a provider up before any call has to import them itself.

5. Prove it

Your existing tests are the contract. They should pass with at most the error text changed, because the validation lines now come from the core. Then load the extensions in the real hosts, from a checkout and from a packed tarball, and call a tool in each. A green unit suite says nothing about whether compiled OMP agrees to load your extension. Ask OMP.