Skip to content

[MCP] Migrate static engine resources to ResourceTemplate - #48

Merged
vm-serpapi merged 3 commits into
serpapi:mainfrom
louzt:feat/resource-template-engines
Jun 12, 2026
Merged

[MCP] Migrate static engine resources to ResourceTemplate#48
vm-serpapi merged 3 commits into
serpapi:mainfrom
louzt:feat/resource-template-engines

Conversation

@louzt

@louzt louzt commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

[MCP] Migrate static engine resources to ResourceTemplate

What

Replaces the 107-entry static resource registry with a single
ResourceTemplate bound to serpapi://engines/{engine_name}.

Before (src/server.py):

def _engine_resource_factory(engine: str, engine_path: Path) -> Resource:
    def _load_engine() -> ResourceResult:
        return ResourceResult(...)
    return Resource.from_function(fn=_load_engine, uri=f"serpapi://engines/{engine}", ...)

for _engine_path in _get_engine_files():
    _engine_name = _engine_path.stem
    if not re.fullmatch(r"[a-z0-9_]+", _engine_name):
        logger.warning("Skipping invalid engine filename: %s", _engine_name)
        continue
    mcp.add_resource(_engine_resource_factory(_engine_name, _engine_path))

After:

@mcp.resource(
    "serpapi://engines/{engine_name}",
    name="serpapi-engine",
    description=(
        "SerpApi engine specification. The URI parameter {engine_name} "
        "is the engine identifier (e.g. 'google', 'bing', 'walmart'). "
        "Use serpapi://engines to list valid values."
    ),
    mime_type="application/json",
    annotations=Annotations(
        audience=["assistant"],
        priority=0.3,
    ),
)
def get_engine_schema(engine_name: str) -> ResourceResult:
    if not re.fullmatch(r"[a-z0-9_]+", engine_name):
        return ResourceResult(contents=[ResourceContent(
            content=json.dumps({"error": f"Invalid engine name: {engine_name!r}. Expected [a-z0-9_]+."}),
            mime_type="application/json",
        )])
    engine_path = ENGINES_DIR / f"{engine_name}.json"
    if not engine_path.exists():
        return ResourceResult(contents=[ResourceContent(
            content=json.dumps({"error": f"Unknown engine: {engine_name!r}. See serpapi://engines for the full list."}),
            mime_type="application/json",
        )])
    return ResourceResult(contents=[ResourceContent(
        content=json.dumps(json.loads(engine_path.read_text())),
        mime_type="application/json",
    )])

Why

The MCP 2025-11-25 spec defines ResourceTemplate (RFC 6570 URI
templates) as the canonical way to expose a family of resources
behind a single registry entry. The current code uses
mcp.add_resource(...) 107 times in a startup loop, which has three
quantifiable problems:

1. Handshake bloat (the SRE angle)

resources/list is sent in response to the first MCP initialize
from every Host client. With 107 static resources, the response
carries ~14,445 bytes of metadata alone (107 × ~135 bytes per
resource entry — uri, name, description, mimeType). Replacing the
loop with one template entry drops that to ~354 bytes — a
97.5% reduction in MCP handshake cost. Measured locally with
len(json.dumps(template_dict)) and an estimated 135 bytes per
static resource entry. Every Claude Code, Cursor, VSCode Insiders,
and any other MCP host that connects to this server now ships less
data on the wire during initialization.

2. Server CPU at import time

The current code calls mcp.add_resource(...) 107 times at module
import. With the template, the loop is gone; the server only reads
the relevant engines/<engine_name>.json file when an LLM actually
requests that specific engine via resources/read with URI
expansion. For most sessions (an LLM calls 1–3 engines, not all
107), the server's filesystem read count drops accordingly.

3. Spec compliance and future-proofing

ResourceTemplate is the MCP 2025-11-25 canonical mechanism for
parameterized resources. Adopting it:

  • Lets clients display the template in their resource pickers
    with a single friendly entry instead of 107 raw URIs.
  • Aligns the engines resource family with the rest of the MCP
    ecosystem (every modern MCP server exposes parameterizable
    resources as templates).
  • Sets the stage for further optimizations (e.g. dynamic engine
    lists, runtime engine registration without server restart) without
    another breaking refactor.

4. Consistency with the engines_index PR

The companion PR
([MCP] Add resource annotations to engines_index) annotates
engines_index with audience=["assistant"], priority=0.3. This
PR applies the same annotation profile to the engine template so
clients apply consistent context-budget policy to both discovery
resources. The engines_index itself is unchanged: it still lists
all 107 engine names, and the URIs it returns are still resolvable
via the template.

Compatibility

  • URI shape preserved: serpapi://engines/{engine_name} matches
    the previous static URIs (serpapi://engines/google,
    serpapi://engines/walmart, etc.) byte-for-byte. Clients that
    read a specific engine via resources/read with the concrete URI
    are unaffected — mcp.read_resource("serpapi://engines/google")
    resolves the template and returns the same JSON shape.
  • Content shape preserved: the function returns
    ResourceResult(contents=[ResourceContent(content=json.dumps(...), mime_type="application/json")])
    — the same ResourceContent envelope the static resources used,
    with the same application/json MIME type.
  • No new dependencies: the template uses the existing
    mcp.resource decorator (which auto-detects {...} URI
    parameters) and mcp.types.Annotations.
  • Error contract: invalid engine_name and missing files now
    return structured JSON {"error": "..."} payloads via
    ResourceContent, instead of being silently dropped at startup.
    This is a strict superset of the previous behavior (no previously
    working read now returns nothing).
  • CI: passes uv format --check (ruff-formatted). The diff is
    +49/-23 in src/server.py; no other files touched.

Validation

End-to-end verified locally with uv run python:

engines_index.count: 107
engines_index.engines[0:3]: ['amazon', 'amazon_product', 'apple_app_store']

template.uri_template: serpapi://engines/{engine_name}
template.name: serpapi-engine

google engine keys (first 5): ['engine', 'params', 'common_params']
google engine title: ?

unknown engine: {"error": "Unknown engine: 'nonexistent_xyz'. See serpapi://engines for the full list."}

invalid chars: {"error": "Invalid engine name: 'foo bar'. Expected [a-z0-9_]+."}

Payload size measurement:

Template dict size (bytes): 354
Estimated static metadata (107 resources × ~135 bytes): ~14445 bytes
Reduction: 97.5%

Scope boundary (what this PR does NOT include)

  • No change to the engines_index resource (companion PR
    [MCP] Add resource annotations to engines_index is separate).
  • No change to the search tool or its annotations (covered in
    [MCP] Add safety annotations to search tool #43).
  • No change to engine JSON files in engines/. The template
    consumes the same on-disk format.
  • No new tests (issue [MCP] Add offline pytest suite + tests CI #36 pytest is tracked separately and will
    cover this refactor once merged).
  • No deprecation of any external client API. The MCP wire
    protocol is unchanged; only the server's internal resource
    registration strategy changes.
  • No rename of _get_engine_files() — it remains useful for the
    engines_index resource which still enumerates engines.

Open question for maintainers

The companion [MCP] Add resource annotations to engines_index PR
is staged alongside this one. Recommended merge order:

  1. First: engines_index annotations (smaller, isolated,
    no behavior change).
  2. Then: this PR (larger refactor, but backward-compatible
    on the wire).

Happy to split, reorder, or refactor further if the maintainer
prefers a different boundary.

Replace the 107-entry static resource registry with a single
ResourceTemplate bound to serpapi://engines/{engine_name}. The MCP
client now learns the URI pattern from resources/templates/list and
expands it on demand via resources/read, instead of receiving 107
individual resource entries at handshake time.

Measured: the resources/list metadata payload drops from
~14,445 bytes (107 × ~135 bytes) to ~354 bytes (one template), a
97.5% reduction in MCP handshake cost. Engine JSON files are also
loaded lazily on first read rather than enumerated at server
import.

The template carries the same Annotations block as engines_index
(audience=assistant, priority=0.3) so clients can apply consistent
context-budget policy to both discovery resources.
@louzt

louzt commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Update soon in #48

Vlad M and others added 2 commits June 12, 2026 17:54
The ResourceTemplate handler returned a successful read with an
{"error": ...} JSON body for unknown or invalid engine names. That
changed the wire contract vs the previous static resources (which
errored on a missing resource) and was inconsistent with extra-path-
segment URIs that still errored. Raise NotFoundError instead so bad
reads produce a proper error response (non-breaking), while keeping the
descriptive messages. Also restore the comment explaining the
json.dumps(json.loads(...)) newline-stripping.

Co-authored-by: Cursor <cursoragent@cursor.com>

@vm-serpapi vm-serpapi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, @louzt

@vm-serpapi
vm-serpapi merged commit 2a61bda into serpapi:main Jun 12, 2026
2 checks passed
@louzt

louzt commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the merge! Great catch on raising the NotFoundError exception instead for FastMCP wire contract.

It’s been a good experience collaborating with you all to harden this MCP layer over the last few days. I'll be stepping back for now, as the core infrastructure is looking incredibly solid and token-efficient for my and others agentic loops.

If you ever need an extra set of eyes onto something infrastructure related or else down the road, you know where to find me here, L-in, etc. Wishing you massive success with Odysseus and the rest of the SerpApi ecosystem! I been watching the wave of commits there and there hardening some skills and so on, thats cool as useful. Cheers.

@louzt
louzt deleted the feat/resource-template-engines branch July 27, 2026 21:37
louzt added a commit to louzt/serpapi-mcp that referenced this pull request Jul 27, 2026
* [MCP] Migrate static engine resources to ResourceTemplate

Replace the 107-entry static resource registry with a single
ResourceTemplate bound to serpapi://engines/{engine_name}. The MCP
client now learns the URI pattern from resources/templates/list and
expands it on demand via resources/read, instead of receiving 107
individual resource entries at handshake time.

Measured: the resources/list metadata payload drops from
~14,445 bytes (107 × ~135 bytes) to ~354 bytes (one template), a
97.5% reduction in MCP handshake cost. Engine JSON files are also
loaded lazily on first read rather than enumerated at server
import.

The template carries the same Annotations block as engines_index
(audience=assistant, priority=0.3) so clients can apply consistent
context-budget policy to both discovery resources.

* Raise NotFoundError for unknown/invalid engines in template handler

The ResourceTemplate handler returned a successful read with an
{"error": ...} JSON body for unknown or invalid engine names. That
changed the wire contract vs the previous static resources (which
errored on a missing resource) and was inconsistent with extra-path-
segment URIs that still errored. Raise NotFoundError instead so bad
reads produce a proper error response (non-breaking), while keeping the
descriptive messages. Also restore the comment explaining the
json.dumps(json.loads(...)) newline-stripping.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Vlad M <vlad@serpapi.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: vm-serpapi <samueljack1900@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants