Skip to content

[BUG]: Gateway tools/refresh never updates tool annotations leading to stale behavior hints after upstream changes #5697

Description

@jbonatakis

🐞 Bug Summary

POST /gateways/{id}/tools/refresh does not propagate changes to a tool's annotations. An annotation-only change upstream is reported as no change (toolsUpdated: 0), and even when refresh does detect a change and rewrite the tool (e.g. its description changed), the annotations keep their gateway-registration-time values. The only way to pick up changed annotations is to delete and re-register the gateway.

Since annotations carry the MCP behavior hints (readOnlyHint, destructiveHint, …) that clients may use for confirmation/auto-approval decisions, a tool whose upstream definition became more dangerous keeps presenting its old, safer-looking hints to every downstream client indefinitely.


🧩 Affected Component

  • mcpgateway - API
  • mcpgateway - UI (admin panel)
  • mcpgateway.wrapper - stdio wrapper
  • Federation or Transports
  • CLI, Makefiles, or shell scripts
  • Container setup (Docker/Podman/Compose)
  • Other (explain below)

🔁 Steps to Reproduce

  1. Start the gateway and mint an admin token:

    docker run -d --name mcpgateway -p 4444:4444 \
      -e HOST=0.0.0.0 \
      -e JWT_SECRET_KEY=my-test-key-but-now-longer-than-32-bytes \
      -e JWT_AUDIENCE=mcpgateway-api -e JWT_ISSUER=mcpgateway \
      -e AUTH_REQUIRED=true \
      -e SSRF_ALLOW_LOCALHOST=true -e SSRF_ALLOW_PRIVATE_NETWORKS=true \
      -e PLATFORM_ADMIN_EMAIL=admin@example.com -e PLATFORM_ADMIN_PASSWORD=changeme \
      -e DATABASE_URL=sqlite:///./mcp.db \
      ghcr.io/ibm/mcp-context-forge:v1.0.5
    
    export TOKEN=$(docker exec mcpgateway python3 -m mcpgateway.utils.create_jwt_token \
      --username admin@example.com --admin --exp 60 \
      --secret my-test-key-but-now-longer-than-32-bytes 2>/dev/null | tail -1)
  2. Save this minimal MCP server (Python 3 stdlib only) as mcp_server.py. It serves one tool; --risk changes only the tool's annotations, --desc changes only its description:

    #!/usr/bin/env python3
    import argparse, json
    from http.server import BaseHTTPRequestHandler, HTTPServer
    
    p = argparse.ArgumentParser()
    p.add_argument("--port", type=int, default=3911)
    p.add_argument("--risk", default="low")
    p.add_argument("--desc", default="A demo tool.")
    a = p.parse_args()
    
    TOOL = {
        "name": "demo",
        "description": a.desc,
        "inputSchema": {"type": "object", "properties": {}},
        "annotations": {"readOnlyHint": True, "x-risk": a.risk},
    }
    
    class H(BaseHTTPRequestHandler):
        def do_POST(self):
            req = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0) or 0) or b"{}")
            rid = req.get("id")
            if rid is None:
                self.send_response(202); self.end_headers(); return
            m = req.get("method")
            if m == "initialize":
                result = {"protocolVersion": req.get("params", {}).get("protocolVersion", "2025-03-26"),
                          "capabilities": {"tools": {}},
                          "serverInfo": {"name": "annotation-repro", "version": "1.0"}}
            elif m == "tools/list":
                result = {"tools": [TOOL]}
            else:
                result = {}
            body = json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Mcp-Session-Id", "repro-session")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        def log_message(self, *args): pass
    
    HTTPServer(("127.0.0.1", a.port), H).serve_forever()
  3. Run it and register it as a gateway:

    python3 mcp_server.py --port 3911 --risk low &
    
    curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
      -d '{"name":"annotation-repro","url":"http://host.docker.internal:3911/mcp","transport":"STREAMABLEHTTP"}' \
      http://localhost:4444/gateways

    GET /tools confirms the annotations were imported at registration: {"readOnlyHint": true, "x-risk": "low"}.

  4. Change only the annotations upstream, then refresh:

    kill %1; python3 mcp_server.py --port 3911 --risk high &
    curl -s -X POST -H "Authorization: Bearer $TOKEN" \
      http://localhost:4444/gateways/<gateway_id>/tools/refresh
  5. Control: change the description as well, refresh again:

    kill %1; python3 mcp_server.py --port 3911 --risk high --desc "A demo tool. (v2)" &
    curl -s -X POST -H "Authorization: Bearer $TOKEN" \
      http://localhost:4444/gateways/<gateway_id>/tools/refresh

🤔 Expected Behavior

  1. Step 4: the annotation-only change should be detected (counted in toolsUpdated) and applied to the catalog, the same way description and inputSchema changes are — annotations are part of the same MCP Tool object returned by tools/list.
  2. Step 5, at minimum: when refresh does rewrite a tool, its annotations should be taken from the current upstream tools/list response rather than kept from registration time.

If preserving admin-customized annotations is a concern (they can be edited via PUT /tools/{id}), the description field already solves that exact problem with its original_description dual-field pattern — the same pattern would fit annotations (see Additional Context).


📓 Logs / Error Output

No errors are raised — the behavior is silent. Observed responses:

Step 4 (upstream now serves "x-risk": "high", verified directly against the MCP server):

// POST /gateways/{id}/tools/refresh
{"success": true, "toolsAdded": 0, "toolsRemoved": 0, "toolsUpdated": 0}

// GET /tools — annotations unchanged
{"tool": "demo", "description": "A demo tool.", "annotations": {"readOnlyHint": true, "x-risk": "low"}}

Step 5 (description change detected and applied — annotations still stale):

// POST /gateways/{id}/tools/refresh
{"success": true, "toolsAdded": 0, "toolsRemoved": 0, "toolsUpdated": 1}

// GET /tools — description updated, annotations frozen at registration-time value
{"tool": "demo", "description": "A demo tool. (v2)", "annotations": {"readOnlyHint": true, "x-risk": "low"}}

🧠 Environment Info

Key Value
Version or commit 1.0.5 (ghcr.io/ibm/mcp-context-forge@sha256:fda7f025a21dd7df75afde0576432e28d39880e1914301c93b323aaf4bfcfcf0, tag v1.0.5)
Runtime Python 3.12.13, Gunicorn (container defaults)
Platform / OS macOS (Docker Desktop host)
Container Docker

MCP protocol version reported by /version: 2025-11-25. Also reproduced
identically on 1.0.4 (sha256:18fb40e1…), and the root cause described
below is present on current main.


🧩 Additional Context (optional)

Root cause — in mcpgateway/services/gateway_service.py, _update_or_create_tools() (current main):

  • The change-detection block (basic_fields_changed / schema_fields_changed / auth_fields_changed / title_changed) compares url, original_description, request_type, headers, input_schema, output_schema, jsonpath_filter, extension_metadata, auth fields, visibility, and title — but never annotations. An annotation-only change therefore never sets fields_to_update.
  • The update branch assigns all of those fields to existing_tool — but never existing_tool.annotations — so even when another field triggers the rewrite, annotations keep their registration-time value. Annotations are only written in _create_db_tool() (the creation path).

Suggested fix shape — since annotations can also be customized by admins via PUT /tools/{id}, a blind overwrite would clobber those edits. The description field already handles this with a dual-field pattern (original_description tracks upstream; the user-facing value is only overwritten when never customized). Tracking original_annotations, including it in change detection, and overwriting user-facing annotations only when uncustomized would sync upstream changes without losing admin overrides.

Related observation — annotation passthrough otherwise works well: virtual servers expose the annotations object (including non-standard keys) verbatim through MCP tools/list. The gap is only refresh's change detection/update path. Notably title — which sits in the same upstream metadata — is synced on refresh, so the current behavior is also internally inconsistent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriageIssues / Features awaiting triage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions