Spaces:
Sleeping
Sleeping
| title: MCP Surface Lint | |
| emoji: 🔎 | |
| colorFrom: indigo | |
| colorTo: gray | |
| sdk: gradio | |
| sdk_version: 6.20.0 | |
| app_file: app.py | |
| pinned: false | |
| license: mit | |
| short_description: Find where an MCP tool surface lets a caller fail silently | |
| tags: | |
| - mcp | |
| - mcp-server | |
| - developer-tools | |
| - static-analysis | |
| # MCP Surface Lint | |
| Static checks over an MCP tool surface. Every rule answers one question: | |
| > Could a caller do the wrong thing and get no error back? | |
| 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, because neither the | |
| caller nor the person reading the transcript has any signal that it happened. | |
| Paste a `tools/list` response, get back the specific places that can happen. | |
| **This app is also an MCP server**, so an agent can lint an agent interface | |
| without going through the UI. | |
| ## Using it as an MCP server | |
| Launched with `demo.launch(mcp_server=True)`. Point a client at: | |
| ``` | |
| https://<space-host>/gradio_api/mcp/sse | |
| ``` | |
| Two tools are published: | |
| | Tool | Purpose | | |
| |---|---| | |
| | `lint_tool_surface(surface_json)` | Run every rule over a surface, return findings with severities | | |
| | `list_rules()` | Enumerate the checks, their ids, and their severities | | |
| The UI event handlers are deliberately excluded with `api_name=False`. Without | |
| that, Gradio also 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 flags all three patterns, so shipping them would be a poor look. | |
| ## The rules | |
| | Id | Severity | Catches | | |
| |---|---|---| | |
| | MCP001 | high | Tool has no description | | |
| | MCP002 | low | Description too short to distinguish from a neighbour | | |
| | MCP003 | high | No `inputSchema` at all | | |
| | MCP004 | high | Parameter with no description | | |
| | MCP005 | high | Parameter with no declared type | | |
| | MCP006 | medium | String parameter that is really a closed set, with no `enum` | | |
| | MCP007 | high | Properties declared but no `required` list | | |
| | MCP008 | low | Numeric parameter with no bounds | | |
| | MCP009 | medium | Unconstrained object parameter | | |
| | MCP010 | medium | Description never says what failure looks like | | |
| | MCP011 | high | Destructive verb with no guard parameter and no warning | | |
| | MCP012 | medium | Two tools a caller cannot choose between by name | | |
| | MCP013 | medium | Two tools sharing an identical description | | |
| Names are compared after crude stemming, because `list_user_records` and | |
| `list_users_record` are the same tool as far as a caller is concerned and raw | |
| token comparison misses that entirely. | |
| ## What it found in its own surface | |
| Pointed at its own published schema, the linter returns exactly one finding, and | |
| it is not in this repo: | |
| ``` | |
| [high] MCP007 lint_tool_surface: Schema declares properties but no 'required' list | |
| ``` | |
| Gradio's `get_input_schema` builds `{"type": "object", "properties": {...}}` and | |
| never emits a `required` array. The information is already there: the same | |
| parameter dicts it iterates carry `parameter_has_default`, and Gradio's own | |
| `gradio skills` command uses that field to mark a parameter required. The | |
| practical effect is that **every** Gradio MCP server publishes tools whose | |
| arguments all look optional, so a caller that omits a mandatory one produces a | |
| schema-valid call. | |
| Reported upstream as [gradio-app/gradio#13670](https://github.com/gradio-app/gradio/issues/13670). | |
| ## Running locally | |
| ```bash | |
| pip install -r requirements.txt | |
| python app.py | |
| ``` | |
| Tests: | |
| ```bash | |
| python -m pytest -q | |
| ``` | |
| Rules are only worth what their tests are worth, so there is also a mutation | |
| matrix. It neuters each rule in turn and records which tests fail. A rule with no | |
| failing test is either dead code or unguarded, and you cannot tell which without | |
| looking: | |
| ```bash | |
| python mutation_check.py | |
| ``` | |
| It refuses to report anything unless the baseline suite is green first, and it | |
| disables bytecode caching, because every mutated file is the same length and | |
| CPython validates a cached `.pyc` on `(mtime, size)`. Inside one second that pair | |
| does not change, so the interpreter happily re-imports the previous mutation's | |
| bytecode and the whole matrix reports the wrong rule's result. | |
| ## License | |
| MIT. | |