"""Tests for the lint rules. Each rule gets a fixture that triggers it and a fixture that does not, so a rule that stops firing shows up as a failure rather than as a quietly shorter report. Run with: python -m pytest -q """ from __future__ import annotations import pytest import lint def codes(findings) -> set[str]: return {f.rule for f in findings} CLEAN_TOOL = { "name": "search_documents", "description": ( "Search indexed documents by keyword. Returns an empty list when nothing " "matches; raises an error when the index is unavailable." ), "inputSchema": { "type": "object", "properties": { "query": {"type": "string", "description": "Keyword expression to match against document text."}, "limit": { "type": "integer", "description": "Maximum number of documents to return.", "minimum": 1, "maximum": 100, }, "sort_order": { "type": "string", "enum": ["relevance", "newest"], "description": "Ordering applied to results before the limit is taken.", }, }, "required": ["query"], }, } def test_clean_tool_produces_no_findings(): assert lint.lint([CLEAN_TOOL]) == [] def test_missing_description(): tool = {**CLEAN_TOOL, "description": ""} assert "MCP001" in codes(lint.lint([tool])) def test_short_description(): tool = {**CLEAN_TOOL, "description": "Searches."} assert "MCP002" in codes(lint.lint([tool])) def test_no_input_schema(): tool = {k: v for k, v in CLEAN_TOOL.items() if k != "inputSchema"} assert "MCP003" in codes(lint.lint([tool])) def test_undocumented_parameter(): tool = { **CLEAN_TOOL, "inputSchema": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, } assert "MCP004" in codes(lint.lint([tool])) def test_untyped_parameter(): tool = { **CLEAN_TOOL, "inputSchema": { "type": "object", "properties": {"query": {"description": "Anything at all."}}, "required": ["query"], }, } assert "MCP005" in codes(lint.lint([tool])) def test_unconstrained_choice(): tool = { **CLEAN_TOOL, "inputSchema": { "type": "object", "properties": { "output_format": {"type": "string", "description": "Format of the response payload."} }, "required": ["output_format"], }, } assert "MCP006" in codes(lint.lint([tool])) def test_missing_required_list(): schema = dict(CLEAN_TOOL["inputSchema"]) del schema["required"] tool = {**CLEAN_TOOL, "inputSchema": schema} assert "MCP007" in codes(lint.lint([tool])) def test_unbounded_number(): tool = { **CLEAN_TOOL, "inputSchema": { "type": "object", "properties": {"limit": {"type": "integer", "description": "How many to return."}}, "required": ["limit"], }, } assert "MCP008" in codes(lint.lint([tool])) def test_freeform_object(): tool = { **CLEAN_TOOL, "inputSchema": { "type": "object", "properties": {"options": {"type": "object", "description": "Arbitrary options."}}, "required": ["options"], }, } assert "MCP009" in codes(lint.lint([tool])) def test_undocumented_failure_modes(): tool = {**CLEAN_TOOL, "description": "Search indexed documents by keyword and give back matches."} assert "MCP010" in codes(lint.lint([tool])) def test_destructive_without_guard(): tool = { "name": "delete_document", "description": "Delete a document from the index by identifier and return the new count.", "inputSchema": { "type": "object", "properties": {"doc_id": {"type": "string", "description": "Identifier of the document."}}, "required": ["doc_id"], }, } assert "MCP011" in codes(lint.lint([tool])) def test_destructive_with_guard_is_accepted(): tool = { "name": "delete_document", "description": "Delete a document. Irreversible. Raises an error if the id is unknown.", "inputSchema": { "type": "object", "properties": { "doc_id": {"type": "string", "description": "Identifier of the document."}, "confirm": {"type": "boolean", "description": "Must be true for the delete to proceed."}, }, "required": ["doc_id", "confirm"], }, } assert "MCP011" not in codes(lint.lint([tool])) def test_ambiguous_names(): a = {**CLEAN_TOOL, "name": "list_user_records"} b = {**CLEAN_TOOL, "name": "list_users_record"} assert "MCP012" in codes(lint.lint([a, b])) def test_distinct_names_are_accepted(): a = {**CLEAN_TOOL, "name": "list_users"} b = {**CLEAN_TOOL, "name": "delete_invoice_attachment"} assert "MCP012" not in codes(lint.lint([a, b])) def test_duplicate_descriptions(): a = {**CLEAN_TOOL, "name": "alpha"} b = {**CLEAN_TOOL, "name": "beta"} assert "MCP013" in codes(lint.lint([a, b])) # -------------------------------------------------------------------------- # Input handling # -------------------------------------------------------------------------- def test_normalize_accepts_bare_list(): assert lint.normalize([CLEAN_TOOL]) == [CLEAN_TOOL] def test_normalize_accepts_tools_key(): assert lint.normalize({"tools": [CLEAN_TOOL]}) == [CLEAN_TOOL] def test_normalize_accepts_jsonrpc_result(): assert lint.normalize({"result": {"tools": [CLEAN_TOOL]}}) == [CLEAN_TOOL] def test_normalize_rejects_scalar(): with pytest.raises(ValueError, match="Expected a list of tools"): lint.normalize(42) def test_normalize_rejects_non_object_entries(): with pytest.raises(ValueError, match="are not objects"): lint.normalize(["not-a-tool"]) def test_normalize_rejects_object_without_tools_key(): with pytest.raises(ValueError, match="'tools' key"): lint.normalize({"servers": []}) def test_severity_counts_totals_match(): findings = lint.lint([{"name": "x"}]) counts = lint.severity_counts(findings) assert sum(counts.values()) == len(findings)