Skip to content

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.

  1. 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 json
    spec = 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 any x-* 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.

  2. Add the entry to _GET_DENYLIST

    _GET_DENYLIST lives near the top of src/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()’s denylisted flag. Write it for an auditor reading the code six months from now.

  3. 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 None
    assert err["error"] == "operation_denylisted"
    assert err["operation_id"] == "getDangerousThing"

    This guards against three regressions:

    • Someone removes the entry without thinking.
    • Someone refactors _validate_call and breaks the denylist check.
    • A future spec refresh renames the operation and the entry stops matching anything.
  4. Run the suite

    Lint + tests
    make check

    ruff will complain about line length on the rationale string if it’s too long; either break it across multiple string literals or shorten the prose.

  5. 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.

  6. Redeploy

    Build and roll out
    git add src/mcsinglewire/server.py tests/test_server.py
    git commit -m "Denylist getDangerousThing (preview-mode side effect risk)"
    make build && make up

    Confirm the deployment picked up the new entry by calling health() from an authenticated MCP session — denylisted_operations should include getDangerousThing alongside getScenario.

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.