Most guidance on this choice hands you a feature table comparing setup, execution model, latency, and portability, as though Skills and MCP tools were competing technologies racing on the same track. A more useful frame sets them along two axes, auditability and flexibility, where auditability is how well you can inspect and predict what the agent will actually do, and flexibility is how much open-ended range it has to interpret a situation and act. Nearly everything that lands in the feature table, from how a capability scales as you add more to how it fails and how far you can trust its output unwatched, follows from where it sits between those two poles.
MCP tools sit toward the auditable end, because a tool exposes fixed input and output schemas that leave the agent only one real decision: which tool to call and when; over a deterministic call you can trace and reproduce. Skills sit toward the flexible end, where a Skill injects natural-language instructions the agent reads at runtime and decides for itself which guidance applies, when, and how to carry it out. That interpretive room is the whole point, since it absorbs the kind of judgment a fixed schema cannot express, and it is also the whole cost, since interpreted instructions can be misread and two runs over the same input can diverge.
Neither end is better in the abstract, since an MCP tool buys auditability at the expense of range while a Skill buys range at the expense of auditability, so the choice comes down to how much interpretation the task genuinely needs and how much unwatched trust you can extend to the result. Rather than argue that abstractly, this article builds the same capability twice, once as an MCP tool and once as a Skill, and reads the difference off what each one actually produced against the same repository.

An MCP server exposes its capabilities through three primitives, and standing a first one up in Node.js takes a few dozen lines. Skills are simpler still: a folder with a SKILL.md whose frontmatter tells the agent when the instructions inside it apply. What that shared literacy leaves open is not how either surface works but which one to reach for, and the answer shifted under everyone’s feet in July 2026.
A Skill reaches the agent through progressive disclosure. Only its name and description load at startup, so dozens can sit installed without crowding the context window, and the body of the SKILL.md enters context only once a request matches that description. A Skill can therefore carry effectively unbounded guidance while costing almost nothing idle, on the condition that the agent recognizes when to trigger it.
MCP’s 2026-07-28 specification is what moved the tradeoff. The earlier protocol opened every connection with an initialize handshake and pinned the client to one server instance through an Mcp-Session-Id header carried on every later request, state that had to survive across calls. The revision retires both, so each request now describes itself and lands on any instance behind an ordinary load balancer. That removed overhead is precisely the weight that once counted against MCP and made a Skill feel like the lighter thing to reach for.
The capability is a changelog generator. Given two points in a repository’s history, it produces an account of what changed between them. The auditable half of that job is reading the commits, a deterministic operation with a fixed shape, and that half is what an MCP tool models cleanly.
The July 2026 SDK ships as split packages rather than the earlier monolith, so the server pulls its core from @modelcontextprotocol/server, its transport from @modelcontextprotocol/node, and an Express adapter from @modelcontextprotocol/express, all at 2.0.0, alongside zod for the schemas.
Reading git history is its own module, and a few of its decisions matter to how trustworthy the tool ends up being. Commit messages are unstructured text that can contain newlines, commas, and anything else a developer types, so splitting git log output on ordinary delimiters is fragile. Passing explicit control characters as field and record separators sidesteps that, since a unit separator between fields and a record separator between commits will not appear in a commit message by accident.
const FIELD = "\x1f";
const RECORD = "\x1e";
const FORMAT = ["%H", "%h", "%an", "%aI", "%s", "%b"].join(FIELD) + RECORD;
const REF_PATTERN = /^[A-Za-z0-9._\/^~-]+$/;
function assertRef(ref: string, label: string): void {
if (!REF_PATTERN.test(ref)) {
throw new Error(`Invalid ${label} ref: ${JSON.stringify(ref)}`);
}
}
The assertRef guard is the second decision. Both refs flow into a git invocation, and while the call uses execFile rather than a shell and so is already free of shell interpretation, rejecting anything that is not a plausible ref token keeps a stray argument from ever being read as a flag. With those two guards in place, the read itself is a single git log over the from..to range, parsed back into typed records.
export async function getCommits(opts: GetCommitsOptions): Promise<CommitRecord[]> {
const { repoPath, from, to } = opts;
const maxCount = opts.maxCount ?? 500;
assertRef(from, "from");
assertRef(to, "to");
const range = `${from}..${to}`;
const args = [
"-C", repoPath, "log", range,
`--max-count=${maxCount}`,
`--pretty=format:${FORMAT}`,
];
const { stdout } = await run("git", args, { maxBuffer: 32 * 1024 * 1024 });
return stdout
.split(RECORD)
.map((chunk) => chunk.replace(/^\n/, ""))
.filter((chunk) => chunk.trim().length > 0)
.map((chunk) => {
const [hash, shortHash, author, date, subject, body] = chunk.split(FIELD);
return { hash, shortHash, author, date, subject, body: (body ?? "").trim() };
});
}
The range convention follows git’s own. The from ref is exclusive, and the to ref is inclusive, so v2.0.1..v2.0.2 means every commit reachable from the newer tag but not the older one. That range argument is doing more than parameterizing a query. Under the stateless protocol, the caller has to name what it wants on every request rather than leaning on a session that remembers a position, and an explicit range is exactly that kind of self-contained handle.
The tool itself wraps that function behind two schemas, and those schemas are the entire contract between the agent and the capability.
const commitSchema = z.object({
hash: z.string(),
shortHash: z.string(),
author: z.string(),
date: z.string(),
subject: z.string(),
body: z.string(),
});
server.registerTool(
"get_commits",
{
title: "Get Commits",
description:
"Return the commits in a repository between two git refs as structured data.",
inputSchema: z.object({
from: z.string().describe("The starting git ref, exclusive"),
to: z.string().describe("The ending git ref, inclusive"),
maxCount: z.number().int().positive().optional(),
}),
outputSchema: z.object({
range: z.string(),
count: z.number(),
commits: z.array(commitSchema),
}),
},
async ({ from, to, maxCount }) => {
const commits = await getCommits({ repoPath: REPO_PATH, from, to, maxCount });
const output = { range: `${from}..${to}`, count: commits.length, commits };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
},
);
The agent supplies a range and receives structured commits, and the space of what it can do with the tool ends there. It returns both a text rendering and a structuredContent object, so a caller can consume the typed data directly instead of parsing it back out of a string.
What makes the server stateless is the transport. There is no session to establish and none to preserve, so the transport is built fresh on each request with its session generator switched off, and a single Express route hands each incoming call straight to it.
const server = new McpServer({ name: "changelog-mcp", version: "1.0.0" });
// ... registerTool as above ...
const app = createMcpExpressApp();
app.post("/mcp", async (req, res) => {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(PORT, () => {
console.log(`changelog-mcp listening on http://127.0.0.1:${PORT}/mcp`);
});
Pointed at a local clone of the ky HTTP client and asked for the commits between its v2.0.1 and v2.0.2 tags, the server answers a single POST with no prior handshake and no session to locate, returning the four commits in the range.
{
"range": "v2.0.1..v2.0.2",
"count": 4,
"commits": [
{ "shortHash": "0a24c44", "author": "Sindre Sorhus", "subject": "2.0.2", ... },
{ "shortHash": "add0703", "author": "Sindre Sorhus", "subject": "Fix init hook URLSearchParams deletions", ... },
{ "shortHash": "8f28eac", "author": "Sindre Sorhus", "subject": "Tweaks", ... },
{ "shortHash": "346f898", "author": "Atharva Singh", "subject": "Fix tuple `searchParams` mutations leaking across init-hook requests (#861)", ... }
]
}
Four commits, each rendered exactly as git recorded it. The release-tag commit titled 2.0.2 is there, the terse Tweaks is there, and every subject line is reproduced verbatim without any judgment about whether a reader would care. That fidelity is the tool’s defining trait. It reports the range faithfully and predictably, and it decides nothing about what the range means.
The MCP tool stops at reading the commits. Turning them into a changelog is the half that resists a fixed schema, because deciding what belongs is judgment rather than transformation.
The Skill is a folder under .claude/skills/ holding a single SKILL.md, whose frontmatter is all the agent sees until the Skill is needed, so its description states both what the Skill does and when it applies.
--- name: changelog description: Write a user-facing changelog or release notes from a repository's git history between two refs. Use when the user asks to generate a changelog, release notes, or a summary of what changed between two versions, tags, or commits. ---
See the full file content on GitHub Gist. It tells the agent to read the range, keep only what a user would notice, so features, changed defaults, and real bug fixes stay while refactors, test-only changes, tooling, and the release commit itself drop, and to read the commit body where a subject line is vague. None of it executes. It is interpreted against whatever commits the agent actually finds, which is the source of both the Skill’s range and its unpredictability.
Run in Claude Code against the same ky clone for a changelog from v2.0.1 to v2.0.2, the agent read the range and got the same four commits. Then it did what the tool cannot: opening each commit to decide whether it earned a place.
git -C ../ky show 8f28eac --stat
The commit titled Tweaks touched only the README and a few comments, warnings about credential forwarding on cross-origin retries with no behavior change, so the agent moved it out of fixes into a documentation note. The 2.0.2 release commit it dropped. The two remaining commits it recognized as one story about searchParams mutations leaking across init-hook requests, and phrased both as observable behavior.
# v2.0.2
## Fixes
- Init hooks that mutate `searchParams` given as an array of tuples no longer leak
those mutations into other requests. Previously, only a shallow clone was made, so
changes inside an init hook could bleed across unrelated requests.
- Deleting a search param inside an init hook now works reliably, and deletion
markers survive option cloning instead of being dropped.
## Documentation
- Clarified that Request/Response objects returned from beforeRetry, and custom
requests passed to ky.retry({request}), are used as-is and not sanitized.
The tool returned four commits including a release tag and a one-word subject, identically every run. The Skill returned two fixes and a documentation note, phrased for someone deciding whether to upgrade, after reading four diffs the tool never opened. That range is its case, and its price is in the same output. Folding Tweaks into documentation and dropping the release commit came from judgment, no rule exposes ahead of time, and on a messier range that same discretion is where a changelog could quietly omit something and never signal that it had.

The choice comes down to one question the two axes have been circling. How much does the task need interpretation, and how much of the result can you trust without watching it?
| Reach for | When | Why | In the build |
|---|---|---|---|
| An MCP tool | The operation has a stable shape and the output must be trusted unwatched, so a read from a system of record, a consequential write, or a result feeding another automated step | The schema is the guarantee, and a deterministic call is one you can trace when it fails | get_commits. Reading a range is the same operation every time, and a downstream step needs it structured, not interpreted |
| A Skill | The task is judgment the agent should exercise fresh each time, and a person reads the result before it matters, so matching house tone or weighing messy input against soft criteria | Forcing judgment into a schema only relocates it into brittle code you now maintain | The changelog write. What counts as user-facing is a call best made against the actual commits, and a person reviews it before it ships |
| Both | The task splits along that seam, which does more than the debate suggests | Each part lands where it fails best: the tool supplying data you can trust and the Skill supplying judgment a person will check | One capability, two ends: a deterministic read that wants a tool, an interpretive write that wants a Skill |
The rule underneath all three is to put each capability where its failure is survivable. A tool fails where you can see it, so trust it unattended, while a Skill fails where you cannot, so keep a reader between it and anything that matters. Stateless MCP narrowed the setup gap enough that this reasoning, and not deployment overhead, is what should decide the question now.
Feature tables between MCP and Skills were never wrong about the details. It just measured the wrong thing, ranking properties as if they were independent when nearly all of them fall out of a single question about how much interpretation a task needs and how much of the result you can trust unwatched. Building the changelog both ways puts that question in plain view. The tool read a commit range the same way every time and decided nothing, while the Skill read four diffs, dropped a release commit, reclassified a third, and produced something closer to publishable, at the cost of judgment no rule exposes in advance.
Stateless MCP is what makes this the moment to reframe the choice, having removed most of the setup weight that used to settle it by default, though not as completely as the announcements imply. What remains is the honest version of the decision. Put deterministic work where its failures are visible, put interpretive work where a person will catch what it gets wrong, and reach for both when the task divides along that line, which it does more often than either camp tends to admit.

Learn how to replace React state, Context, and event handlers with native HTML and CSS features for dark mode, modals, accordions, carousels, and more.

A real-app benchmark of cnfast’s drop-in cn() replacement: isolated speed tests look great, but does any of it survive contact with an actual React render?

Compare the top AI development tools and models of August 2026. View updated rankings, feature breakdowns, and find the best fit for you.

Learn to build multi-turn AI agents with Genkit’s Agents API — with persistent Firestore sessions, detached long-running turns, and human approval gates.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now