A Real Problem First
You've wired Claude into Jira — it can search tickets, create issues, update status. It runs. Your team is happy.
Three weeks later, a colleague on another team wants the same Jira tools for their Agent project.
Two options:
- Send them the Jira tool code to copy
- Tell them to install an MCP Server: 5 minutes, done
MCP was designed to make the second option the default.
What Function Calling Can and Can't Do
Function Calling (also called Tool Use) lets an LLM invoke external tools. The mechanics are straightforward:
tools = [{
"name": "search_jira",
"description": "Search Jira tickets",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search keywords"},
"project": {"type": "string", "description": "Project key, optional"}
},
"required": ["query"]
}
}]
response = client.messages.create(
model="claude-sonnet-4-6",
tools=tools,
messages=[{"role": "user", "content": "Find recent P0 bugs"}]
)
if response.stop_reason == "tool_use":
tool_call = response.content[0]
result = search_jira(tool_call.input["query"])
# return result to LLM to continue reasoning
This works well. It's in production everywhere.
The structural problem: tool definitions and implementations both live in the caller.
Every project using Jira tools needs to:
- Maintain a
search_jiraschema definition - Maintain a
search_jiraexecution function - Handle auth, retries, result formatting
Three teams, three copies. The Jira API changes, three places to update.
MCP's Core Design
MCP (Model Context Protocol) moves tool definitions out of the caller and into an independent Server process.
Function Calling model:
┌─────────────────────────────────────┐
│ Your Agent code │
│ ├── LLM call logic │
│ ├── Tool schema definitions │ ← every Agent maintains its own
│ └── Tool execution (Jira/GitHub/…) │ ← every Agent maintains its own
└─────────────────────────────────────┘
MCP model:
┌──────────────────┐ ┌─────────────────────┐
│ Host (Agent/IDE)│────▶│ MCP Server │
│ only call logic │ │ ├── Tool schemas │ ← maintained once
└──────────────────┘ │ └── Tool execution │ ← maintained once
└─────────────────────┘
An MCP Server is an independent process that communicates with Hosts via a standard protocol (JSON-RPC 2.0). Any MCP-compatible Host — Claude Desktop, Claude Code, a custom Agent — connects to this Server directly. No re-implementation needed.
Same Scenario, Two Implementations
"Search Jira tickets" in both approaches:
Option A: Function Calling
# agent.py — tool schema and execution are both here
import anthropic
from jira import JIRA
jira_client = JIRA(
server="https://your-company.atlassian.net",
basic_auth=("user@example.com", os.environ["JIRA_API_TOKEN"])
)
JIRA_TOOLS = [{
"name": "search_jira",
"description": "Search Jira tickets, return title, status, priority",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer", "default": 10}
},
"required": ["query"]
}
}]
def execute_tool(name: str, args: dict) -> str:
if name == "search_jira":
issues = jira_client.search_issues(
f'text ~ "{args["query"]}"',
maxResults=args.get("max_results", 10)
)
return "\n".join([
f"[{i.key}] {i.fields.summary} ({i.fields.status.name})"
for i in issues
])
raise ValueError(f"Unknown tool: {name}")
def run_agent(user_input: str):
client = anthropic.Anthropic()
messages = [{"role": "user", "content": user_input}]
while True:
response = client.messages.create(
model="claude-sonnet-4-6",
tools=JIRA_TOOLS,
messages=messages
)
if response.stop_reason != "tool_use":
return response.content[0].text
tool_call = next(b for b in response.content if b.type == "tool_use")
result = execute_tool(tool_call.name, tool_call.input)
messages.extend([
{"role": "assistant", "content": response.content},
{"role": "user", "content": [{"type": "tool_result",
"tool_use_id": tool_call.id,
"content": result}]}
])
Option B: MCP Server
# jira_mcp_server.py — standalone process, usable by any Host
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from jira import JIRA
jira_client = JIRA(
server="https://your-company.atlassian.net",
basic_auth=("user@example.com", os.environ["JIRA_API_TOKEN"])
)
server = Server("jira-tools")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [Tool(
name="search_jira",
description="Search Jira tickets, return title, status, priority",
inputSchema={
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer", "default": 10}
},
"required": ["query"]
}
)]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "search_jira":
issues = jira_client.search_issues(
f'text ~ "{arguments["query"]}"',
maxResults=arguments.get("max_results", 10)
)
result = "\n".join([
f"[{i.key}] {i.fields.summary} ({i.fields.status.name})"
for i in issues
])
return [TextContent(type="text", text=result)]
if __name__ == "__main__":
asyncio.run(stdio_server(server))
# agent.py — only Agent logic remains, no Jira code at all
# Tool discovery and invocation handled by the MCP framework
# No Jira-related code needed here
The second team onboards:
# install
pip install jira-mcp-server
# add to Claude Code settings — tools become available immediately
# zero changes to Agent code
Core Comparison
Dimension Function Calling MCP
──────────────────────────────────────────────────────────────────
Tool definition Inside caller code Independent Server process
Reuse method Copy code Install / connect Server
Cross-project sync Manual, prone to drift Server version is canonical
Cross-model use Configure per model One Server, any Host
State management Stateless Server maintains state
Deployment cost No extra process Must manage Server process
Right scale 1-2 projects use a tool Many projects share tools
MCP is a standardization layer on top of Function Calling; they work together. Inside an MCP Server, tools are still invoked through the Function Calling mechanism between the Host and the LLM.
When to Use MCP vs Function Calling
Function Calling is enough when:
- Only one project uses the tool; no reuse planned
- The tool is simple — 10-20 lines of logic
- You're prototyping; no overhead needed
- Tool logic is tightly coupled to Agent logic; separation adds nothing
An MCP Server makes sense when:
- The tool runs in multiple Agent projects (Jira, GitHub, databases)
- The tool has state (connection pools, auth sessions, caches)
- Claude Desktop or Claude Code users need to install and use it directly
- The team wants a shared internal tool standard across projects
Decision questions:
Will more than 2 projects use this tool?
→ Yes → consider MCP Server
→ No → Function Calling is fine
Does the tool need to maintain connection state?
→ Yes → MCP Server (process manages state across calls)
→ No → keep going
Do non-engineers need to configure and use this tool?
→ Yes → MCP Server (see Claude Desktop ecosystem)
→ No → Function Calling is fine
Summary
Function Calling already solves 'can the LLM call a tool.' MCP solves how tools get standardized and reused: pull tool definitions out of every Agent project, put them in a standalone Server, and any MCP-compatible Host connects without reimplementing anything.
The tradeoff is process management complexity. The gain is a single maintained implementation that all dependents consume.
The next article goes into the protocol itself: the Host / Client / Server three-layer model, the JSON-RPC message format, and a walkthrough of a real communication session using MCP Inspector.
References
Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.
Find more useful knowledge and interesting products on my Homepage
Top comments (0)