---
title: "TypeScript tools"
description: "An MCP tool in TypeScript, on the official SDK, that every node runs on its own Node.js 24 without a build step."
canonical_url: "https://docs.oz0.ai/build/parts/typescript-tools"
---
# 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

```bash [Terminal]
oz0 plugin new acme-orders --template ts-tool --description "Looks up orders"
cd acme-orders
npm install
```

```text
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
```

```yaml [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](https://docs.oz0.ai/govern/tool-policy) reads:

```ts [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](https://docs.oz0.ai/build/parts/tools/definitions#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

```bash [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:

```ts [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](https://docs.oz0.ai/build/plugins/develop-and-test) 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](https://docs.oz0.ai/build/plugins/packages#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](https://docs.oz0.ai/build/plugins/claude-code-and-agent-plugins) 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/orders-mcp@1.4.0`. See [Existing MCP servers](https://docs.oz0.ai/build/plugins/packages#from-npm).


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
