ProtoboxProtobox
Dev Notes

How to register MCP tools without running a server

Hand-rolling an MCP server means owning transport, auth, hosting, and schema drift. Registering the same tools against a hosted MCP endpoint skips all four.

DGDean GroverFounder, ProtoboxFollow
August 21, 2026
10 min read
Warm watercolor illustration of a person connecting modular components and cables at a warm sunlit workbench

You have an internal orders API. It has been in production for three years, it has auth, it has docs, and now the team wants agents to call it. Claude should be able to look up an order. Cursor should be able to check a refund status while someone debugs a webhook.

The obvious move is "write an MCP server". And the first hour of that goes great, which is exactly the trap. This post walks the whole cost of the hand-rolled path with real code from the official TypeScript SDK, then shows the alternative: registering the same tools as data against a hosted MCP endpoint with @protoboxai/sdk, no server of your own in sight.

The server is the easy part

A working MCP server that wraps one internal endpoint is about 40 lines with the official SDK. That is not the expensive part, but you have to see it to see where the cost actually lands.

The official package is @modelcontextprotocol/sdk. The class is McpServer, the method is registerTool(), and the handler is a plain async function that calls your API:

server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
 
const server = new McpServer({ name: "orders", version: "1.0.0" });
 
server.registerTool(
  "get_order",
  {
    description: "Look up an order by ID, including status and line items",
    inputSchema: { orderId: z.string() },
  },
  async ({ orderId }) => {
    const res = await fetch(`https://orders.internal/v2/orders/${orderId}`, {
      headers: { Authorization: `Bearer ${process.env.ORDERS_TOKEN}` },
    });
    return { content: [{ type: "text", text: await res.text() }] };
  }
);

Wire that to a stdio transport and Claude Desktop can call it from your laptop today. We did exactly this build in Build your first MCP server in 10 minutes, and it's a genuinely good afternoon. The problem is that "on your laptop" is where the easy part ends.

What do you still own after npm install?

For a team-wide, remote MCP server, the handler code above is maybe a fifth of the work. The rest is transport, authorization, hosting, and the operational surface that comes with each. None of it is exotic, all of it is yours to run.

Here is the bill, itemized:

LayerWhat the spec asks forWhat that means in practice
TransportStreamable HTTP (introduced in the 2025-03-26 revision)HTTP server, session IDs, SSE streaming, resumability
AuthorizationOAuth 2.1 resource server per the current spec (2025-11-25)Token validation, scopes, metadata endpoints, key rotation
HostingA URL that is up when the agent callsDeploy pipeline, TLS, uptime, someone on call
RegistrationClients need the URLRemote servers go into Claude via Custom Connectors, not the desktop config file

Each row is a solved problem individually. Stacked, they are a platform project. Teams that speak HTTP and OAuth can absolutely build this, the same way teams that speak SQL can absolutely run their own Postgres. But is wrapping an orders API in a tool schema worth running a service for?

And there's one row missing from that table, because it deserves its own section.

The sync problem nobody budgets for

The most expensive part of a hand-rolled MCP server is not building it, it's keeping the tool definitions truthful as the API underneath them moves. The schema in your server code and the API it wraps are two sources of truth, and two sources of truth drift.

Play it forward six months. The orders team adds a currency field. Someone renames refund_status to refundState in v3. A new endpoint for partial refunds ships.

Every one of those changes now requires someone to remember the MCP server exists, edit the Zod schemas and descriptions, and redeploy it. In practice, nobody remembers. The MCP server becomes the repo with four commits, three of them from the week it was born.

Meanwhile agents keep calling tools whose descriptions describe last quarter's API. The failure mode is quiet: the agent passes a parameter that no longer exists, gets a 400, and either retries in a loop or confidently tells the user the order does not exist.

Tool definitions are metadata about an API. Encoding metadata in deployed server code gives it a release cycle it should not have.

Register tools as data instead

The other path is to stop writing a server and start registering definitions. A tool becomes a record: name, description, JSON Schema for inputs, and an HTTP configuration pointing at your internal endpoint. A hosted MCP runtime serves it over Streamable HTTP, fronts it with auth, and executes the HTTP call when an agent invokes it.

This is the same shape of trade as Postgres versus Neon: the protocol work is undifferentiated, so you buy it as a runtime and keep the part that is actually yours, the definitions. To be fair about the market: Speakeasy's Gram does a version of this, Cloudflare has managed MCP hosting, and self-hosting an open runtime on Fly or Vercel is a real option. What follows uses @protoboxai/sdk, because that is the one I can show honestly.

Client setup is one constructor:

register.ts
import { ProtoboxSDK } from "@protoboxai/sdk";
 
const sdk = new ProtoboxSDK({
  apiKey: process.env.PROTOBOX_API_KEY!,
  baseUrl: "https://platform.protobox.ai",
});

Now the same get_order tool from the hand-rolled server, as a registration instead of a deployment. The inputSchema is plain JSON Schema, and the url is a template: {{orderId}} gets substituted from the agent's arguments at call time.

register.ts
const { data: tool } = await sdk.tools.create({
  name: "get_order",
  description: "Look up an order by ID, including status and line items",
  type: "http",
  inputSchema: {
    type: "object",
    properties: {
      orderId: { type: "string", description: "Internal order ID" },
    },
    required: ["orderId"],
  },
  configuration: {
    http: {
      method: "GET",
      url: "https://orders.internal/v2/orders/{{orderId}}",
      headers: { Authorization: "Bearer {{ORDERS_TOKEN}}" },
      timeout: 10000,
    },
  },
  tags: ["orders"],
});

That's the entire server. No transport code, no process to deploy, no repo to forget about. The definition lives in the registry, and because this is a script rather than a click path, it can live in your codebase next to the API it describes and run idempotently from CI.

There's a catch worth naming, though it's narrower than it first looks. type: "http" covers tools that are HTTP calls, which is most internal-API tools. The registry accepts two other types as well: javascript, a sandboxed snippet for the cases where the agent's arguments need reshaping before the call, and code for a Python or JavaScript worker. So a bit of glue logic between agent and endpoint does not push you off this path. What does push you off is process-local state, or execution that policy says must run on your own machines. That's registerTool() territory, and that's fine.

Group tools into a toolset

An agent should connect to a scoped bundle, not to every tool the workspace has ever registered. Toolsets are that bundle: a named collection of tool IDs that becomes the MCP surface an agent actually sees.

Register a second tool for refunds the same way, then group both:

register.ts
const { data: toolset } = await sdk.toolsets.create({
  name: "order_support",
  description: "Read-only order lookups for support agents",
  tools: [tool.id, refundTool.id],
  enabled: true,
  tags: ["support"],
});
 
// Later, as the surface grows:
await sdk.toolsets.manageTools(toolset.id, {
  add: [shipmentTool.id],
});

The scoping matters more than it looks. Selection accuracy degrades once an agent faces a few dozen tools, which we dug into in the MCP monolith problem and managing 50 agent tools at scale. With toolsets, the registry can hold a hundred definitions while each agent connects to the eight it needs.

Toolsets also carry per-toolset overrides, and this is the feature that earns its keep once two different agents share a tool. The description an agent sees is the main thing steering whether it picks the tool and what it passes in, and the right description for a support agent is not the right description for a billing agent. setOverrides() changes the name or description one toolset presents, without touching the registry definition every other toolset reads:

register.ts
await sdk.toolsets.setOverrides(toolset.id, {
  [tool.id]: {
    description:
      "Look up an order for a support conversation. Read-only. Takes internal IDs like ord_8842, not customer-facing order numbers.",
  },
});

One definition, tuned prompts per audience. With a hand-rolled server you would be forking the tool or threading a flag through registerTool().

Connect an agent to the endpoint

Agents connect to a URL plus headers, and the SDK hands you both preassembled. sdk.mcp.getConfig() returns the server URL and ready-to-paste configs for Claude and Cursor, scoped to a toolset if you pass one:

connect.ts
const { data: cfg } = await sdk.mcp.getConfig({
  toolsetId: toolset.id,
});
 
console.log(cfg.serverUrl);
console.log(cfg.toolCount);
console.log(JSON.stringify(cfg.agentConfigs.claude, null, 2));

Read toolCount before you paste anything anywhere. It reflects the scoped surface, so if it says 0, you passed the wrong toolset ID or the toolset is disabled, and you get to find that out now instead of watching an agent report that it has no tools. The response also carries toolsetName, environment, and a status field for the same reason: the config call doubles as a preflight check.

agentConfigs has three shapes inside it. claude and cursor are complete mcpServers blocks, URL and auth headers included, in each client's expected structure. generic is the raw { url, headers } pair for everything else: Windsurf, a LangGraph agent, your own MCP client, anything that speaks the protocol but has its own config format.

For Claude, paste the URL into the Custom Connectors UI. Remote servers do not go in claude_desktop_config.json, that file is for local stdio servers only. People paste the remote URL in there anyway, and the failure mode is maddening: the file accepts it, nothing connects, and no error ever tells you why. Cursor takes its block from cfg.agentConfigs.cursor. The full connection walkthrough is in the docs.

One more preflight worth wiring in. sdk.mcp.getStatus() pings the endpoint itself and comes back with a health verdict and a response time:

connect.ts
const { data: status } = await sdk.mcp.getStatus();
// { healthy: true, message: "MCP server responding", responseTimeMs: 41 }

Run it before handing the URL to a teammate, or as the first step of the CI job we get to below. It answers "is this the config or the server" before that question gets asked in a Slack thread.

claude_desktop_config.json
Live
{
"mcpServers":
{
"orders":
{
"url": "https://orders.protobox.app/mcp",
"transport": "sse",
"apiKey": "pbk_live_...a4f2"
}
}
}
Tools
12 registered
Latency p99
38ms
Auth
Bearer + scopes

Test it before an agent does

Remember the quiet failure mode from the sync section, the agent looping on a 400? The counter is running the tool through its real execution path yourself, with arguments you choose, before any agent touches it. sdk.tools.test() does exactly that and returns what the agent would see:

verify.ts
const res = await sdk.tools.test(tool.id, { orderId: "ord_8842" });
 
console.log(res.data.success);    // true
console.log(res.data.latencyMs);  // 87
console.log(res.data.data);       // the orders API response
console.log(res.data.traceId);    // for the trace when it isn't true

The result also carries an executionId. Every test run lands in the same execution log as real agent calls, so a test you ran on Tuesday is still queryable on Friday when someone asks what changed.

Once agents are live, the registry keeps an execution log you can query instead of grepping server output. Workspace-wide, filterable, pre-joined with tool names:

verify.ts
// Every denied call in the last 24 hours
// (outcome takes "ok" | "denied" | "failed", one at a time)
const { data: log } = await sdk.tools.listExecutions({
  outcome: "denied",
  fromDate: new Date(Date.now() - 86_400_000).toISOString(),
});

With a hand-rolled server, both of these are features you would build: a test harness that exercises the real transport, and structured execution logging with retention. Here they come with the registration.

Close the sync loop from CI

Programmatic registration pays off most on day 180, not day 1, because sdk.tools.update() turns schema drift into a pipeline step. The definitions live in code next to the API, so the same release that changes the API can update the tools that describe it.

The shape is small enough to read whole:

sync-tools.ts
// Runs in CI on every orders-api release
const { data: existing } = await sdk.tools.list({ tag: "orders" });
 
for (const def of localDefinitions) {
  const current = existing.items.find((t) => t.name === def.name);
  if (!current) {
    await sdk.tools.create(def);
  } else {
    await sdk.tools.update(current.id, {
      description: def.description,
      inputSchema: def.inputSchema,
      configuration: def.configuration,
    });
  }
}

The other half of sync is retirement. When an endpoint goes away, the tool that wraps it should stop being offered, and the same loop handles it:

sync-tools.ts
// Disable anything registered under this tag that local defs no longer declare
for (const t of existing.items) {
  if (!localDefinitions.some((d) => d.name === t.name)) {
    await sdk.tools.disable(t.id);
  }
}

Disable rather than delete. A disabled tool drops out of every toolset's tools/list immediately, but its execution history stays queryable, which is exactly what you want when someone asks why an agent stopped doing something last sprint.

When the orders team renames refund_status, the definition change rides the same pull request, and every connected agent sees the corrected schema on the next tools/list. No second deploy, and no server for anyone to forget.

When is hand-rolling still the right call?

Write your own MCP server when the tools are not HTTP calls: they need local process state, they orchestrate multi-step logic that should not live behind a single endpoint, or policy demands the whole stack inside your perimeter. The official SDK is good, the reference servers are worth reading, and self-hosting is a legitimate end state, not a consolation prize. Protobox's own runtime is open source for exactly that reason; managed is the paid product, not the only option.

But when the job is "let agents call the API we already run", the server is ceremony. That orders API from the top of this post survived three years of production without an MCP server in front of it; what it was missing was a description agents can read, not another service. Register the definition, scope it with a toolset, hand the agent a URL, and put the sync step in CI. The part you keep writing is the part that was always yours: what the tool is called, what it takes, and what it hits.

Register your first tool this afternoon

The free tier covers 1 server and 1,000 requests a month, no credit card. If you are already shipping MCP, that is your internal API exposed before the next standup.

Start free

Related reading on the Chanl blog: Building MCP tools your CX agents actually use.

DG

Founder, Protobox

Building TBD bio at Protobox — tools, testing, and observability for customer experience.

Changelog MCP y notas para devs

Actualizaciones cortas y ocasionales sobre la spec MCP, nuevas funciones de Protobox y patrones que vemos en producción. Sin marketing innecesario.

Sé de los primeros

Frequently Asked Questions