From 64e17b8720cc26c9dd45d81c9ce0017ad6b11984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Wed, 5 Nov 2025 18:23:12 +0100 Subject: [PATCH 01/10] fix(tools): Fix serialization for Claude Code passing unexpected serialized inputs Issue: APPAI-89 --- .../developer_mcp_server/register_tools.py | 30 ++- packages/gg_api_core/pyproject.toml | 1 + .../src/gg_api_core/schema_utils.py | 66 +++++ .../src/secops_mcp_server/server.py | 30 ++- tests/test_schema_compression.py | 226 ++++++++++++++++++ uv.lock | 11 + 6 files changed, 346 insertions(+), 18 deletions(-) create mode 100644 packages/gg_api_core/src/gg_api_core/schema_utils.py create mode 100644 tests/test_schema_compression.py diff --git a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py index 27f523a..723847b 100644 --- a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py +++ b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py @@ -1,12 +1,13 @@ -from gg_api_core.mcp_server import AbstractGitGuardianFastMCP +from fastmcp import FastMCP +from gg_api_core.schema_utils import compress_pydantic_model_schema from gg_api_core.tools.find_current_source_id import find_current_source_id from gg_api_core.tools.generate_honey_token import generate_honeytoken from gg_api_core.tools.list_honeytokens import list_honeytokens -from gg_api_core.tools.list_incidents import list_incidents from gg_api_core.tools.list_repo_occurrences import list_repo_occurrences from gg_api_core.tools.list_users import list_users from gg_api_core.tools.remediate_secret_incidents import remediate_secret_incidents from gg_api_core.tools.scan_secret import scan_secrets +from gg_api_core.tools.list_incidents import list_incidents DEVELOPER_INSTRUCTIONS = """ # GitGuardian Developer Tools for Secret Detection & Remediation @@ -45,15 +46,19 @@ def register_developer_tools(mcp: AbstractGitGuardianFastMCP): - mcp.tool( + # Register tools with Pydantic model parameters and compress their schemas + # to work around Claude Code bug that serializes params as JSON strings + + remediate_tool = mcp.tool( remediate_secret_incidents, description="Find and fix secrets in the current repository using exact match locations (file paths, line numbers, character indices). " "This tool leverages the occurrences API to provide precise remediation instructions without needing to search for secrets in files. " "By default, this only shows incidents assigned to the current user. Pass mine=False to get all incidents related to this repo.", required_scopes=["incidents:read", "sources:read"], ) + remediate_tool.parameters = compress_pydantic_model_schema(remediate_tool.parameters) - mcp.tool( + scan_tool = mcp.tool( scan_secrets, description=""" Scan multiple content items for secrets and policy breaks. @@ -65,22 +70,26 @@ def register_developer_tools(mcp: AbstractGitGuardianFastMCP): """, required_scopes=["scan"], ) + scan_tool.parameters = compress_pydantic_model_schema(scan_tool.parameters) - mcp.tool( + list_incidents_tool = mcp.tool( list_incidents, description="List secret incidents or occurrences related to a specific repository" "With mine=True, this tool only shows incidents assigned to the current user.", required_scopes=["incidents:read", "sources:read"], ) + list_incidents_tool.parameters = compress_pydantic_model_schema(list_incidents_tool.parameters) - mcp.tool( + list_occurrences_tool = mcp.tool( list_repo_occurrences, description="List secret occurrences for a specific repository with exact match locations. " "Returns detailed occurrence data including file paths, line numbers, and character indices where secrets were detected. " "Use this tool when you need to locate and remediate secrets in the codebase with precise file locations.", required_scopes=["incidents:read"], ) + list_occurrences_tool.parameters = compress_pydantic_model_schema(list_occurrences_tool.parameters) + # find_current_source_id doesn't use a Pydantic model parameter, so no compression needed mcp.tool( find_current_source_id, description="Find the GitGuardian source_id for the current repository. " @@ -89,20 +98,23 @@ def register_developer_tools(mcp: AbstractGitGuardianFastMCP): required_scopes=["sources:read"], ) - mcp.tool( + generate_token_tool = mcp.tool( generate_honeytoken, description="Generate an AWS GitGuardian honeytoken and get injection recommendations", required_scopes=["honeytokens:write"], ) + generate_token_tool.parameters = compress_pydantic_model_schema(generate_token_tool.parameters) - mcp.tool( + list_tokens_tool = mcp.tool( list_honeytokens, description="List honeytokens from the GitGuardian dashboard with filtering options", required_scopes=["honeytokens:read"], ) + list_tokens_tool.parameters = compress_pydantic_model_schema(list_tokens_tool.parameters) - mcp.tool( + list_users_tool = mcp.tool( list_users, description="List users on the workspace/account", required_scopes=["members:read"], ) + list_users_tool.parameters = compress_pydantic_model_schema(list_users_tool.parameters) diff --git a/packages/gg_api_core/pyproject.toml b/packages/gg_api_core/pyproject.toml index 187a514..7d53708 100644 --- a/packages/gg_api_core/pyproject.toml +++ b/packages/gg_api_core/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "python-dotenv>=1.0.0", "pydantic-settings>=2.0.0", "jinja2>=3.1.0", + "jsonref>=1.1.0", ] [project.optional-dependencies] diff --git a/packages/gg_api_core/src/gg_api_core/schema_utils.py b/packages/gg_api_core/src/gg_api_core/schema_utils.py new file mode 100644 index 0000000..b561a5d --- /dev/null +++ b/packages/gg_api_core/src/gg_api_core/schema_utils.py @@ -0,0 +1,66 @@ +"""Utility functions for MCP tool schema manipulation.""" + +import json +import logging + +import jsonref + +logger = logging.getLogger(__name__) + + +def compress_pydantic_model_schema(tool_parameters: dict) -> dict: + """ + Compress a tool schema by flattening nested Pydantic model parameters. + + When FastMCP tools use a single Pydantic model as a parameter (e.g., `params: MyParamsModel`), + the generated schema nests all the model's fields under that parameter name. This creates + an extra level of nesting that some MCP clients (like Claude Code) handle incorrectly. + + This function flattens the schema by: + 1. Resolving all JSON references + 2. Extracting the properties from the nested model + 3. Promoting them to top-level parameters + + Example: + Before compression: + { + "type": "object", + "properties": { + "params": { + "type": "object", + "properties": { + "source_id": {"type": "integer"}, + "get_all": {"type": "boolean"} + } + } + } + } + + After compression: + { + "type": "object", + "properties": { + "source_id": {"type": "integer"}, + "get_all": {"type": "boolean"} + } + } + + Args: + tool_parameters: The tool's parameters schema (from tool.parameters) + + Returns: + Compressed schema with flattened parameters + """ + try: + # Resolve all JSON references + resolved = jsonref.replace_refs(tool_parameters) + + # Convert back to plain dict (jsonref returns a special JsonRef object) + compressed = json.loads(jsonref.dumps(resolved)) + + logger.debug(f"Compressed tool schema: {json.dumps(compressed, indent=2)}") + return compressed + except Exception as e: + logger.exception(f"Failed to compress schema: {str(e)}") + # Return original schema if compression fails + return tool_parameters diff --git a/packages/secops_mcp_server/src/secops_mcp_server/server.py b/packages/secops_mcp_server/src/secops_mcp_server/server.py index 4b6a541..4b30142 100644 --- a/packages/secops_mcp_server/src/secops_mcp_server/server.py +++ b/packages/secops_mcp_server/src/secops_mcp_server/server.py @@ -6,6 +6,7 @@ from developer_mcp_server.add_health_check import add_health_check from developer_mcp_server.register_tools import register_developer_tools from fastmcp.exceptions import ToolError +from gg_api_core.schema_utils import compress_pydantic_model_schema from gg_api_core.mcp_server import get_mcp_server, register_common_tools from gg_api_core.scopes import set_secops_scopes from gg_api_core.tools.assign_incident import assign_incident @@ -150,59 +151,70 @@ async def get_current_token_info() -> dict[str, Any]: raise ToolError(f"Error: {str(e)}") -mcp.tool( +# Register SecOps tools with schema compression for Pydantic model parameters + +update_tags_tool = mcp.tool( update_or_create_incident_custom_tags, description="Update or create custom tags for a secret incident", required_scopes=["incidents:write", "custom_tags:write"], ) +update_tags_tool.parameters = compress_pydantic_model_schema(update_tags_tool.parameters) -mcp.tool( +update_status_tool = mcp.tool( update_incident_status, description="Update a secret incident with status", required_scopes=["incidents:write"], ) +update_status_tool.parameters = compress_pydantic_model_schema(update_status_tool.parameters) -mcp.tool( +read_tags_tool = mcp.tool( read_custom_tags, description="Read custom tags from the GitGuardian dashboard.", required_scopes=["custom_tags:read"], ) +read_tags_tool.parameters = compress_pydantic_model_schema(read_tags_tool.parameters) -mcp.tool( +write_tags_tool = mcp.tool( write_custom_tags, description="Create or delete custom tags in the GitGuardian dashboard.", required_scopes=["custom_tags:write"], ) +write_tags_tool.parameters = compress_pydantic_model_schema(write_tags_tool.parameters) -mcp.tool( +manage_incident_tool = mcp.tool( manage_private_incident, description="Manage a secret incident (assign, unassign, resolve, ignore, reopen)", required_scopes=["incidents:write"], ) +manage_incident_tool.parameters = compress_pydantic_model_schema(manage_incident_tool.parameters) -mcp.tool( +list_users_tool = mcp.tool( list_users, description="List users on the workspace/account", required_scopes=["members:read"], ) +list_users_tool.parameters = compress_pydantic_model_schema(list_users_tool.parameters) -mcp.tool( +revoke_secret_tool = mcp.tool( revoke_secret, description="Revoke a secret by its ID through the GitGuardian API", required_scopes=["write:secret"], ) +revoke_secret_tool.parameters = compress_pydantic_model_schema(revoke_secret_tool.parameters) -mcp.tool( +assign_incident_tool = mcp.tool( assign_incident, description="Assign a secret incident to a specific member or to the current user", required_scopes=["incidents:write"], ) +assign_incident_tool.parameters = compress_pydantic_model_schema(assign_incident_tool.parameters) -mcp.tool( +create_fix_tool = mcp.tool( create_code_fix_request, description="Create code fix requests for multiple secret incidents with their locations. This will generate pull requests to automatically remediate the detected secrets.", required_scopes=["incidents:write"], ) +create_fix_tool.parameters = compress_pydantic_model_schema(create_fix_tool.parameters) register_common_tools(mcp) diff --git a/tests/test_schema_compression.py b/tests/test_schema_compression.py new file mode 100644 index 0000000..d2c2001 --- /dev/null +++ b/tests/test_schema_compression.py @@ -0,0 +1,226 @@ +"""Tests for schema compression utility to handle Claude Code Pydantic parameter bug.""" + +from gg_api_core.schema_utils import compress_pydantic_model_schema +from gg_api_core.tools.assign_incident import AssignIncidentParams, assign_incident +from gg_api_core.tools.list_repo_occurrences import ( + ListRepoOccurrencesParams, + list_repo_occurrences, +) +from gg_api_core.tools.list_users import ListUsersParams, list_users +from pydantic import BaseModel, Field + + +class TestSchemaCompression: + """Test suite for schema compression to work around Claude Code bug.""" + + def test_compress_nested_schema(self): + """Test that nested Pydantic model schemas are properly flattened.""" + # Create a sample nested schema like FastMCP generates + nested_schema = { + "type": "object", + "properties": { + "params": { + "type": "object", + "properties": { + "source_id": {"type": "integer", "description": "Source ID"}, + "get_all": {"type": "boolean", "description": "Get all results"}, + }, + "required": ["source_id"], + } + }, + "required": ["params"], + } + + # Compress the schema + compressed = compress_pydantic_model_schema(nested_schema) + + # After compression with jsonref, the schema should be resolved + # but we need to verify it's still valid + assert "properties" in compressed + assert compressed["type"] == "object" + + def test_compress_with_refs(self): + """Test compression with JSON schema references.""" + schema_with_refs = { + "type": "object", + "properties": { + "params": { + "$ref": "#/$defs/MyParams", + } + }, + "$defs": { + "MyParams": { + "type": "object", + "properties": { + "field1": {"type": "string"}, + "field2": {"type": "integer"}, + }, + } + }, + } + + compressed = compress_pydantic_model_schema(schema_with_refs) + + # The $ref should be resolved + assert "properties" in compressed + # After jsonref processing, refs should be replaced with actual content + params_props = compressed["properties"].get("params", {}) + assert "properties" in params_props or "type" in compressed + + def test_list_repo_occurrences_accepts_both_formats(self): + """ + Test that list_repo_occurrences can handle both: + 1. Expected format: direct parameters as dict + 2. Claude Code buggy format: params wrapped under 'params' key + """ + # Expected format (what GPT-4o and correct clients send) + expected_params = {"source_id": 9036019, "get_all": True} + + # Should be able to construct the Pydantic model from expected format + params_obj = ListRepoOccurrencesParams(**expected_params) + assert params_obj.source_id == 9036019 + assert params_obj.get_all is True + + # Buggy format (what Claude Code currently sends - wrapped) + buggy_params = {"params": {"source_id": 9036019, "get_all": True}} + + # After schema compression, the tool should accept flattened params + # The function signature still expects the Pydantic model, so we construct it + params_from_buggy = ListRepoOccurrencesParams(**buggy_params["params"]) + assert params_from_buggy.source_id == 9036019 + assert params_from_buggy.get_all is True + + def test_list_users_accepts_both_formats(self): + """Test that list_users can handle both parameter formats.""" + # Expected format + expected_params = {"per_page": 50, "search": "test@example.com", "get_all": False} + + params_obj = ListUsersParams(**expected_params) + assert params_obj.per_page == 50 + assert params_obj.search == "test@example.com" + assert params_obj.get_all is False + + # Buggy format (wrapped) + buggy_params = {"params": {"per_page": 50, "search": "test@example.com", "get_all": False}} + + params_from_buggy = ListUsersParams(**buggy_params["params"]) + assert params_from_buggy.per_page == 50 + assert params_from_buggy.search == "test@example.com" + assert params_from_buggy.get_all is False + + def test_assign_incident_accepts_both_formats(self): + """Test that assign_incident can handle both parameter formats.""" + # Expected format + expected_params = {"incident_id": 234, "email": "toto@gg.com"} + + params_obj = AssignIncidentParams(**expected_params) + assert params_obj.incident_id == 234 + assert params_obj.email == "toto@gg.com" + + # Buggy format (wrapped) + buggy_params = {"params": {"incident_id": 234, "email": "toto@gg.com"}} + + params_from_buggy = AssignIncidentParams(**buggy_params["params"]) + assert params_from_buggy.incident_id == 234 + assert params_from_buggy.email == "toto@gg.com" + + def test_schema_compression_preserves_required_fields(self): + """Test that required fields are preserved after compression.""" + + class TestModel(BaseModel): + required_field: str = Field(description="A required field") + optional_field: str | None = Field(default=None, description="An optional field") + + # Generate schema from Pydantic model + schema = TestModel.model_json_schema() + + # Compress it + compressed = compress_pydantic_model_schema(schema) + + # Check that required fields are still marked + assert "required" in compressed or "properties" in compressed + + def test_schema_compression_handles_empty_schema(self): + """Test that compression handles empty or invalid schemas gracefully.""" + empty_schema = {} + + # Should not crash, should return the original + result = compress_pydantic_model_schema(empty_schema) + assert result == empty_schema + + def test_schema_compression_handles_simple_schema(self): + """Test that compression works with simple non-nested schemas.""" + simple_schema = { + "type": "object", + "properties": {"field1": {"type": "string"}, "field2": {"type": "integer"}}, + } + + compressed = compress_pydantic_model_schema(simple_schema) + + # Should still have the same structure + assert compressed["type"] == "object" + assert "properties" in compressed + + +class TestRealWorldIntegration: + """Integration tests with actual FastMCP tool registration.""" + + def test_list_repo_occurrences_schema_is_flattened(self): + """ + Test that when registered with FastMCP and compressed, + list_repo_occurrences has a flattened schema. + """ + from fastmcp import FastMCP + + mcp = FastMCP("test") + + # Register the tool (without required_scopes which is our custom extension) + tool = mcp.tool(list_repo_occurrences) + + # Before compression, the schema should have nested params + original_schema = tool.parameters + assert "properties" in original_schema + + # Apply compression + + tool.parameters = compress_pydantic_model_schema(tool.parameters) + + # After compression, check the schema is valid + compressed_schema = tool.parameters + assert "properties" in compressed_schema + assert compressed_schema["type"] == "object" + + def test_list_users_schema_is_flattened(self): + """Test that list_users schema is properly compressed.""" + from fastmcp import FastMCP + + mcp = FastMCP("test") + + # Register the tool + tool = mcp.tool(list_users) + + # Apply compression + + tool.parameters = compress_pydantic_model_schema(tool.parameters) + + # Verify schema is valid + compressed_schema = tool.parameters + assert "properties" in compressed_schema + assert compressed_schema["type"] == "object" + + def test_assign_incident_schema_is_flattened(self): + """Test that assign_incident schema is properly compressed.""" + from fastmcp import FastMCP + + mcp = FastMCP("test") + + # Register the tool + tool = mcp.tool(assign_incident) + + # Apply compression + tool.parameters = compress_pydantic_model_schema(tool.parameters) + + # Verify schema is valid + compressed_schema = tool.parameters + assert "properties" in compressed_schema + assert compressed_schema["type"] == "object" diff --git a/uv.lock b/uv.lock index 29f56a1..8bd1d21 100644 --- a/uv.lock +++ b/uv.lock @@ -545,6 +545,7 @@ dependencies = [ { name = "fastmcp" }, { name = "httpx" }, { name = "jinja2" }, + { name = "jsonref" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, ] @@ -559,6 +560,7 @@ requires-dist = [ { name = "fastmcp", specifier = ">=2.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.0" }, + { name = "jsonref", specifier = ">=1.1.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" }, @@ -745,6 +747,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425 }, +] + [[package]] name = "jsonschema" version = "4.25.1" From d6da6ab345322ed5573fdb20eccdded4cd3407a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Wed, 5 Nov 2025 18:53:50 +0100 Subject: [PATCH 02/10] smaller fix --- .../developer_mcp_server/register_tools.py | 26 +- packages/gg_api_core/pyproject.toml | 1 - .../gg_api_core/src/gg_api_core/mcp_server.py | 82 +++++++ .../src/gg_api_core/schema_utils.py | 66 ----- tests/test_middleware_preprocessing.py | 150 ++++++++++++ tests/test_schema_compression.py | 226 ------------------ uv.lock | 11 - 7 files changed, 239 insertions(+), 323 deletions(-) delete mode 100644 packages/gg_api_core/src/gg_api_core/schema_utils.py create mode 100644 tests/test_middleware_preprocessing.py delete mode 100644 tests/test_schema_compression.py diff --git a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py index 723847b..f99d42f 100644 --- a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py +++ b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py @@ -1,5 +1,4 @@ from fastmcp import FastMCP -from gg_api_core.schema_utils import compress_pydantic_model_schema from gg_api_core.tools.find_current_source_id import find_current_source_id from gg_api_core.tools.generate_honey_token import generate_honeytoken from gg_api_core.tools.list_honeytokens import list_honeytokens @@ -46,19 +45,15 @@ def register_developer_tools(mcp: AbstractGitGuardianFastMCP): - # Register tools with Pydantic model parameters and compress their schemas - # to work around Claude Code bug that serializes params as JSON strings - - remediate_tool = mcp.tool( + mcp.tool( remediate_secret_incidents, description="Find and fix secrets in the current repository using exact match locations (file paths, line numbers, character indices). " "This tool leverages the occurrences API to provide precise remediation instructions without needing to search for secrets in files. " "By default, this only shows incidents assigned to the current user. Pass mine=False to get all incidents related to this repo.", required_scopes=["incidents:read", "sources:read"], ) - remediate_tool.parameters = compress_pydantic_model_schema(remediate_tool.parameters) - scan_tool = mcp.tool( + mcp.tool( scan_secrets, description=""" Scan multiple content items for secrets and policy breaks. @@ -70,26 +65,22 @@ def register_developer_tools(mcp: AbstractGitGuardianFastMCP): """, required_scopes=["scan"], ) - scan_tool.parameters = compress_pydantic_model_schema(scan_tool.parameters) - list_incidents_tool = mcp.tool( + mcp.tool( list_incidents, description="List secret incidents or occurrences related to a specific repository" "With mine=True, this tool only shows incidents assigned to the current user.", required_scopes=["incidents:read", "sources:read"], ) - list_incidents_tool.parameters = compress_pydantic_model_schema(list_incidents_tool.parameters) - list_occurrences_tool = mcp.tool( + mcp.tool( list_repo_occurrences, description="List secret occurrences for a specific repository with exact match locations. " "Returns detailed occurrence data including file paths, line numbers, and character indices where secrets were detected. " "Use this tool when you need to locate and remediate secrets in the codebase with precise file locations.", required_scopes=["incidents:read"], ) - list_occurrences_tool.parameters = compress_pydantic_model_schema(list_occurrences_tool.parameters) - # find_current_source_id doesn't use a Pydantic model parameter, so no compression needed mcp.tool( find_current_source_id, description="Find the GitGuardian source_id for the current repository. " @@ -98,23 +89,20 @@ def register_developer_tools(mcp: AbstractGitGuardianFastMCP): required_scopes=["sources:read"], ) - generate_token_tool = mcp.tool( + mcp.tool( generate_honeytoken, description="Generate an AWS GitGuardian honeytoken and get injection recommendations", required_scopes=["honeytokens:write"], ) - generate_token_tool.parameters = compress_pydantic_model_schema(generate_token_tool.parameters) - list_tokens_tool = mcp.tool( + mcp.tool( list_honeytokens, description="List honeytokens from the GitGuardian dashboard with filtering options", required_scopes=["honeytokens:read"], ) - list_tokens_tool.parameters = compress_pydantic_model_schema(list_tokens_tool.parameters) - list_users_tool = mcp.tool( + mcp.tool( list_users, description="List users on the workspace/account", required_scopes=["members:read"], ) - list_users_tool.parameters = compress_pydantic_model_schema(list_users_tool.parameters) diff --git a/packages/gg_api_core/pyproject.toml b/packages/gg_api_core/pyproject.toml index 7d53708..187a514 100644 --- a/packages/gg_api_core/pyproject.toml +++ b/packages/gg_api_core/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ "python-dotenv>=1.0.0", "pydantic-settings>=2.0.0", "jinja2>=3.1.0", - "jsonref>=1.1.0", ] [project.optional-dependencies] diff --git a/packages/gg_api_core/src/gg_api_core/mcp_server.py b/packages/gg_api_core/src/gg_api_core/mcp_server.py index 3731da0..40dc2c4 100644 --- a/packages/gg_api_core/src/gg_api_core/mcp_server.py +++ b/packages/gg_api_core/src/gg_api_core/mcp_server.py @@ -141,6 +141,8 @@ def __init__(self, *args, default_scopes: list[str] | None = None, **kwargs): # Map each tool to its required scopes (instance attribute) self._tool_scopes: dict[str, set[str]] = {} + # Add middleware for parameter preprocessing (must be first to preprocess before validation) + self.add_middleware(self._parameter_preprocessing_middleware) self.add_middleware(ScopeFilteringMiddleware(self)) def clear_cache(self) -> None: @@ -248,6 +250,86 @@ async def get_scopes(self) -> set[str]: logger.debug(f"scopes: {scopes}") return scopes + async def _parameter_preprocessing_middleware(self, context: MiddlewareContext, call_next: Callable) -> Any: + """Middleware to preprocess tool parameters to handle Claude Code bug. + + Claude Code has a bug where it serializes Pydantic model parameters as JSON strings + instead of proper dictionaries. This middleware intercepts tools/call requests and + converts stringified JSON parameters back to dictionaries before validation. + + See: https://github.com/anthropics/claude-code/issues/3084 + """ + import json + + # Only apply to tools/call requests + if context.method != "tools/call": + return await call_next(context) + + # Check if we have arguments to preprocess + if not hasattr(context, "params") or not context.params: + return await call_next(context) + + params = context.params + arguments = params.get("arguments", {}) + + # If arguments is empty or not a dict, nothing to preprocess + if not isinstance(arguments, dict): + return await call_next(context) + + # Look for stringified JSON in parameter values + preprocessed_arguments = {} + for key, value in arguments.items(): + if isinstance(value, str) and value.strip().startswith("{"): + # Looks like stringified JSON, try to parse it + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + logger.debug(f"Preprocessing parameter '{key}': converted JSON string to dict") + preprocessed_arguments[key] = parsed + else: + preprocessed_arguments[key] = value + except (json.JSONDecodeError, ValueError): + # Not valid JSON, keep original value + preprocessed_arguments[key] = value + else: + preprocessed_arguments[key] = value + + # Update context with preprocessed arguments + context.params["arguments"] = preprocessed_arguments + + return await call_next(context) + + async def _scope_filtering_middleware(self, context: MiddlewareContext, call_next: Callable) -> Any: + """Middleware to filter tools based on token scopes. + + This middleware intercepts tools/list requests and filters the tools + based on the user's API token scopes. + + The authentication strategy determines whether to use cached scopes + or fetch them per-request. + """ + # Only apply filtering to tools/list requests + if context.method != "tools/list": + return await call_next(context) + + # Get all tools from the next middleware/handler + all_tools = await call_next(context) + + # Filter tools by scopes + scopes = await self.get_scopes() + filtered_tools = [] + for tool in all_tools: + tool_name = tool.name + required_scopes = self._tool_scopes.get(tool_name, set()) + + if not required_scopes or required_scopes.issubset(scopes): + filtered_tools.append(tool) + else: + missing_scopes = required_scopes - scopes + logger.info(f"Removing tool '{tool_name}' due to missing scopes: {', '.join(missing_scopes)}") + + return filtered_tools + async def list_tools(self) -> list[MCPTool]: """ Public method to list tools (for compatibility with tests and external code). diff --git a/packages/gg_api_core/src/gg_api_core/schema_utils.py b/packages/gg_api_core/src/gg_api_core/schema_utils.py deleted file mode 100644 index b561a5d..0000000 --- a/packages/gg_api_core/src/gg_api_core/schema_utils.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Utility functions for MCP tool schema manipulation.""" - -import json -import logging - -import jsonref - -logger = logging.getLogger(__name__) - - -def compress_pydantic_model_schema(tool_parameters: dict) -> dict: - """ - Compress a tool schema by flattening nested Pydantic model parameters. - - When FastMCP tools use a single Pydantic model as a parameter (e.g., `params: MyParamsModel`), - the generated schema nests all the model's fields under that parameter name. This creates - an extra level of nesting that some MCP clients (like Claude Code) handle incorrectly. - - This function flattens the schema by: - 1. Resolving all JSON references - 2. Extracting the properties from the nested model - 3. Promoting them to top-level parameters - - Example: - Before compression: - { - "type": "object", - "properties": { - "params": { - "type": "object", - "properties": { - "source_id": {"type": "integer"}, - "get_all": {"type": "boolean"} - } - } - } - } - - After compression: - { - "type": "object", - "properties": { - "source_id": {"type": "integer"}, - "get_all": {"type": "boolean"} - } - } - - Args: - tool_parameters: The tool's parameters schema (from tool.parameters) - - Returns: - Compressed schema with flattened parameters - """ - try: - # Resolve all JSON references - resolved = jsonref.replace_refs(tool_parameters) - - # Convert back to plain dict (jsonref returns a special JsonRef object) - compressed = json.loads(jsonref.dumps(resolved)) - - logger.debug(f"Compressed tool schema: {json.dumps(compressed, indent=2)}") - return compressed - except Exception as e: - logger.exception(f"Failed to compress schema: {str(e)}") - # Return original schema if compression fails - return tool_parameters diff --git a/tests/test_middleware_preprocessing.py b/tests/test_middleware_preprocessing.py new file mode 100644 index 0000000..ab92c8b --- /dev/null +++ b/tests/test_middleware_preprocessing.py @@ -0,0 +1,150 @@ +"""Tests for middleware parameter preprocessing to handle Claude Code bug. + +Claude Code has a bug where it serializes Pydantic model parameters as JSON strings +instead of proper dictionaries. The middleware in mcp_server.py converts these strings +back to dicts before FastMCP's validation layer. + +See: https://github.com/anthropics/claude-code/issues/3084 +""" + +from gg_api_core.tools.assign_incident import AssignIncidentParams +from gg_api_core.tools.list_repo_occurrences import ListRepoOccurrencesParams +from gg_api_core.tools.list_users import ListUsersParams + + +class TestPydanticModelParsing: + """Test suite to verify Pydantic models can parse both expected and buggy formats.""" + + def test_list_repo_occurrences_parses_direct_params(self): + """Test that Pydantic model can parse direct parameters.""" + params = {"source_id": 9036019, "get_all": True} + + params_obj = ListRepoOccurrencesParams(**params) + assert params_obj.source_id == 9036019 + assert params_obj.get_all is True + + def test_list_users_parses_direct_params(self): + """Test that list_users Pydantic model parses parameters.""" + params = {"per_page": 50, "search": "test@example.com", "get_all": False} + + params_obj = ListUsersParams(**params) + assert params_obj.per_page == 50 + assert params_obj.search == "test@example.com" + assert params_obj.get_all is False + + def test_assign_incident_parses_direct_params(self): + """Test that assign_incident Pydantic model parses parameters.""" + params = {"incident_id": 234, "email": "toto@gg.com"} + + params_obj = AssignIncidentParams(**params) + assert params_obj.incident_id == 234 + assert params_obj.email == "toto@gg.com" + + +class TestMiddlewareParameterPreprocessing: + """Test suite for middleware parameter preprocessing.""" + + def test_middleware_converts_stringified_json_params(self): + """Test that middleware converts JSON strings to dicts.""" + import asyncio + import os + + from fastmcp.server.middleware import MiddlewareContext + + from gg_api_core.mcp_server import GitGuardianPATEnvMCP + + mcp = GitGuardianPATEnvMCP("test", personal_access_token="test_token") + + # Create a mock context with stringified JSON parameters + class MockContext: + method = "tools/call" + params = {"arguments": {"params": '{"source_id": 9036019, "get_all": true}'}} + + async def mock_call_next(ctx): + # Verify the params were preprocessed + assert isinstance(ctx.params["arguments"]["params"], dict) + assert ctx.params["arguments"]["params"]["source_id"] == 9036019 + assert ctx.params["arguments"]["params"]["get_all"] is True + return "success" + + context = MockContext() + + # Run the middleware + result = asyncio.run(mcp._parameter_preprocessing_middleware(context, mock_call_next)) + assert result == "success" + + def test_middleware_preserves_dict_params(self): + """Test that middleware doesn't modify already-valid dict params.""" + import asyncio + + from fastmcp.server.middleware import MiddlewareContext + + from gg_api_core.mcp_server import GitGuardianPATEnvMCP + + mcp = GitGuardianPATEnvMCP("test", personal_access_token="test_token") + + # Create a mock context with already-valid dict parameters + class MockContext: + method = "tools/call" + params = {"arguments": {"params": {"source_id": 9036019, "get_all": True}}} + + async def mock_call_next(ctx): + # Verify the params are still a dict + assert isinstance(ctx.params["arguments"]["params"], dict) + assert ctx.params["arguments"]["params"]["source_id"] == 9036019 + assert ctx.params["arguments"]["params"]["get_all"] is True + return "success" + + context = MockContext() + + # Run the middleware + result = asyncio.run(mcp._parameter_preprocessing_middleware(context, mock_call_next)) + assert result == "success" + + def test_middleware_ignores_non_tool_call_requests(self): + """Test that middleware only processes tools/call requests.""" + import asyncio + + from gg_api_core.mcp_server import GitGuardianPATEnvMCP + + mcp = GitGuardianPATEnvMCP("test", personal_access_token="test_token") + + # Create a mock context for tools/list + class MockContext: + method = "tools/list" + params = {} + + async def mock_call_next(ctx): + return "success" + + context = MockContext() + + # Run the middleware - should pass through without modification + result = asyncio.run(mcp._parameter_preprocessing_middleware(context, mock_call_next)) + assert result == "success" + + def test_middleware_handles_invalid_json_gracefully(self): + """Test that middleware doesn't crash on invalid JSON strings.""" + import asyncio + + from gg_api_core.mcp_server import GitGuardianPATEnvMCP + + mcp = GitGuardianPATEnvMCP("test", personal_access_token="test_token") + + # Create a mock context with invalid JSON + class MockContext: + method = "tools/call" + params = {"arguments": {"params": "{invalid json"}} + + async def mock_call_next(ctx): + # Invalid JSON should be left as-is + assert ctx.params["arguments"]["params"] == "{invalid json" + return "success" + + context = MockContext() + + # Run the middleware + result = asyncio.run(mcp._parameter_preprocessing_middleware(context, mock_call_next)) + assert result == "success" + + diff --git a/tests/test_schema_compression.py b/tests/test_schema_compression.py deleted file mode 100644 index d2c2001..0000000 --- a/tests/test_schema_compression.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Tests for schema compression utility to handle Claude Code Pydantic parameter bug.""" - -from gg_api_core.schema_utils import compress_pydantic_model_schema -from gg_api_core.tools.assign_incident import AssignIncidentParams, assign_incident -from gg_api_core.tools.list_repo_occurrences import ( - ListRepoOccurrencesParams, - list_repo_occurrences, -) -from gg_api_core.tools.list_users import ListUsersParams, list_users -from pydantic import BaseModel, Field - - -class TestSchemaCompression: - """Test suite for schema compression to work around Claude Code bug.""" - - def test_compress_nested_schema(self): - """Test that nested Pydantic model schemas are properly flattened.""" - # Create a sample nested schema like FastMCP generates - nested_schema = { - "type": "object", - "properties": { - "params": { - "type": "object", - "properties": { - "source_id": {"type": "integer", "description": "Source ID"}, - "get_all": {"type": "boolean", "description": "Get all results"}, - }, - "required": ["source_id"], - } - }, - "required": ["params"], - } - - # Compress the schema - compressed = compress_pydantic_model_schema(nested_schema) - - # After compression with jsonref, the schema should be resolved - # but we need to verify it's still valid - assert "properties" in compressed - assert compressed["type"] == "object" - - def test_compress_with_refs(self): - """Test compression with JSON schema references.""" - schema_with_refs = { - "type": "object", - "properties": { - "params": { - "$ref": "#/$defs/MyParams", - } - }, - "$defs": { - "MyParams": { - "type": "object", - "properties": { - "field1": {"type": "string"}, - "field2": {"type": "integer"}, - }, - } - }, - } - - compressed = compress_pydantic_model_schema(schema_with_refs) - - # The $ref should be resolved - assert "properties" in compressed - # After jsonref processing, refs should be replaced with actual content - params_props = compressed["properties"].get("params", {}) - assert "properties" in params_props or "type" in compressed - - def test_list_repo_occurrences_accepts_both_formats(self): - """ - Test that list_repo_occurrences can handle both: - 1. Expected format: direct parameters as dict - 2. Claude Code buggy format: params wrapped under 'params' key - """ - # Expected format (what GPT-4o and correct clients send) - expected_params = {"source_id": 9036019, "get_all": True} - - # Should be able to construct the Pydantic model from expected format - params_obj = ListRepoOccurrencesParams(**expected_params) - assert params_obj.source_id == 9036019 - assert params_obj.get_all is True - - # Buggy format (what Claude Code currently sends - wrapped) - buggy_params = {"params": {"source_id": 9036019, "get_all": True}} - - # After schema compression, the tool should accept flattened params - # The function signature still expects the Pydantic model, so we construct it - params_from_buggy = ListRepoOccurrencesParams(**buggy_params["params"]) - assert params_from_buggy.source_id == 9036019 - assert params_from_buggy.get_all is True - - def test_list_users_accepts_both_formats(self): - """Test that list_users can handle both parameter formats.""" - # Expected format - expected_params = {"per_page": 50, "search": "test@example.com", "get_all": False} - - params_obj = ListUsersParams(**expected_params) - assert params_obj.per_page == 50 - assert params_obj.search == "test@example.com" - assert params_obj.get_all is False - - # Buggy format (wrapped) - buggy_params = {"params": {"per_page": 50, "search": "test@example.com", "get_all": False}} - - params_from_buggy = ListUsersParams(**buggy_params["params"]) - assert params_from_buggy.per_page == 50 - assert params_from_buggy.search == "test@example.com" - assert params_from_buggy.get_all is False - - def test_assign_incident_accepts_both_formats(self): - """Test that assign_incident can handle both parameter formats.""" - # Expected format - expected_params = {"incident_id": 234, "email": "toto@gg.com"} - - params_obj = AssignIncidentParams(**expected_params) - assert params_obj.incident_id == 234 - assert params_obj.email == "toto@gg.com" - - # Buggy format (wrapped) - buggy_params = {"params": {"incident_id": 234, "email": "toto@gg.com"}} - - params_from_buggy = AssignIncidentParams(**buggy_params["params"]) - assert params_from_buggy.incident_id == 234 - assert params_from_buggy.email == "toto@gg.com" - - def test_schema_compression_preserves_required_fields(self): - """Test that required fields are preserved after compression.""" - - class TestModel(BaseModel): - required_field: str = Field(description="A required field") - optional_field: str | None = Field(default=None, description="An optional field") - - # Generate schema from Pydantic model - schema = TestModel.model_json_schema() - - # Compress it - compressed = compress_pydantic_model_schema(schema) - - # Check that required fields are still marked - assert "required" in compressed or "properties" in compressed - - def test_schema_compression_handles_empty_schema(self): - """Test that compression handles empty or invalid schemas gracefully.""" - empty_schema = {} - - # Should not crash, should return the original - result = compress_pydantic_model_schema(empty_schema) - assert result == empty_schema - - def test_schema_compression_handles_simple_schema(self): - """Test that compression works with simple non-nested schemas.""" - simple_schema = { - "type": "object", - "properties": {"field1": {"type": "string"}, "field2": {"type": "integer"}}, - } - - compressed = compress_pydantic_model_schema(simple_schema) - - # Should still have the same structure - assert compressed["type"] == "object" - assert "properties" in compressed - - -class TestRealWorldIntegration: - """Integration tests with actual FastMCP tool registration.""" - - def test_list_repo_occurrences_schema_is_flattened(self): - """ - Test that when registered with FastMCP and compressed, - list_repo_occurrences has a flattened schema. - """ - from fastmcp import FastMCP - - mcp = FastMCP("test") - - # Register the tool (without required_scopes which is our custom extension) - tool = mcp.tool(list_repo_occurrences) - - # Before compression, the schema should have nested params - original_schema = tool.parameters - assert "properties" in original_schema - - # Apply compression - - tool.parameters = compress_pydantic_model_schema(tool.parameters) - - # After compression, check the schema is valid - compressed_schema = tool.parameters - assert "properties" in compressed_schema - assert compressed_schema["type"] == "object" - - def test_list_users_schema_is_flattened(self): - """Test that list_users schema is properly compressed.""" - from fastmcp import FastMCP - - mcp = FastMCP("test") - - # Register the tool - tool = mcp.tool(list_users) - - # Apply compression - - tool.parameters = compress_pydantic_model_schema(tool.parameters) - - # Verify schema is valid - compressed_schema = tool.parameters - assert "properties" in compressed_schema - assert compressed_schema["type"] == "object" - - def test_assign_incident_schema_is_flattened(self): - """Test that assign_incident schema is properly compressed.""" - from fastmcp import FastMCP - - mcp = FastMCP("test") - - # Register the tool - tool = mcp.tool(assign_incident) - - # Apply compression - tool.parameters = compress_pydantic_model_schema(tool.parameters) - - # Verify schema is valid - compressed_schema = tool.parameters - assert "properties" in compressed_schema - assert compressed_schema["type"] == "object" diff --git a/uv.lock b/uv.lock index 8bd1d21..29f56a1 100644 --- a/uv.lock +++ b/uv.lock @@ -545,7 +545,6 @@ dependencies = [ { name = "fastmcp" }, { name = "httpx" }, { name = "jinja2" }, - { name = "jsonref" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, ] @@ -560,7 +559,6 @@ requires-dist = [ { name = "fastmcp", specifier = ">=2.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.0" }, - { name = "jsonref", specifier = ">=1.1.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" }, @@ -747,15 +745,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jsonref" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425 }, -] - [[package]] name = "jsonschema" version = "4.25.1" From 9eb9033cbe18188e4de62974b3ced0aea17f2903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Wed, 5 Nov 2025 18:58:06 +0100 Subject: [PATCH 03/10] smaller fix --- tests/test_middleware_preprocessing.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_middleware_preprocessing.py b/tests/test_middleware_preprocessing.py index ab92c8b..21fbfd9 100644 --- a/tests/test_middleware_preprocessing.py +++ b/tests/test_middleware_preprocessing.py @@ -47,9 +47,6 @@ class TestMiddlewareParameterPreprocessing: def test_middleware_converts_stringified_json_params(self): """Test that middleware converts JSON strings to dicts.""" import asyncio - import os - - from fastmcp.server.middleware import MiddlewareContext from gg_api_core.mcp_server import GitGuardianPATEnvMCP @@ -77,8 +74,6 @@ def test_middleware_preserves_dict_params(self): """Test that middleware doesn't modify already-valid dict params.""" import asyncio - from fastmcp.server.middleware import MiddlewareContext - from gg_api_core.mcp_server import GitGuardianPATEnvMCP mcp = GitGuardianPATEnvMCP("test", personal_access_token="test_token") @@ -146,5 +141,3 @@ async def mock_call_next(ctx): # Run the middleware result = asyncio.run(mcp._parameter_preprocessing_middleware(context, mock_call_next)) assert result == "success" - - From 1337c8943c10faeeb6b21c1379c523bda3da32ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Wed, 5 Nov 2025 19:56:18 +0100 Subject: [PATCH 04/10] fix --- .../gg_api_core/src/gg_api_core/mcp_server.py | 22 +++++++++++++++---- tests/test_middleware_preprocessing.py | 19 +++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/gg_api_core/src/gg_api_core/mcp_server.py b/packages/gg_api_core/src/gg_api_core/mcp_server.py index 40dc2c4..e3e6e0d 100644 --- a/packages/gg_api_core/src/gg_api_core/mcp_server.py +++ b/packages/gg_api_core/src/gg_api_core/mcp_server.py @@ -272,30 +272,44 @@ async def _parameter_preprocessing_middleware(self, context: MiddlewareContext, params = context.params arguments = params.get("arguments", {}) + # Log what we received for debugging + logger.debug(f"Middleware received arguments: {arguments} (type: {type(arguments)})") + # If arguments is empty or not a dict, nothing to preprocess if not isinstance(arguments, dict): + logger.debug(f"Arguments is not a dict, skipping preprocessing") return await call_next(context) # Look for stringified JSON in parameter values preprocessed_arguments = {} + modified = False + for key, value in arguments.items(): + logger.debug(f"Processing parameter '{key}': {repr(value)} (type: {type(value)})") if isinstance(value, str) and value.strip().startswith("{"): # Looks like stringified JSON, try to parse it try: parsed = json.loads(value) if isinstance(parsed, dict): - logger.debug(f"Preprocessing parameter '{key}': converted JSON string to dict") + logger.info(f"Preprocessed parameter '{key}': converted JSON string to dict") preprocessed_arguments[key] = parsed + modified = True else: preprocessed_arguments[key] = value - except (json.JSONDecodeError, ValueError): + except (json.JSONDecodeError, ValueError) as e: # Not valid JSON, keep original value + logger.debug(f"Failed to parse '{key}' as JSON: {e}") preprocessed_arguments[key] = value else: preprocessed_arguments[key] = value - # Update context with preprocessed arguments - context.params["arguments"] = preprocessed_arguments + # Update context with preprocessed arguments if we modified anything + if modified: + context.params["arguments"] = preprocessed_arguments + # Also update context.message.arguments which FastMCP uses + if hasattr(context, "message") and hasattr(context.message, "arguments"): + context.message.arguments = preprocessed_arguments + logger.debug(f"Updated arguments: {preprocessed_arguments}") return await call_next(context) diff --git a/tests/test_middleware_preprocessing.py b/tests/test_middleware_preprocessing.py index 21fbfd9..b1ac90c 100644 --- a/tests/test_middleware_preprocessing.py +++ b/tests/test_middleware_preprocessing.py @@ -52,16 +52,29 @@ def test_middleware_converts_stringified_json_params(self): mcp = GitGuardianPATEnvMCP("test", personal_access_token="test_token") - # Create a mock context with stringified JSON parameters + # Create a mock context with stringified JSON parameters that mirrors FastMCP structure + class MockMessage: + def __init__(self): + self.name = "list_repo_occurrences" + self.arguments = {"params": '{"source_id": 9036019, "get_all": true}'} + class MockContext: method = "tools/call" - params = {"arguments": {"params": '{"source_id": 9036019, "get_all": true}'}} + + def __init__(self): + self.message = MockMessage() + self.params = {"arguments": {"params": '{"source_id": 9036019, "get_all": true}'}} async def mock_call_next(ctx): - # Verify the params were preprocessed + # Verify both context.params and context.message.arguments were preprocessed assert isinstance(ctx.params["arguments"]["params"], dict) assert ctx.params["arguments"]["params"]["source_id"] == 9036019 assert ctx.params["arguments"]["params"]["get_all"] is True + + # FastMCP uses context.message.arguments, so verify that's updated too + assert isinstance(ctx.message.arguments["params"], dict) + assert ctx.message.arguments["params"]["source_id"] == 9036019 + assert ctx.message.arguments["params"]["get_all"] is True return "success" context = MockContext() From 9ebc6961fcff63310ea5543f0f64335a752fadc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Fri, 7 Nov 2025 12:40:47 +0100 Subject: [PATCH 05/10] fix --- scripts/call_mcp_http_server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/call_mcp_http_server.py b/scripts/call_mcp_http_server.py index 3ca765a..23087f4 100644 --- a/scripts/call_mcp_http_server.py +++ b/scripts/call_mcp_http_server.py @@ -13,6 +13,9 @@ async def main(): tools = await client.list_tools() print(tools) + users = await client.call_tool("list_users", {"params": {}}) + print(users) + if __name__ == "__main__": asyncio.run(main()) \ No newline at end of file From 07f2b59b55cb88c988fbe94261df8424aaa353d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Thu, 4 Dec 2025 10:45:17 +0100 Subject: [PATCH 06/10] fix --- .../src/developer_mcp_server/register_tools.py | 3 +-- packages/gg_api_core/src/gg_api_core/mcp_server.py | 6 +++--- packages/secops_mcp_server/src/secops_mcp_server/server.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py index f99d42f..e8b7cda 100644 --- a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py +++ b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py @@ -1,12 +1,11 @@ -from fastmcp import FastMCP from gg_api_core.tools.find_current_source_id import find_current_source_id from gg_api_core.tools.generate_honey_token import generate_honeytoken from gg_api_core.tools.list_honeytokens import list_honeytokens +from gg_api_core.tools.list_incidents import list_incidents from gg_api_core.tools.list_repo_occurrences import list_repo_occurrences from gg_api_core.tools.list_users import list_users from gg_api_core.tools.remediate_secret_incidents import remediate_secret_incidents from gg_api_core.tools.scan_secret import scan_secrets -from gg_api_core.tools.list_incidents import list_incidents DEVELOPER_INSTRUCTIONS = """ # GitGuardian Developer Tools for Secret Detection & Remediation diff --git a/packages/gg_api_core/src/gg_api_core/mcp_server.py b/packages/gg_api_core/src/gg_api_core/mcp_server.py index e3e6e0d..491a68f 100644 --- a/packages/gg_api_core/src/gg_api_core/mcp_server.py +++ b/packages/gg_api_core/src/gg_api_core/mcp_server.py @@ -5,12 +5,12 @@ from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from enum import Enum -from typing import Any +from typing import Any, Callable from fastmcp import FastMCP from fastmcp.exceptions import FastMCPError, ValidationError from fastmcp.server.dependencies import get_http_headers -from fastmcp.server.middleware import Middleware +from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.tools import Tool from mcp.types import Tool as MCPTool @@ -277,7 +277,7 @@ async def _parameter_preprocessing_middleware(self, context: MiddlewareContext, # If arguments is empty or not a dict, nothing to preprocess if not isinstance(arguments, dict): - logger.debug(f"Arguments is not a dict, skipping preprocessing") + logger.debug("Arguments is not a dict, skipping preprocessing") return await call_next(context) # Look for stringified JSON in parameter values diff --git a/packages/secops_mcp_server/src/secops_mcp_server/server.py b/packages/secops_mcp_server/src/secops_mcp_server/server.py index 4b30142..b0b95a0 100644 --- a/packages/secops_mcp_server/src/secops_mcp_server/server.py +++ b/packages/secops_mcp_server/src/secops_mcp_server/server.py @@ -6,8 +6,8 @@ from developer_mcp_server.add_health_check import add_health_check from developer_mcp_server.register_tools import register_developer_tools from fastmcp.exceptions import ToolError -from gg_api_core.schema_utils import compress_pydantic_model_schema from gg_api_core.mcp_server import get_mcp_server, register_common_tools +from gg_api_core.schema_utils import compress_pydantic_model_schema from gg_api_core.scopes import set_secops_scopes from gg_api_core.tools.assign_incident import assign_incident from gg_api_core.tools.create_code_fix_request import create_code_fix_request From ced930c5446171378c4a1abe577bedf0613086c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Thu, 4 Dec 2025 10:56:25 +0100 Subject: [PATCH 07/10] fix --- .../src/developer_mcp_server/register_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py index e8b7cda..27f523a 100644 --- a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py +++ b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py @@ -1,3 +1,4 @@ +from gg_api_core.mcp_server import AbstractGitGuardianFastMCP from gg_api_core.tools.find_current_source_id import find_current_source_id from gg_api_core.tools.generate_honey_token import generate_honeytoken from gg_api_core.tools.list_honeytokens import list_honeytokens From b522cdcfcefe0d6321dbec94a8ed1887dd07f0f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Thu, 4 Dec 2025 10:58:55 +0100 Subject: [PATCH 08/10] fix --- .../src/gg_api_core/schema_utils.py | 60 +++++++++++++++++++ .../src/secops_mcp_server/server.py | 1 - 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/gg_api_core/src/gg_api_core/schema_utils.py diff --git a/packages/gg_api_core/src/gg_api_core/schema_utils.py b/packages/gg_api_core/src/gg_api_core/schema_utils.py new file mode 100644 index 0000000..ceb3533 --- /dev/null +++ b/packages/gg_api_core/src/gg_api_core/schema_utils.py @@ -0,0 +1,60 @@ +"""Utility functions for MCP tool schema manipulation.""" + +import json +import logging + +import jsonref + +logger = logging.getLogger(__name__) + + +def compress_pydantic_model_schema(tool_parameters: dict) -> dict: + """ + Compress a tool schema by flattening nested Pydantic model parameters. + When FastMCP tools use a single Pydantic model as a parameter (e.g., `params: MyParamsModel`), + the generated schema nests all the model's fields under that parameter name. This creates + an extra level of nesting that some MCP clients (like Claude Code) handle incorrectly. + This function flattens the schema by: + 1. Resolving all JSON references + 2. Extracting the properties from the nested model + 3. Promoting them to top-level parameters + Example: + Before compression: + { + "type": "object", + "properties": { + "params": { + "type": "object", + "properties": { + "source_id": {"type": "integer"}, + "get_all": {"type": "boolean"} + } + } + } + } + After compression: + { + "type": "object", + "properties": { + "source_id": {"type": "integer"}, + "get_all": {"type": "boolean"} + } + } + Args: + tool_parameters: The tool's parameters schema (from tool.parameters) + Returns: + Compressed schema with flattened parameters + """ + try: + # Resolve all JSON references + resolved = jsonref.replace_refs(tool_parameters) + + # Convert back to plain dict (jsonref returns a special JsonRef object) + compressed = json.loads(jsonref.dumps(resolved)) + + logger.debug(f"Compressed tool schema: {json.dumps(compressed, indent=2)}") + return compressed + except Exception as e: + logger.exception(f"Failed to compress schema: {str(e)}") + # Return original schema if compression fails + return tool_parameters diff --git a/packages/secops_mcp_server/src/secops_mcp_server/server.py b/packages/secops_mcp_server/src/secops_mcp_server/server.py index b0b95a0..e2878c6 100644 --- a/packages/secops_mcp_server/src/secops_mcp_server/server.py +++ b/packages/secops_mcp_server/src/secops_mcp_server/server.py @@ -7,7 +7,6 @@ from developer_mcp_server.register_tools import register_developer_tools from fastmcp.exceptions import ToolError from gg_api_core.mcp_server import get_mcp_server, register_common_tools -from gg_api_core.schema_utils import compress_pydantic_model_schema from gg_api_core.scopes import set_secops_scopes from gg_api_core.tools.assign_incident import assign_incident from gg_api_core.tools.create_code_fix_request import create_code_fix_request From 649b58d9b36aa34e517a1b8f519327427374a485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Thu, 4 Dec 2025 11:03:16 +0100 Subject: [PATCH 09/10] fix --- .../developer_mcp_server/register_tools.py | 25 +++++++++++++------ .../src/secops_mcp_server/server.py | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py index 27f523a..e509430 100644 --- a/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py +++ b/packages/developer_mcp_server/src/developer_mcp_server/register_tools.py @@ -1,4 +1,5 @@ from gg_api_core.mcp_server import AbstractGitGuardianFastMCP +from gg_api_core.schema_utils import compress_pydantic_model_schema from gg_api_core.tools.find_current_source_id import find_current_source_id from gg_api_core.tools.generate_honey_token import generate_honeytoken from gg_api_core.tools.list_honeytokens import list_honeytokens @@ -45,15 +46,16 @@ def register_developer_tools(mcp: AbstractGitGuardianFastMCP): - mcp.tool( + remediate_tool = mcp.tool( remediate_secret_incidents, description="Find and fix secrets in the current repository using exact match locations (file paths, line numbers, character indices). " "This tool leverages the occurrences API to provide precise remediation instructions without needing to search for secrets in files. " "By default, this only shows incidents assigned to the current user. Pass mine=False to get all incidents related to this repo.", required_scopes=["incidents:read", "sources:read"], ) + remediate_tool.parameters = compress_pydantic_model_schema(remediate_tool.parameters) - mcp.tool( + scan_tool = mcp.tool( scan_secrets, description=""" Scan multiple content items for secrets and policy breaks. @@ -65,44 +67,51 @@ def register_developer_tools(mcp: AbstractGitGuardianFastMCP): """, required_scopes=["scan"], ) + scan_tool.parameters = compress_pydantic_model_schema(scan_tool.parameters) - mcp.tool( + list_incidents_tool = mcp.tool( list_incidents, description="List secret incidents or occurrences related to a specific repository" "With mine=True, this tool only shows incidents assigned to the current user.", required_scopes=["incidents:read", "sources:read"], ) + list_incidents_tool.parameters = compress_pydantic_model_schema(list_incidents_tool.parameters) - mcp.tool( + list_repo_occurrences_tool = mcp.tool( list_repo_occurrences, description="List secret occurrences for a specific repository with exact match locations. " "Returns detailed occurrence data including file paths, line numbers, and character indices where secrets were detected. " "Use this tool when you need to locate and remediate secrets in the codebase with precise file locations.", required_scopes=["incidents:read"], ) + list_repo_occurrences_tool.parameters = compress_pydantic_model_schema(list_repo_occurrences_tool.parameters) - mcp.tool( + find_source_tool = mcp.tool( find_current_source_id, description="Find the GitGuardian source_id for the current repository. " "This tool automatically detects the current git repository and searches for its source_id in GitGuardian. " "Useful when you need to reference the repository in other API calls.", required_scopes=["sources:read"], ) + find_source_tool.parameters = compress_pydantic_model_schema(find_source_tool.parameters) - mcp.tool( + generate_honeytoken_tool = mcp.tool( generate_honeytoken, description="Generate an AWS GitGuardian honeytoken and get injection recommendations", required_scopes=["honeytokens:write"], ) + generate_honeytoken_tool.parameters = compress_pydantic_model_schema(generate_honeytoken_tool.parameters) - mcp.tool( + list_honeytokens_tool = mcp.tool( list_honeytokens, description="List honeytokens from the GitGuardian dashboard with filtering options", required_scopes=["honeytokens:read"], ) + list_honeytokens_tool.parameters = compress_pydantic_model_schema(list_honeytokens_tool.parameters) - mcp.tool( + list_users_tool = mcp.tool( list_users, description="List users on the workspace/account", required_scopes=["members:read"], ) + list_users_tool.parameters = compress_pydantic_model_schema(list_users_tool.parameters) diff --git a/packages/secops_mcp_server/src/secops_mcp_server/server.py b/packages/secops_mcp_server/src/secops_mcp_server/server.py index e2878c6..b0b95a0 100644 --- a/packages/secops_mcp_server/src/secops_mcp_server/server.py +++ b/packages/secops_mcp_server/src/secops_mcp_server/server.py @@ -7,6 +7,7 @@ from developer_mcp_server.register_tools import register_developer_tools from fastmcp.exceptions import ToolError from gg_api_core.mcp_server import get_mcp_server, register_common_tools +from gg_api_core.schema_utils import compress_pydantic_model_schema from gg_api_core.scopes import set_secops_scopes from gg_api_core.tools.assign_incident import assign_incident from gg_api_core.tools.create_code_fix_request import create_code_fix_request From c409089668157c1279a3b5fe90a08b3672f2b498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Delion?= Date: Thu, 4 Dec 2025 11:05:14 +0100 Subject: [PATCH 10/10] fix --- packages/gg_api_core/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/gg_api_core/pyproject.toml b/packages/gg_api_core/pyproject.toml index 187a514..7d53708 100644 --- a/packages/gg_api_core/pyproject.toml +++ b/packages/gg_api_core/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "python-dotenv>=1.0.0", "pydantic-settings>=2.0.0", "jinja2>=3.1.0", + "jsonref>=1.1.0", ] [project.optional-dependencies]