Skip to content

Commit aef57aa

Browse files
committed
Add OAuth key ring file support to MCP serve command
Add checkpoint store for run persistence and OAuth key ring support Introduce InMemoryCheckpointStore and SQLiteCheckpointStore to save and restore agent context after each tool call, enabling reliable resume. Add --checkpoint-store CLI flag for run and resume commands, and wire checkpointing into AgentRunner. Add OAuth key ring file support (--oauth-key-ring-file, --oauth-active-kid) for MCP HTTP server, and make DPoP proof replay TTL configurable via --oauth-dpop-replay-ttl.
1 parent 43df385 commit aef57aa

10 files changed

Lines changed: 550 additions & 4 deletions

File tree

teaagent/chat_agent.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class ChatAgentConfig:
4444
stream: bool = False
4545
on_chunk: Optional[Callable[[str], None]] = None
4646
approval_handler: Optional[ApprovalHandler] = None
47+
checkpoint_store: Any = None
4748

4849
@classmethod
4950
def from_root(cls, root: str | Path, **kwargs: Any) -> 'ChatAgentConfig':
@@ -115,6 +116,7 @@ def run_chat_agent(
115116
task_spec: Optional[str] = None,
116117
depth: int = 0,
117118
initial_observations: Optional[list[dict[str, Any]]] = None,
119+
initial_context_extra: Optional[dict[str, Any]] = None,
118120
) -> RunResult:
119121
tool_registry = registry or build_workspace_tool_registry(config.root)
120122
if config.enable_subagent and depth < config.max_subagent_depth:
@@ -150,6 +152,7 @@ def run_chat_agent(
150152
),
151153
approval_handler=config.approval_handler,
152154
compactor=ContextCompactor(memory_keys=('task_spec', 'memories')),
155+
checkpoint_store=config.checkpoint_store,
153156
)
154157
run_id = uuid4().hex
155158
heartbeat: Optional[Heartbeat] = None
@@ -164,6 +167,7 @@ def run_chat_agent(
164167
decide=lambda context: engine.decide(with_memories(context, memories)),
165168
run_id=run_id,
166169
initial_observations=initial_observations,
170+
initial_context_extra=initial_context_extra,
167171
)
168172
finally:
169173
if heartbeat is not None:

teaagent/checkpoint.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import sqlite3
5+
from datetime import datetime, timezone
6+
from pathlib import Path
7+
from typing import Any, Optional
8+
9+
_CHECKPOINT_KEYS = ('task', 'observations', 'compacted_summary', 'memory_keys')
10+
_SCHEMA_VERSION = 1
11+
12+
13+
class InMemoryCheckpointStore:
14+
def __init__(self) -> None:
15+
self._data: dict[str, dict[str, Any]] = {}
16+
17+
def save(self, run_id: str, context: dict[str, Any]) -> None:
18+
self._data[run_id] = _extract_checkpoint(context)
19+
20+
def load(self, run_id: str) -> Optional[dict[str, Any]]:
21+
return self._data.get(run_id)
22+
23+
def delete(self, run_id: str) -> None:
24+
self._data.pop(run_id, None)
25+
26+
27+
class SQLiteCheckpointStore:
28+
def __init__(self, path: str | Path) -> None:
29+
self._path = Path(path)
30+
self._path.parent.mkdir(parents=True, exist_ok=True)
31+
self._init_db()
32+
33+
def _connect(self) -> sqlite3.Connection:
34+
conn = sqlite3.connect(str(self._path), timeout=10)
35+
conn.execute('PRAGMA journal_mode=WAL')
36+
return conn
37+
38+
def _init_db(self) -> None:
39+
with self._connect() as conn:
40+
conn.execute(
41+
"""
42+
CREATE TABLE IF NOT EXISTS checkpoints (
43+
run_id TEXT PRIMARY KEY,
44+
context_json TEXT NOT NULL,
45+
updated_at TEXT NOT NULL,
46+
schema_version INTEGER NOT NULL DEFAULT 1
47+
)
48+
"""
49+
)
50+
51+
def save(self, run_id: str, context: dict[str, Any]) -> None:
52+
snapshot = _extract_checkpoint(context)
53+
now = datetime.now(timezone.utc).isoformat()
54+
with self._connect() as conn:
55+
conn.execute(
56+
"""
57+
INSERT INTO checkpoints (run_id, context_json, updated_at, schema_version)
58+
VALUES (?, ?, ?, ?)
59+
ON CONFLICT(run_id) DO UPDATE SET
60+
context_json = excluded.context_json,
61+
updated_at = excluded.updated_at
62+
""",
63+
(
64+
run_id,
65+
json.dumps(snapshot, ensure_ascii=False),
66+
now,
67+
_SCHEMA_VERSION,
68+
),
69+
)
70+
71+
def load(self, run_id: str) -> Optional[dict[str, Any]]:
72+
with self._connect() as conn:
73+
row = conn.execute(
74+
'SELECT context_json FROM checkpoints WHERE run_id = ?',
75+
(run_id,),
76+
).fetchone()
77+
if row is None:
78+
return None
79+
return json.loads(row[0])
80+
81+
def delete(self, run_id: str) -> None:
82+
with self._connect() as conn:
83+
conn.execute('DELETE FROM checkpoints WHERE run_id = ?', (run_id,))
84+
85+
86+
def _extract_checkpoint(context: dict[str, Any]) -> dict[str, Any]:
87+
return {k: context[k] for k in _CHECKPOINT_KEYS if k in context}

teaagent/cli/_agent_parsers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ def _run(subs: argparse._SubParsersAction, handler: Callable) -> None: # type:
104104
action='store_true',
105105
help='Also print OpenTelemetry spans to stderr (debug).',
106106
)
107+
p.add_argument(
108+
'--checkpoint-store',
109+
default=None,
110+
metavar='PATH',
111+
help='SQLite path for run checkpoint storage. Saves context after each tool call.',
112+
)
107113
p.set_defaults(func=handler)
108114

109115

@@ -203,6 +209,12 @@ def _resume(subs: argparse._SubParsersAction, handler: Callable) -> None: # typ
203209
action='store_true',
204210
help='Re-run the original task from scratch instead of replaying observations from the prior run.',
205211
)
212+
p.add_argument(
213+
'--checkpoint-store',
214+
default=None,
215+
metavar='PATH',
216+
help='SQLite path for checkpoint storage. Used to restore compacted context on resume.',
217+
)
206218
p.set_defaults(func=handler)
207219

208220

teaagent/cli/_handlers/_agent.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,23 @@ def agent_resume_command(args: argparse.Namespace) -> int:
2727
return 1
2828

2929
initial_observations: list[dict[str, Any]] = []
30+
initial_context_extra: Optional[dict[str, Any]] = None
3031
auto_approved: Optional[str] = None
32+
3133
if not args.fresh_restart:
32-
initial_observations = store.observations_for_run(args.run_id)
34+
checkpoint_path = getattr(args, 'checkpoint_store', None)
35+
checkpoint = None
36+
if checkpoint_path:
37+
from teaagent.checkpoint import SQLiteCheckpointStore
38+
39+
checkpoint = SQLiteCheckpointStore(checkpoint_path).load(args.run_id)
40+
if checkpoint is not None:
41+
initial_observations = checkpoint.get('observations', [])
42+
initial_context_extra = {
43+
k: v for k, v in checkpoint.items() if k not in ('task', 'observations')
44+
}
45+
else:
46+
initial_observations = store.observations_for_run(args.run_id)
3347
pending = store.pending_approval_for_run(args.run_id)
3448
if pending and pending['call_id'] not in args.approve_call_id:
3549
args.approve_call_id = list(args.approve_call_id) + [pending['call_id']]
@@ -40,6 +54,7 @@ def agent_resume_command(args: argparse.Namespace) -> int:
4054
original_task,
4155
resumed_from=args.run_id,
4256
initial_observations=initial_observations,
57+
initial_context_extra=initial_context_extra,
4358
auto_approved_call_id=auto_approved,
4459
)
4560

@@ -50,6 +65,7 @@ def _execute_agent_task(
5065
*,
5166
resumed_from: Optional[str] = None,
5267
initial_observations: Optional[list[dict[str, Any]]] = None,
68+
initial_context_extra: Optional[dict[str, Any]] = None,
5369
auto_approved_call_id: Optional[str] = None,
5470
) -> int:
5571
task_spec = None
@@ -102,6 +118,12 @@ def _execute_agent_task(
102118
print(f'Telemetry setup failed: {exc}', file=sys.stderr)
103119

104120
approval_handler = cli_approval_handler if args.hitl_approval else None
121+
checkpoint_store = None
122+
checkpoint_path = getattr(args, 'checkpoint_store', None)
123+
if checkpoint_path:
124+
from teaagent.checkpoint import SQLiteCheckpointStore
125+
126+
checkpoint_store = SQLiteCheckpointStore(checkpoint_path)
105127
result = run_chat_agent(
106128
task=task,
107129
adapter=adapter,
@@ -117,10 +139,12 @@ def _execute_agent_task(
117139
max_subagent_depth=args.max_subagent_depth,
118140
heartbeat_seconds=args.heartbeat,
119141
approval_handler=approval_handler,
142+
checkpoint_store=checkpoint_store,
120143
),
121144
audit=audit,
122145
task_spec=task_spec,
123146
initial_observations=initial_observations,
147+
initial_context_extra=initial_context_extra,
124148
)
125149
store.logger_for_result(result, audit)
126150
if _telemetry_sink is not None:

teaagent/cli/_handlers/_mcp.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,42 @@
11
from __future__ import annotations
22

33
import argparse
4+
import json
45
import sys
6+
from pathlib import Path
57

68
from teaagent.mcp_http import is_loopback_host
79
from teaagent.mcp_server import serve_mcp_stdio
10+
from teaagent.oauth21 import OAuth21AuthorizationServer, OAuthKeyRing
811
from teaagent.workspace_tools import build_workspace_tool_registry
912

1013

1114
def mcp_serve_command(args: argparse.Namespace) -> int:
12-
from teaagent.oauth21 import OAuth21AuthorizationServer
1315

1416
registry = build_workspace_tool_registry(args.root)
1517
if args.http:
1618
oauth_server = None
19+
key_ring = None
20+
if args.oauth_key_ring_file:
21+
key_ring, error = _load_key_ring(
22+
Path(args.oauth_key_ring_file),
23+
active_kid_override=args.oauth_active_kid,
24+
)
25+
if error:
26+
print(error, file=sys.stderr)
27+
return 1
28+
elif args.oauth_active_kid:
29+
print(
30+
'--oauth-active-kid requires --oauth-key-ring-file.',
31+
file=sys.stderr,
32+
)
33+
return 1
1734
if args.oauth_issuer and args.oauth_signing_key:
1835
oauth_server = OAuth21AuthorizationServer(
1936
signing_key=args.oauth_signing_key,
2037
issuer=args.oauth_issuer,
2138
token_ttl=args.oauth_token_ttl,
39+
dpop_replay_ttl=args.oauth_dpop_replay_ttl,
2240
)
2341
for spec in args.oauth_client or []:
2442
parts = spec.split(':', 2)
@@ -54,3 +72,41 @@ def mcp_serve_command(args: argparse.Namespace) -> int:
5472
oauth_server=oauth_server,
5573
)
5674
return serve_mcp_stdio(registry)
75+
76+
77+
def _load_key_ring(
78+
path: Path, *, active_kid_override: str | None
79+
) -> tuple[OAuthKeyRing | None, str | None]:
80+
if not path.exists():
81+
return None, f'OAuth key ring file not found: {path}'
82+
try:
83+
payload = json.loads(path.read_text(encoding='utf-8'))
84+
except (OSError, json.JSONDecodeError) as exc:
85+
return None, f'Failed to read OAuth key ring file {path}: {exc}'
86+
if not isinstance(payload, dict):
87+
return None, 'OAuth key ring file must contain a JSON object.'
88+
raw_keys = payload.get('keys')
89+
if not isinstance(raw_keys, dict) or not raw_keys:
90+
return None, "OAuth key ring file requires non-empty 'keys' object."
91+
92+
keys: dict[str, bytes] = {}
93+
for kid, secret in raw_keys.items():
94+
if not isinstance(kid, str) or not kid:
95+
return None, 'OAuth key ring keys must use non-empty string kids.'
96+
if not isinstance(secret, str) or len(secret) < 16:
97+
return (
98+
None,
99+
f'OAuth key ring secret for kid {kid!r} must be a string >= 16 chars.',
100+
)
101+
keys[kid] = secret.encode('utf-8')
102+
103+
active_kid = active_kid_override or payload.get('active_kid')
104+
if not isinstance(active_kid, str) or not active_kid:
105+
return (
106+
None,
107+
"OAuth key ring file requires string 'active_kid' or --oauth-active-kid.",
108+
)
109+
if active_kid not in keys:
110+
return None, f'OAuth active kid {active_kid!r} not found in key ring keys.'
111+
112+
return OAuthKeyRing(active_kid=active_kid, keys=keys), None

teaagent/cli/_mcp_parsers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ def register(
5757
default=None,
5858
help='HMAC signing key for JWT access tokens (min 16 chars).',
5959
)
60+
serve.add_argument(
61+
'--oauth-key-ring-file',
62+
default=None,
63+
help='Path to JSON key ring file: {"active_kid":"...","keys":{"kid":"secret"}}.',
64+
)
65+
serve.add_argument(
66+
'--oauth-active-kid',
67+
default=None,
68+
help='Override active key id (kid) from the key ring file.',
69+
)
6070
serve.add_argument(
6171
'--oauth-client',
6272
action='append',
@@ -71,4 +81,10 @@ def register(
7181
default=3600,
7282
help='Access token TTL in seconds. Default 3600.',
7383
)
84+
serve.add_argument(
85+
'--oauth-dpop-replay-ttl',
86+
type=int,
87+
default=60,
88+
help='DPoP proof replay-cache TTL in seconds. Default 60.',
89+
)
7490
serve.set_defaults(func=handlers['serve'])

teaagent/oauth21/_server.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def __init__(
4343
*,
4444
token_ttl: int = _DEFAULT_ACCESS_TOKEN_TTL,
4545
nonce_ttl: int = _NONCE_TTL_SECONDS,
46+
dpop_replay_ttl: int = _PROOF_MAX_AGE_SECONDS,
4647
store: Optional[OAuthStore] = None,
4748
key_ring: Optional[OAuthKeyRing] = None,
4849
) -> None:
@@ -53,6 +54,7 @@ def __init__(
5354
self._issuer = issuer
5455
self._token_ttl = token_ttl
5556
self._nonce_ttl = nonce_ttl
57+
self._dpop_replay_ttl = dpop_replay_ttl
5658
self._store = store or InMemoryOAuthStore()
5759
self._dpop_replay_cache = DPoPReplayCache()
5860

@@ -269,10 +271,10 @@ def _validate_dpop_and_extract_jkt(self, proof_jwt: str) -> str:
269271
_verify_dpop_signature(proof_jwt, jwk)
270272
iat = payload.get('iat', 0)
271273
now = time.time()
272-
if abs(now - iat) > _PROOF_MAX_AGE_SECONDS:
274+
if abs(now - iat) > self._dpop_replay_ttl:
273275
raise InvalidDPoPError('DPoP proof is too old')
274276
self._dpop_replay_cache.remember_once(
275-
payload.get('jti'), iat=float(iat), now=now, ttl=_PROOF_MAX_AGE_SECONDS
277+
payload.get('jti'), iat=float(iat), now=now, ttl=self._dpop_replay_ttl
276278
)
277279
return compute_jwk_thumbprint(jwk)
278280

0 commit comments

Comments
 (0)