Migrating from MCP to Function Calling: Complete Migration Guide
As AI applications mature, developers are increasingly evaluating the trade-offs between different tool-use paradigms. The Model Context Protocol (MCP) introduced a standardized way to expose external resources and tools to language models, but many teams are now migrating back to native function calling for tighter control, lower latency, and simpler deployment. This guide walks you through the entire migration process with practical examples.
What Is MCP and How Does It Differ from Function Calling?
MCP is an open protocol that standardizes how applications provide context to LLMs. It uses a client-server architecture where an MCP server exposes tools, resources, and prompts through a JSON-RPC interface. Function calling, on the other hand, is a native capability built directly into most modern LLM APIs (OpenAI, Anthropic, Google Gemini) where the model can request to invoke predefined functions and return structured arguments.
The key differences include:
- Architecture: MCP requires a separate server process communicating over stdio or SSE, while function calling lives entirely within your application code.
- Discovery: MCP servers dynamically advertise their capabilities, whereas function definitions are statically declared in each API request.
- State management: MCP servers can maintain long-lived state and sessions; function calling is stateless per request.
- Overhead: MCP adds a protocol layer and serialization overhead that function calling avoids.
Why Migrate from MCP to Function Calling?
Several practical reasons drive teams toward function calling. First, latency: every MCP tool invocation involves a round trip through the JSON-RPC layer, which adds measurable overhead on every call. Second, operational complexity: deploying and monitoring a separate MCP server process increases your infrastructure burden. Third, debugging: tracing issues across process boundaries is harder than debugging a single application. Finally, cost: many MCP patterns involve redundant schema serialization and context that inflates token usage.
Function calling also gives you direct control over argument validation, retry logic, and error handling without conforming to the MCP protocol's constraints.
Step 1: Audit Your Existing MCP Tools
Before writing any new code, catalog every tool your MCP server exposes. For each tool, document its name, description, input schema, and the underlying business logic it invokes. Here is a typical MCP server definition you might be starting from:
// mcp-server.ts — existing MCP server
import { Server } from "@modelcontextprotocol/sdk/server";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
const server = new Server(
{ name: "weather-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", async () => ({
tools: [
{
name: "get_weather",
description: "Get current weather for a city",
inputSchema: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
units: { type: "string", enum: ["metric", "imperial"] }
},
required: ["city"]
}
},
{
name: "get_forecast",
description: "Get a 5-day forecast for a city",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
days: { type: "number", minimum: 1, maximum: 7 }
},
required: ["city"]
}
}
]
}));
server.setRequestHandler("tools/call", async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "get_weather":
return await fetchWeather(args.city, args.units);
case "get_forecast":
return await fetchForecast(args.city, args.days);
default:
throw new Error(`Unknown tool: ${name}`);
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
From this server, you have two tools to migrate: get_weather and get_forecast. Note their schemas and the helper functions fetchWeather and fetchForecast — those will carry over directly.
Step 2: Define Native Function Schemas
Function calling schemas are structurally similar to MCP input schemas, but they are declared inline in your API request. Here is how to translate the MCP tool definitions into OpenAI-compatible function schemas:
// functions.ts — function definitions for the LLM
export const functionDefinitions = [
{
type: "function",
function: {
name: "get_weather",
description: "Get current weather for a city",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
units: {
type: "string",
enum: ["metric", "imperial"],
description: "Temperature units"
}
},
required: ["city"]
}
}
},
{
type: "function",
function: {
name: "get_forecast",
description: "Get a 5-day forecast for a city",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
days: {
type: "number",
minimum: 1,
maximum: 7,
description: "Number of forecast days"
}
},
required: ["city"]
}
}
}
];
The main structural change is wrapping each definition in a function key and using parameters instead of inputSchema. The JSON Schema content itself remains identical.
Step 3: Implement the Function Dispatch Layer
With MCP, the protocol handled routing tool calls to handlers. Now you need a dispatcher in your application. This is straightforward but should be designed for extensibility:
// dispatcher.ts — routes function calls to implementations
import { fetchWeather, fetchForecast } from "./weatherService.js";
export async function executeFunction(name: string, args: Record<string, unknown>) {
try {
switch (name) {
case "get_weather":
validateArgs(args, { city: "string" });
return await fetchWeather(args.city as string, args.units as string);
case "get_forecast":
validateArgs(args, { city: "string" });
return await fetchForecast(args.city as string, args.days as number);
default:
return { error: `Unknown function: ${name}` };
}
} catch (err) {
return { error: err instanceof Error ? err.message : "Execution failed" };
}
}
function validateArgs(args: Record<string, unknown>, required: Record<string, string>) {
for (const [key, type] of Object.entries(required)) {
if (!(key in args)) {
throw new Error(`Missing required argument: ${key}`);
}
if (typeof args[key] !== type) {
throw new Error(`Argument ${key} must be of type ${type}`);
}
}
}
Step 4: Build the Conversation Loop
MCP abstracted the conversation loop away from you. With function calling, you own it. The loop is: send a message, check if the model wants to call a function, execute it, feed the result back, and repeat until the model produces a final answer.
// chat.ts — main conversation loop with function calling
import OpenAI from "openai";
import { functionDefinitions } from "./functions.js";
import { executeFunction } from "./dispatcher.js";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function runConversation(userMessage: string): Promise<string> {
const messages: OpenAI.ChatCompletionMessageParam[] = [
{
role: "system",
content: "You are a helpful weather assistant. Use the available functions to answer questions accurately."
},
{ role: "user", content: userMessage }
];
const maxIterations = 5;
for (let i = 0; i < maxIterations; i++) {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages,
tools: functionDefinitions,
tool_choice: "auto"
});
const choice = response.choices[0];
messages.push(choice.message);
// If no tool calls, we have our final answer
if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) {
return choice.message.content ?? "";
}
// Execute each requested function call
for (const toolCall of choice.message.tool_calls) {
const fnName = toolCall.function.name;
const fnArgs = JSON.parse(toolCall.function.arguments);
console.log(`Executing: ${fnName}(${JSON.stringify(fnArgs)})`);
const result = await executeFunction(fnName, fnArgs);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(result)
});
}
}
return "Reached maximum function call iterations without a final answer.";
}
Step 5: Handle Resources and Prompts
MCP servers often expose resources (data sources) and prompts (templates) in addition to tools. Function calling has no direct equivalent for these, so you need to adapt them:
- Resources: Convert resource URIs into functions that fetch and return content. For example, an MCP resource
file://docs/readmebecomes aread_documentfunction that accepts a path parameter. - Prompts: Move prompt templates into your application code as string templates or helper functions that construct system messages dynamically.
// resources.ts — converting MCP resources to functions
export const resourceFunctions = [
{
type: "function",
function: {
name: "read_document",
description: "Read the contents of a document by path",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Document path or identifier" }
},
required: ["path"]
}
}
}
];
export async function readDocument(path: string): Promise<string> {
// Previously handled by MCP resource handler
const fs = await import("fs/promises");
return fs.readFile(path, "utf-8");
}
// Prompts become simple template functions
export function buildAnalysisPrompt(topic: string): string {
return `Analyze the following topic in depth, considering multiple perspectives: ${topic}`;
}
Step 6: Migrate Authentication and Context
MCP servers often manage their own authentication to external services and maintain session state. When migrating, you need to handle these concerns in your application layer. Create a service container that initializes connections once and shares them across function calls:
// services.ts — shared service container
export class ServiceContainer {
private weatherApi: WeatherApiClient;
private cache: Map<string, { data: unknown; expiry: number }>;
constructor(apiKey: string) {
this.weatherApi = new WeatherApiClient(apiKey);
this.cache = new Map();
}
async getWeather(city: string, units: string) {
const cacheKey = `weather:${city}:${units}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expiry > Date.now()) {
return cached.data;
}
const data = await this.weatherApi.getCurrent(city, units);
this.cache.set(cacheKey, { data, expiry: Date.now() + 300_000 });
return data;
}
async getForecast(city: string, days: number) {
return this.weatherApi.getForecast(city, days);
}
}
Then inject this container into your dispatcher so all function implementations share the same connections and caching layer.
Step 7: Update Your Client Integration
If your application previously used an MCP client to connect to the server, replace that client code with direct function calls. Here is a before-and-after comparison:
// BEFORE: MCP client integration
import { Client } from "@modelcontextprotocol/sdk/client";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio";
const transport = new StdioClientTransport({
command: "node",
args: ["mcp-server.ts"]
});
const mcpClient = new Client({ name: "my-app", version: "1.0.0" });
await mcpClient.connect(transport);
const tools = await mcpClient.listTools();
const result = await mcpClient.callTool({
name: "get_weather",
arguments: { city: "Tokyo" }
});
// AFTER: Direct function calling
import { runConversation } from "./chat.js";
const answer = await runConversation("What is the weather in Tokyo?");
console.log(answer);
Best Practices for the Migration
Follow these practices to ensure a smooth transition and a robust function calling implementation:
- Migrate incrementally: Start with one or two simple tools, validate the end-to-end flow, then migrate the rest. Do not attempt a big-bang rewrite.
- Keep schemas DRY: Generate function definitions from a single source of truth, such as TypeScript types or Zod schemas, to avoid drift between definitions and implementations.
- Always validate arguments: The model can produce malformed arguments. Validate every input before passing it to your business logic, and return clear error messages that the model can use to self-correct.
- Cap iteration loops: Always set a maximum number of function call rounds to prevent infinite loops where the model repeatedly calls functions without converging on an answer.
- Log every call: Record function names, arguments, results, and latency. This is critical for debugging and for understanding model behavior in production.
- Handle parallel calls: Modern models can request multiple function calls in a single response. Execute independent calls concurrently with
Promise.allto reduce latency. - Version your functions: If you change a function signature, use a versioned name like
get_weather_v2or manage versions through your deployment pipeline to avoid breaking existing conversations. - Test with edge cases: Test empty arguments, missing required fields, extremely long strings, and unexpected types. The model will eventually produce all of these.
Common Pitfalls and How to Avoid Them
During migration, teams frequently encounter a few specific issues. Token bloat is common when function results are large — always truncate or summarize large payloads before feeding them back to the model. Another pitfall is overloading the model with too many functions; if you have more than 15 to 20 functions, consider grouping them or using a routing layer. Finally, do not forget to handle the tool_choice parameter thoughtfully — setting it to "auto" is usually correct, but "none" can be useful when you want the model to answer from context alone.
// Example: truncating large function results
function truncateResult(data: unknown, maxChars = 4000): string {
const str = JSON.stringify(data);
if (str.length <= maxChars) return str;
return str.slice(0, maxChars) + "\n...[truncated]";
}
// Use in the conversation loop
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: truncateResult(result)
});
Conclusion
Migrating from MCP to native function calling trades protocol-level abstraction for direct control, simpler deployment, and reduced latency. By auditing your existing tools, translating schemas, building a dispatch layer, and owning the conversation loop, you can complete the migration incrementally and safely. The result is a leaner architecture where your application talks directly to the model API, with full visibility into every function call and no external server process to manage. While MCP remains valuable for cross-application tool sharing and dynamic discovery, function calling is the right choice for most production AI applications that prioritize performance, simplicity, and operational control.