Spaces:
Sleeping
Sleeping
| """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) | |