# SDK vs CLI vs MCP, a dev story

My team builds internal software for ourselves. Not products. The unglamorous stuff that keeps a developer relations org running: a release-readiness checker, a script that reconciles our event calendar, a dashboard for pull requests that have gone quiet. Most of it gets written by [Claude Code](https://docs.claude.com/en/docs/claude-code/overview), and lately a fair amount of it gets run by Claude Code too. Last month I hit a decision I'd been putting off. The release-readiness tool needs data out of the GitHub API. I know how to call that API. What I didn't know was how to hand that ability to an agent, and there were three doors in front of me, all opening onto the same endpoints. Door one: install the [Octokit SDK](https://github.com/octokit/octokit.js) and have the agent write TypeScript against it. Door two: install [`gh`](https://cli.github.com/) and let it run shell commands. Door three: connect the [GitHub MCP server](https://github.com/github/github-mcp-server) and let it call tools. I picked door one, then door three, then changed my mind again. What finally settled it wasn't a benchmark. It was noticing I'd been asking the wrong question.

## What I'm actually building

 Every morning the tool answers one question: is this repository safe to cut a release from? Underneath that it's four API calls. Open pull requests, filtered down to the ones that haven't moved in a week and aren't drafts. Check runs on the latest commit on main. Any draft release that already exists. Then a short summary written to a file I read over coffee. About 200 lines. The code isn't interesting. What turned out to matter is who runs it, and that varies more than I expected. Some mornings I run it by hand. Most mornings a cron job does. And every couple of weeks I'm sitting in a Claude Code session asking something fuzzier, like why last Thursday's release got held up, where the agent needs to poke around GitHub in ways I never anticipated when I wrote the script. Those three callers want different things, which is the part I missed. ## Door one: the SDK

 An *SDK* is a typed client library for one language. Install it, get autocomplete, and the compiler catches your typos. ```
import { Octokit } from "@octokit/rest";

const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

const { data: pulls } = await octokit.rest.pulls.list({
  owner: "postman-devrel",
  repo: "release-tools",
  state: "open",
  per_page: 100,
});

const stale = pulls.filter((pr) => {
  const daysSinceUpdate = (Date.now() - Date.parse(pr.updated_at)) / 86_400_000;
  return daysSinceUpdate > 7 && !pr.draft;
});

console.log(`${stale.length} stale pull requests`);

```

 This is what I wrote first and it's still what the cron job runs. The types earn their keep. GitHub renamed a field in a response payload last spring and TypeScript caught it at build time rather than at 6 AM in a log nobody was reading. Where it falls down is improvisation. An SDK is a build-time contract, so for the agent to use it, somebody has to have written the calling code already. Sitting in a session asking about Thursday's release, the agent can't reach for `octokit.rest.checks.listForRef` unless I exposed it. It can write fresh code, and it will happily do that, but then I'm compiling throwaway TypeScript to answer a question I'll only ask once. I did that twice before admitting it wasn't working. Credentials are the other thing. The SDK reads a token from the process environment, so the secret lives wherever the process lives. Fine for [GitHub Actions with a scoped token](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication). Less fine when the process is an agent session on my laptop. ## Door two: the command-line interface

 A *command-line interface* (CLI) wraps the same API in commands you run from a terminal. GitHub's is `gh`, and it does one thing the SDK can't: it works before anyone writes integration code. ```
gh pr list \
  --repo postman-devrel/release-tools \
  --state open \
  --json number,title,updatedAt,isDraft \
  --jq '[.[] | select(.isDraft == false)] | length'

```

 ```
6

```

 Claude Code was already fluent in it, which surprised me more than it probably should have. Nobody had to teach it `gh`. When it wasn't sure about a flag it ran `gh pr list --help` and read the output the way a person would. The terminal is the interface agents already have, so there's no adapter to write and nothing to configure. It's also close to free. A CLI costs nothing in your context window until the moment the agent runs it, and the response is whatever you asked for. That `--jq` filter means one integer comes back instead of six pull request objects. Try getting a tool that owns its own response shape to do that. The costs are real too. Output is text, so something has to parse it. Errors show up as exit codes and stderr instead of typed exceptions. And `gh` authenticates as exactly one identity, which is what you want on your own machine and the wrong answer for anything twelve people share. ## Door three: the MCP server

 The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is a standard for letting a model discover and call tools. A server advertises what it offers, and the client loads those definitions into the model's context so it knows they exist. ```
{
  "mcpServers": {
    "github": {
      "type": "http",
      "url": "https://api.githubcopilot.com/mcp/"
    }
  }
}

```

 Two lines of configuration and the agent could read pull requests, check runs, releases, issues, and plenty besides. For the improvisational case it was the best experience by a distance. Nothing to shell out to, nothing to parse, structured arguments in and structured results back. Then I looked at the bill before I'd typed a word. The client loads every tool definition up front. Somebody [measured the GitHub MCP server at roughly 42,000 tokens](https://getunblocked.com/blog/mcp-token-budget-autopsy/) of schemas, argument types, and example payloads. A survey of more than 3,000 servers put the median nearer 1,900, but medians aren't what you install. The servers people actually reach for are the fat ones, and three of them together can eat over 10% of a 200,000 token window before the conversation starts. That's context I'd rather spend on the repository. So stop loading everything: ```
{
  "mcpServers": {
    "github": {
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
        "ghcr.io/github/github-mcp-server",
        "--toolsets", "pull_requests,repos",
        "--read-only"
      ],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
      }
    }
  }
}

```

 Scoping to two toolsets and adding `--read-only` cut it down a lot. That flag skips every write tool, which for a morning report that mutates nothing is cheaper and safer than any instruction I could put in a prompt. Relief is coming at the protocol level as well, since clients have started deferring tool definitions and letting the model search for them on demand once they cross some share of the window. What kept MCP in the mix wasn't the ergonomics, though. It's the only one of the three with an authorization model that survives more than one user. The protocol carries per-user auth, so the server acts as whoever is asking and can log it. I can't rebuild that with a shell command, and the day our tooling outgrows my laptop is the day I need it. ## How I actually decided

 I stopped asking which interface is best. The better question is who calls it, and that produced a table I now keep in the repository's README: | Question | SDK | CLI | MCP server |
|---|---|---|---|
| Who calls it | Your application code | An operator or an agent in a shell | An agent through a client |
| Known at build time | Yes | No | No |
| Context cost when idle | None | None | Tool definitions, always loaded |
| Response shape | Typed objects | Text you filter | Structured results |
| Credentials | Process environment | Local machine auth | Per-user, with audit logging |
| Best fit | Shipped integrations | Unattended agent work | Interactive and multi-user work |

 For the release checker the answer stopped being one door. The cron job runs SDK code, because I want a compiler and I want it to fail loudly at build time. Interactive sessions use `gh`, because it costs nothing until it's used and Claude Code needed no help from me. The MCP server goes on for exploratory work across repositories where I can't predict the calls. That felt like a cop-out when I first wrote it down. It isn't. Three consumers, three surfaces. ## Gotchas I've hit

### Count your tool definitions before you blame the model

 I spent an afternoon convinced Claude Code had gotten worse at a task. It hadn't. I'd added two MCP servers that week, and between them they'd quietly displaced the part of the context that used to hold the repository's file tree. Check what your servers cost before you start rewriting prompts. The [MCP specification's tools section](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) is worth an hour of your time so you know what's being sent on your behalf. ### A CLI that prints JSON is a different tool from one that prints tables

 `gh pr list` renders a table for humans. `gh pr list --json` returns data. Agents cope with the first and are reliable with the second. If you generate a CLI for agents to drive, make structured output a first-class flag and document it in `--help`, because `--help` is where the agent will look. ### Read-only mode is the cheapest safety win available

 Almost everything my tooling does is read. Turning writes off removed a whole category of mistake I'd otherwise be prompting my way around, and it shrank the tool definitions at the same time. ### Don't hand-maintain three surfaces

 This is the one that changed my mind about the problem. I nearly wrote a small wrapper CLI around our internal API so agents could drive it. Then I thought about keeping that wrapper in sync with the SDK, and both of them in sync with the API, by hand, indefinitely, and I closed the editor. ## One specification, three surfaces

 So the three aren't rivals. They're projections of the same API aimed at different consumers, and the only sane way to have all of them is to generate them from one specification instead of writing and maintaining each by hand. That's the part I'd been dreading, and it turns out to be mostly solved. Not by one tool, though, which took me a while to accept. ### Fern for the SDK and the CLI

 [Fern](https://buildwithfern.com/) reads an [OpenAPI](https://www.openapis.org/) file and produces both surfaces a human touches. Type-safe SDKs across nine languages, and a CLI published to npm with progressive disclosure and automatic versioning. ```
npm install -g fern-api
fern init --openapi ./openapi.yml
fern generate

```

 Both targets sit in the same `generators.yml` group, so one `fern generate` rebuilds them together and they can't drift apart. That was the exact objection that nearly had me hand-writing a wrapper, and it goes away. What Fern won't do is generate an MCP server for your API. It hosts a documentation MCP server, which sounds like the same thing and isn't. That one answers questions about your API. It doesn't call it. Genuinely useful when an agent is trying to work out how your endpoints fit together, but it isn't the third surface. ### The Postman MCP Generator for the MCP server

 For that, [Postman's MCP Generator](https://learning.postman.com/docs/postman-ai/mcp-servers/generate) does the work. You pick the requests you want exposed, Postman turns each one into a tool, and you download a server that works with MCP-compatible agents including Claude Code, Cursor, and VS Code Copilot. One constraint to know before you plan around it: the generator is documented against requests from the [Postman API Network](https://www.postman.com/explore/mcp-generator), so the smooth path today is for APIs already published there. For something purely internal you're importing the specification into a workspace first, and I'd check that route works for you before building a pipeline on it. Worth knowing that Postman also runs an [MCP server for its own platform](https://blog.postman.com/postman-launches-full-support-for-model-context-protocol-mcp-build-better-ai-agents-faster/) and ships a [Claude Code plugin](https://blog.postman.com/announcing-the-postman-plugin-for-claude-code/), so the agent side of this is well travelled. ### Wiring the two together

 The join between them isn't an export from one tool into the other. Both read the same OpenAPI file: Fern generates from it, Postman imports it. That leaves exactly one artifact to keep honest, which is the whole point: ```
openapi.yml
   |
   |-- fern generate ........ SDKs (nine languages) + CLI on npm
   |
   +-- import to Postman .... MCP Generator ..... MCP server

```

 Put the specification under version control, run `fern generate` in CI whenever it changes, and re-import to Postman on the same trigger. One commit to `openapi.yml`, three surfaces rebuilt, nothing kept in sync by hand. That's what dissolved the choice I spent a month on. You stop picking a door and start shipping all of them, and each consumer takes the one that suits it. ## Try this on your own API

 Take whichever API your team calls most and run the comparison yourself. An afternoon is enough: 1. Write the smallest useful script against the official SDK. Note whether the types caught anything.
2. Do the same work through the vendor's CLI with structured output flags turned on, and compare response sizes.
3. Connect the vendor's MCP server. Before you send a message, check what share of your context window the tool definitions took. Scope the toolsets and check again.
 
 Then ask who's calling this in six months. Your own application code means generate an SDK. An agent working unattended means generate a CLI. A team behind one shared integration means stand up an MCP server. For most APIs worth the trouble the honest answer is all three, at which point the question isn't which one you pick, it's whether you're generating them from the specification or maintaining them by hand. I'd like to know where this falls apart for other people. The token arithmetic in particular moves every few months, and the numbers I measured in July may not survive the year. ## Resources

- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/2026-07-28) and the [tools section](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)
- [GitHub MCP server on GitHub](https://github.com/github/github-mcp-server), including toolset and read-only flags
- [GitHub CLI manual](https://cli.github.com/manual/) for the `--json` and `--jq` output flags
- [Octokit for JavaScript](https://github.com/octokit/octokit.js)
- [Fern](https://buildwithfern.com/) for generating SDKs and a CLI from one specification, plus the [SDK quickstart](https://buildwithfern.com/learn/sdks/overview/quickstart)
- [Postman MCP Generator documentation](https://learning.postman.com/docs/postman-ai/mcp-servers/generate) and the [MCP Generator itself](https://www.postman.com/explore/mcp-generator)
- [Postman MCP server documentation](https://learning.postman.com/docs/postman-ai-agent-builder/mcp-requests/overview/)