mcp-ops-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_ops_kit/__init__.py +16 -0
- mcp_ops_kit/audit.py +315 -0
- mcp_ops_kit/auth.py +572 -0
- mcp_ops_kit/crypto.py +141 -0
- mcp_ops_kit/errors.py +27 -0
- mcp_ops_kit/evals/__init__.py +0 -0
- mcp_ops_kit/evals/agents.py +54 -0
- mcp_ops_kit/evals/grader.py +275 -0
- mcp_ops_kit/evals/harness.py +256 -0
- mcp_ops_kit/evals/openai_agent.py +159 -0
- mcp_ops_kit/evals/run.py +445 -0
- mcp_ops_kit/limits.py +34 -0
- mcp_ops_kit/oauth.py +381 -0
- mcp_ops_kit/py.typed +0 -0
- mcp_ops_kit/server.py +356 -0
- mcp_ops_kit/smoke.py +83 -0
- mcp_ops_kit/sqlite.py +285 -0
- mcp_ops_kit-0.1.0.dist-info/METADATA +127 -0
- mcp_ops_kit-0.1.0.dist-info/RECORD +21 -0
- mcp_ops_kit-0.1.0.dist-info/WHEEL +4 -0
- mcp_ops_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp_ops_kit/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""mcp-ops-kit: the domain-free parts of an ops MCP server and its trajectory evals.
|
|
2
|
+
|
|
3
|
+
Extracted from two working domains in this repo (the clinic in ``mcp_ops_server`` and the
|
|
4
|
+
salon in ``salon_ops``), not designed up front. A domain supplies its tools, its store and
|
|
5
|
+
an ``AuthConfig``; the kit supplies:
|
|
6
|
+
|
|
7
|
+
- ``auth``: hashed bearer tokens, scopes and a subject binding, enforced in server code;
|
|
8
|
+
- ``limits``: a per-client rate limit, applied before argument validation;
|
|
9
|
+
- ``server``: the MCP server wiring, the authenticated HTTP app, ``/healthz``, a
|
|
10
|
+
fail-closed entry point;
|
|
11
|
+
- ``sqlite``: one deadline per call over SQLite (lock wait, busy wait, execution);
|
|
12
|
+
- ``evals``: the harness, the trajectory grader, the replay and OpenAI agents, the runner
|
|
13
|
+
with its report and CI gate, and an end-to-end smoke test.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
mcp_ops_kit/audit.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Audit log (45 CFR 164.312(b)): one record per tool call, encrypted and hash-chained.
|
|
2
|
+
|
|
3
|
+
Each record says who called (client ID and token ID), which tool, which scope and subject
|
|
4
|
+
it was checked against, the outcome and when, and never the call's arguments or results.
|
|
5
|
+
The subject is recorded only if it is a well-formed ID of the domain's subject (e.g.
|
|
6
|
+
``PAT-0008``); anything else is dropped, so free text cannot reach the log.
|
|
7
|
+
|
|
8
|
+
Integrity: record *n* carries ``prev``, the chain value of record *n-1*, and its own chain
|
|
9
|
+
value is ``HMAC-SHA256(audit-mac key, record n)``. Editing, deleting, inserting or reordering
|
|
10
|
+
a record breaks the chain from that point, and without the key it cannot be recomputed.
|
|
11
|
+
Confidentiality: each record is stored as AES-256-GCM ciphertext bound to its sequence
|
|
12
|
+
number. One line per record::
|
|
13
|
+
|
|
14
|
+
{"seq": 7, "ct": "<base64url of nonce || ciphertext || tag>"}
|
|
15
|
+
|
|
16
|
+
What it cannot show by itself: records cut off the *end* of the file. ``verify`` prints the
|
|
17
|
+
last sequence number and chain value; record them elsewhere (a ticket, a second log) to
|
|
18
|
+
detect truncation later. One process writes a log at a time (an exclusive lock).
|
|
19
|
+
|
|
20
|
+
python -m mcp_ops_kit.audit verify --file audit.log --key-env MCP_OPS_DATA_KEY
|
|
21
|
+
python -m mcp_ops_kit.audit show --file audit.log --key-env MCP_OPS_DATA_KEY
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import base64
|
|
26
|
+
import fcntl
|
|
27
|
+
import hashlib
|
|
28
|
+
import hmac
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import threading
|
|
34
|
+
from collections.abc import Callable, Iterator
|
|
35
|
+
from contextvars import ContextVar
|
|
36
|
+
from dataclasses import dataclass, fields
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
41
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
42
|
+
|
|
43
|
+
from mcp_ops_kit.crypto import DataKey, DecryptionError, seal, unseal
|
|
44
|
+
from mcp_ops_kit.errors import ConfigError
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
GENESIS = "0" * 64
|
|
49
|
+
OUTCOMES = ("ok", "error", "denied", "rate_limited", "invalid")
|
|
50
|
+
# Every key a record has, in the order ``show`` prints them. Nothing else is ever written.
|
|
51
|
+
RECORD_KEYS = (
|
|
52
|
+
"seq",
|
|
53
|
+
"time",
|
|
54
|
+
"client_id",
|
|
55
|
+
"token_id",
|
|
56
|
+
"tool",
|
|
57
|
+
"scope",
|
|
58
|
+
"subject",
|
|
59
|
+
"outcome",
|
|
60
|
+
"error",
|
|
61
|
+
"prev",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AuditTamperedError(Exception):
|
|
66
|
+
"""The log failed verification at ``line`` (1-based)."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, line: int, reason: str) -> None:
|
|
69
|
+
super().__init__(f"line {line}: {reason}")
|
|
70
|
+
self.line = line
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Call:
|
|
75
|
+
"""What one tool call has told the audit log so far. The guard fills ``scope``,
|
|
76
|
+
``subject`` and ``checked``; the server fills ``outcome`` and ``error``."""
|
|
77
|
+
|
|
78
|
+
tool: str
|
|
79
|
+
client_id: str | None
|
|
80
|
+
token_id: str | None
|
|
81
|
+
scope: str | None = None
|
|
82
|
+
subject: str | None = None
|
|
83
|
+
checked: bool = False
|
|
84
|
+
outcome: str | None = None
|
|
85
|
+
error: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_CURRENT: ContextVar[Call | None] = ContextVar("mcp_ops_kit_audit_call", default=None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def mark(**updates: object) -> None:
|
|
92
|
+
"""Note facts about the call being audited (a no-op when nothing is audited). The
|
|
93
|
+
first ``outcome`` set wins: a refusal stays a refusal whatever is raised after it."""
|
|
94
|
+
call = _CURRENT.get()
|
|
95
|
+
if call is None:
|
|
96
|
+
return
|
|
97
|
+
for name, value in updates.items():
|
|
98
|
+
if name == "outcome" and call.outcome is not None:
|
|
99
|
+
continue
|
|
100
|
+
setattr(call, name, value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _canonical(record: dict[str, Any]) -> bytes:
|
|
104
|
+
return json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _aad(seq: int) -> bytes:
|
|
108
|
+
return f"mcp-ops-kit/audit/v1/{seq}".encode()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _Keys:
|
|
112
|
+
def __init__(self, key: DataKey) -> None:
|
|
113
|
+
self.enc = key.subkey("audit-enc")
|
|
114
|
+
self.mac = key.subkey("audit-mac")
|
|
115
|
+
|
|
116
|
+
def chain(self, record: dict[str, Any]) -> str:
|
|
117
|
+
return hmac.new(self.mac, _canonical(record), hashlib.sha256).hexdigest()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _decode_line(keys: _Keys, line: bytes, number: int) -> dict[str, Any]:
|
|
121
|
+
try:
|
|
122
|
+
outer = json.loads(line)
|
|
123
|
+
seq, ct = outer["seq"], outer["ct"]
|
|
124
|
+
if set(outer) != {"seq", "ct"} or not isinstance(seq, int) or not isinstance(ct, str):
|
|
125
|
+
raise ValueError("unexpected fields")
|
|
126
|
+
blob = base64.urlsafe_b64decode(ct + "=" * (-len(ct) % 4))
|
|
127
|
+
record = json.loads(unseal(keys.enc, blob, _aad(seq)))
|
|
128
|
+
except DecryptionError as exc:
|
|
129
|
+
raise AuditTamperedError(number, f"does not decrypt ({exc})") from exc
|
|
130
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
131
|
+
raise AuditTamperedError(number, f"not an audit record ({exc})") from exc
|
|
132
|
+
if not isinstance(record, dict) or tuple(sorted(record)) != tuple(sorted(RECORD_KEYS)):
|
|
133
|
+
raise AuditTamperedError(number, "record has unexpected fields")
|
|
134
|
+
if record["seq"] != seq:
|
|
135
|
+
raise AuditTamperedError(number, "sequence number inside and outside differ")
|
|
136
|
+
return record
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def records(path: str, key: DataKey) -> Iterator[dict[str, Any]]:
|
|
140
|
+
"""Yield every record, checking the whole chain; raises ``AuditTamperedError`` at the
|
|
141
|
+
first line that breaks it. A missing file is an empty log."""
|
|
142
|
+
keys = _Keys(key)
|
|
143
|
+
prev, expected = GENESIS, 1
|
|
144
|
+
try:
|
|
145
|
+
f = open(path, "rb") # noqa: SIM115 - closed below; a generator holds it open
|
|
146
|
+
except FileNotFoundError:
|
|
147
|
+
return
|
|
148
|
+
with f:
|
|
149
|
+
for number, line in enumerate(f, start=1):
|
|
150
|
+
if not line.endswith(b"\n"):
|
|
151
|
+
raise AuditTamperedError(number, "incomplete last line")
|
|
152
|
+
record = _decode_line(keys, line, number)
|
|
153
|
+
if record["seq"] != expected:
|
|
154
|
+
raise AuditTamperedError(
|
|
155
|
+
number, f"sequence {record['seq']} where {expected} was expected"
|
|
156
|
+
)
|
|
157
|
+
if not hmac.compare_digest(record["prev"], prev):
|
|
158
|
+
raise AuditTamperedError(number, "chain broken: prev does not match")
|
|
159
|
+
prev, expected = keys.chain(record), expected + 1
|
|
160
|
+
yield record
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def verify(path: str, key: DataKey) -> tuple[int, str]:
|
|
164
|
+
"""Check the whole log. Returns (records, chain value of the last one)."""
|
|
165
|
+
keys, count, head = _Keys(key), 0, GENESIS
|
|
166
|
+
for record in records(path, key):
|
|
167
|
+
count, head = record["seq"], keys.chain(record)
|
|
168
|
+
return count, head
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class AuditLog:
|
|
172
|
+
"""The append side. Opened once per server process; refuses to start on a log whose
|
|
173
|
+
last record does not decrypt (wrong key, or tampered), or that another process holds.
|
|
174
|
+
|
|
175
|
+
``begin`` / ``end`` bracket one tool call (``server.OpsServer`` calls them). If a record
|
|
176
|
+
cannot be written (disk full, file gone), the call that was just made still returns its
|
|
177
|
+
real result, since it has already happened, but the log is marked failed and every
|
|
178
|
+
later call is refused: the server does not act without an audit trail.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
path: str,
|
|
184
|
+
key: DataKey,
|
|
185
|
+
*,
|
|
186
|
+
fsync: bool = True,
|
|
187
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
188
|
+
) -> None:
|
|
189
|
+
self.path = path
|
|
190
|
+
self._keys = _Keys(key)
|
|
191
|
+
self._fsync = fsync
|
|
192
|
+
self._clock = clock
|
|
193
|
+
self._lock = threading.Lock()
|
|
194
|
+
self.failed: str | None = None
|
|
195
|
+
try:
|
|
196
|
+
self._fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600)
|
|
197
|
+
except OSError as exc:
|
|
198
|
+
raise ConfigError(f"Cannot open audit log {path}: {exc.strerror}") from exc
|
|
199
|
+
try:
|
|
200
|
+
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
201
|
+
except OSError as exc:
|
|
202
|
+
os.close(self._fd)
|
|
203
|
+
raise ConfigError(f"Audit log {path} is in use by another process") from exc
|
|
204
|
+
try:
|
|
205
|
+
self._seq, self._prev = self._tail()
|
|
206
|
+
except BaseException:
|
|
207
|
+
self.close()
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
def _tail(self) -> tuple[int, str]:
|
|
211
|
+
with open(self._fd, "rb", closefd=False) as f:
|
|
212
|
+
lines = f.read().splitlines(keepends=True)
|
|
213
|
+
if not lines:
|
|
214
|
+
return 0, GENESIS
|
|
215
|
+
if not lines[-1].endswith(b"\n"):
|
|
216
|
+
raise ConfigError(
|
|
217
|
+
f"Audit log {self.path}: the last line is incomplete (a crash during a write?). "
|
|
218
|
+
"Run verify on it before doing anything else."
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
last = _decode_line(self._keys, lines[-1], len(lines))
|
|
222
|
+
except AuditTamperedError as exc:
|
|
223
|
+
raise ConfigError(
|
|
224
|
+
f"Audit log {self.path} {exc}: the wrong data key, or the log was changed. "
|
|
225
|
+
"Refusing to append to it."
|
|
226
|
+
) from exc
|
|
227
|
+
if last["seq"] != len(lines):
|
|
228
|
+
raise ConfigError(
|
|
229
|
+
f"Audit log {self.path}: {len(lines)} lines but last seq {last['seq']}"
|
|
230
|
+
)
|
|
231
|
+
return last["seq"], self._keys.chain(last)
|
|
232
|
+
|
|
233
|
+
def append(self, **values: Any) -> int:
|
|
234
|
+
"""Write one record; returns its sequence number."""
|
|
235
|
+
with self._lock:
|
|
236
|
+
seq = self._seq + 1
|
|
237
|
+
record = {k: values.get(k) for k in RECORD_KEYS}
|
|
238
|
+
stamp = self._clock().astimezone(UTC).isoformat(timespec="milliseconds")
|
|
239
|
+
record |= {"seq": seq, "time": stamp.replace("+00:00", "Z"), "prev": self._prev}
|
|
240
|
+
blob = seal(self._keys.enc, _canonical(record), _aad(seq))
|
|
241
|
+
ct = base64.urlsafe_b64encode(blob).decode().rstrip("=")
|
|
242
|
+
line = json.dumps({"seq": seq, "ct": ct}, separators=(",", ":")).encode() + b"\n"
|
|
243
|
+
self._check_still_the_file()
|
|
244
|
+
written = os.write(self._fd, line)
|
|
245
|
+
if written != len(line):
|
|
246
|
+
raise OSError(f"short write to audit log ({written} of {len(line)} bytes)")
|
|
247
|
+
if self._fsync:
|
|
248
|
+
os.fsync(self._fd)
|
|
249
|
+
self._seq, self._prev = seq, self._keys.chain(record)
|
|
250
|
+
return seq
|
|
251
|
+
|
|
252
|
+
def _check_still_the_file(self) -> None:
|
|
253
|
+
"""Writes go to the open file descriptor. If the log was deleted or replaced while
|
|
254
|
+
open, they would land in a file nobody can read, and another process could start a
|
|
255
|
+
new log at the path (the lock is on the old file). So refuse, and the server stops
|
|
256
|
+
acting (Codex review, PR #20)."""
|
|
257
|
+
open_file = os.fstat(self._fd)
|
|
258
|
+
try:
|
|
259
|
+
at_path = os.stat(self.path)
|
|
260
|
+
except FileNotFoundError as exc:
|
|
261
|
+
raise OSError(f"audit log {self.path} was deleted while in use") from exc
|
|
262
|
+
if (open_file.st_dev, open_file.st_ino) != (at_path.st_dev, at_path.st_ino):
|
|
263
|
+
raise OSError(f"audit log {self.path} was replaced while in use")
|
|
264
|
+
|
|
265
|
+
# ---- one tool call ---------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def begin(self, tool: str) -> tuple[Call, object]:
|
|
268
|
+
"""Start auditing a call. Refuses it outright if an earlier record failed to write."""
|
|
269
|
+
if self.failed is not None:
|
|
270
|
+
raise ToolError("The audit log is unavailable, so this server is refusing calls.")
|
|
271
|
+
token = get_access_token()
|
|
272
|
+
token_id = hashlib.sha256(token.token.encode()).hexdigest()[:12] if token else None
|
|
273
|
+
call = Call(tool, token.client_id if token else None, token_id)
|
|
274
|
+
return call, _CURRENT.set(call)
|
|
275
|
+
|
|
276
|
+
def end(self, call: Call, reset: object) -> None:
|
|
277
|
+
_CURRENT.reset(reset) # type: ignore[arg-type] - the token begin() returned
|
|
278
|
+
values = {f.name: getattr(call, f.name) for f in fields(Call) if f.name != "checked"}
|
|
279
|
+
try:
|
|
280
|
+
self.append(**values)
|
|
281
|
+
except OSError as exc:
|
|
282
|
+
self.failed = str(exc)
|
|
283
|
+
logger.critical("Audit log %s write failed; refusing further calls: %s", self.path, exc)
|
|
284
|
+
|
|
285
|
+
def close(self) -> None:
|
|
286
|
+
if self._fd >= 0:
|
|
287
|
+
os.close(self._fd) # releases the lock
|
|
288
|
+
self._fd = -1
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main(argv: list[str] | None = None) -> None:
|
|
292
|
+
parser = argparse.ArgumentParser(prog="python -m mcp_ops_kit.audit")
|
|
293
|
+
parser.add_argument("command", choices=["verify", "show"])
|
|
294
|
+
parser.add_argument("--file", required=True)
|
|
295
|
+
parser.add_argument(
|
|
296
|
+
"--key-env", required=True, help="the variable holding the data key, e.g. MCP_OPS_DATA_KEY"
|
|
297
|
+
)
|
|
298
|
+
args = parser.parse_args(argv)
|
|
299
|
+
raw = os.environ.get(args.key_env)
|
|
300
|
+
if not raw:
|
|
301
|
+
sys.exit(f"{args.key_env} is not set")
|
|
302
|
+
key = DataKey.from_text(raw, args.key_env)
|
|
303
|
+
try:
|
|
304
|
+
if args.command == "verify":
|
|
305
|
+
count, head = verify(args.file, key)
|
|
306
|
+
print(f"ok: {count} records, chain intact; last seq {count}, head {head}") # noqa: T201
|
|
307
|
+
else:
|
|
308
|
+
for record in records(args.file, key):
|
|
309
|
+
print(json.dumps({k: record[k] for k in RECORD_KEYS})) # noqa: T201
|
|
310
|
+
except AuditTamperedError as exc:
|
|
311
|
+
sys.exit(f"TAMPERED: {args.file} {exc}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
main()
|