All skills
Skillintermediate

Resources-Based MCP Server Pattern

<overview> **Achieving 98% Context Reduction Through On-Demand Operation Loading**

Claude Code Knowledge Pack7/10/2026

Overview

Resources-Based MCP Server Pattern

<overview> **Achieving 98% Context Reduction Through On-Demand Operation Loading**

When wrapping large APIs (50+ operations) in MCP servers, traditional architecture consumes 15,000-30,000 tokens just loading tool definitions. This pattern reduces that overhead to ~300 tokens while maintaining full functionality.

This guide explains the architectural pattern used in production servers to achieve 90-98% context reduction. </overview>

The Problem

<traditional_architecture> Traditional MCP Server Architecture

Most MCP servers expose operations as individual tools:

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(name="operation_1", description="...", inputSchema={...}),
        Tool(name="operation_2", description="...", inputSchema={...}),
        Tool(name="operation_3", description="...", inputSchema={...}),
        # ... 78 more tools
    ]

Problem: Every tool definition is sent to Claude on every conversation start, consuming massive context before any actual work begins.

Real metrics with 81 operations:

  • Tool definitions: ~15,000 tokens
  • Context available for conversation: 185,000 tokens (200k - 15k)
  • Overhead: 7.5% of available context wasted on metadata

For APIs with 100+ operations, this can consume 20,000-30,000 tokens or more. </traditional_architecture>

The Solution

<resources_based_architecture> Resources-Based Architecture

Instead of loading all tools upfront, expose a minimal set of meta-tools for discovery and execution, with operation schemas stored as MCP resources that are loaded on-demand.

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(name="discover", description="Browse available operations"),
        Tool(name="get_schema", description="Get operation parameters"),
        Tool(name="execute", description="Execute an operation"),
        Tool(name="continue", description="Paginate large responses")
    ]

Result: Only 4 tool definitions loaded upfront (~300 tokens), with 81 operation schemas available as resources. </resources_based_architecture>

How It Works

<meta_tools_layer>

Meta-Tools Layer

Four tools handle all interactions:

discover - Operation Discovery

Tool(
    name="circle_discover",
    description="Browse all available Circle operations organized by category",
    inputSchema={"type": "object", "properties": {}}
)

Returns hierarchical tree of all available operations.

get_schema - Schema Retrieval

Tool(
    name="circle_get_schema",
    description="Get detailed schema for a specific operation",
    inputSchema={
        "type": "object",
        "properties": {
            "operation": {
                "type": "string",
                "description": "Operation identifier (e.g., 'posts.create')"
            }
        }
    }
)

Returns full parameter schema for one operation.

execute - Operation Execution

Tool(
    name="circle_execute",
    description="Execute a Circle operation with parameters",
    inputSchema={
        "type": "object",
        "properties": {
            "operation": {"type": "string"},
            "params": {"type": "object"}
        }
    }
)

Routes to actual implementation based on operation string.

continue - Pagination

Tool(
    name="circle_continue",
    description="Continue retrieving paginated results",
    inputSchema={
        "type": "object",
        "properties": {
            "session_id": {"type": "string"}
        }
    }
)

Handles chunked responses for large datasets. </meta_tools_layer>

<operations_schema>

Operations Schema File

All operation definitions live in operations.json:

{
  "operations": {
    "posts": {
      "list": {
        "name": "circle_list_posts",
        "description": "List posts in Circle",
        "inputSchema": {
          "type": "object",
          "properties": {
            "space_id": {"type": "integer"},
            "page": {"type": "integer"},
            "per_page": {"type": "integer"}
          }
        }
      },
      "create": {
        "name": "circle_create_post",
        "description": "Create a new post",
        "inputSchema": { ... }
      }
    },
    "members": { ... },
    "events": { ... }
  }
}

</operations_schema>

<mcp_resources>

MCP Resources API

Operations are exposed as resources with hierarchical URIs:

@server.list_resources()
async def list_resources() -> list[Resource]:
    resources = []

    # Index resource (full tree)
    resources.append(Resource(
        uri="circle://operations/index",
        name="Operations Index",
        description="Complete tree of all operations"
    ))

    # Category resources
    for category in OPERATIONS.keys():
        resources.append(Resource(
            uri=f"circle://operations/{category}",
            name=f"{category} Operations"
        ))

    # Individual operations
    for category, actions in OPERATIONS.items():
        for action, schema in actions.items():
            resources.append(Resource(
                uri=f"circle://operations/{category}/{action}",
                name=schema["name"],
                description=schema["description"]
            ))

    return resources

Claude can:

  • Browse circle://operations/index to see all operations
  • Read circle://operations/posts/create to get schema
  • Never loads operations it doesn't use in a conversation </mcp_resources>

<operation_dispatch>

Operation Dispatch

Map operation strings to actual implementations:

def _operation_to_tool_name(operation: str) -> str:
    """Convert 'posts.create' -> 'circle_create_post'"""
    parts = operation.split(".")
    category, action = parts
    return f"circle_{action}_{category.rstrip('s')}"

def _get_tool_handlers(client):
    """Build dispatch dictionary"""
    return {
        "circle_list_posts": client.list_posts,
        "circle_create_post": client.create_post,
        "circle_get_post": client.get_post,
        # ... all other operations
    }

@server.call_tool()
async def call_tool(name: str, arguments: Any):
    if name == "circle_execute":
        operation = arguments["operation"]
        params = arguments["params"]

        # Convert operation string to handler
        tool_name = _operation_to_tool_name(operation)
        handlers = _get_tool_handlers(client)
        handler = handlers[tool_name]

        # Execute
        result = await handler(**params)
        return [TextContent(type="text", text=json.dumps(result))]

</operation_dispatch>

Implementation Guide

<step_1>

Step 1: Design Your Operation Namespace

Organize operations hierarchically:

posts/
  ├── list
  ├── create
  ├── get
  ├── update
  └── delete
members/
  ├── list
  ├── create
  └── search
batch/
  ├── posts/
  │   └── delete
  └── members/
      └── create

</step_1>

<step_2>

Step 2: Extract Tool Definitions to JSON

Move all tool schemas from code to data:

Before (in Python):

Tool(
    name="circle_create_post",
    description="Create a new post",
    inputSchema={
        "type": "object",
        "properties": {
            "space_id": {"type": "integer"},
            "name": {"type": "string"},
            "body": {"type": "string"}
        },
        "required": ["space_id", "name", "body"]
    }
)

After (in operations.json):

{
  "operations": {
    "posts": {
      "create": {
        "name": "circle_create_post",
        "description": "Create a new post",
        "inputSchema": {
          "type": "object",
          "properties": {
            "space_id": {"type": "integer"},
            "name": {"type": "string"},
            "body": {"type": "string"}
          },
          "required": ["space_id", "name", "body"]
        }
      }
    }
  }
}

</step_2>

<step_3>

Step 3: Implement Meta-Tools

Create the 4 core meta-tools (see Meta-Tools Layer above). </step_3>

<step_4>

Step 4: Build Operation Dispatcher

def _get_tool_handlers(client):
    """Map operation names to actual implementations"""
    return {
        "your_operation_1": client.method_1,
        "your_operation_2": client.method_2,
        # ... all operations
    }

def _operation_to_tool_name(operation: str) -> str:
    """Convert 'category.action' to 'your_operation_name'"""
    # Your naming convention logic
    pass

@server.call_tool()
async def call_tool(name: str, arguments: Any):
    if name == "your_execute":
        operation = arguments["operation"]
        params = arguments["params"]

        tool_name = _operation_to_tool_name(operation)
        handlers = _get_tool_handlers(client)
        handler = handlers[tool_name]

        return await handler(**params)

</step_4>

<step_5>

Step 5: Expose as MCP Resources

@server.list_resources()
async def list_resources() -> list[Resource]:
    resources = []
    for category, actions in OPERATIONS.items():
        for action, schema in actions.items():
            resources.append(Resource(
                uri=f"yourapp://operations/{category}/{action}",
                name=schema["name"],
                description=schema["description"]
            ))
    return resources

@server.read_resource()
async def read_resource(uri: str) -> str:
    # Parse URI and return operation schema
    category, action = parse_uri(uri)
    schema = OPERATIONS[category][action]
    return json.dumps(schema, indent=2)

</step_5>

<step_6>

Step 6: Add Pagination (Optional)

For large responses (>20k tokens), implement chunking:

def chunk_by_tokens(data: dict, chunk_size: int = 15000) -> list[dict]:
    """Split large responses into chunks"""
    if 'data' not in data:
        return [data]

    items = data['data']
    chunks = []
    current_chunk = []
    current_tokens = 0

    for item in items:
        item_tokens = estimate_tokens(item)
        if current_tokens + item_tokens > chunk_size:
            chunks.append({'data': current_chunk})
            current_chunk = [item]
            current_tokens = item_tokens
        else:
            current_chunk.append(item)
            current_tokens += item_tokens

    if current_chunk:
        chunks.append({'data': current_chunk})

    return chunks

</step_6>

Trade-offs

<advantages> ### Advantages

Massive context savings (90-98% reduction) ✅ Scales to any number of operations (100, 200, 500+ operations) ✅ Cleaner code (schemas in data, not code) ✅ Easy to maintain (add operations by editing JSON) ✅ Better for LLMs (only loads relevant operations per conversation) </advantages>

<disadvantages> ### Disadvantages

Extra discovery step (Claude must call discover or get_schema first) ❌ More complex implementation (dispatch layer, resources API) ❌ Slightly slower first call (needs to fetch schema before executing) ❌ Not ideal for < 20 operations (overhead not worth it) </disadvantages>

<performance_characteristics>

Performance Characteristics

First operation in conversation:

  1. Claude calls discover to browse operations (~300 tokens response)
  2. Claude calls get_schema for specific operation (~200 tokens response)
  3. Claude calls execute with parameters
  4. Total: 3 tool calls vs 1 in traditional approach

Subsequent operations:

  1. Claude already knows operations, just calls execute
  2. Total: 1 tool call (same as traditional)

Net result: Small overhead on first operation, massive context savings overall. </performance_characteristics>

When to Use This Pattern

<use_when>

✅ Use resources-based architecture when:

  • You have 3+ operations - Context is precious at every scale, not just large APIs
  • Operations are grouped logically - Natural hierarchy exists (CRUD, categories)
  • Not all operations used per conversation - Most conversations only use 2-5 operations
  • Context window is precious - You need maximum space for actual conversation
  • Operations change frequently - Easier to maintain in JSON than code

Updated threshold: Use on-demand discovery for ANY MCP server with 3+ operations.

Traditional wisdom says "only for 20+ operations," but context efficiency matters at every scale. Even 40% savings (200-500 tokens) compounds across conversations when:

  • Conversations span many turns
  • Multiple MCP servers are loaded
  • Working with large codebases
  • Every token counts toward the 200k context window </use_when>

<dont_use_when>

❌ Stick with traditional tools when:

  • You have 1-2 operations only - Overhead not worth the complexity
  • All operations used in most conversations - No benefit to on-demand loading
  • Simplicity is priority - Traditional approach is easier to understand </dont_use_when>

Context Savings by Operation Count

OperationsTraditionalOn-DemandSavings% Saved
1-2~200~300-100-50%
3~300~30000%
5~500~30020040%
10~1,000~30070070%
15~1,500~3001,20080%
50~5,000~3004,70094%
100~10,000~3009,70097%

Threshold: 3+ operations → use on-demand discovery pattern

Real-World Results

<circle_mcp_metrics>

Circle MCP Server Metrics

Before (tools-based v1):

  • 81 tool definitions loaded upfront
  • ~15,000 tokens consumed
  • Context available: 185,000 tokens
  • Overhead: 7.5%

After (resources-based v2):

  • 4 meta-tools loaded upfront
  • ~300 tokens consumed
  • Context available: 199,700 tokens
  • Overhead: 0.15%
  • Savings: 98% context reduction </circle_mcp_metrics>

<typical_conversation>

Typical Conversation Pattern

Conversation using 3 operations:

Traditional approach:

  • Load 81 tools: 15,000 tokens
  • Use 3 operations: 0 tokens (already loaded)
  • Total overhead: 15,000 tokens

Resources-based approach:

  • Load 4 meta-tools: 300 tokens
  • Discover operations: 300 tokens (first time only)
  • Get 3 schemas: 600 tokens (200 each, first time only)
  • Execute 3 operations: 0 tokens (dispatch only)
  • Total overhead: 1,200 tokens

Savings: 92% even with discovery overhead </typical_conversation>

Example: Building a GitHub MCP Server

<github_example> Let's apply this pattern to a hypothetical GitHub API server with 50+ operations:

1. Design Namespace

repos/
  ├── list
  ├── create
  ├── get
  └── delete
issues/
  ├── list
  ├── create
  ├── update
  └── close
pulls/
  ├── list
  ├── create
  ├── merge
  └── review
actions/
  ├── list_workflows
  ├── trigger_workflow
  └── get_run

**2. Cr