Parts

TypeScript tools

An MCP tool in TypeScript, on the official SDK, that every node runs on its own Node.js 24 without a build step.

For: plugin authors.

Use the ts-tool template to write an MCP server with the official TypeScript SDK. The plugin is a Node.js project: nodes install its dependencies from the lockfile and run its code on the same Node.js 24 LTS runtime used for npm packages.

Node.js 24 strips TypeScript types, so the template needs no build step.

Scaffold it

Terminal
oz0 plugin new acme-orders --template ts-tool --description "Looks up orders"
cd acme-orders
npm install
acme-orders/
├── oz0-plugin.yaml          runtime: node, one tool: node src/server.ts
├── package.json             the SDK and zod, pinned; TypeScript and the SDK's client for tests
├── package-lock.json        written by npm install: commit it, nodes install exactly this
├── tsconfig.json            type checking only (noEmit), erasable syntax only
├── src/server.ts            the MCP server
├── test/server.test.ts      its tests, with node --test
├── skills/acme-orders/SKILL.md
├── AGENTS.md                the rules and commands, for you and coding agents
└── .claude/skills/oz0-plugin/SKILL.md
oz0-plugin.yaml
apiVersion: oz0/v1
name: acme-orders
version: 0.1.0
description: "Looks up orders"
runtime: node
tools:
  - id: acme_orders
    mcp: ["node", "src/server.ts"]
    affinity: any
skills: [skills/]

The server

The template uses the official SDK's v2 packages, pinned: @modelcontextprotocol/server 2.3.1 and zod 4.6.5. Each registerTool is a function agents call as acme_orders.<name>; its description and input schema are what the model reads, and its annotations what the tenant's tool policy reads:

src/server.ts
import { McpServer } from '@modelcontextprotocol/server'
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
import * as z from 'zod'

export function createServer(): McpServer {
  const server = new McpServer({ name: 'acme-orders', version: '0.1.0' })
  server.registerTool(
    'get_order',
    {
      description: 'An order by its ID: status, items and total.',
      inputSchema: z.object({ order_id: z.string().describe('The order ID, such as 1234') }),
      annotations: { readOnlyHint: true, openWorldHint: false },
    },
    async ({ order_id }) => ({ content: [{ type: 'text', text: JSON.stringify(await lookUp(order_id)) }] }),
  )
  return server
}

if (import.meta.main) {
  await createServer().connect(new StdioServerTransport())
}

Annotate every function: readOnlyHint for one that changes nothing, destructiveHint and idempotentHint for one that writes, and openWorldHint: false for one that stays inside your systems. A function without annotations counts as destructive and open-world, and the tenant's policy may hold every call for a person. See Say what a tool does.

Secrets work as in Python: declare them in requires.secrets, and read process.env.ACME_ORDERS_TOKEN.

TypeScript without a build

Nodes run node src/server.ts. Node.js 24 strips the types and runs the JavaScript left, so keep to TypeScript that can be erased:

  • no enum, namespace or parameter properties (constructor(private x: string)), which need code generated for them;
  • import local files with their extension: import { lookUp } from './orders.ts';
  • import type for imports used only as types.

tsconfig.json sets erasableSyntaxOnly, so npm run typecheck (TypeScript 6.0.3, without emitting anything) points at anything Node.js could not run.

Test it

Terminal
npm test              # node --test: the function, and the server over stdio with the SDK's client
npm run typecheck
oz0 plugin lint

The template's test starts the server over stdio, as a node's runtime does, lists its tools, checks the annotations and calls one:

test/server.test.ts
const client = new Client({ name: 'acme-orders-test', version: '0.1.0' })
await client.connect(new StdioClientTransport({ command: process.execPath, args: ['src/server.ts'] }))
const { tools } = await client.listTools()
assert.equal(tools.find((t) => t.name === 'hello')?.annotations?.readOnlyHint, true)

Then run it in dev mode with oz0 dev: the dev node installs the dependencies and starts the server, and every save reinstalls it.

What a node does with it

When a node installs a plugin with runtime: node and a package.json:

  1. It installs the dependencies with its own Node.js and npm: npm ci --omit=dev --ignore-scripts from package-lock.json. New installs and updates require that lockfile; oz0 dev and lint only warn while you develop. A bundle installed before the lockfile rule can still use npm install without a lockfile (Lockfiles). Dev dependencies, TypeScript among them, are not installed, and no install script runs, so keep everything the server needs at run time in dependencies and do not rely on a postinstall.
  2. A tool command that starts with node runs the node's Node.js (nodejs.Version, 24.21.0 today) from the plugin's folder; any other command runs as it is.
  3. The tool gets the minimal environment every tool gets, plus its env: and declared secrets.

node_modules never travels in the bundle: plugin pack and the edge leave it out, and each node installs its own.

This applies to plugins with an oz0-plugin.yaml of their own. An imported Claude Code plugin or Agent Plugin with a package.json gets nothing installed: its admin accepted an inventory that lists no npm packages, so its node commands run from the node's PATH, as plugin lint warns.

Publish an npm package instead

A server you publish to npm, or one that exists already, needs no code in the plugin at all: package: npm:@acme/[email protected]. See Existing MCP servers.

Copyright © 2026