Imagine your team has an internal productivity API, built over the years, that powers projects, tasks, comments, roles, notifications, and access control. It’s stable and well‑used, but it’s not yet ready for AI agents.
The challenge is to expose this system as a clean, composable set of MCP tools, not just wrappers, but schema‑driven, predictable, and reusable.
Here’s how you can do it.
1️⃣ Understand your API surface
Group endpoints into logical domains:
- Users
- Tasks
- Comments
- Roles
- Projects
For each domain, list the operations: fetch
, list
, create
, update
, delete
. This mapping leads to tool names like get_user
, list_projects
, create_task
.
2️⃣ MCP tools represent capabilities, not routes
Each tool is a single, self‑contained capability:
name: "get_user"
description: "Retrieve a user by ID"
inputSchema: { userId: string }
outputSchema: { name: string; email: string }
handler: async ({ userId }) => { /* ... */ }
Flatten all request inputs into inputSchema and define predictable JSON outputs. Remove HTTP status codes from the interface.
3️⃣ Transform OpenAPI into Agent‑Friendly Schemas
From OpenAPI:
GET /users/{id}
- path param: id: string
- 200 response: { name, email }
To MCP tool:
name: "get_user",
inputSchema: { userId: z.string() },
outputSchema: { name: z.string(), email: z.string() }
This makes tools self‑documenting and composable.
4️⃣ Handle real‑world patterns
We applied consistent schemas for:
- CRUD (
get_user
,create_task
) - Filtering/Search (
search_tasks
) - Batch Operations (
create_tasks_batch
) - File Uploads/Downloads (signed URLs)
Example filter:
inputSchema: {
status: z.enum(["open", "in_progress", "closed"]),
assignedTo: z.string().optional()
}
5️⃣ Keep tools predictable and reusable
Stick to conventions (get_
, list_
, create_
, update_
, delete_
), share object schemas, and avoid mixing unrelated actions in a single tool.
6️⃣ Clean, testable implementation
Modular structure:
src/
tools/
user-tools.ts
task-tools.ts
schemas/
shared.ts
server.ts
Example:
export const getUserTool: MCPTool = {
name: "get_user",
description: "Retrieve a user by ID",
inputSchema: z.object({ userId: z.string() }),
outputSchema: UserSchema,
handler: async ({ userId }) => {
const user = getUserById(userId);
if (!user) throw { code: "NOT_FOUND", message: "User not found" };
return user;
}
};
7️⃣ Test with a mock backend
With mockDB:
it("get_user: throws on missing user", async () => {
await expect(getUserTool.handler({ userId: "nope" }))
.rejects.toMatchObject({ code: "NOT_FOUND" });
});
Result: A predictable, composable MCP tool layer with structured errors and maintainable code, ready for AI agents to use reliably.
Your turn. Let me know your experiences about mapping API to MCP in the comments section below.
Top comments (0)