🐞 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
🔁 Steps to Reproduce
-
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)
-
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()
-
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"}.
-
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
-
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
- 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.
- 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.
🐞 Bug Summary
POST /gateways/{id}/tools/refreshdoes not propagate changes to a tool'sannotations. 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- APImcpgateway- UI (admin panel)mcpgateway.wrapper- stdio wrapper🔁 Steps to Reproduce
Start the gateway and mint an admin token:
Save this minimal MCP server (Python 3 stdlib only) as
mcp_server.py. It serves one tool;--riskchanges only the tool's annotations,--descchanges only its description:Run it and register it as a gateway:
GET /toolsconfirms the annotations were imported at registration:{"readOnlyHint": true, "x-risk": "low"}.Change only the annotations upstream, then refresh:
Control: change the description as well, refresh again:
🤔 Expected Behavior
toolsUpdated) and applied to the catalog, the same waydescriptionandinputSchemachanges are — annotations are part of the same MCP Tool object returned bytools/list.annotationsshould be taken from the current upstreamtools/listresponse rather than kept from registration time.If preserving admin-customized annotations is a concern (they can be edited via
PUT /tools/{id}), thedescriptionfield already solves that exact problem with itsoriginal_descriptiondual-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):Step 5 (description change detected and applied — annotations still stale):
🧠 Environment Info
1.0.5(ghcr.io/ibm/mcp-context-forge@sha256:fda7f025a21dd7df75afde0576432e28d39880e1914301c93b323aaf4bfcfcf0, tagv1.0.5)Python 3.12.13, Gunicorn(container defaults)macOS (Docker Desktop host)DockerMCP protocol version reported by
/version:2025-11-25. Also reproducedidentically on
1.0.4(sha256:18fb40e1…), and the root cause describedbelow is present on current
main.🧩 Additional Context (optional)
Root cause — in
mcpgateway/services/gateway_service.py,_update_or_create_tools()(currentmain):basic_fields_changed/schema_fields_changed/auth_fields_changed/title_changed) comparesurl,original_description,request_type,headers,input_schema,output_schema,jsonpath_filter,extension_metadata, auth fields,visibility, andtitle— but neverannotations. An annotation-only change therefore never setsfields_to_update.existing_tool— but neverexisting_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. Thedescriptionfield already handles this with a dual-field pattern (original_descriptiontracks upstream; the user-facing value is only overwritten when never customized). Trackingoriginal_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. Notablytitle— which sits in the same upstream metadata — is synced on refresh, so the current behavior is also internally inconsistent.