I have two ways to generate a commit message in this repo. One is git_commit.py
, a 20-line script I run by hand: python git_commit.py
, it grabs the staged diff, shells out to claude -p
, prints a Conventional Commit message. The other is generate_commit_message
, a tool exposed through an MCP server, callable by any agent that has that server wired in.
Strip both down and they call the identical prompt against the identical model through the identical subprocess. For a while I described the second one as "the agentic version" without being able to say what made it agentic, other than vibes and the word MCP being involved. That bothered me enough to actually go look at the diff between them, and the answer turned out to be smaller and more specific than I expected — and it's not the part people usually point to.
Here's the script:
diff = subprocess.check_output(["git", "diff", "--staged"], text=True)
if not diff.strip():
print("Nothing staged. Run `git add` first.")
raise SystemExit(1)
raw = subprocess.check_output(
["claude", "-p", SYSTEM + "\n\n" + diff],
text=True,
).strip()
And here's the tool:
@mcp.tool()
def generate_commit_message(diff: str) -> str:
"""Generate a Conventional Commits message from a git diff string."""
return _claude(diff, system="...")
The AI call is the same call. _claude()
inside server.py
is just subprocess.check_output(["claude", "-p", ...])
wrapped in a helper — same OAuth session, same model, no API key involved either way (this repo doesn't have ANTHROPIC_API_KEY
set; both paths ride the existing claude
CLI login). If "agentic" meant "an LLM is involved," these would be identical, and the whole distinction would be marketing.
What's different is one line: @mcp.tool()
. That decorator is doing something the script never does — it's turning a Python function's signature into a schema an agent can read without executing the function first. generate_commit_message(diff: str) -> str
becomes a JSON Schema object with a name, a description pulled from the docstring, and a typed parameter list, published over the MCP protocol before any tool ever gets called. An agent deciding what to do next reads that schema, not the function body. It doesn't know or care that _claude()
shells out to a CLI — it knows the tool takes a string called diff
and returns a string, and that's the entire interface contract it has to reason about.
The script has none of that. git_commit.py
is a black box to anything except a human who already knows to run python git_commit.py
. There's no schema, no discoverability, no way for something else to find out it exists short of reading the source or being told about it in a prompt. It's not less capable code — it's the exact same capability — but it's only reachable by a human who already has the instructions memorized.
This mattered in a very unglamorous way the first time I tried to let an agent use both interchangeably. I'd assumed that since the underlying call was identical, wiring an agent up to "just shell out to git_commit.py
directly" via a generic bash-execution tool would behave the same as calling the MCP tool. It didn't, for a reason that's obvious in hindsight: the script's failure mode is a print()
and a nonzero exit code —
if not diff.strip():
print("Nothing staged. Run `git add` first.")
raise SystemExit(1)
— which is fine for a human staring at a terminal, and useless for an agent parsing a bash tool's stdout/exit-code pair generically. It has to guess whether that string means "expected condition, try something else" or "real error, stop." The MCP tool doesn't have this problem because the interface itself is typed: a -> str
return means the agent gets a message back, full stop, and error signaling goes through the protocol's own mechanism rather than being smuggled through print statements and exit codes that were designed for human eyes.
So the actual definition I landed on, for my own purposes: a script is agentic not because an LLM is inside it, and not even because it's "automated" — it's agentic when the boundary of what it does is described in a form something other than a human can read and act on before calling it. Everything upstream of that boundary — subprocess calls, prompt strings, whatever — can be identical. The schema is the whole difference.
My first instinct, before I actually diffed the two files, was that the "real" difference must be in how much smarter or more structured the MCP version's prompt was — like agentic tools need better prompts, more guardrails, more validation baked into the call itself. That's not it. Both paths strip AI attribution from the output before it ever reaches a commit message — the script does it inline, the tool does it because _claude()
does it for every caller:
_STRIP = ("co-author", "co-authored", "generated by", "claude", "anthropic", "openai", "llm", "ai:")
def _claude(prompt: str, system: str = None) -> str:
full = (system + "\n\n" + prompt) if system else prompt
raw = subprocess.check_output(["claude", "-p", full], text=True).strip()
return "\n".join(
l for l in raw.splitlines()
if not any(s in l.lower() for s in _STRIP)
).strip()
Same filter, same eight substrings, same behavior either way — so that's not where the schema bought me anything. But the shape of where it lives is different, and that's the part worth noticing. In the script, the filter is duplicated inline at the call site; every future script that shells out to claude -p
has to remember to copy those four lines, and nothing enforces that it will. In the server, every tool that wants an AI call goes through _claude()
, so the filter is applied once, centrally, and any new @mcp.tool()
that reuses the helper inherits it automatically without having to know it exists.
That's not a difference the schema itself creates — @mcp.tool()
doesn't know or care what's inside _claude()
. It's a difference in what having a defined tool boundary nudged me toward: once a function is the thing other tools call through rather than a script other humans copy-paste from, centralizing shared behavior behind it stops being optional discipline and starts being the obvious place to put it. The schema makes the interface legible to an agent. It's the boundary itself — agentic or not — that makes safety-critical logic worth centralizing instead of duplicated at every call site.