Skip to content

perf(server): MCP schema generation eagerly expands the full tool catalog at startup #1042

Description

@bokelley

Summary

Creating an MCP server eagerly generates and fully inlines input/output JSON Schemas for the entire AdCP tool catalog, even when the handler advertises only a small subset. In a downstream seller this makes server construction CPU- and memory-heavy enough to produce startup timeouts under parallel CI load.

This is not a 7.0.2 regression: 7.0.1 and 7.0.2 are effectively identical. The same path is slightly larger/slower in 8.0.0b3.

Measurements

Environment: Python 3.13.13 on macOS arm64. Definition size is compact JSON-serialized size; RSS is resource.getrusage(...).ru_maxrss converted to MiB.

SDK schema generation definitions before definitions after max RSS before max RSS after
7.0.1 6.746s 0.022 MB 50.287 MB 335.7 MiB 573.8 MiB
7.0.2 6.825s 0.022 MB 50.287 MB 336.0 MiB 574.2 MiB
8.0.0b3 7.987s 0.024 MB 53.596 MB 488.1 MiB 734.6 MiB

For the downstream seller on 7.0.2:

  • Normal full app construction: roughly 11–12s, of which about 7.2s is _build_mcp_and_a2a_app.
  • Under coverage: the SDK transport construction phase rises to 15.3s.
  • With 13 CPU workers active: that phase rises to 14.0s.
  • The SDK has 64 tool definitions; the handler advertises 14, but schema generation still calls _model_to_json_schema 126 times for the global input/output surface.

The largest expanded definitions included:

Tool input + output size Advertised by this handler?
comply_test_controller 8.319 MB No
get_task_status 8.307 MB Yes
build_creative 7.924 MB No
preview_creative 5.632 MB No

Profile

A cProfile run scoped to _build_mcp_and_a2a_app attributed the work as follows (profiled timings include profiler overhead):

  • 139,037,285 function calls
  • _ensure_pydantic_schemas_applied: 22.999s cumulative
  • _model_to_json_schema: 126 calls, 22.994s cumulative
  • _inline_refs: 126 calls, 16.554s cumulative
  • _resolve: about 7.1 million recursive calls
  • copy.deepcopy: about 13.0 million calls, 12.925s cumulative

The eager schema pass also appears to partially defeat the import-memory improvement from #959: server construction touches the complete model surface after import and retains the expanded global definitions.

Minimal measurement

import json
import resource
import time

import adcp
from adcp.server import mcp_tools


def rss_mib():
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (1024 * 1024)


def encoded_size(value):
    return len(json.dumps(value, separators=(",", ":")))


before_size = encoded_size(mcp_tools.ADCP_TOOL_DEFINITIONS)
before_rss = rss_mib()
started = time.perf_counter()
mcp_tools._ensure_pydantic_schemas_applied()
elapsed = time.perf_counter() - started

print(
    {
        "version": adcp.__version__,
        "generation_s": elapsed,
        "definitions_before": before_size,
        "definitions_after": encoded_size(mcp_tools.ADCP_TOOL_DEFINITIONS),
        "max_rss_before_mib": before_rss,
        "max_rss_after_mib": rss_mib(),
    }
)

Possible directions

  1. Determine the advertised tool set before schema generation and generate schemas only for those tools.
  2. Precompute/version-pin the MCP schemas as package assets instead of rebuilding them at process startup.
  3. Reduce recursive deep-copying during $ref expansion, potentially by memoizing resolved definitions or retaining $defs where client compatibility permits.
  4. Add startup time, expanded schema size, and peak-memory regression benchmarks.

Selective generation seems especially valuable: in this example, 50 of 64 tool surfaces are generated and retained but never advertised.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions