Spaces:
Sleeping
Sleeping
File size: 9,393 Bytes
57ad771 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | """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())
|