# How to choose between calling an SDK directly and using MCP

> Wire three API operations into one agent as in-process typed tools and as MCP tools, then measure startup, tokens per request, and what a rename breaks under each.

Source: https://voxgig.com/howto/direct-sdk-calls-versus-mcp-in-an-agent

- Audience: agent-builder
- Level: intermediate
- Languages: typescript, go
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Call the SDK directly when one agent you compile owns the integration. The tools then cost nothing to start, carry only the operations the task needs, and a renamed field fails the type check. Go through an MCP server when several clients must share the same tools. Then filter the server's tool list on the client, because every listed definition travels in every request.

---
## You will need

Node 22 or later, Go 1.25 or later to build the catalog's generated server, and an API with the
three operations your task needs. Verified 2026-10-21 against Node 22.22.0, Go 1.25.0,
@modelcontextprotocol/server 2.3.1, @modelcontextprotocol/client 2.3.1, Zod 4.6.7, gpt-tokenizer
4.1.0, TypeScript 5.9.3, and the Open-Meteo `go-mcp` module v0.0.7. The API is a stand-in on
127.0.0.1 with nine operations and fixed data, and the model is a scripted policy, so no request
leaves the machine. The SDK, servers, and transports are real.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [In-process typed SDK tools](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview) | One agent, one codebase, and operations you choose by hand | A tool layer every other client has to repeat, and input schemas you write beside the SDK's own types | Several clients need the same tools, or the tools must outlive this agent |
| [Stdio MCP server built with the official MCP SDK](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) | Tools shared with editors and desktop clients on the same machine | A Node process to spawn per client, JSON text in place of typed values, and every listed tool in every request until the client filters | The agent is the only consumer, or it runs where it cannot spawn a process |
| [Streamable HTTP MCP server built with the official MCP SDK](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) | Tools shared across machines and teams from one deployment | A service to run, secure, and keep up, plus a network hop on every tool call | Everything runs in one process on one machine |
| [Voxgig SDK catalog Go MCP server](https://voxgig.com/voxgig-sdk) | A public API in the catalog, and you want its operations as tools without writing a server | A Go build, and a generated tool surface shaped by the catalog's entity model rather than by your task | The API is not in the catalog, or the task needs tools named for its operations |

Voxgig maintains the SDK catalog. It is one of four options here, not the recommendation.

The in-process layer is the cheapest to start and the hardest to share: it lives in one process,
and every other client rewrites it. An MCP server over the same SDK is shared by any client that
speaks the protocol. It pays in a process or a service to run, text in place of types, and a tool
list the client has to trim. The catalog's generated server saves writing one, and its tools are
the catalog's two generic operations rather than your three.

## Wire the three operations twice

The direct layer wraps three methods of the typed client as tools for the loop. Each tool trims
the record to what the model needs, and the field names come from the client's types.

```ts title="direct.ts"
    {
      name: 'getCharger',
      description: 'Fetch one charger by id.',
      inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
      execute: async ({ id }) => {
        const c = await sdk.getCharger(id as string)
        // Trim to the fields the model needs. The names come from the client's types.
        return { id: c.id, site: c.site, kw: c.kw, status: c.status }
      },
    },
```

The MCP server calls the same client. Like a generated server, it registers every operation the
client has, nine here, with the
[`registerTool`](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) call of
`@modelcontextprotocol/server`, and returns each result as the JSON text the protocol carries.

```ts title="mcp-server.ts"
  server.registerTool('listChargers', { description: 'List chargers, optionally filtered by site.', inputSchema: z.object({ site: z.string().optional() }) },
    ({ site }) => guard(() => sdk.listChargers({ site }))())
  server.registerTool('getCharger', { description: 'Fetch one charger by id.', inputSchema: z.object({ id: z.string() }) },
    ({ id }) => guard(() => sdk.getCharger(id))())
```

`guard` turns the client's `ApiError` into a result with `isError: true`, the
[tools specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)'s
way for a tool to report a failure the model should read. The agent's side connects, lists the
tools, and keeps the ones the task names:

```ts title="mcp-client.ts"
  const chosen = allow ? tools.filter((t) => allow.includes(t.name)) : tools
```

Run the task through both routes, each against a fresh copy of the API. The loop is shared, and
a scripted policy stands in for the model.

```bash
node --experimental-strip-types demo.ts
```

```text output
in-process typed SDK
  tools the model sees: listChargers, getCharger, startSession
  call    listChargers({"site":"Cork"})
  result  [{"id":"chg_cork_01","site":"Cork","kw":50,"status":"available"},{"id":"chg_cork_02","site":"Cork","kw":22,"status":"offline"}]
  call    getCharger({"id":"chg_cork_01"})
  result  {"id":"chg_cork_01","site":"Cork","kw":50,"status":"available"}
  call    startSession({"chargerId":"chg_cork_01"})
  result  {"id":"ses_1001","chargerId":"chg_cork_01","status":"charging","startedAt":"2026-06-01T09:00:00Z"}
  final   Started session ses_1001 on chg_cork_01 at Cork (50 kW).
  requests the API saw: GET /chargers?site=Cork, GET /chargers/chg_cork_01, POST /sessions

stdio MCP server, three tools allowed
  tools the model sees: listChargers, getCharger, startSession
  call    listChargers({"site":"Cork"})
  result  [{"id":"chg_cork_01","site":"Cork","kw":50,"status":"available"},{"id":"chg_cork_02","site":"Cork","kw":22,"status":"offline"}]
  call    getCharger({"id":"chg_cork_01"})
  result  {"id":"chg_cork_01","site":"Cork","kw":50,"status":"available"}
  call    startSession({"chargerId":"chg_cork_01"})
  result  {"id":"ses_1001","chargerId":"chg_cork_01","status":"charging","startedAt":"2026-06-01T09:00:00Z"}
  final   Started session ses_1001 on chg_cork_01 at Cork (50 kW).
  requests the API saw: GET /chargers?site=Cork, GET /chargers/chg_cork_01, POST /sessions
```

The two transcripts match line for line, and so do the requests the API received. The routes
differ in what the model is not shown: the process boundary, the serialization, and the number of
definitions in each request.[^1]

## Build the generated server and measure

The catalog's server for the Open-Meteo API is a Go program on the
[official MCP Go SDK](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp), in the
`go-mcp` directory of [its repository](https://github.com/voxgig-sdk/open-meteo-sdk). The
sample's `go.mod` names it with a [`tool` directive](https://go.dev/ref/mod#go-mod-file-tool),
so one command builds it.

```bash
go build -o bin/open-meteo-mcp github.com/voxgig-sdk/open-meteo-sdk/go-mcp && ls bin
```

```text output
open-meteo-mcp
```

The measurement runs the same task through every route and times the step each needs before the
loop can start. Tokens are counted with the o200k encoding of
[gpt-tokenizer](https://github.com/niieani/gpt-tokenizer), a stand-in for the model's own. The
catalog server gets `OPEN_METEO_BASE` pointing at a second stand-in, so its one tool call stays
on the machine.

```bash
node --experimental-strip-types measure.ts
```

```text output
in-process typed SDK
  startup duration_ms: 0.3
  tools available: 3, sent to the model: 3
  tool definitions: 110 tokens per request
  task: 4 requests, 440 tokens of definitions, 105 tokens of results

stdio MCP server, every tool
  startup duration_ms: 208.8
  tools available: 9, sent to the model: 9
  tool definitions: 485 tokens per request
  task: 4 requests, 1940 tokens of definitions, 105 tokens of results

stdio MCP server, three tools allowed
  startup duration_ms: 209.1
  tools available: 9, sent to the model: 3
  tool definitions: 159 tokens per request
  task: 4 requests, 636 tokens of definitions, 105 tokens of results

Streamable HTTP MCP server, three tools allowed
  startup duration_ms: 71.5
  tools available: 9, sent to the model: 3
  tool definitions: 159 tokens per request
  task: 4 requests, 636 tokens of definitions, 105 tokens of results

catalog Go MCP server (Open-Meteo)
  startup duration_ms: 11.4
  listed by the server: open-meteo_list, open-meteo_load
  tool definitions: 231 tokens per request
  open-meteo_load asked the API for: GET /v1/forecast?latitude=51.9&longitude=-8.47
  result: { "current": { "temperature_2m": 12.4, "time": "2026-06-01T09:00" }, "latitude": 51.9, "longitude": -8.47 }
```

The token lines repeat on every request for the life of the agent, so read them first. The
three hand-written definitions cost 110 tokens. The same three through the server cost 159,
because the server stamps a `$schema` URL on every input schema it lists.[^2] Taking all nine
costs 485 per request, and over a four-request task that is 1,940 tokens of definitions against
105 tokens of results. The durations are this machine's: a few hundred milliseconds to spawn a
Node server, about ten for the Go binary, and a fraction of a millisecond in process. The HTTP
figure leaves out its server's own start.

The catalog server lists two tools, `open-meteo_list` and `open-meteo_load`, each taking an
`entity` name and a `query` map, and the load call reached the stand-in as `GET /v1/forecast`.
That is the catalog's shape for every API: generic operations over an entity model, not one tool
per endpoint.

## Rename a field and watch each route

The API renames `kw` to `powerKw`, and the MCP server is regenerated against the new client.
`#sdk` in `package.json` resolves to the regenerated client under the `v2`
[condition](https://nodejs.org/api/packages.html#subpath-imports) for both processes, while the
agent's own code still reads `kw`.

```bash
node --conditions=v2 --experimental-strip-types change.ts
```

```text output
getCharger returned: {"id":"chg_cork_01","site":"Cork","powerKw":50,"status":"available"}
requests: 4, failed results: 0
final answer: Started session ses_1001 on chg_cork_01 at Cork (undefined kW).
```

The run completed in four requests without a failed result. The answer carries `undefined`
because the scripted policy reads `kw` by name from text that, over MCP, no compiler checks. A
model would read `powerKw` in the same text, so the failure lands in code that reads a result by
field name. The direct layer is compiled against the client, and `tsc` with the same condition
finds the read before anything runs:

```bash
npx tsc -p tsconfig.v2.json
```

```text output
direct.ts(22,48): error TS2339: Property 'kw' does not exist on type 'Charger'.
```

`tsconfig.v2.json` sets
[`customConditions`](https://www.typescriptlang.org/tsconfig/#customConditions) to `v2`, so the
compiler resolves `#sdk` the way Node does. Node's
[type stripping](https://nodejs.org/api/typescript.html) runs the same file unchecked, so the
check belongs in the build, not the run.

## Check it worked

Six of nine tests, with the [Node test runner](https://nodejs.org/api/test.html), pin what the
three runs showed. The three routes make identical API calls and give the same answer, and nine
definitions cost more than twice what three cost. A failed call reaches the loop as a failed
result under both routes, the rename passes through MCP unnoticed, `tsc` rejects the direct
layer, and the catalog server answers a load. The other three check that the client refuses an id
of `..`, the HTTP server a foreign `Origin` or `Host`, and that a server dying before it listens
fails the wiring.

```bash
node --experimental-strip-types --test wiring.test.ts
```

```text output
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6062.496114
```

The last test skips itself when `bin/open-meteo-mcp` is missing.

## When it goes wrong

Answers and screens built from tool results show `undefined` or a blank after a deployment. A
server you do not own was regenerated, and code in the agent reads a field name from before the
change. Pin the server version you tested against, and validate each tool result against your own
schema before anything reads it; the type check covers the direct layer only.

The client lists no tools from the stdio server, or waits forever. Something other than a message
was written to `stdout`, usually a `console.log` left in for debugging. The
[stdio binding](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio)
makes `stdout` the wire, so log to `stderr`.

`go build` prints `go: downloading go1.25.0` before it compiles anything. The catalog module's
`go.mod` declares `go 1.25.0`, and a Go 1.24 installation fetches that toolchain under the
default [toolchain rules](https://go.dev/doc/toolchain) rather than refusing.[^3] Install Go 1.25
where the build runs, or let the download happen once.

## When not to do this

Do not put an MCP server between one agent and one API when you compile both. The server costs a
process or a service, turns typed values into text, and hands the model a tool list it did not
ask for. It buys a sharing you do not need yet; add the server when a second client appears.

Do not take every tool a server lists. The second step shows nine definitions costing more than
four times the task's results on every request. The fix is one filter on the client, in code
where the list can be reviewed.

Do not point the agent at the catalog's Go MCP server expecting tools named for your operations.
It exposes two generic tools, list and load, over the catalog's entity model: good for exploring
an API, wrong for a task that wants `startSession` by name. The catalog's SDKs are also
unofficial and regenerated on a schedule you do not set, so pin the version you tested against.

Do not read the startup figures as a verdict. They vary by machine, and a running server costs
nothing to start. The token figures repeat on every request, which is why they decide more.

## Related how-tos

- [Choose between stdio and Streamable HTTP for an MCP server](/howto/choose-an-mcp-transport)

- [Measure the token cost of an agent task against your API](/howto/measure-token-cost-per-agent-task)

- [Wire an API client into the OpenAI Agents SDK](/howto/wire-an-api-client-into-openai-agents-sdk)

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

- [Judge whether an unofficial SDK is safe to depend on](/howto/judge-an-unofficial-sdk-before-adoption)

- [Build a tool-calling loop with the Claude Agent SDK](/howto/tool-calling-loop-with-the-claude-agent-sdk)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Go 1.25.0, @modelcontextprotocol/server 2.3.1,
@modelcontextprotocol/client 2.3.1, Zod 4.6.7, gpt-tokenizer 4.1.0, TypeScript 5.9.3, and
github.com/voxgig-sdk/open-meteo-sdk/go-mcp v0.0.7. Every output block is what the command
preceding it printed. The API and the Open-Meteo endpoint are stand-ins on 127.0.0.1 and the
model is a scripted policy, so the transcripts reproduce exactly and the durations do not. Token
counts use the o200k encoding, not any model's own tokenizer.

[^1]: The Claude Agent SDK draws the same line inside one process. Its
[TypeScript reference](https://code.claude.com/docs/en/agent-sdk/typescript) defines custom tools
with `tool()` and groups them with `createSdkMcpServer()`, an MCP server instance that runs in
the caller's process, passed to the agent under `mcpServers` with `type: "sdk"`. The in-process
option there is an MCP server with the transport removed, so in that SDK the choice reduces to a
process boundary.

[^2]: The stamp is `"$schema": "https://json-schema.org/draft/2020-12/schema"`, 49 tokens under
o200k across the three tools, sixteen or seventeen apiece by what precedes it. JSON Schema's
[core specification](https://json-schema.org/draft/2020-12/json-schema-core#section-8.1.1) wants
the keyword in a document's root schema object and permits it in the root of an embedded schema
resource. A tool's input schema is a root to the server that emits it and an embedded object to
the request that carries it, and a tokenizer counts it either way.

[^3]: The [toolchain rules](https://go.dev/doc/toolchain) say that a `go` line newer than the
installed toolchain, in the main module or a dependency, makes the `go` command download that
toolchain and run it. The catalog's `go-mcp` module
declares `go 1.25.0`, so `go get` from a 1.24 module rewrote the sample's own `go` line to
`1.25.0` and then fetched go1.25.0 before building a 15 MB binary.