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())