mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
update(versions): update pins with a declared source (web-app-wordpress)
501 lines
19 KiB
Python
501 lines
19 KiB
Python
"""Prove one MCP provider actually honours its declared contract.
|
|
|
|
Runs at deploy time, not in Playwright: the check needs the provider's real
|
|
credential, and a Playwright run would capture it in the trace.
|
|
|
|
Over ``streamable_http`` it asserts, in order, that the endpoint
|
|
|
|
1. refuses a caller presenting no credential,
|
|
2. refuses a caller presenting a wrong one,
|
|
3. completes an authenticated ``initialize`` handshake,
|
|
4. advertises exactly the tools the checked-in contract names, and
|
|
5. answers one deterministic read call without an error.
|
|
|
|
Steps 1 and 2 are what separate a guarded endpoint from one that merely
|
|
happens to be unreachable today. Step 4 is what turns "the metadata says three
|
|
tools" into "the server serves three tools": an upstream that grew a fourth
|
|
fails here rather than in front of a client.
|
|
|
|
For a provider with no adapter in front of it, step 4 is also the only runtime
|
|
evidence behind ``mutating_tools_enabled: false``, which nothing else enforces
|
|
there: relaxing it to a subset would let an upstream release add a mutating
|
|
tool while the metadata still promises none.
|
|
|
|
Over ``sse`` the same five assertions hold, driven through a real session:
|
|
the stream is the response channel and the endpoint it announces is the
|
|
request channel.
|
|
|
|
A provider whose credential is a path segment rather than a header is refused
|
|
by presenting a wrong segment, so the negative probes attack whatever actually
|
|
guards the endpoint.
|
|
|
|
Environment:
|
|
MCP_URL: endpoint to probe.
|
|
MCP_TRANSPORT: ``streamable_http`` or ``sse``.
|
|
MCP_PATH_KEY: credential carried in the URL path, or "" for none.
|
|
MCP_AUTH_HEADER: Authorization header value the provider expects.
|
|
MCP_EXPECTED_TOOLS: JSON list of the exact tool names the platform permits.
|
|
MCP_UPSTREAM_SERVES: JSON list of what the upstream offers at the pinned
|
|
version, when that differs from the permitted set; "[]" when it does
|
|
not, so drift and policy are reported to different readers.
|
|
MCP_READ_TOOL: tool to invoke for the deterministic read.
|
|
MCP_READ_ARGUMENTS: JSON object of arguments for that tool.
|
|
MCP_HOST_HEADER: vhost to address a host-routed provider by, or "".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import queue
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
URL = os.environ["MCP_URL"]
|
|
TRANSPORT = os.environ["MCP_TRANSPORT"]
|
|
PATH_KEY = os.environ.get("MCP_PATH_KEY", "")
|
|
AUTH = os.environ["MCP_AUTH_HEADER"]
|
|
EXPECTED = sorted(json.loads(os.environ.get("MCP_EXPECTED_TOOLS", "[]")))
|
|
UPSTREAM_SERVES = sorted(json.loads(os.environ.get("MCP_UPSTREAM_SERVES", "[]")))
|
|
READ_TOOL = os.environ.get("MCP_READ_TOOL", "")
|
|
READ_ARGUMENTS = json.loads(os.environ.get("MCP_READ_ARGUMENTS", "{}"))
|
|
HOST_HEADER = os.environ.get("MCP_HOST_HEADER", "")
|
|
|
|
PROTOCOL_VERSION = "2025-06-18"
|
|
INITIALIZE = {
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "infinito-contract-probe", "version": "1"},
|
|
}
|
|
TIMEOUT = 30
|
|
SESSION_TIMEOUT = 20
|
|
WRONG_CREDENTIAL = "Bearer " + "0" * 40
|
|
SESSION_HEADER = "Mcp-Session-Id"
|
|
|
|
SESSION = {"id": "", "protocol": ""}
|
|
|
|
|
|
def unguarded_url():
|
|
"""Return the URL with its path credential replaced by a wrong one.
|
|
|
|
A server that carries its credential in the path ignores the Authorization
|
|
header entirely, so varying the header would probe nothing and the negative
|
|
cases would answer exactly like the positive one.
|
|
"""
|
|
if not PATH_KEY:
|
|
return URL
|
|
head, separator, tail = URL.rpartition(PATH_KEY)
|
|
if not separator:
|
|
reject("the declared path credential is absent from the probed URL")
|
|
return head + "0" * len(PATH_KEY) + tail
|
|
|
|
|
|
def rpc(method, params=None, authorization=None, url=None, notification=False):
|
|
"""Return ``(status, body)`` of one JSON-RPC call.
|
|
|
|
Args:
|
|
method: MCP method name.
|
|
params: method params, or None.
|
|
authorization: header value to present, or None to present none.
|
|
url: endpoint to call, defaulting to the probed one.
|
|
notification: omit the id, so the server answers without a result.
|
|
"""
|
|
payload = {"jsonrpc": "2.0", "method": method}
|
|
if not notification:
|
|
payload["id"] = 1
|
|
if params is not None:
|
|
payload["params"] = params
|
|
request = urllib.request.Request( # noqa: S310 fixed internal http origin
|
|
url or URL, data=json.dumps(payload).encode(), method="POST"
|
|
)
|
|
request.add_header("Content-Type", "application/json")
|
|
request.add_header("Accept", "application/json, text/event-stream")
|
|
if HOST_HEADER:
|
|
request.add_header("Host", HOST_HEADER)
|
|
if authorization:
|
|
request.add_header("Authorization", authorization)
|
|
if SESSION["id"]:
|
|
request.add_header(SESSION_HEADER, SESSION["id"])
|
|
if SESSION["protocol"]:
|
|
request.add_header("MCP-Protocol-Version", SESSION["protocol"])
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 fixed internal http origin
|
|
session = response.headers.get(SESSION_HEADER)
|
|
if session:
|
|
SESSION["id"] = session
|
|
return response.status, json_body(
|
|
response.headers.get("Content-Type", ""),
|
|
response.read().decode(errors="replace"),
|
|
)
|
|
except urllib.error.HTTPError as error:
|
|
return error.code, json_body(
|
|
error.headers.get("Content-Type", ""),
|
|
error.read().decode(errors="replace"),
|
|
)
|
|
except OSError as error:
|
|
return 0, f"unreachable: {error}"
|
|
|
|
|
|
def json_body(content_type, body):
|
|
"""Return the JSON-RPC payload of one response, whatever framing it used.
|
|
|
|
Args:
|
|
content_type: the response's Content-Type header.
|
|
body: its decoded body.
|
|
|
|
Streamable HTTP lets a server answer a POST with an event stream instead of
|
|
a plain body, carrying the same JSON-RPC object in a ``data:`` line. Feeding
|
|
that framing straight to a JSON parser raises, which reads as a broken
|
|
server rather than as a different, permitted encoding.
|
|
"""
|
|
if "text/event-stream" not in content_type:
|
|
return body
|
|
for line in body.splitlines():
|
|
if line.startswith("data:"):
|
|
return line.split(":", 1)[1].strip()
|
|
return body
|
|
|
|
|
|
def reject(message):
|
|
sys.stderr.write(f"REJECTED {message}\n")
|
|
sys.exit(1)
|
|
|
|
|
|
def reject_error(label, error):
|
|
if error:
|
|
reject(f"{label} returned {str(error)[:200]}")
|
|
|
|
|
|
def unreachable(message):
|
|
"""Fail without the REJECTED marker, so the caller keeps retrying.
|
|
|
|
A sidecar that has not finished starting is indistinguishable here from one
|
|
that will never answer. Marking the run REJECTED would settle the caller's
|
|
retry condition on the first attempt and turn a slow start into a failure.
|
|
"""
|
|
sys.stderr.write(f"UNREACHABLE {message}\n")
|
|
sys.exit(1)
|
|
|
|
|
|
def assert_read_call(status, body):
|
|
"""Fail the read call, retrying while the provider itself is still starting.
|
|
|
|
Args:
|
|
status: HTTP status the adapter answered with.
|
|
body: its response body.
|
|
|
|
An adapter that answers 502 has reached the provider's port and been
|
|
refused, or has not reached it at all. Only the second case, and a provider
|
|
answering 5xx, are worth waiting for: a 4xx means the adapter's credential
|
|
or its contracted path is wrong and no amount of retrying fixes it.
|
|
"""
|
|
if status == 200:
|
|
return
|
|
upstream = 0
|
|
try:
|
|
upstream = int(
|
|
((json.loads(body).get("error") or {}).get("data") or {}).get(
|
|
"upstream_status", 0
|
|
)
|
|
)
|
|
except (ValueError, AttributeError):
|
|
upstream = 0
|
|
if (status == 502 and upstream >= 500) or (status == 502 and upstream == 0):
|
|
unreachable(
|
|
f"read call {READ_TOOL}: the provider answered {upstream or 'nothing'}"
|
|
)
|
|
reject(f"read call {READ_TOOL} answered {status}: {body[:200]}")
|
|
|
|
|
|
def speaks_json_rpc(body):
|
|
"""Return whether a body is the JSON object an MCP server would answer with.
|
|
|
|
A server-side error page proves the endpoint is not serving yet, not that it
|
|
serves the unauthenticated. Treating a PHP fatal rendered as HTTP 200 as a
|
|
refusal verdict settles the caller's retry condition on the first attempt.
|
|
"""
|
|
try:
|
|
return isinstance(json.loads(body or "null"), dict)
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def rpc_error(body):
|
|
"""Return the JSON-RPC ``error`` object a response body carries, or None."""
|
|
try:
|
|
payload = json.loads(body or "null")
|
|
except ValueError:
|
|
return None
|
|
return payload.get("error") if isinstance(payload, dict) else None
|
|
|
|
|
|
def refused(status, body):
|
|
"""Return whether a response refused the caller rather than serving it.
|
|
|
|
Args:
|
|
status: HTTP status of the response.
|
|
body: its JSON-RPC payload.
|
|
|
|
JSON-RPC carries its own error channel, so a server may refuse a caller
|
|
with a transport-level 200 and an ``error`` object. Moodle does exactly
|
|
that. Demanding a 4xx would read a correct refusal as a served request.
|
|
"""
|
|
return status >= 400 or bool(rpc_error(body))
|
|
|
|
|
|
def assert_refused(label, authorization, url=None):
|
|
"""Prove one credential is refused, retrying while the endpoint is unserved.
|
|
|
|
Args:
|
|
label: which credential is being presented, for the failure message.
|
|
authorization: the header value to present, or None.
|
|
url: endpoint to probe, defaulting to the module's URL.
|
|
"""
|
|
session_before = SESSION["id"]
|
|
try:
|
|
status, body = rpc(
|
|
"initialize", INITIALIZE, authorization=authorization, url=url
|
|
)
|
|
finally:
|
|
SESSION["id"] = session_before
|
|
if status == 0:
|
|
unreachable(f"{label} probe could not reach {url or URL}: {body}")
|
|
if not 400 <= status < 500 and not speaks_json_rpc(body):
|
|
unreachable(
|
|
f"{label} probe answered {status} with no JSON-RPC body: {body[:200]}"
|
|
)
|
|
if not refused(status, body):
|
|
reject(f"{label} probe answered {status}: {body[:200]}")
|
|
if '"tools"' in body:
|
|
reject(f"{label} probe disclosed the tool inventory")
|
|
|
|
|
|
class SseSession:
|
|
"""One open SSE stream plus the message endpoint it announced.
|
|
|
|
The stream is the response channel and the announced endpoint is the
|
|
request channel, so a call means: post to the endpoint, then read the
|
|
matching id off the stream that is already open. A reader thread is what
|
|
keeps the two from deadlocking, since the response to a POST can arrive on
|
|
the stream before the POST itself returns.
|
|
"""
|
|
|
|
def __init__(self, url, authorization):
|
|
self.url = url
|
|
self.authorization = authorization
|
|
self.endpoint = None
|
|
self.status = 0
|
|
self.content_type = ""
|
|
self.error = None
|
|
self._messages = queue.Queue()
|
|
self._announced = threading.Event()
|
|
self._response = None
|
|
self._reader = threading.Thread(target=self._read, daemon=True)
|
|
|
|
def open(self):
|
|
"""Start the stream and wait for its endpoint announcement."""
|
|
self._reader.start()
|
|
self._announced.wait(SESSION_TIMEOUT)
|
|
return self
|
|
|
|
def _read(self):
|
|
request = urllib.request.Request(self.url, method="GET") # noqa: S310 fixed internal http origin
|
|
request.add_header("Accept", "text/event-stream")
|
|
if self.authorization:
|
|
request.add_header("Authorization", self.authorization)
|
|
try:
|
|
self._response = urllib.request.urlopen(request, timeout=SESSION_TIMEOUT) # noqa: S310 fixed internal http origin
|
|
except urllib.error.HTTPError as error:
|
|
self.status, self.error = error.code, error
|
|
self._announced.set()
|
|
return
|
|
except OSError as error:
|
|
self.error = error
|
|
self._announced.set()
|
|
return
|
|
|
|
self.status = self._response.status
|
|
self.content_type = self._response.headers.get("Content-Type", "")
|
|
event = None
|
|
try:
|
|
with contextlib.suppress(OSError, ValueError):
|
|
for raw in self._response:
|
|
line = raw.decode(errors="replace").rstrip("\r\n")
|
|
if line.startswith("event:"):
|
|
event = line.split(":", 1)[1].strip()
|
|
elif line.startswith("data:"):
|
|
data = line.split(":", 1)[1].strip()
|
|
if event == "endpoint":
|
|
self.endpoint = urllib.parse.urljoin(self.url, data)
|
|
self._announced.set()
|
|
else:
|
|
self._messages.put(data)
|
|
elif not line:
|
|
event = None
|
|
finally:
|
|
self._announced.set()
|
|
|
|
def call(self, method, params=None, request_id=1):
|
|
"""Post one JSON-RPC request and return the matching stream response.
|
|
|
|
Args:
|
|
method: MCP method name.
|
|
params: method params, or None.
|
|
request_id: JSON-RPC id to match the streamed response against.
|
|
"""
|
|
payload = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
|
if params is not None:
|
|
payload["params"] = params
|
|
request = urllib.request.Request( # noqa: S310 fixed internal http origin
|
|
self.endpoint, data=json.dumps(payload).encode(), method="POST"
|
|
)
|
|
request.add_header("Content-Type", "application/json")
|
|
if self.authorization:
|
|
request.add_header("Authorization", self.authorization)
|
|
with urllib.request.urlopen(request, timeout=SESSION_TIMEOUT): # noqa: S310 fixed internal http origin
|
|
pass
|
|
|
|
deadline = time.monotonic() + SESSION_TIMEOUT
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
reject(f"{method} got no response on the stream")
|
|
try:
|
|
parsed = json.loads(self._messages.get(timeout=remaining))
|
|
except queue.Empty:
|
|
reject(f"{method} got no response on the stream")
|
|
if parsed.get("id") == request_id:
|
|
return parsed
|
|
|
|
|
|
def probe_sse():
|
|
"""Prove an SSE provider is guarded, then drive a real session through it."""
|
|
guarded = unguarded_url()
|
|
for label, authorization in (
|
|
("unauthenticated", None),
|
|
("wrong-credential", WRONG_CREDENTIAL),
|
|
):
|
|
session = SseSession(guarded, authorization).open()
|
|
status, content_type, endpoint = (
|
|
session.status,
|
|
session.content_type,
|
|
session.endpoint,
|
|
)
|
|
if status == 0:
|
|
unreachable(f"{label} probe could not reach {guarded}: {session.error}")
|
|
if status < 400:
|
|
reject(f"{label} probe answered {status} {content_type}")
|
|
if endpoint:
|
|
reject(f"{label} probe was served a session at {endpoint}")
|
|
|
|
session = SseSession(URL, AUTH).open()
|
|
if session.status == 0:
|
|
unreachable(f"authenticated probe could not reach {URL}: {session.error}")
|
|
if session.status != 200 or "text/event-stream" not in session.content_type:
|
|
reject(f"authenticated stream answered {session.status}")
|
|
if not session.endpoint:
|
|
reject("authenticated stream announced no message endpoint")
|
|
|
|
answer = session.call("initialize", INITIALIZE)
|
|
reject_error("authenticated initialize", answer.get("error"))
|
|
|
|
answer = session.call("tools/list", request_id=2)
|
|
reject_error("authenticated tools/list", answer.get("error"))
|
|
served = sorted(
|
|
str(tool.get("name"))
|
|
for tool in (answer.get("result") or {}).get("tools") or []
|
|
if tool.get("name")
|
|
)
|
|
assert_surface(served)
|
|
|
|
if READ_TOOL:
|
|
answer = session.call(
|
|
"tools/call",
|
|
{"name": READ_TOOL, "arguments": READ_ARGUMENTS},
|
|
request_id=3,
|
|
)
|
|
reject_error(f"read call {READ_TOOL}", answer.get("error"))
|
|
if (answer.get("result") or {}).get("isError"):
|
|
reject(f"read call {READ_TOOL} returned an error result")
|
|
print("OK")
|
|
|
|
|
|
def assert_surface(served):
|
|
"""Refuse a served tool set that does not match what the role recorded.
|
|
|
|
Args:
|
|
served: the tool names the upstream answered ``tools/list`` with.
|
|
|
|
Two comparisons with different addressees. Drift against ``upstream_serves``
|
|
means the vendor changed the surface, so the checked-in record is stale and
|
|
a human has to re-read the contract. A mismatch against the allowlist means
|
|
the platform's own policy is not what is being served. Reporting both as one
|
|
failure sent every upstream version bump to the wrong reader.
|
|
"""
|
|
if UPSTREAM_SERVES and served != UPSTREAM_SERVES:
|
|
grew = sorted(set(served) - set(UPSTREAM_SERVES))
|
|
gone = sorted(set(UPSTREAM_SERVES) - set(served))
|
|
reject(
|
|
f"upstream drift: the recorded surface is stale "
|
|
f"(added {grew}, removed {gone}); re-read the contract at this "
|
|
f"version before deploying"
|
|
)
|
|
if EXPECTED and served != EXPECTED:
|
|
reject(f"server serves {served} but the contract declares {EXPECTED}")
|
|
|
|
|
|
def main():
|
|
|
|
if TRANSPORT == "sse":
|
|
probe_sse()
|
|
return
|
|
|
|
guarded = unguarded_url()
|
|
assert_refused("unauthenticated", None, guarded)
|
|
assert_refused("wrong-credential", WRONG_CREDENTIAL, guarded)
|
|
|
|
status, body = rpc("initialize", INITIALIZE, authorization=AUTH)
|
|
if status == 0:
|
|
unreachable(f"authenticated probe could not reach {URL}: {body}")
|
|
if status != 200:
|
|
reject(f"authenticated initialize answered {status}: {body[:200]}")
|
|
reject_error("authenticated initialize", rpc_error(body))
|
|
result = json.loads(body).get("result") or {}
|
|
SESSION["protocol"] = result.get("protocolVersion", "")
|
|
|
|
rpc("notifications/initialized", authorization=AUTH, notification=True)
|
|
|
|
status, body = rpc("tools/list", authorization=AUTH)
|
|
if status != 200:
|
|
reject(f"authenticated tools/list answered {status}: {body[:200]}")
|
|
reject_error("authenticated tools/list", rpc_error(body))
|
|
|
|
served = sorted(
|
|
str(tool.get("name"))
|
|
for tool in (json.loads(body).get("result") or {}).get("tools") or []
|
|
if tool.get("name")
|
|
)
|
|
assert_surface(served)
|
|
|
|
if READ_TOOL:
|
|
status, body = rpc(
|
|
"tools/call",
|
|
{"name": READ_TOOL, "arguments": READ_ARGUMENTS},
|
|
authorization=AUTH,
|
|
)
|
|
assert_read_call(status, body)
|
|
reject_error(f"read call {READ_TOOL}", rpc_error(body))
|
|
if (json.loads(body).get("result") or {}).get("isError"):
|
|
reject(f"read call {READ_TOOL} returned an error result")
|
|
|
|
print("OK")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|