Add an operation to the GET denylist
mcsinglewire blocks writes at the transport layer. But some GETs have
side effects too — the canonical example is getScenario, whose
Singlewire spec describes a “simulate answers” mode that interpolates
recipients from sites and roles, with wording too ambiguous to trust.
Those operations belong on the static denylist.
This guide adds a hypothetical operationId getDangerousThing. The
existing entry for getScenario in src/mcsinglewire/server.py is
the working example.
-
Confirm the operation actually has side effects
Before denylisting, read the operation’s spec entry carefully:
Print the spec entry uv run python -c "import jsonspec = json.load(open('src/mcsinglewire/data/openapi.json'))for path, methods in spec['paths'].items():for method, op in methods.items():if op.get('operationId') == 'getDangerousThing':print(json.dumps(op, indent=2))"Look at
summary,description, and anyx-*extension fields. Words that justify denylisting:- “simulate”, “preview”, “dispatch”, “send”, “trigger”, “execute”
- “would be delivered”, “would be sent”
- Anything that mentions recipient resolution, group expansion, or notification dispatch as part of a GET
If you’re unsure, ask Singlewire support in writing. The denylist is a safety net; better to denylist conservatively and remove later than to discover post-hoc that a GET fired off a real notification.
-
Add the entry to
_GET_DENYLIST_GET_DENYLISTlives near the top ofsrc/mcsinglewire/server.py(under the comment “Denylist of operationIds whose spec semantics suggest possible side effects despite using GET”). Add a key/value pair:src/mcsinglewire/server.py _GET_DENYLIST: dict[str, str] = {"getScenario": ("Singlewire spec describes a 'simulate answers' mode for this GET ""that interpolates recipients from sites and roles; the 'would be ""sent' wording is ambiguous about whether notifications are actually ""dispatched. Refused until Singlewire confirms in writing this is ""purely a dry-run with no recipient-side effect."),"getDangerousThing": ("Spec description says 'returns a preview of recipients that would be ""notified'; the word 'would' is too ambiguous to confirm this is a ""no-op. Refused until Singlewire confirms in writing."),}The rationale is part of the API: it’s surfaced in error responses and in
openapi_describe()’sdenylistedflag. Write it for an auditor reading the code six months from now. -
Add a test
Edit
tests/test_server.py:tests/test_server.py def test_api_call_refuses_denylisted_dangerous_thing(spec):err, _ = _validate_call(spec, "getDangerousThing", path_params=None, query_params=None)assert err is not Noneassert err["error"] == "operation_denylisted"assert err["operation_id"] == "getDangerousThing"This guards against three regressions:
- Someone removes the entry without thinking.
- Someone refactors
_validate_calland breaks the denylist check. - A future spec refresh renames the operation and the entry stops matching anything.
-
Run the suite
Lint + tests make checkruffwill complain about line length on the rationale string if it’s too long; either break it across multiple string literals or shorten the prose. -
Verify the response shape
In a running server:
Confirm the refusal api_call("getDangerousThing", path_params={"id": "anything"})# → {"error": "operation_denylisted",# "operation_id": "getDangerousThing",# "message": "Spec description says ..."}The response is a structured dict, not an exception — that’s intentional. LLMs handle structured errors gracefully and adapt the next call.
-
Redeploy
Build and roll out git add src/mcsinglewire/server.py tests/test_server.pygit commit -m "Denylist getDangerousThing (preview-mode side effect risk)"make build && make upConfirm the deployment picked up the new entry by calling
health()from an authenticated MCP session —denylisted_operationsshould includegetDangerousThingalongsidegetScenario.
When to remove an entry
Section titled “When to remove an entry”You can remove an entry once you have written confirmation from Singlewire that the GET is a pure read. The confirmation goes in the commit message; future auditors will look for it.
Removal is the same workflow in reverse: delete the entry, delete the
test, redeploy. The audit trail remains in git log.