Tool Design
Cross-Framework Portability
Defining tools once and exporting adapters for MCP, Claude Agent SDK, and OpenAI
Last verified 2026-09-25
Summary
Define tool logic once in a framework-neutral registry module; emit adapters for each framework (, Claude Agent through MCP, @openai/agents, Vercel AI SDK, LangChain). This reduces drift and duplication, but each adapter still needs framework-specific tests. Pattern: shared registry (metadata, handler, schema), then adapters (MCP server.tool, Claude Code MCP config, OpenAI function/tool declarations).
- Shared registry: Tool metadata, handler, schema (Zod)
- MCP adapter: server.tool() registration
- Claude Agent SDK adapter: expose the registry as MCP or allowed SDK tools
- OpenAI adapter: FunctionDeclaration with handler wrapper
- Vercel AI SDK adapter: tools object
- Benefits: Reduced duplication, lower drift, adapter-specific tests
Tool definitions are business logic. Frameworks are deployment targets. Define your tool once in a framework-neutral module; emit adapters for each framework (MCP, Claude Agent SDK, @openai/agents, Vercel AI SDK). This keeps logic centralized and ensures consistency across platforms.
The Pattern
Step 1: Shared Registry
Define tool metadata and handler once:
// tools/registry.ts: shared across all frameworks
import { z } from "zod";
const searchDocsSchema = z
.object({
query: z
.string()
.describe('Free text search query. Examples: "authentication", "API rate limits"'),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(10)
.describe("Max results to return (default 10, max 100)"),
offset: z.number().int().min(0).default(0).describe("Pagination offset for large result sets"),
format: z
.enum(["concise", "detailed"])
.default("concise")
.describe("concise: title + URL only. detailed: full snippet (slower, more tokens)"),
})
.strict();
type SearchDocsInput = z.infer<typeof searchDocsSchema>;
export async function searchDocsHandler(input: SearchDocsInput) {
const results = await internal.search(input.query, {
limit: input.limit,
offset: input.offset,
});
return {
results: results.map((doc) => ({
id: doc.id,
title: doc.title,
url: doc.url,
...(input.format === "detailed" && { snippet: doc.snippet }),
})),
has_more: results.length === input.limit,
next_offset: input.offset + input.limit,
};
}
export const searchDocsTool = {
name: "search_docs",
description: `Search internal documentation by keyword. Use this to find setup guides, API references, and troubleshooting steps.
Do not use this for general knowledge (use web search instead) or to create new docs (use create_doc). Returns snippets ranked by relevance.`,
schema: searchDocsSchema,
handler: searchDocsHandler,
};
// Other tools exported same way
export const createDocTool = {
/* ... */
};
export const updateDocTool = {
/* ... */
};
export const allTools = [searchDocsTool, createDocTool, updateDocTool];Step 2: MCP Adapter
// tools/mcp.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { allTools, searchDocsTool, createDocTool, updateDocTool } from "./registry";
const server = new Server({
name: "docs-mcp",
version: "1.0.0",
});
// Register all tools with MCP-specific annotations
server.tool(
searchDocsTool.name,
searchDocsTool.description,
searchDocsTool.schema,
{
annotations: {
readOnlyHint: true,
idempotentHint: true,
},
},
searchDocsTool.handler,
);
server.tool(
createDocTool.name,
createDocTool.description,
createDocTool.schema,
{
annotations: {
destructiveHint: false,
idempotentHint: true, // Supports idempotency_key parameter
},
},
createDocTool.handler,
);
// ... register remaining tools
export default server;Step 3: Claude Agent SDK / MCP Adapter
Claude Code and the Claude Agent SDK are usually best served by exposing the same shared registry through MCP. That keeps the tool contract portable to Claude Code, Claude Desktop-style clients, OpenAI remote MCP, Gemini connectors, and other MCP clients.
// .mcp.json
{
"mcpServers": {
"docs": {
"command": "node",
"args": ["dist/tools/mcp.js"],
"env": {
"DOCS_API_TOKEN": "${DOCS_API_TOKEN}"
}
}
}
}Then use the Claude Agent SDK with the repo or service MCP configuration and an allowlist:
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "Find the authentication guide and summarize the setup steps.",
options: {
mcpConfig: ".mcp.json",
allowedTools: ["mcp__docs__search_docs"],
maxTurns: 6,
},
})) {
// Stream messages, tool calls, and final result through your app.
}For Claude Managed Agents, register equivalent tool definitions or MCP servers in the managed-agent session rather than creating a separate in-process adapter. Keep the registry as the source of truth.
Step 4: OpenAI Agents SDK Adapter
// tools/openai-agents.ts
import { Tool } from "@openai/agents";
import { zodToJsonSchema } from "zod-to-json-schema";
import { allTools } from "./registry";
export const openaiTools = allTools.map(
(t) =>
new Tool({
name: t.name,
description: t.description,
parameters: zodToJsonSchema(t.schema),
execute: t.handler,
}),
);
export const searchDocsTool = openaiTools.find((t) => t.name === "search_docs");
export const createDocTool = openaiTools.find((t) => t.name === "create_doc");In an OpenAI agent:
import { Agent } from "@openai/agents";
import { searchDocsTool, createDocTool } from "./tools/openai-agents";
const agent = new Agent({
tools: [searchDocsTool, createDocTool],
});
const result = await agent.run("Find the authentication guide");Step 5: Vercel AI SDK Adapter
// tools/vercel-ai.ts
import { tool } from "ai";
import { zodToJsonSchema } from "zod-to-json-schema";
import { allTools } from "./registry";
export const vercelTools = allTools.map((t) =>
tool({
description: t.description,
parameters: zodToJsonSchema(t.schema),
execute: t.handler,
}),
);
export const searchDocsTool = vercelTools.find((t) => t.name === "search_docs");Adapters for Other Frameworks
The same registry entry - name, description, schema, handler - projects onto every other runtime's native tool shape. The frameworks differ in surface syntax, not in the four fields.
LangChain
LangChain uses the @tool decorator with Pydantic models. Context (auth, DB session) flows through InjectedToolArg, never through tool parameters.
from langchain_core.tools import tool, InjectedToolArg
from pydantic import BaseModel, Field
from typing import Annotated
class GetOrderInput(BaseModel):
order_id: str = Field(description="Order ID returned by list_orders or create_order")
@tool("get_order", args_schema=GetOrderInput)
def get_order(
order_id: str,
db_session: Annotated[Session, InjectedToolArg], # injected at runtime
) -> dict:
"""Retrieves full details for a single order including line items and status.
Use when you have a specific order ID. For searching orders by customer or
date range, use search_orders instead."""
order = db_session.orders.get(order_id)
if not order:
raise ToolException(f"Order {order_id} not found")
return order.to_dict()Mastra
Mastra's createTool takes Zod input/output schemas and a typed execute context, and carries under mcp.annotations.
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const getOrderTool = createTool({
id: "get-order",
description: `Retrieves full details for a single order. Use when you have a
specific order ID. For searching or listing orders, use search-orders instead.`,
inputSchema: z.object({
orderId: z.string().describe("Order ID from a prior list-orders or create-order call"),
}),
outputSchema: z.object({
id: z.string(),
status: z.enum(["pending", "processing", "shipped", "delivered", "cancelled"]),
total: z.number(),
}),
mcp: { annotations: { readOnlyHint: true, idempotentHint: true } },
execute: async ({ context }) => fetchOrder(context.orderId),
});CrewAI
CrewAI assigns tools to agents through agent.tools rather than a central registry. Each tool subclasses BaseTool; the agent's role, goal, and backstory supply its operating context.
from crewai import Agent
from crewai_tools import BaseTool
class GetOrderTool(BaseTool):
name: str = "get_order"
description: str = """Retrieves full details for a single order including line
items and shipping status. Use when you have an order ID."""
def _run(self, order_id: str) -> str:
return fetch_order(order_id).to_json()
order_agent = Agent(
role="Order Manager",
goal="Help customers track and manage their orders accurately",
backstory="An expert in order fulfillment with access to the order system",
tools=[GetOrderTool()],
)Semantic Kernel
Semantic Kernel has been in maintenance mode (critical fixes and security patches only) since Microsoft Agent Framework reached general availability in April 2026; new tool-design work targets Agent Framework instead. Semantic Kernel annotates class methods with @kernel_function, groups them into plugins, and injects services through dependency injection - the shape below still applies to existing Semantic Kernel codebases.
from semantic_kernel.functions import kernel_function
from typing import Annotated
class OrderPlugin:
def __init__(self, order_service: OrderService):
self._order_service = order_service
@kernel_function(
name="get_order",
description="""Retrieves full details for a single order. Use when you have a
specific order ID. For searching orders, use search_orders instead.""",
)
async def get_order(
self, order_id: Annotated[str, "Order ID from list_orders or create_order"]
) -> str:
order = await self._order_service.get(order_id)
return json.dumps(order.to_dict())Amazon Bedrock
Bedrock accepts tools two ways. For Bedrock Agents, describe the entire in 3.0 and upload it as an action group - see OpenAPI for Agents for spec design. For direct in the Converse API, pass a toolSpec with an inline JSON input schema.
tool_config = {
"tools": [
{
"toolSpec": {
"name": "get_order",
"description": """Retrieves full details for a single order. Use when
you have a specific order ID. For searching orders, use search_orders.""",
"inputSchema": {
"json": {
"type": "object",
"required": ["order_id"],
"properties": {
"order_id": {"type": "string", "description": "The order identifier"}
},
}
},
}
}
]
}Handling Framework-Specific Features
Annotations (MCP Only)
MCP supports behavioral hints that other frameworks don't. Add them at the adapter layer:
// tools/mcp.ts
const toolAnnotations = {
search_docs: { readOnlyHint: true, idempotentHint: true },
create_doc: { destructiveHint: false, idempotentHint: true },
delete_doc: { destructiveHint: true, idempotentHint: false },
};
for (const toolDef of allTools) {
server.tool(
toolDef.name,
toolDef.description,
toolDef.schema,
{ annotations: toolAnnotations[toolDef.name] || {} },
toolDef.handler,
);
}Output Schemas
MCP supports outputSchema for type-safe responses. Add at the adapter layer:
// tools/mcp.ts
import { z } from "zod";
const SearchDocsOutputSchema = z.object({
results: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string(),
snippet: z.string().optional(),
}),
),
has_more: z.boolean(),
next_offset: z.number().int(),
});
server.tool(
"search_docs",
searchDocsTool.description,
searchDocsTool.schema,
{ outputSchema: SearchDocsOutputSchema },
searchDocsTool.handler,
);The MCP adapter is also the layer where schema dialect differences bite. MCP's 2026-07-28 revision widens inputSchema and outputSchema to any JSON Schema 2020-12 keyword including $ref (SEP-2106), while several other frameworks still accept only a flat subset. Keep the shared registry on the narrowest dialect all your targets accept and let each adapter widen from there, rather than authoring $ref-heavy schemas that only the MCP adapter can carry.
Dynamic Tool Selection (Anthropic/OpenAI)
For agents with a large tool surface, both providers support server-side deferred loading rather than a client-side pre-filter:
Anthropic tool search tool: include a tool_search_tool_bm25_20251119 (natural-language queries) or tool_search_tool_regex_20251119 (regex patterns) entry in tools, and mark the tools you don't need up front with defer_loading: true. Claude searches names, descriptions, and argument fields and the API expands matches into full definitions on demand - see Tool Curation for the full mechanics.
OpenAI deferred loading: pair defer_loading: true on each candidate tool with a ToolSearchTool() added exactly once to the agent - defer_loading alone on every tool with no search tool present does not work.
const agent = new Agent({
tools: [ToolSearchTool(), ...allTools.map((t) => ({ ...t, defer_loading: true }))],
});Testing Strategy
Test the shared handler independently of frameworks:
// test/registry.test.ts
import { searchDocsHandler, searchDocsSchema } from "../tools/registry";
describe("searchDocsHandler", () => {
it("returns paginated results", async () => {
const result = await searchDocsHandler({
query: "authentication",
limit: 10,
offset: 0,
format: "concise",
});
expect(result.results).toHaveLength(10);
expect(result).toHaveProperty("has_more");
expect(result).toHaveProperty("next_offset");
});
it("validates input schema", () => {
const badInput = { query: "test", limit: 1000 }; // limit > 100
expect(() => searchDocsSchema.parse(badInput)).toThrow();
});
});Then test each adapter separately:
// test/mcp.test.ts
import server from "../tools/mcp";
describe("MCP adapter", () => {
it("exposes search_docs tool with annotations", async () => {
const tools = await server.listTools({});
const searchDocs = tools.find((t) => t.name === "search_docs");
expect(searchDocs).toBeDefined();
});
});
// test/claude-agent-sdk.test.ts
import { searchDocsTool } from "../tools/claude-agent-sdk";
describe("Claude Agent SDK adapter", () => {
it("tool has correct shape", () => {
expect(searchDocsTool).toHaveProperty("name", "search_docs");
expect(searchDocsTool).toHaveProperty("description");
expect(searchDocsTool).toHaveProperty("inputSchema");
expect(searchDocsTool).toHaveProperty("execute");
});
});Project Layout
src/
├── tools/
│ ├── registry.ts # Shared handlers + metadata
│ ├── mcp.ts # MCP Server adapter
│ ├── claude-agent-sdk.ts # Claude Agent SDK adapter
│ ├── openai-agents.ts # OpenAI Agents SDK adapter
│ └── vercel-ai.ts # Vercel AI SDK adapter
├── test/
│ ├── registry.test.ts # Handler tests
│ ├── mcp.test.ts # MCP adapter tests
│ └── ...
└── index.ts # Export all adaptersBenefits
- Single source of truth - Tool logic defined once, tested once, deployed many ways
- Consistency - Same behavior across all frameworks; no drift
- Maintainability - Update description, schema, or handler in one place
- Testing - Unit tests on shared handlers apply to all adapters
- Flexibility - Add a new framework by writing a new adapter without touching core logic
See also
/templates/tools/tool-definition.ts- starter template/templates/tools/tool-registry.ts- registry pattern for >10 tools- (MCP 2026-07-28) - current JSON Schema 2020-12 behavior
- (Claude Agent SDK)
- (OpenAI Agents SDK)
- (Vercel AI SDK)