Tools as a Service: Exposing Internal APIs to Agents via MCP
Transform internal APIs into MCP tools that any AI agent can discover and use. Cover API-to-MCP mapping, schema generation, and multi-tenant tool exposure.
Published on • September 7, 2026
AI Assistant

Every company has internal APIs that do useful things — create tickets, query databases, manage infrastructure. These APIs are built for developers, with documentation, SDKs, and integration guides. But agents do not read documentation. They need discoverable, self-describing tools with typed schemas and clear semantics.
The Model Context Protocol (MCP) provides exactly this: a standard way to expose functions as tools that any MCP-compatible agent can discover, understand, and invoke. In this post, we show how to transform your internal APIs into MCP tools — making them available to AI agents across your organization.
Why This Matters
Internal APIs are valuable but underutilized by AI agents. The typical agent workflow is:
- Human describes what they want
- Agent figures out which API to call
- Agent constructs the request
- Agent handles the response
Steps 2-4 are brittle when agents have to guess at API shapes, authentication, and error handling. MCP eliminates this by providing:
- Self-describing tools with JSON Schema input validation
- Automatic discovery so agents know what is available
- Standardized error handling across all tools
- Transport flexibility — stdio for local, HTTP for remote
The result: your internal APIs become a tool catalog that any MCP-compatible agent can use without custom integration code.
API-to-MCP Mapping Patterns
The mapping from REST API to MCP tool is not always one-to-one. Here are the common patterns:
Pattern 1: Direct Mapping
A simple REST endpoint maps directly to an MCP tool:
# REST API endpoint
@app.get("/api/users/{user_id}")
def get_user(user_id: str): ...
# Becomes an MCP tool
@mcp.tool()
async def get_user(user_id: str) -> str:
"""Get user details by ID.
Args:
user_id: The unique user identifier
"""
# Reuse the same business logic
user = await user_service.get_by_id(user_id)
return json.dumps({
"id": user.id,
"name": user.name,
"email": user.email,
"role": user.role,
})
Pattern 2: Aggregation
Combine multiple API calls into a single tool that represents a user workflow:
@mcp.tool()
async def get_user_context(user_id: str) -> str:
"""Get complete user context: profile, recent activity, and permissions.
Args:
user_id: The unique user identifier
"""
# Parallel API calls
profile, activity, permissions = await asyncio.gather(
user_service.get_profile(user_id),
activity_service.get_recent(user_id, limit=10),
permission_service.get_all(user_id),
)
return json.dumps({
"profile": profile,
"recent_activity": activity,
"permissions": permissions,
})
Pattern 3: Abstraction
Hide complex multi-step workflows behind a simple tool interface:
@mcp.tool()
async def onboard_user(name: str, email: str, team: str) -> str:
"""Onboard a new user: create account, assign team, send welcome email.
Args:
name: Full name of the new user
email: Work email address
team: Team to assign the user to (e.g., 'engineering', 'sales')
"""
# Step 1: Create user account
user = await user_service.create(name=name, email=email)
# Step 2: Assign to team
await team_service.add_member(team_id=team, user_id=user.id)
# Step 3: Send welcome email
await email_service.send(
to=email,
template="welcome",
context={"name": name, "team": team},
)
return json.dumps({
"status": "success",
"user_id": user.id,
"team": team,
"welcome_email_sent": True,
})
Schema Generation From OpenAPI
If you already have OpenAPI specs, you can auto-generate MCP tool definitions:
import yaml
from dataclasses import dataclass
@dataclass
class MCPToolDef:
name: str
description: str
input_schema: dict
def openapi_to_mcp_tools(spec_path: str) -> list[MCPToolDef]:
"""Convert OpenAPI spec to MCP tool definitions."""
with open(spec_path) as f:
spec = yaml.safe_load(f)
tools = []
for path, methods in spec.get("paths", {}).items():
for method, operation in methods.items():
if method not in ("get", "post", "put", "delete"):
continue
# Convert OpenAPI schema to JSON Schema for MCP
operation_id = operation.get("operationId", f"{method}_{path.replace('/', '_')}")
description = operation.get("summary", operation.get("description", ""))
# Extract parameters
properties = {}
required = []
for param in operation.get("parameters", []):
prop = {
"type": _map_openapi_type(param.get("schema", {}).get("type", "string")),
"description": param.get("description", ""),
}
properties[param["name"]] = prop
if param.get("required"):
required.append(param["name"])
# Extract request body schema
if "requestBody" in operation:
body_schema = operation["requestBody"].get("content", {}).get("application/json", {}).get("schema", {})
for prop_name, prop_def in body_schema.get("properties", {}).items():
properties[prop_name] = {
"type": _map_openapi_type(prop_def.get("type", "string")),
"description": prop_def.get("description", ""),
}
required.extend(body_schema.get("required", []))
tools.append(MCPToolDef(
name=operation_id,
description=description,
input_schema={
"type": "object",
"properties": properties,
"required": required,
},
))
return tools
def _map_openapi_type(openapi_type: str) -> str:
mapping = {"integer": "integer", "number": "number", "boolean": "boolean"}
return mapping.get(openapi_type, "string")
Multi-Tenant Tool Exposure
When exposing tools to multiple teams or tenants, implement isolation:
from mcp.server import MCPServer
from dataclasses import dataclass
@dataclass
class TenantConfig:
tenant_id: str
allowed_scopes: list[str]
rate_limit: int # requests per minute
class MultiTenantMCPServer:
def __init__(self):
self.mcp = MCPServer("multi-tenant-tools")
self.tenants: dict[str, TenantConfig] = {}
self.rate_counters: dict[str, int] = {}
def register_tenant(self, config: TenantConfig):
self.tenants[config.tenant_id] = config
async def handle_tool_call(self, tool_name: str, args: dict, tenant_id: str) -> str:
"""Handle a tool call with tenant isolation."""
# Verify tenant exists
config = self.tenants.get(tenant_id)
if not config:
return json.dumps({"error": "Unknown tenant"})
# Check rate limit
if self.rate_counters.get(tenant_id, 0) >= config.rate_limit:
return json.dumps({"error": "Rate limit exceeded"})
# Check scope
required_scope = self._get_tool_scope(tool_name)
if required_scope not in config.allowed_scopes:
return json.dumps({"error": f"Insufficient permissions. Required: {required_scope}"})
# Increment rate counter
self.rate_counters[tenant_id] = self.rate_counters.get(tenant_id, 0) + 1
# Execute with tenant context
result = await self._execute_tool(tool_name, args, tenant_id)
return result
def _get_tool_scope(self, tool_name: str) -> str:
scope_map = {
"get_user": "users:read",
"create_ticket": "tickets:write",
"query_database": "data:read",
}
return scope_map.get(tool_name, "default")
Getting Started Tutorial
Step 1: Identify Your APIs
List the internal APIs you want to expose. Start with read-only operations that are safe for agents to call:
# Safe to expose (read-only)
GET /api/users/{id}
GET /api/orders?status=pending
GET /api/knowledge/search?q=...
# Requires careful scoping (write operations)
POST /api/tickets
PUT /api/users/{id}/role
POST /api/refunds
Step 2: Wrap With MCP
Create an MCP server with tool definitions:
from mcp.server import MCPServer
import httpx
mcp = MCPServer("internal-tools")
API_BASE = "https://api.internal.yourcompany.com"
@mcp.tool()
async def search_knowledge(query: str, category: str = "all") -> str:
"""Search the internal knowledge base.
Args:
query: Search query string
category: Filter by category (all, engineering, product, hr)
"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{API_BASE}/knowledge/search",
params={"q": query, "category": category},
headers={"X-API-Key": "$INTERNAL_KEY"},
)
results = response.json()
# Format for agent consumption
return "\n".join([
f"[{r['title']}] {r['snippet']} (Source: {r['source']})"
for r in results[:5]
])
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
Step 3: Configure Authentication
from mcp.server.auth import BearerTokenAuth
mcp = MCPServer(
"internal-tools",
auth=BearerTokenAuth(
# Use your existing auth provider
jwks_uri="https://auth.internal/.well-known/jwks.json",
issuer="https://auth.internal",
),
)
Step 4: Register With Discovery
import httpx
async def register_with_discovery():
"""Register this MCP server with the central registry."""
async with httpx.AsyncClient() as client:
await client.post(
"https://discovery.internal/register",
json={
"name": "internal-tools",
"url": "https://mcp.internal.yourcompany.com",
"capabilities": {
"tools": [
{"name": "search_knowledge", "description": "Search internal knowledge base"},
{"name": "get_user", "description": "Get user details"},
{"name": "create_ticket", "description": "Create support ticket"},
]
},
"auth_required": True,
},
)
Step 5: Test With an MCP Client
from mcp import ClientSession
from mcp.client.auth import BearerTokenAuth
async def test_tools():
async with ClientSession(
url="https://mcp.internal.yourcompany.com/mcp",
auth=BearerTokenAuth(token="your-test-token"),
) as session:
# Discover available tools
tools = await session.list_tools()
for tool in tools.tools:
print(f"Tool: {tool.name} - {tool.description}")
# Call a tool
result = await session.call_tool(
"search_knowledge",
{"query": "deployment process", "category": "engineering"},
)
print(result.content)
Best Practices
- Start with read-only tools: Write operations carry risk. Expose read tools first, then carefully add write tools with confirmation requirements.
- Document tool semantics: The tool description is what the LLM reads to decide when to use it. Make descriptions precise and include edge cases.
- Version your tools: When tool behavior changes, release a new version. The
server/discovermethod lets clients detect version changes. - Implement audit logging: Log every tool call with tenant ID, user ID, arguments, and results. This is essential for compliance and debugging.
- Provide tool-level sandboxing: For write tools, implement dry-run modes that show what would happen without executing.
Common Pitfalls
- Exposing internal IDs: Do not leak database IDs or internal identifiers. Use opaque, externally-safe identifiers.
- Forgetting error responses: Tools that can fail need clear error messages. “Internal server error” is not actionable for an agent.
- Over-tooling: Ten tools is manageable. Fifty tools overwhelm the LLM’s tool selection. Group related operations into composite tools.
- Ignoring concurrency: Multiple agents may call the same tool simultaneously. Ensure your tools are idempotent where possible.
Conclusion and Next Steps
MCP transforms internal APIs from developer-only tools into a universal tool catalog for AI agents. Start with read-only wrappers around your most-used APIs, add authentication, and register with a discovery service.
The payoff is significant: once your APIs are MCP tools, any agent — Claude, ChatGPT, or your custom orchestrator — can use them without writing integration code. Your internal tools become a service for agents, not just for developers.