Why Toolgrain keeps MCP tools separate from servers
DEV Community

Why Toolgrain keeps MCP tools separate from servers

I started Toolgrain while building Pushwing and experimenting with Elysia for an MCP integration. I wanted a TypeScript workflow where I could define a tool independently, get useful types from its input and output schemas, and reuse that definition across servers.

The approach I was using wasnโ€™t giving me that combination, and the documentation I found didnโ€™t give me a clear path to the structure I wanted. I thought a toolkit built around those needs could be useful.

Standalone tool definitions

The central design decision in Toolgrain is to make a tool a standalone definition. It bundles its description, Zod schemas, and handler. A server registers those definitions under the names exposed to MCP clients, and a transport handles how requests arrive.

Inventory example

Hereโ€™s a small inventory example using the published packages. It runs on Node.js 24.20.0 or later within 24.x, with TypeScript strict mode.

In a new directory:

npm init -y
npm install @toolgrain/mcp@0.1.2 @toolgrain/node@0.1.2 zod@^4.6.6
npm install --save-dev typescript @types/node@^24.10.0

Save this as server.mts:

import { createServer, tool } from "@toolgrain/mcp";
import { stdio } from "@toolgrain/node";
import { z } from "zod";

const inventory = { notebook: 12, pencil: 48 };

const getStock = tool({
  description: "Look up the stock available for a product",
  annotations: { readOnlyHint: true },
  input: z.object({ sku: z.enum(["notebook", "pencil"]) }),
  output: z.object({
    sku: z.string(),
    quantity: z.number().int().nonnegative(),
  }),
  execute: ({ sku }) => ({ sku, quantity: inventory[sku] }),
});

const server = createServer({
  name: "inventory-demo",
  version: "1.0.0",
  tools: { get_stock: getStock },
});

await server.listen(stdio());

Compile it:

npx tsc server.mts --strict --module NodeNext --target ES2022 --types node

Then call the tool with MCP Inspectorโ€™s CLI:

npx --yes @modelcontextprotocol/inspector@2.11.0 --cli \
  --protocol-era modern --format json \
  --method tools/call --tool-name get_stock \
  --tool-args-json '{"sku":"pencil"}' \
  node server.mjs

Inspector starts the server, calls the tool, and exits. The responseโ€™s result.structuredContent contains:

{ "sku": "pencil", "quantity": 48 }

Why the separation matters

A few details explain the separation:

  • getStock is the TypeScript value; get_stock is the registered name that MCP clients discover and call.
  • The input schema gives the handler its inferred sku type. Server execution validates both input and the declared output.
  • The same frozen tool definition can be registered in another server, including one that uses HTTP.
  • Direct definition execute() calls are raw handler calls. They bypass server validation.

In an application, the handler can call an existing service instead of reading the in-memory inventory. Toolgrain also has context providers for application dependencies.

Scope and background

The current implementation focuses on tool discovery and execution over stdio and independent HTTP POST requests with JSON responses. Sessions, SSE, resources, and prompts are outside its current scope.

The separation between tool definitions, server execution, and transports came directly from the workflow I wanted while building Pushwing. The Toolgrain source is MIT-licensed. Its tool authoring guide documents the validation and execution behavior in more detail.

How do you organize tool definitions when the same application operation needs to be exposed through more than one MCP server?

This article was drafted with AI assistance from my description of the project. The example was verified against Toolgrain 0.1.2.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.