From 9f7484ab760ef6dce67e85a5a28f39fb09f916be Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Mon, 6 Oct 2025 23:31:56 -0400 Subject: [PATCH] add tests --- .../community/test_community_tool_context.py | 209 ++++++++++ tests/test_context_parameters.py | 389 ++++++++++++++++++ tests/test_tool_context.py | 196 +++++++++ 3 files changed, 794 insertions(+) create mode 100644 tests/test_context_parameters.py diff --git a/tests/community/test_community_tool_context.py b/tests/community/test_community_tool_context.py index 41903d8..0cd360f 100644 --- a/tests/community/test_community_tool_context.py +++ b/tests/community/test_community_tool_context.py @@ -368,6 +368,215 @@ def late_tool(value: str): ) assert "Processed: test" in str(result) + @pytest.mark.asyncio + async def test_custom_context_description(self): + """Test that custom context description is correctly applied in community FastMCP.""" + server = create_community_todo_server() + custom_description = "Explain why you need to use this tool" + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=custom_description + ) + track(server, "test_project", options) + + async with create_community_test_client(server) as client: + tools_result = await client.list_tools() + + # Check each tool (except get_more_tools) + for tool in tools_result: + if tool.name == "get_more_tools": + continue + + # Verify context parameter has custom description + context_schema = tool.inputSchema["properties"]["context"] + assert context_schema["description"] == custom_description + + @pytest.mark.asyncio + async def test_custom_context_description_empty_string(self): + """Test edge case with empty string custom description in community FastMCP.""" + server = create_community_todo_server() + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description="" + ) + track(server, "test_project", options) + + async with create_community_test_client(server) as client: + tools_result = await client.list_tools() + + # Find a tool to test + add_todo_tool = next(t for t in tools_result if t.name == "add_todo") + + # Verify context exists with empty description + context_schema = add_todo_tool.inputSchema["properties"]["context"] + assert context_schema["description"] == "" + assert context_schema["type"] == "string" + + @pytest.mark.asyncio + async def test_custom_context_description_special_characters(self): + """Test custom description with special characters and Unicode in community FastMCP.""" + server = create_community_todo_server() + special_description = "Why use this? 🚀 Include: 'quotes', \"double\", newlines\n, tabs\t." + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=special_description + ) + track(server, "test_project", options) + + async with create_community_test_client(server) as client: + tools_result = await client.list_tools() + + # Verify special characters are preserved + add_todo_tool = next(t for t in tools_result if t.name == "add_todo") + context_schema = add_todo_tool.inputSchema["properties"]["context"] + assert context_schema["description"] == special_description + + @pytest.mark.asyncio + async def test_custom_context_description_very_long(self): + """Test with a very long description string in community FastMCP.""" + server = create_community_todo_server() + # Create a very long description + long_description = "This is a very detailed description for the context. " * 50 + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=long_description + ) + track(server, "test_project", options) + + async with create_community_test_client(server) as client: + tools_result = await client.list_tools() + + # Verify long description is preserved + add_todo_tool = next(t for t in tools_result if t.name == "add_todo") + context_schema = add_todo_tool.inputSchema["properties"]["context"] + assert context_schema["description"] == long_description + assert len(context_schema["description"]) > 1000 + + @pytest.mark.asyncio + async def test_default_context_description(self): + """Verify the default description is used when not specified in community FastMCP.""" + server = create_community_todo_server() + # Don't specify custom_context_description, should use default + options = MCPCatOptions(enable_tool_call_context=True) + track(server, "test_project", options) + + async with create_community_test_client(server) as client: + tools_result = await client.list_tools() + + # Check for default description + add_todo_tool = next(t for t in tools_result if t.name == "add_todo") + context_schema = add_todo_tool.inputSchema["properties"]["context"] + assert context_schema["description"] == "Describe why you are calling this tool and how it fits into your overall task" + + @pytest.mark.asyncio + async def test_custom_context_description_with_multiple_tools(self): + """Test that custom description is applied to all tools consistently in community FastMCP.""" + if not HAS_COMMUNITY_FASTMCP: + pytest.skip("Community FastMCP not available") + + from fastmcp import FastMCP + + server = FastMCP("test-server") + + @server.tool + def tool1(param: str): + """First tool.""" + return f"Tool 1: {param}" + + @server.tool + def tool2(value: int): + """Second tool.""" + return f"Tool 2: {value}" + + @server.tool + def tool3(): + """Third tool with no params.""" + return "Tool 3" + + custom_desc = "Custom community context for all tools" + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=custom_desc + ) + track(server, "test_project", options) + + async with create_community_test_client(server) as client: + tools_result = await client.list_tools() + + # All tools should have the same custom context description + for tool in tools_result: + if tool.name in ["tool1", "tool2", "tool3"]: + assert "context" in tool.inputSchema["properties"] + assert tool.inputSchema["properties"]["context"]["description"] == custom_desc + + @pytest.mark.asyncio + async def test_custom_context_with_tool_call(self): + """Test tool calls work correctly with custom context description in community FastMCP.""" + server = create_community_todo_server() + custom_desc = "Provide reasoning for this action in the community server" + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=custom_desc + ) + track(server, "test_project", options) + + async with create_community_test_client(server) as client: + # Verify the custom description is set + tools_result = await client.list_tools() + add_todo_tool = next(t for t in tools_result if t.name == "add_todo") + assert add_todo_tool.inputSchema["properties"]["context"]["description"] == custom_desc + + # Call the tool with context + result = await client.call_tool( + "add_todo", + { + "text": "Test with custom description in community", + "context": "Adding todo to test custom context description in community FastMCP" + } + ) + + # Should succeed + assert "Added todo" in str(result) + + @pytest.mark.asyncio + async def test_custom_context_with_dynamically_added_tool(self): + """Test that dynamically added tools get custom context description in community FastMCP.""" + if not HAS_COMMUNITY_FASTMCP: + pytest.skip("Community FastMCP not available") + + from fastmcp import FastMCP + + server = FastMCP("test-server") + + # Track with custom context description + custom_desc = "Dynamic tool custom context" + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=custom_desc + ) + track(server, "test_project", options) + + # Add tool AFTER tracking + @server.tool + def dynamic_tool(data: str): + """Tool added after tracking with custom context.""" + return f"Dynamic result: {data}" + + async with create_community_test_client(server) as client: + tools_result = await client.list_tools() + dynamic_tool_def = next(t for t in tools_result if t.name == "dynamic_tool") + + # Should have context parameter with custom description + assert "context" in dynamic_tool_def.inputSchema["properties"] + assert dynamic_tool_def.inputSchema["properties"]["context"]["description"] == custom_desc + + # Test calling it + result = await client.call_tool( + "dynamic_tool", + {"data": "test", "context": "Using dynamic tool with custom context"} + ) + assert "Dynamic result: test" in str(result) + if __name__ == "__main__": pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/test_context_parameters.py b/tests/test_context_parameters.py new file mode 100644 index 0000000..1b66155 --- /dev/null +++ b/tests/test_context_parameters.py @@ -0,0 +1,389 @@ +"""Unit tests for context_parameters module.""" + +import pytest +from copy import deepcopy +from typing import Any + +from mcpcat.modules.context_parameters import ( + add_context_parameter_to_tools, + add_context_parameter_to_schema, +) + + +class TestAddContextParameterToSchema: + """Unit tests for add_context_parameter_to_schema function.""" + + def test_add_context_to_empty_schema(self): + """Test adding context to an empty schema.""" + schema = {} + custom_desc = "Test description" + + result = add_context_parameter_to_schema(schema, custom_desc) + + # Verify properties were added + assert "properties" in result + assert "context" in result["properties"] + assert result["properties"]["context"]["type"] == "string" + assert result["properties"]["context"]["description"] == custom_desc + + # Verify required was added + assert "required" in result + assert "context" in result["required"] + + # Verify original wasn't modified + assert "properties" not in schema + + def test_add_context_to_schema_with_existing_properties(self): + """Test adding context to schema with existing properties.""" + schema = { + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name"] + } + custom_desc = "Why this tool?" + + result = add_context_parameter_to_schema(schema, custom_desc) + + # Verify original properties still exist + assert "name" in result["properties"] + assert "age" in result["properties"] + + # Verify context was added + assert "context" in result["properties"] + assert result["properties"]["context"]["description"] == custom_desc + + # Verify required array was updated + assert "name" in result["required"] + assert "context" in result["required"] + assert len(result["required"]) == 2 + + # Verify original wasn't modified + assert "context" not in schema["properties"] + assert "context" not in schema["required"] + + def test_add_context_to_schema_with_no_required(self): + """Test adding context when schema has no required field.""" + schema = { + "properties": { + "optional_field": {"type": "string"} + } + } + custom_desc = "Context for optional schema" + + result = add_context_parameter_to_schema(schema, custom_desc) + + # Verify required array was created with context + assert "required" in result + assert result["required"] == ["context"] + + # Verify properties were updated + assert "optional_field" in result["properties"] + assert "context" in result["properties"] + + def test_schema_immutability(self): + """Test that original schema is not modified.""" + original_schema = { + "properties": { + "field1": {"type": "string"}, + "field2": {"type": "integer"} + }, + "required": ["field1"] + } + + # Deep copy to compare later + schema_copy = deepcopy(original_schema) + + result = add_context_parameter_to_schema(original_schema, "Test") + + # Original should be unchanged + assert original_schema == schema_copy + + # Result should be different + assert result != original_schema + assert "context" in result["properties"] + assert "context" not in original_schema["properties"] + + def test_context_already_in_required(self): + """Test when context is already in required array.""" + schema = { + "properties": { + "context": {"type": "string", "description": "Existing context"} + }, + "required": ["context"] + } + custom_desc = "New context description" + + result = add_context_parameter_to_schema(schema, custom_desc) + + # Context should be overwritten with new description + assert result["properties"]["context"]["description"] == custom_desc + + # Required should still contain context (no duplicate) + assert result["required"].count("context") == 1 + + def test_empty_custom_description(self): + """Test with empty string custom description.""" + schema = {"properties": {}} + + result = add_context_parameter_to_schema(schema, "") + + assert result["properties"]["context"]["description"] == "" + assert result["properties"]["context"]["type"] == "string" + + def test_special_characters_in_description(self): + """Test with special characters in custom description.""" + schema = {} + special_desc = "Unicode: 🚀 Quotes: \"test\" Newline:\nTab:\t" + + result = add_context_parameter_to_schema(schema, special_desc) + + assert result["properties"]["context"]["description"] == special_desc + + def test_very_long_description(self): + """Test with very long custom description.""" + schema = {} + long_desc = "A" * 10000 # 10,000 characters + + result = add_context_parameter_to_schema(schema, long_desc) + + assert result["properties"]["context"]["description"] == long_desc + assert len(result["properties"]["context"]["description"]) == 10000 + + def test_nested_properties_preserved(self): + """Test that nested/complex properties are preserved.""" + schema = { + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }, + "tags": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["user"] + } + + result = add_context_parameter_to_schema(schema, "Test") + + # Verify nested properties are preserved + assert "user" in result["properties"] + assert result["properties"]["user"]["properties"]["name"]["type"] == "string" + assert "tags" in result["properties"] + assert result["properties"]["tags"]["type"] == "array" + + # Verify context was added + assert "context" in result["properties"] + assert "context" in result["required"] + + +class TestAddContextParameterToTools: + """Unit tests for add_context_parameter_to_tools function.""" + + def test_empty_tools_list(self): + """Test with empty tools list.""" + tools = [] + result = add_context_parameter_to_tools(tools, "Test description") + + assert result == [] + + def test_single_tool_with_input_schema(self): + """Test with a single tool that has inputSchema.""" + tools = [ + { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "properties": { + "param": {"type": "string"} + }, + "required": ["param"] + } + } + ] + custom_desc = "Tool context" + + result = add_context_parameter_to_tools(tools, custom_desc) + + assert len(result) == 1 + assert "context" in result[0]["inputSchema"]["properties"] + assert result[0]["inputSchema"]["properties"]["context"]["description"] == custom_desc + assert "context" in result[0]["inputSchema"]["required"] + + # Original tools list should be unchanged + assert "context" not in tools[0]["inputSchema"]["properties"] + + def test_tool_without_input_schema(self): + """Test with a tool that has no inputSchema.""" + tools = [ + { + "name": "simple_tool", + "description": "A simple tool" + } + ] + + result = add_context_parameter_to_tools(tools, "Test") + + # Tool should be copied but not modified (no inputSchema) + assert len(result) == 1 + assert result[0]["name"] == "simple_tool" + assert "inputSchema" not in result[0] + + def test_multiple_tools(self): + """Test with multiple tools of different types.""" + tools = [ + { + "name": "tool1", + "inputSchema": {"properties": {}} + }, + { + "name": "tool2", + "inputSchema": { + "properties": {"field": {"type": "string"}}, + "required": ["field"] + } + }, + { + "name": "tool3", + "description": "No schema" + } + ] + custom_desc = "Multi-tool context" + + result = add_context_parameter_to_tools(tools, custom_desc) + + assert len(result) == 3 + + # Tool 1: empty properties + assert "context" in result[0]["inputSchema"]["properties"] + assert result[0]["inputSchema"]["properties"]["context"]["description"] == custom_desc + + # Tool 2: existing properties + assert "field" in result[1]["inputSchema"]["properties"] + assert "context" in result[1]["inputSchema"]["properties"] + assert result[1]["inputSchema"]["properties"]["context"]["description"] == custom_desc + + # Tool 3: no inputSchema + assert "inputSchema" not in result[2] + + def test_tools_immutability(self): + """Test that original tools list is not modified.""" + original_tools = [ + { + "name": "test_tool", + "inputSchema": { + "properties": {"param": {"type": "string"}} + } + } + ] + + tools_copy = deepcopy(original_tools) + + result = add_context_parameter_to_tools(original_tools, "Test") + + # Original should be unchanged + assert original_tools == tools_copy + + # Result should be different + assert result != original_tools + assert "context" in result[0]["inputSchema"]["properties"] + assert "context" not in original_tools[0]["inputSchema"]["properties"] + + def test_tool_with_complex_schema(self): + """Test with a tool that has a complex schema.""" + tools = [ + { + "name": "complex_tool", + "inputSchema": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "level": {"type": "integer", "minimum": 0, "maximum": 10} + } + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "value": {"type": "number"} + } + } + } + }, + "required": ["config"], + "additionalProperties": False + } + } + ] + + result = add_context_parameter_to_tools(tools, "Complex context") + + # Verify complex properties are preserved + assert "config" in result[0]["inputSchema"]["properties"] + assert "items" in result[0]["inputSchema"]["properties"] + assert result[0]["inputSchema"]["additionalProperties"] == False + + # Verify context was added + assert "context" in result[0]["inputSchema"]["properties"] + assert "context" in result[0]["inputSchema"]["required"] + + def test_special_tool_fields_preserved(self): + """Test that other tool fields are preserved.""" + tools = [ + { + "name": "full_tool", + "description": "A complete tool", + "version": "1.0.0", + "deprecated": False, + "inputSchema": {"properties": {}}, + "outputSchema": {"type": "string"}, + "metadata": {"author": "test"} + } + ] + + result = add_context_parameter_to_tools(tools, "Test") + + # All fields should be preserved + assert result[0]["name"] == "full_tool" + assert result[0]["description"] == "A complete tool" + assert result[0]["version"] == "1.0.0" + assert result[0]["deprecated"] == False + assert result[0]["outputSchema"] == {"type": "string"} + assert result[0]["metadata"] == {"author": "test"} + + # And context should be added + assert "context" in result[0]["inputSchema"]["properties"] + + def test_unicode_in_tool_names_and_descriptions(self): + """Test tools with Unicode characters in various fields.""" + tools = [ + { + "name": "emoji_tool_🚀", + "description": "Tool with emojis 🎉", + "inputSchema": { + "properties": { + "field_with_emoji_🌟": {"type": "string"} + } + } + } + ] + custom_desc = "Context with emoji 🤔" + + result = add_context_parameter_to_tools(tools, custom_desc) + + # Unicode should be preserved everywhere + assert result[0]["name"] == "emoji_tool_🚀" + assert result[0]["description"] == "Tool with emojis 🎉" + assert "field_with_emoji_🌟" in result[0]["inputSchema"]["properties"] + assert result[0]["inputSchema"]["properties"]["context"]["description"] == custom_desc \ No newline at end of file diff --git a/tests/test_tool_context.py b/tests/test_tool_context.py index 7a0c817..a2268fc 100644 --- a/tests/test_tool_context.py +++ b/tests/test_tool_context.py @@ -606,3 +606,199 @@ async def test_error_handling_graceful_fallback(self): {"context": "Listing todos"}, # Try with context ) assert result.content + + @pytest.mark.asyncio + async def test_custom_context_description(self): + """Test that custom context description is correctly applied.""" + server = create_todo_server() + custom_description = "Explain your reasoning for using this tool" + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=custom_description + ) + track(server, "test_project", options) + + async with create_test_client(server) as client: + tools_result = await client.list_tools() + + # Check each tool (except get_more_tools) + for tool in tools_result.tools: + if tool.name == "get_more_tools": + continue + + # Verify context parameter has custom description + context_schema = tool.inputSchema["properties"]["context"] + assert context_schema["description"] == custom_description + + @pytest.mark.asyncio + async def test_custom_context_description_empty_string(self): + """Test edge case with empty string custom description.""" + server = create_todo_server() + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description="" # Empty string + ) + track(server, "test_project", options) + + async with create_test_client(server) as client: + tools_result = await client.list_tools() + + # Find a tool to test + add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo") + + # Verify context exists with empty description + context_schema = add_todo_tool.inputSchema["properties"]["context"] + assert context_schema["description"] == "" + assert context_schema["type"] == "string" + + @pytest.mark.asyncio + async def test_custom_context_description_special_characters(self): + """Test custom description with special characters and Unicode.""" + server = create_todo_server() + special_description = "Why are you using this? 🤔 Include: quotes\"', newlines\n, tabs\t, etc." + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=special_description + ) + track(server, "test_project", options) + + async with create_test_client(server) as client: + tools_result = await client.list_tools() + + # Verify special characters are preserved + add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo") + context_schema = add_todo_tool.inputSchema["properties"]["context"] + assert context_schema["description"] == special_description + + @pytest.mark.asyncio + async def test_custom_context_description_very_long(self): + """Test with a very long description string.""" + server = create_todo_server() + # Create a very long description + long_description = "This is a very detailed description. " * 50 # ~1800 characters + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=long_description + ) + track(server, "test_project", options) + + async with create_test_client(server) as client: + tools_result = await client.list_tools() + + # Verify long description is preserved + add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo") + context_schema = add_todo_tool.inputSchema["properties"]["context"] + assert context_schema["description"] == long_description + assert len(context_schema["description"]) > 1000 + + @pytest.mark.asyncio + async def test_default_context_description(self): + """Verify the default description is used when not specified.""" + server = create_todo_server() + # Don't specify custom_context_description, should use default + options = MCPCatOptions(enable_tool_call_context=True) + track(server, "test_project", options) + + async with create_test_client(server) as client: + tools_result = await client.list_tools() + + # Check for default description + add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo") + context_schema = add_todo_tool.inputSchema["properties"]["context"] + assert context_schema["description"] == "Describe why you are calling this tool and how it fits into your overall task" + + @pytest.mark.asyncio + async def test_custom_context_description_with_multiple_tools(self): + """Test that custom description is applied to all tools consistently.""" + mcp = FastMCP("test-server") + + @mcp.tool() + def tool1(param: str): + """First tool.""" + return f"Tool 1: {param}" + + @mcp.tool() + def tool2(value: int): + """Second tool.""" + return f"Tool 2: {value}" + + @mcp.tool() + def tool3(): + """Third tool with no params.""" + return "Tool 3" + + server = mcp + custom_desc = "Custom context for all tools" + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=custom_desc + ) + track(server, "test_project", options) + + async with create_test_client(server) as client: + tools_result = await client.list_tools() + + # All tools should have the same custom context description + for tool in tools_result.tools: + if tool.name in ["tool1", "tool2", "tool3"]: + assert "context" in tool.inputSchema["properties"] + assert tool.inputSchema["properties"]["context"]["description"] == custom_desc + + @pytest.mark.asyncio + async def test_custom_context_description_change_between_tracks(self): + """Test changing custom description between track calls.""" + server = create_todo_server() + + # First track with one description + options1 = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description="First description" + ) + track(server, "test_project", options1) + + # Second track with different description + options2 = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description="Second description" + ) + track(server, "test_project", options2) + + async with create_test_client(server) as client: + tools_result = await client.list_tools() + + # Should use the most recent description + add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo") + context_schema = add_todo_tool.inputSchema["properties"]["context"] + # Due to wrapping behavior, the first track's description might persist + # This test documents the actual behavior + assert context_schema["description"] in ["First description", "Second description"] + + @pytest.mark.asyncio + async def test_custom_context_with_tool_call(self): + """Test tool calls work correctly with custom context description.""" + server = create_todo_server() + custom_desc = "Provide detailed reasoning for this action" + options = MCPCatOptions( + enable_tool_call_context=True, + custom_context_description=custom_desc + ) + track(server, "test_project", options) + + async with create_test_client(server) as client: + # Verify the custom description is set + tools_result = await client.list_tools() + add_todo_tool = next(t for t in tools_result.tools if t.name == "add_todo") + assert add_todo_tool.inputSchema["properties"]["context"]["description"] == custom_desc + + # Call the tool with context + result = await client.call_tool( + "add_todo", + { + "text": "Test with custom description", + "context": "Adding todo to test custom context description feature" + } + ) + + # Should succeed + assert result.content + assert "Added todo" in result.content[0].text