---
title: "Cross-Framework Portability"
description: "Defining tools once and exporting adapters for MCP, Claude Agent SDK, and OpenAI"
url: "https://agentsurface.dev/docs/tool-design/cross-framework-portability"
lastVerified: 2026-09-25
lastModified: 2026-09-25T05:46:01.000Z
---



## Summary [#summary]

Define tool logic once in a framework-neutral registry module; emit adapters for each framework (MCP, Claude Agent SDK 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 [#the-pattern]

### Step 1: Shared Registry [#step-1-shared-registry]

Define tool metadata and handler once:

```typescript
// 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 [#step-2-mcp-adapter]

```typescript
// 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 [#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.

```json
// .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:

```typescript
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 [#step-4-openai-agents-sdk-adapter]

```typescript
// 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:

```typescript
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 [#step-5-vercel-ai-sdk-adapter]

```typescript
// 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 [#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]

LangChain uses the `@tool` decorator with Pydantic models. Context (auth, DB session) flows through `InjectedToolArg`, never through tool parameters.

```python
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]

Mastra's `createTool` takes Zod input/output schemas and a typed execute context, and carries MCP annotations under `mcp.annotations`.

```typescript
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]

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.

```python
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]

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.

```python
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 [#amazon-bedrock]

Bedrock accepts tools two ways. For Bedrock Agents, describe the entire API in OpenAPI 3.0 and upload it as an action group - see [OpenAPI for Agents](/docs/api-surface/openapi-for-agents) for spec design. For direct tool use in the Converse API, pass a `toolSpec` with an inline JSON input schema.

```python
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 [#handling-framework-specific-features]

### Annotations (MCP Only) [#annotations-mcp-only]

MCP supports behavioral hints that other frameworks don't. Add them at the adapter layer:

```typescript
// 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 [#output-schemas]

MCP supports `outputSchema` for type-safe responses. Add at the adapter layer:

```typescript
// 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) [#dynamic-tool-selection-anthropicopenai]

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](/docs/tool-design/tool-curation#dynamic-tool-selection) 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.

```typescript
const agent = new Agent({
  tools: [ToolSearchTool(), ...allTools.map((t) => ({ ...t, defer_loading: true }))],
});
```

## Testing Strategy [#testing-strategy]

Test the shared handler independently of frameworks:

```typescript
// 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:

```typescript
// 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 [#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 adapters
```

## Benefits [#benefits]

* **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 [#see-also]

* `/templates/tools/tool-definition.ts` - starter template
* `/templates/tools/tool-registry.ts` - registry pattern for >10 tools
* ([MCP 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/)) - current JSON Schema 2020-12 behavior
* ([Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview))
* ([OpenAI Agents SDK](https://openai.github.io/openai-agents-js/))
* ([Vercel AI SDK](https://ai-sdk.dev/docs/reference/ai-sdk-core/))
