diff --git a/AGENTS.md b/AGENTS.md index bcefd95..1ebb2fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,9 @@ This file defines how coding agents should work in this repository. - Keep the MCP server compatible with standard MCP lifecycle/tool methods (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`) while preserving legacy direct JSON-RPC method calls for local scripts. +- MCP `tools/list` responses must be read-only snapshots from the caller's + perspective; local embedding callers must not be able to mutate the server's + shared tool registry through a returned response object. - JSON-RPC handlers must reject explicit request versions other than `"2.0"` with `-32600 Invalid Request` before applying notification silence, including id-less notifications, while preserving omitted-version legacy/internal calls diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 74888ac..b241512 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -46,6 +46,10 @@ tool names: - `status` - `list_gpus` +`tools/list` responses are snapshots. Local embedding callers can inspect or +modify the returned tool metadata without mutating the server's shared MCP tool +registry. + Minimal client config: ```yaml diff --git a/src/keep_gpu/mcp/server.py b/src/keep_gpu/mcp/server.py index 761eb33..8f0f67a 100644 --- a/src/keep_gpu/mcp/server.py +++ b/src/keep_gpu/mcp/server.py @@ -29,6 +29,7 @@ import argparse import atexit +import copy import json import mimetypes import sys @@ -964,7 +965,7 @@ def _handle_request(server: KeepGPUServer, payload: Any) -> Optional[Dict[str, A if method == "initialize": result = _mcp_initialize_result(params) elif method == "tools/list": - result = {"tools": MCP_TOOLS} + result = {"tools": copy.deepcopy(MCP_TOOLS)} elif method == "tools/call": result = _mcp_call_tool(server, params) else: diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index a060f11..4681c79 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -1,3 +1,4 @@ +import copy import json import math import os @@ -1381,6 +1382,48 @@ def test_mcp_tools_list_exposes_keepgpu_actions(): assert job_id_schema["pattern"] == JOB_ID_PATTERN_TEXT +def test_mcp_tools_list_returns_snapshot_not_mutable_registry(): + server = make_server() + tools_req = {"jsonrpc": "2.0", "id": 20, "method": "tools/list"} + original_tools = copy.deepcopy(server_module.MCP_TOOLS) + + try: + first_resp = _handle_request(server, tools_req) + returned_tools = first_resp["result"]["tools"] + returned_tools[0]["name"] = "poisoned" + returned_tools[0]["inputSchema"]["properties"]["gpu_ids"]["items"][ + "minimum" + ] = 999 + + second_resp = _handle_request(server, tools_req) + second_tools = {tool["name"]: tool for tool in second_resp["result"]["tools"]} + + assert set(second_tools) == {"start_keep", "stop_keep", "status", "list_gpus"} + assert ( + second_tools["start_keep"]["inputSchema"]["properties"]["gpu_ids"]["items"][ + "minimum" + ] + == 0 + ) + + call_resp = _handle_request( + server, + { + "jsonrpc": "2.0", + "id": 21, + "method": "tools/call", + "params": { + "name": "start_keep", + "arguments": {"job_id": "mcp-tools-snapshot", "gpu_ids": [0]}, + }, + }, + ) + + assert call_resp["result"]["isError"] is False + finally: + server_module.MCP_TOOLS[:] = original_tools + + def test_jsonrpc_rejects_explicit_invalid_request_version(): server = make_server() req = {"jsonrpc": "1.0", "id": 12, "method": "tools/list"}