robworks-software's picture
Add MCP tool-surface linter with rule tests and mutation matrix
57ad771 verified
Raw
History Blame Contribute Delete
9.39 kB
"""MCP Surface Lint - a Gradio app that is also an MCP server.
Paste a tool surface, get back the places where a caller could do the wrong
thing and receive no error for it. The same checks are exposed as MCP tools, so
an agent can lint an agent interface without going through the UI.
Launched with `mcp_server=True`; see the README for the endpoint.
"""
from __future__ import annotations
import json
from typing import Any, Iterator
import gradio as gr
import lint
SEVERITY_LABEL = {"high": "high", "medium": "medium", "low": "low"}
HEADERS = ["severity", "tool", "rule", "finding"]
EXAMPLE_LEAKY = json.dumps(
{
"tools": [
{
"name": "get_records",
"description": "Gets records.",
"inputSchema": {
"type": "object",
"properties": {
"table": {"type": "string"},
"limit": {"type": "integer", "description": "How many rows."},
"output_format": {"type": "string", "description": "Response shape."},
},
},
},
{
"name": "get_record",
"description": "Gets records.",
"inputSchema": {"type": "object", "properties": {"id": {"description": "Row id."}}},
},
{
"name": "delete_table",
"description": "Removes a table and returns the remaining count.",
"inputSchema": {
"type": "object",
"properties": {"table": {"type": "string", "description": "Table name."}},
"required": ["table"],
},
},
]
},
indent=2,
)
EXAMPLE_CLEAN = json.dumps(
{
"tools": [
{
"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 matched against document text.",
},
"limit": {
"type": "integer",
"description": "Maximum documents to return.",
"minimum": 1,
"maximum": 100,
},
},
"required": ["query"],
},
}
]
},
indent=2,
)
def _summary(counts: dict[str, int], tool_count: int, done: bool) -> str:
total = sum(counts.values())
if not done:
return f"Scanning {tool_count} tools... {total} findings so far."
if total == 0:
return (
f"**{tool_count} tools, no findings.** Every tool declares its types, "
"documents its parameters, and says what failure looks like."
)
return (
f"**{total} findings across {tool_count} tools** - "
f"{counts['high']} high, {counts['medium']} medium, {counts['low']} low."
)
def analyse(raw: str, severities: list[str]) -> Iterator[tuple[Any, str, list[dict], Any]]:
"""Stream findings as they are produced.
Yields (table rows, summary markdown, findings for state, detail visibility).
"""
if not (raw or "").strip():
yield [], "Paste a tool surface to begin.", [], gr.update(visible=False)
return
try:
tools = lint.normalize(json.loads(raw))
except json.JSONDecodeError as err:
yield [], f"**Not valid JSON.** {err}", [], gr.update(visible=False)
return
except ValueError as err:
yield [], f"**Unusable input.** {err}", [], gr.update(visible=False)
return
wanted = set(severities) or set(lint.SEVERITIES)
rows: list[list[str]] = []
kept: list[dict] = []
counts = {s: 0 for s in lint.SEVERITIES}
for finding in lint.lint_iter(tools):
if finding.severity not in wanted:
continue
counts[finding.severity] += 1
rows.append(finding.as_row())
kept.append(finding.__dict__)
# Emitting per finding keeps a large surface responsive instead of
# showing nothing until the whole scan completes.
yield rows[:], _summary(counts, len(tools), done=False), kept[:], gr.update(visible=False)
yield (
rows,
_summary(counts, len(tools), done=True),
kept,
gr.update(visible=bool(kept)),
)
def explain(findings: list[dict], event: gr.SelectData) -> str:
"""Show the reasoning behind whichever finding the user selected."""
if not findings:
return ""
index = event.index[0] if isinstance(event.index, (list, tuple)) else event.index
if index is None or index >= len(findings):
return ""
f = findings[index]
return (
f"### {f['rule']} - {f['tool']}\n\n"
f"**{f['message']}**\n\n{f['detail']}"
)
with gr.Blocks(title="MCP Surface Lint") as demo:
gr.Markdown(
"# MCP Surface Lint\n"
"Static checks over an MCP tool surface. Every rule answers one question: "
"**could a caller do the wrong thing and get no error back?**\n\n"
"A tool that crashes on bad input is fine. A tool that accepts bad input and "
"returns something plausible is the expensive kind of broken.\n\n"
"This app is also an MCP server, so an agent can run these checks directly."
)
findings_state = gr.State([])
with gr.Row():
with gr.Column(scale=1):
surface = gr.Code(
label="Tool surface (a tools/list response, or a bare list of tools)",
language="json",
lines=22,
value=EXAMPLE_LEAKY,
)
severity_filter = gr.CheckboxGroup(
choices=list(lint.SEVERITIES),
value=list(lint.SEVERITIES),
label="Severities to report",
)
run = gr.Button("Lint surface", variant="primary")
with gr.Column(scale=1):
summary = gr.Markdown("Paste a tool surface to begin.")
table = gr.Dataframe(
headers=HEADERS,
column_count=(len(HEADERS), "fixed"),
interactive=False,
wrap=True,
label="Findings (select a row for the reasoning)",
)
detail = gr.Markdown(visible=False)
gr.Examples(
examples=[[EXAMPLE_LEAKY], [EXAMPLE_CLEAN]],
inputs=[surface],
label="Examples: a leaky surface, and one that passes",
)
# Keep the UI handlers off the MCP surface. Without this Gradio publishes
# `analyse`, `analyse_1` and `explain` as tools: a duplicate name pair, plus a
# handler taking a gr.State that cannot survive a tool call. This linter would
# flag all three, so it had better not ship them.
run.click(
analyse,
inputs=[surface, severity_filter],
outputs=[table, summary, findings_state, detail],
api_name=False,
)
severity_filter.change(
analyse,
inputs=[surface, severity_filter],
outputs=[table, summary, findings_state, detail],
api_name=False,
)
table.select(explain, inputs=[findings_state], outputs=[detail], api_name=False)
# MCP-only surface. No UI component, but callable as a tool - which is the
# point: the linter is itself something an agent can call.
@gr.api
def lint_tool_surface(surface_json: str) -> dict:
"""Lint an MCP tool surface for places a caller could fail silently.
Returns an object with tool_count, counts by severity, and findings, each
carrying the tool, rule id, severity, message and reasoning. Raises
ValueError when the input is not valid JSON or is not a list of tools.
Args:
surface_json: JSON string: either a tools/list response (an object with a "tools" key) or a bare list of tool objects. Must not be empty.
"""
tools = lint.normalize(json.loads(surface_json))
findings = lint.lint(tools)
return {
"tool_count": len(tools),
"counts": lint.severity_counts(findings),
"findings": [f.__dict__ for f in findings],
}
@gr.api
def list_rules() -> list[dict]:
"""List every check this linter applies, with its id and severity.
Returns one object per rule with rule, severity and message, derived by
running the rule set against a deliberately incomplete tool. Takes no
arguments and never raises.
"""
probe = {"name": "probe", "inputSchema": {"properties": {"arg": {}}}}
seen: dict[str, dict] = {}
for f in lint.lint([probe, {**probe, "name": "probes"}]):
seen.setdefault(f.rule, {"rule": f.rule, "severity": f.severity, "message": f.message})
return sorted(seen.values(), key=lambda r: r["rule"])
if __name__ == "__main__":
demo.launch(mcp_server=True, theme=gr.themes.Soft())