Skip to content
Blog

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:

  1. Human describes what they want
  2. Agent figures out which API to call
  3. Agent constructs the request
  4. 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/discover method 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.