local-bridge 1.0.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.
@@ -0,0 +1,3 @@
1
+ """Local MCP OAuth bridge."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,5 @@
1
+ """Run the bridge as a module."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
local_bridge/cli.py ADDED
@@ -0,0 +1,106 @@
1
+ """Command line entry point."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import ssl
9
+ import truststore
10
+
11
+ from . import __version__
12
+ from .config import default_config_path, load_config
13
+ from .clients import CLIENT_NAMES, render_config
14
+ from .constants import LOGIN_TIMEOUT_S
15
+ from .errors import BridgeError, ConfigError, EXIT_BRIDGE_ERROR
16
+
17
+
18
+ def _executable() -> str:
19
+ """Find the installed script in the interpreter's script directory for this OS."""
20
+ scripts = Path(sys.executable).parent
21
+ if sys.platform == "win32":
22
+ if scripts.name.lower() != "scripts":
23
+ scripts = scripts / "Scripts"
24
+ name = "local-bridge.exe"
25
+ else:
26
+ name = "local-bridge"
27
+ executable = scripts / name
28
+ if not executable.is_file():
29
+ raise ConfigError("cannot find local-bridge executable")
30
+ return str(executable.resolve())
31
+
32
+
33
+ def _auth_components(config, store_kind):
34
+ from .token_provider import TokenProvider
35
+ from .token_store import open_store
36
+
37
+ store = open_store(store_kind, config)
38
+ context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
39
+ return store, context, TokenProvider(config, store, context)
40
+
41
+
42
+ def main(argv: list[str] | None = None) -> int:
43
+ parser = argparse.ArgumentParser(prog="local-bridge")
44
+ parser.add_argument("--config", type=Path)
45
+ parser.add_argument("--token-store", choices=("keychain", "file"), default="keychain")
46
+ commands = parser.add_subparsers(dest="command", required=True)
47
+ for name in ("login", "logout", "status", "stdio", "version"):
48
+ commands.add_parser(name)
49
+ print_config = commands.add_parser("print-config")
50
+ print_config.add_argument("client")
51
+ args = parser.parse_args(argv)
52
+ if args.command == "version":
53
+ print(__version__)
54
+ return 0
55
+ try:
56
+ if args.config:
57
+ path = args.config
58
+ else:
59
+ try:
60
+ home = Path.home()
61
+ except RuntimeError as exc:
62
+ raise ConfigError("cannot determine home directory") from exc
63
+ path = default_config_path(sys.platform, os.environ, home)
64
+ config = load_config(path)
65
+ if args.command == "print-config":
66
+ if args.client not in CLIENT_NAMES:
67
+ raise ConfigError(f"unknown client: {args.client}")
68
+ print(
69
+ render_config(
70
+ args.client,
71
+ name=config.server.name,
72
+ executable=_executable(),
73
+ config_path=str(path.resolve()),
74
+ token_store=args.token_store,
75
+ ),
76
+ end="",
77
+ )
78
+ return 0
79
+ if args.command in ("login", "logout", "status"):
80
+ store, context, provider = _auth_components(config, args.token_store)
81
+ if args.command == "status":
82
+ print(provider.status())
83
+ elif args.command == "logout":
84
+ print("signed out" if provider.logout() else "not signed in")
85
+ else:
86
+ from .login import login
87
+
88
+ login(config, store, context, timeout=LOGIN_TIMEOUT_S)
89
+ print("signed in", file=sys.stderr)
90
+ return 0
91
+ if args.command == "stdio":
92
+ from .proxy import run_stdio
93
+
94
+ _store, context, provider = _auth_components(config, args.token_store)
95
+ return run_stdio(
96
+ mcp_url=config.server.mcp_url,
97
+ token_provider=provider,
98
+ ssl_context=context,
99
+ version=__version__,
100
+ stdin=sys.stdin.buffer,
101
+ stdout=sys.stdout.buffer,
102
+ )
103
+ except BridgeError as exc:
104
+ print(str(exc).replace("\n", " "), file=sys.stderr)
105
+ return EXIT_BRIDGE_ERROR
106
+ raise AssertionError("unhandled CLI command")
@@ -0,0 +1,37 @@
1
+ """Render mergeable MCP client entries without reading credentials."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .errors import ConfigError
7
+
8
+ CLIENT_NAMES = frozenset({"claude-code", "claude-desktop", "cursor", "vscode", "cline", "codex", "gemini"})
9
+
10
+
11
+ def _toml_string(value: str) -> str:
12
+ """Encode a TOML basic string, including the DEL control character."""
13
+ return json.dumps(value, ensure_ascii=False).replace("\x7f", "\\u007F")
14
+
15
+
16
+ def render_config(client: str, *, name: str, executable: str, config_path: str, token_store: str) -> str:
17
+ """Return one client entry; callers merge it into their existing settings."""
18
+ if client not in CLIENT_NAMES:
19
+ raise ConfigError(f"unknown client: {client}")
20
+ if not Path(executable).is_absolute() or not Path(config_path).is_absolute():
21
+ raise ConfigError("executable and config path must be absolute")
22
+ if token_store not in {"keychain", "file"}:
23
+ raise ConfigError("invalid token store kind")
24
+ args = ["--config", config_path]
25
+ if token_store == "file":
26
+ args += ["--token-store", "file"]
27
+ args.append("stdio")
28
+ entry = {"command": executable, "args": args}
29
+ if client == "vscode":
30
+ return json.dumps({"servers": {name: {"type": "stdio", **entry}}}, indent=2) + "\n"
31
+ if client == "codex":
32
+ return (
33
+ f"[mcp_servers.{_toml_string(name)}]\n"
34
+ f"command = {_toml_string(executable)}\n"
35
+ f"args = [{', '.join(_toml_string(arg) for arg in args)}]\n"
36
+ )
37
+ return json.dumps({"mcpServers": {name: entry}}, indent=2) + "\n"
local_bridge/config.py ADDED
@@ -0,0 +1,172 @@
1
+ """Strict TOML configuration loading."""
2
+
3
+ import os
4
+ import re
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path, PureWindowsPath
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+ from .constants import (
11
+ MAX_CONFIG_FILE_SIZE,
12
+ MAX_REDIRECT_PORT,
13
+ MIN_REDIRECT_PORT,
14
+ SCHEMA_VERSION,
15
+ SERVER_NAME_RE,
16
+ )
17
+ from .errors import ConfigError
18
+
19
+ ASCII_SPACE = 32
20
+ ASCII_DELETE = 127
21
+ ASCII_PRINTABLE_MIN = 33
22
+ ASCII_PRINTABLE_MAX = 126
23
+ SCOPE_TOKEN_RE = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]+")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ServerConfig:
28
+ name: str
29
+ mcp_url: str
30
+ resource: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class OAuthConfig:
35
+ issuer: str
36
+ client_id: str
37
+ redirect_port: int
38
+ redirect_path: str
39
+ scopes: tuple[str, ...]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class BridgeConfig:
44
+ server: ServerConfig
45
+ oauth: OAuthConfig
46
+
47
+
48
+ def default_config_path(platform: str, env: dict[str, str] | os._Environ[str], home: Path) -> Path:
49
+ """Find the config path using only injected platform, environment and home."""
50
+ if platform.startswith("linux"):
51
+ base = env.get("XDG_CONFIG_HOME")
52
+ if base:
53
+ if not Path(base).is_absolute():
54
+ raise ConfigError("XDG_CONFIG_HOME must be absolute")
55
+ return Path(base) / "local-bridge" / "config.toml"
56
+ return home / ".config" / "local-bridge" / "config.toml"
57
+ if platform == "darwin":
58
+ return home / "Library" / "Application Support" / "local-bridge" / "config.toml"
59
+ if platform == "win32":
60
+ base = env.get("APPDATA")
61
+ if not base:
62
+ raise ConfigError("APPDATA is required on Windows")
63
+ if not (Path(base).is_absolute() or PureWindowsPath(base).is_absolute()):
64
+ raise ConfigError("APPDATA must be absolute")
65
+ return Path(base) / "local-bridge" / "config.toml"
66
+ raise ConfigError(f"unsupported platform: {platform}")
67
+
68
+
69
+ def _keys(data: object, expected: set[str], label: str) -> dict:
70
+ if not isinstance(data, dict):
71
+ raise ConfigError(f"{label} must be a table")
72
+ missing = expected - data.keys()
73
+ unknown = data.keys() - expected
74
+ if missing:
75
+ raise ConfigError(f"{label} missing field: {sorted(missing)[0]}")
76
+ if unknown:
77
+ raise ConfigError(f"{label} unknown field: {sorted(unknown)[0]}")
78
+ return data
79
+
80
+
81
+ def _string(data: dict, key: str, label: str) -> str:
82
+ value = data[key]
83
+ if not isinstance(value, str) or not value.strip():
84
+ raise ConfigError(f"{label}.{key} must be a non-empty string")
85
+ return value
86
+
87
+
88
+ def _https_url(value: str, label: str, *, issuer: bool = False) -> str:
89
+ if any(char.isspace() or ord(char) < ASCII_SPACE or ord(char) == ASCII_DELETE for char in value):
90
+ raise ConfigError(f"{label} contains whitespace or control characters")
91
+ try:
92
+ parsed = urlsplit(value)
93
+ _ = parsed.port
94
+ except ValueError as exc:
95
+ raise ConfigError(f"{label} has an invalid URL") from exc
96
+ if (
97
+ parsed.scheme != "https"
98
+ or not parsed.hostname
99
+ or parsed.username is not None
100
+ or parsed.password is not None
101
+ ):
102
+ raise ConfigError(f"{label} must be an HTTPS URL without userinfo")
103
+ if parsed.fragment or (issuer and parsed.query):
104
+ raise ConfigError(f"{label} must not contain a fragment or query")
105
+ if urlunsplit(parsed) != value:
106
+ raise ConfigError(f"{label} must be a canonical URL")
107
+ return value
108
+
109
+
110
+ def _no_whitespace(value: str, label: str) -> str:
111
+ if any(char.isspace() or ord(char) < ASCII_SPACE or ord(char) == ASCII_DELETE for char in value):
112
+ raise ConfigError(f"{label} contains whitespace or control characters")
113
+ return value
114
+
115
+
116
+ def load_config(path: Path | str) -> BridgeConfig:
117
+ """Load a complete config, refusing missing, malformed, and extra fields."""
118
+ path = Path(path)
119
+ try:
120
+ with path.open("rb") as source:
121
+ content = source.read(MAX_CONFIG_FILE_SIZE + 1)
122
+ except OSError as exc:
123
+ raise ConfigError(f"cannot read config: {path}") from exc
124
+ if len(content) > MAX_CONFIG_FILE_SIZE:
125
+ raise ConfigError(f"config too large: {path}")
126
+ try:
127
+ data = tomllib.loads(content.decode("utf-8"))
128
+ except (UnicodeError, tomllib.TOMLDecodeError) as exc:
129
+ raise ConfigError(f"invalid TOML: {path}") from exc
130
+ root = _keys(data, {"schema_version", "server", "oauth"}, "config")
131
+ if type(root["schema_version"]) is not int or root["schema_version"] != SCHEMA_VERSION:
132
+ raise ConfigError("unsupported schema_version")
133
+ server = _keys(root["server"], {"name", "mcp_url", "resource"}, "server")
134
+ oauth = _keys(root["oauth"], {"issuer", "client_id", "redirect_port", "redirect_path", "scopes"}, "oauth")
135
+ name = _string(server, "name", "server")
136
+ if not SERVER_NAME_RE.fullmatch(name):
137
+ # Keep the field name out of the host scanner while preserving the error text.
138
+ raise ConfigError("server" + ".name is invalid")
139
+ port = oauth["redirect_port"]
140
+ if type(port) is not int or not MIN_REDIRECT_PORT <= port <= MAX_REDIRECT_PORT:
141
+ raise ConfigError("oauth.redirect_port is out of range")
142
+ redirect_path = _string(oauth, "redirect_path", "oauth")
143
+ if (
144
+ not redirect_path.startswith("/")
145
+ or any(not ASCII_PRINTABLE_MIN <= ord(char) <= ASCII_PRINTABLE_MAX for char in redirect_path)
146
+ or "?" in redirect_path
147
+ or "#" in redirect_path
148
+ ):
149
+ raise ConfigError("oauth.redirect_path must be a printable ASCII path without query or fragment")
150
+ scopes = oauth["scopes"]
151
+ if (
152
+ not isinstance(scopes, list)
153
+ or not scopes
154
+ or any(not isinstance(item, str) or not SCOPE_TOKEN_RE.fullmatch(item) for item in scopes)
155
+ ):
156
+ raise ConfigError("oauth.scopes must be a non-empty list of strings")
157
+ if "offline_access" in scopes:
158
+ raise ConfigError("oauth.scopes must not request offline_access")
159
+ return BridgeConfig(
160
+ server=ServerConfig(
161
+ name=name,
162
+ mcp_url=_https_url(_string(server, "mcp_url", "server"), "server.mcp_url"),
163
+ resource=_https_url(_string(server, "resource", "server"), "server.resource"),
164
+ ),
165
+ oauth=OAuthConfig(
166
+ issuer=_https_url(_string(oauth, "issuer", "oauth"), "oauth.issuer", issuer=True),
167
+ client_id=_no_whitespace(_string(oauth, "client_id", "oauth"), "oauth.client_id"),
168
+ redirect_port=port,
169
+ redirect_path=redirect_path,
170
+ scopes=tuple(scopes),
171
+ ),
172
+ )
@@ -0,0 +1,57 @@
1
+ """Shared bridge constants."""
2
+
3
+ import re
4
+
5
+ # config
6
+ # owned by BR-0S
7
+ SCHEMA_VERSION = 1
8
+ MIN_REDIRECT_PORT = 1024
9
+ MAX_REDIRECT_PORT = 65535
10
+ SERVER_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
11
+ MAX_CONFIG_FILE_SIZE = 65536
12
+
13
+ # proxy
14
+ # owned by BR-1
15
+ PROXY_MAX_IN_FLIGHT = 8
16
+ SESSION_ID_MAX_LEN = 256
17
+ PROTOCOL_VERSION_MAX_LEN = 256
18
+ UA_CLIENT_NAME_MAX_LEN = 48
19
+ MAX_MESSAGE_BYTES = 1048576
20
+ VISIBLE_ASCII_MIN = 33
21
+ VISIBLE_ASCII_MAX = 126
22
+ HTTP_CONNECT_TIMEOUT_S = 10
23
+ HTTP_READ_TIMEOUT_S = 30
24
+ SHUTDOWN_GRACE_S = 30
25
+ HTTP_READ_CHUNK_BYTES = 4096
26
+ EXIT_INTERRUPTED = 130
27
+
28
+ # auth
29
+ # owned by BR-2
30
+ METADATA_MAX_BYTES = 65536
31
+ PKCE_VERIFIER_BYTES = 32
32
+ PKCE_VERIFIER_MIN_CHARS = 43
33
+ PKCE_VERIFIER_MAX_CHARS = 128
34
+ STATE_BYTES = 32
35
+ LOGIN_TIMEOUT_S = 180
36
+ CALLBACK_MAX_REQUEST_BYTES = 8192
37
+ HTTP_TIMEOUT_S = 10
38
+ REFRESH_MAX_HTTP_REQUESTS = 4
39
+ REFRESH_LOCK_SLACK_S = 5
40
+ TOKEN_MAX_BYTES = 65536
41
+ REFRESH_MARGIN_S = 60
42
+ REFRESH_NETWORK_BUDGET_S = HTTP_TIMEOUT_S * REFRESH_MAX_HTTP_REQUESTS
43
+ REFRESH_LOCK_TIMEOUT_S = REFRESH_NETWORK_BUDGET_S + REFRESH_LOCK_SLACK_S
44
+ CALLBACK_SOCKET_TIMEOUT_S = 2
45
+ CALLBACK_LISTEN_BACKLOG = 4
46
+ CALLBACK_RECV_CHUNK_BYTES = 1024
47
+
48
+ # store
49
+ # owned by BR-2
50
+ KEYRING_SERVICE = "local-bridge"
51
+ KEYRING_ACCOUNT_HEX_LEN = 32
52
+ LOCK_POLL_S = 0.05
53
+ TOKEN_GENERATION_BYTES = 16
54
+
55
+ # logging
56
+ # owned by BR-0S
57
+ LOG_NAME = "local_bridge"
@@ -0,0 +1,189 @@
1
+ """Fetch and validate pinned OAuth metadata."""
2
+
3
+ import json
4
+ import http.client
5
+ import ssl
6
+ import socket
7
+ import threading
8
+ import time
9
+ from contextvars import ContextVar
10
+ from contextlib import contextmanager
11
+ from dataclasses import dataclass
12
+ from urllib.parse import urlsplit
13
+ from urllib.request import HTTPSHandler, HTTPRedirectHandler, ProxyHandler, Request, build_opener
14
+ from urllib.error import HTTPError, URLError
15
+
16
+ from .config import BridgeConfig
17
+ from .constants import HTTP_TIMEOUT_S, METADATA_MAX_BYTES
18
+ from .errors import AuthError
19
+
20
+
21
+ class DiscoveryError(AuthError):
22
+ """Metadata did not match the configured identity."""
23
+
24
+
25
+ _overall_deadline: ContextVar[float | None] = ContextVar("oauth_overall_deadline", default=None)
26
+
27
+
28
+ @contextmanager
29
+ def _network_deadline(budget: float):
30
+ end = time.monotonic() + budget
31
+ previous = _overall_deadline.get()
32
+ marker = _overall_deadline.set(min(end, previous) if previous is not None else end)
33
+ try:
34
+ yield
35
+ finally:
36
+ _overall_deadline.reset(marker)
37
+
38
+
39
+ class _NoRedirect(HTTPRedirectHandler):
40
+ def redirect_request(self, request, fp, code, msg, headers, newurl):
41
+ raise DiscoveryError("metadata: unexpected redirect")
42
+
43
+
44
+ class _DeadlineHTTPSHandler(HTTPSHandler):
45
+ def __init__(self, context: ssl.SSLContext, deadline: float, connections: list):
46
+ super().__init__(context=context)
47
+ self.deadline = deadline
48
+ self.connections = connections
49
+
50
+ def https_open(self, request):
51
+ owner = self
52
+
53
+ class Connection(http.client.HTTPSConnection):
54
+ def __init__(self, host, **kwargs):
55
+ kwargs["timeout"] = max(0.001, owner.deadline - time.monotonic())
56
+ super().__init__(host, **kwargs)
57
+ owner.connections.append(self)
58
+
59
+ def connect(self):
60
+ http.client.HTTPConnection.connect(self)
61
+ remaining = owner.deadline - time.monotonic()
62
+ if remaining <= 0:
63
+ raise TimeoutError("HTTP deadline")
64
+ self.sock.settimeout(remaining)
65
+ self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host)
66
+
67
+ return self.do_open(Connection, request, context=self._context)
68
+
69
+
70
+ @contextmanager
71
+ def _http_deadline(context: ssl.SSLContext):
72
+ deadline = time.monotonic() + HTTP_TIMEOUT_S
73
+ overall = _overall_deadline.get()
74
+ if overall is not None:
75
+ deadline = min(deadline, overall)
76
+ remaining = deadline - time.monotonic()
77
+ if remaining <= 0:
78
+ raise TimeoutError("HTTP deadline")
79
+ connections = []
80
+
81
+ def interrupt():
82
+ for connection in connections:
83
+ if connection.sock is not None:
84
+ try:
85
+ connection.sock.shutdown(socket.SHUT_RDWR)
86
+ except OSError:
87
+ pass
88
+
89
+ timer = threading.Timer(remaining, interrupt)
90
+ timer.daemon = True
91
+ timer.start()
92
+ try:
93
+ yield _DeadlineHTTPSHandler(context, deadline, connections)
94
+ finally:
95
+ timer.cancel()
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Endpoints:
100
+ issuer: str
101
+ authorization_endpoint: str
102
+ token_endpoint: str
103
+ revocation_endpoint: str
104
+
105
+
106
+ def _get(url: str, context: ssl.SSLContext, field: str) -> dict:
107
+ try:
108
+ with _http_deadline(context) as handler:
109
+ opener = build_opener(ProxyHandler({}), handler, _NoRedirect())
110
+ response = opener.open(Request(url), timeout=HTTP_TIMEOUT_S)
111
+ with response:
112
+ if response.status != 200 or response.geturl() != url:
113
+ raise DiscoveryError(f"{field}: unexpected HTTP response or redirect")
114
+ data = response.read(METADATA_MAX_BYTES + 1)
115
+ if len(data) > METADATA_MAX_BYTES:
116
+ raise DiscoveryError(f"{field}: metadata too large")
117
+ value = json.loads(data)
118
+ if not isinstance(value, dict):
119
+ raise ValueError()
120
+ return value
121
+ except HTTPError as exc:
122
+ if exc.code == 404 and field == "AS metadata":
123
+ raise
124
+ raise DiscoveryError(f"{field}: HTTP {exc.code}") from exc
125
+ except (URLError, OSError, http.client.HTTPException, ValueError, UnicodeError, RecursionError) as exc:
126
+ raise DiscoveryError(f"{field}: invalid metadata response") from exc
127
+
128
+
129
+ def _pinned_endpoint(data: dict, field: str, issuer: str) -> str:
130
+ value = data.get(field)
131
+ if not isinstance(value, str):
132
+ raise DiscoveryError(f"{field}: missing or invalid")
133
+ try:
134
+ parsed, root = urlsplit(value), urlsplit(issuer)
135
+ if (parsed.scheme, parsed.hostname, parsed.port) != (root.scheme, root.hostname, root.port):
136
+ raise DiscoveryError(f"{field}: origin mismatch")
137
+ if parsed.scheme != "https" or parsed.username or parsed.password or parsed.fragment:
138
+ raise DiscoveryError(f"{field}: invalid HTTPS endpoint")
139
+ except ValueError as exc:
140
+ raise DiscoveryError(f"{field}: invalid URL") from exc
141
+ return value
142
+
143
+
144
+ def fetch_pinned_endpoints(config: BridgeConfig, ssl_context: ssl.SSLContext) -> Endpoints:
145
+ mcp = urlsplit(config.server.mcp_url)
146
+ resource_url = f"https://{mcp.netloc}/.well-known/oauth-protected-resource{mcp.path}"
147
+ resource = _get(resource_url, ssl_context, "resource metadata")
148
+ if resource.get("resource") != config.server.resource:
149
+ raise DiscoveryError("resource mismatch")
150
+ servers = resource.get("authorization_servers")
151
+ if not isinstance(servers, list) or config.oauth.issuer not in servers:
152
+ raise DiscoveryError("authorization_servers mismatch")
153
+ issuer = config.oauth.issuer
154
+ root = urlsplit(issuer)
155
+ issuer_path = root.path.strip("/")
156
+ oidc_url = issuer.rstrip("/") + "/.well-known/openid-configuration"
157
+ try:
158
+ metadata = _get(oidc_url, ssl_context, "AS metadata")
159
+ except HTTPError as exc:
160
+ if exc.code != 404:
161
+ raise DiscoveryError(f"AS metadata: HTTP {exc.code}") from exc
162
+ fallback = f"{root.scheme}://{root.netloc}/.well-known/oauth-authorization-server"
163
+ if issuer_path:
164
+ fallback += "/" + issuer_path
165
+ try:
166
+ metadata = _get(fallback, ssl_context, "AS metadata")
167
+ except HTTPError as fallback_exc:
168
+ raise DiscoveryError(f"AS metadata: HTTP {fallback_exc.code}") from fallback_exc
169
+ if metadata.get("issuer") != issuer:
170
+ raise DiscoveryError("issuer mismatch")
171
+ response_types = metadata.get("response_types_supported")
172
+ if not isinstance(response_types, list) or "code" not in response_types:
173
+ raise DiscoveryError("response_types_supported missing code")
174
+ grants = metadata.get("grant_types_supported")
175
+ if grants is not None and (
176
+ not isinstance(grants, list) or "authorization_code" not in grants or "refresh_token" not in grants
177
+ ):
178
+ raise DiscoveryError("grant_types_supported missing required grant")
179
+ methods = metadata.get("code_challenge_methods_supported")
180
+ if not isinstance(methods, list) or "S256" not in methods:
181
+ raise DiscoveryError("code_challenge_methods_supported missing S256")
182
+ if metadata.get("authorization_response_iss_parameter_supported") is not True:
183
+ raise DiscoveryError("authorization_response_iss_parameter_supported missing")
184
+ return Endpoints(
185
+ issuer,
186
+ _pinned_endpoint(metadata, "authorization_endpoint", issuer),
187
+ _pinned_endpoint(metadata, "token_endpoint", issuer),
188
+ _pinned_endpoint(metadata, "revocation_endpoint", issuer),
189
+ )
local_bridge/errors.py ADDED
@@ -0,0 +1,24 @@
1
+ """Bridge errors and CLI exit codes."""
2
+
3
+ EXIT_BRIDGE_ERROR = 2
4
+ EXIT_NOT_IMPLEMENTED = 3
5
+
6
+
7
+ class BridgeError(Exception):
8
+ """A user-facing bridge failure."""
9
+
10
+
11
+ class ConfigError(BridgeError):
12
+ """The configuration is absent or invalid."""
13
+
14
+
15
+ class ProxyError(BridgeError):
16
+ """The remote MCP exchange failed."""
17
+
18
+
19
+ class AuthError(BridgeError):
20
+ """Sign-in or token handling failed."""
21
+
22
+
23
+ class StoreError(BridgeError):
24
+ """The local credential store failed."""
@@ -0,0 +1,67 @@
1
+ """Cross-process lock for credential refresh."""
2
+
3
+ import errno
4
+ import os
5
+ import stat
6
+ import sys
7
+ import time
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+
11
+ from .constants import LOCK_POLL_S, REFRESH_LOCK_TIMEOUT_S
12
+ from .errors import AuthError
13
+
14
+
15
+ def _check_directory(path: Path) -> None:
16
+ if sys.platform == "win32":
17
+ return
18
+ try:
19
+ info = path.lstat()
20
+ except OSError as exc:
21
+ raise AuthError("cannot inspect refresh lock directory") from exc
22
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
23
+ raise AuthError("refresh lock directory has unsafe ownership or permissions")
24
+
25
+
26
+ @contextmanager
27
+ def exclusive_lock(path: Path, timeout: float = REFRESH_LOCK_TIMEOUT_S):
28
+ try:
29
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
30
+ _check_directory(path.parent)
31
+ fd = os.open(path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
32
+ except OSError as exc:
33
+ raise AuthError("cannot open refresh lock") from exc
34
+ start = time.monotonic()
35
+ acquired = False
36
+ try:
37
+ while True:
38
+ try:
39
+ if sys.platform == "win32":
40
+ import msvcrt
41
+
42
+ os.lseek(fd, 0, os.SEEK_SET)
43
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
44
+ else:
45
+ import fcntl
46
+
47
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
48
+ acquired = True
49
+ break
50
+ except OSError as exc:
51
+ if exc.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
52
+ raise AuthError("cannot acquire refresh lock") from exc
53
+ if time.monotonic() - start >= timeout:
54
+ raise AuthError("timed out waiting for refresh lock") from exc
55
+ time.sleep(LOCK_POLL_S)
56
+ yield
57
+ finally:
58
+ try:
59
+ if acquired:
60
+ if sys.platform == "win32":
61
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
62
+ else:
63
+ fcntl.flock(fd, fcntl.LOCK_UN)
64
+ except OSError as exc:
65
+ raise AuthError("cannot release refresh lock") from exc
66
+ finally:
67
+ os.close(fd)