|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import argparse |
| 4 | +import json |
4 | 5 | import sys |
| 6 | +from pathlib import Path |
5 | 7 |
|
6 | 8 | from teaagent.mcp_http import is_loopback_host |
7 | 9 | from teaagent.mcp_server import serve_mcp_stdio |
| 10 | +from teaagent.oauth21 import OAuth21AuthorizationServer, OAuthKeyRing |
8 | 11 | from teaagent.workspace_tools import build_workspace_tool_registry |
9 | 12 |
|
10 | 13 |
|
11 | 14 | def mcp_serve_command(args: argparse.Namespace) -> int: |
12 | | - from teaagent.oauth21 import OAuth21AuthorizationServer |
13 | 15 |
|
14 | 16 | registry = build_workspace_tool_registry(args.root) |
15 | 17 | if args.http: |
16 | 18 | 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 |
17 | 34 | if args.oauth_issuer and args.oauth_signing_key: |
18 | 35 | oauth_server = OAuth21AuthorizationServer( |
19 | 36 | signing_key=args.oauth_signing_key, |
20 | 37 | issuer=args.oauth_issuer, |
21 | 38 | token_ttl=args.oauth_token_ttl, |
| 39 | + dpop_replay_ttl=args.oauth_dpop_replay_ttl, |
22 | 40 | ) |
23 | 41 | for spec in args.oauth_client or []: |
24 | 42 | parts = spec.split(':', 2) |
@@ -54,3 +72,41 @@ def mcp_serve_command(args: argparse.Namespace) -> int: |
54 | 72 | oauth_server=oauth_server, |
55 | 73 | ) |
56 | 74 | 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 |
0 commit comments