mcp-surface-lint / test_lint.py
robworks-software's picture
Add MCP tool-surface linter with rule tests and mutation matrix
57ad771 verified
Raw
History Blame Contribute Delete
6.45 kB
"""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)