trackly-mcp-proxy 0.0.1__tar.gz

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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: trackly-mcp-proxy
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: fastmcp==3.4.7
6
+ Requires-Dist: google-auth==2.30.0
7
+ Requires-Dist: google-auth-oauthlib==1.4.1
8
+ Requires-Dist: httpx==0.28.1
9
+ Requires-Dist: requests==2.34.2
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trackly-mcp-proxy"
7
+ version = "0.0.1"
8
+ requires-python = ">=3.11"
9
+ dependencies = [
10
+ "fastmcp==3.4.7",
11
+ "google-auth==2.30.0",
12
+ "google-auth-oauthlib==1.4.1",
13
+ "httpx==0.28.1",
14
+ "requests==2.34.2",
15
+ ]
16
+
17
+ [project.scripts]
18
+ trackly-mcp-proxy = "trackly_mcp_proxy.main:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Local stdio<->HTTP bridge that authenticates analysts to the Trackly MCP server."""
@@ -0,0 +1,201 @@
1
+ """Local stdio<->HTTP bridge that obtains the Google ID token Cloud Run IAM demands.
2
+
3
+ Runs on analyst laptops via `uvx trackly-mcp-proxy --url https://...` and proxies
4
+ an MCP client speaking stdio to the deployed trackly-data-mcp-server over
5
+ Streamable HTTP, attaching a fresh Bearer ID token to every request.
6
+
7
+ Phase 0 (a) in ARCHITECTURE_PLAN.md Section 9 is settled: a Workspace
8
+ user-account ID token carries this proxy's own OAuth client id as `aud`, and
9
+ the deploy declares that client id as the service's Cloud Run custom audience,
10
+ so token acquisition stays isolated in fetch_id_token() and rollout step 2
11
+ confirms the claims once against the real client.
12
+ """
13
+
14
+ import argparse
15
+ import base64
16
+ import json
17
+ import os
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Callable, Generator
22
+ from urllib.parse import urlsplit
23
+
24
+ import httpx
25
+ from fastmcp.client import Client
26
+ from fastmcp.client.transports import StreamableHttpTransport
27
+ from fastmcp.server import create_proxy
28
+ from google.auth.exceptions import RefreshError
29
+ from google.auth.transport.requests import Request as GoogleAuthRequest
30
+ from google.oauth2.credentials import Credentials
31
+ from google_auth_oauthlib.flow import InstalledAppFlow
32
+
33
+ OAUTH_CLIENT_ID = os.environ.get("TRACKLY_MCP_OAUTH_CLIENT_ID", "")
34
+ OAUTH_CLIENT_SECRET = os.environ.get("TRACKLY_MCP_OAUTH_CLIENT_SECRET", "")
35
+ OAUTH_SCOPES = ["openid", "email"]
36
+ OAUTH_AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
37
+ OAUTH_TOKEN_URI = "https://oauth2.googleapis.com/token"
38
+ OAUTH_CLIENT_NOT_CONFIGURED_MESSAGE = (
39
+ "OAuth client not configured — see ARCHITECTURE_PLAN.md §9 Phase 0 (a)"
40
+ )
41
+ OAUTH_CLIENT_NOT_CONFIGURED_EXIT_CODE = 2
42
+ ID_TOKEN_MISSING_MESSAGE = (
43
+ "No id_token returned for scopes openid,email — the OAuth client cannot "
44
+ "mint ID tokens; see ARCHITECTURE_PLAN.md §9 Phase 0 (a)"
45
+ )
46
+
47
+ CREDENTIALS_FILE_MODE = 0o600
48
+ CREDENTIALS_DIR_MODE = 0o700
49
+
50
+ DEFAULT_MCP_PATH = "/mcp"
51
+ REFRESH_MARGIN_S = 300
52
+ CLIENT_CHOICES = ("claude-desktop", "cursor", "unknown")
53
+
54
+
55
+ def parse_args(argv: list[str] | None) -> argparse.Namespace:
56
+ """Parse the proxy CLI: target MCP server URL and reported client type."""
57
+ parser = argparse.ArgumentParser(prog="trackly-mcp-proxy")
58
+ parser.add_argument("--url", required=True, help="Trackly MCP server URL")
59
+ parser.add_argument(
60
+ "--client",
61
+ default="unknown",
62
+ choices=CLIENT_CHOICES,
63
+ help="Client identifier sent as X-Trackly-Client for server-side audit",
64
+ )
65
+ return parser.parse_args(argv)
66
+
67
+
68
+ def normalize_url(url: str) -> str:
69
+ """Reject non-https URLs, then append /mcp when the URL has no path; FastMCP's
70
+ http transport mounts there by default and the client transport does not add it."""
71
+ parsed = urlsplit(url)
72
+ if parsed.scheme != "https":
73
+ raise SystemExit("--url must be https")
74
+ if parsed.path in ("", "/"):
75
+ return f"{url.rstrip('/')}{DEFAULT_MCP_PATH}"
76
+ return url
77
+
78
+
79
+ def _decode_jwt_exp(token: str) -> float:
80
+ """Read the `exp` claim from a JWT payload without verifying its signature,
81
+ used only to schedule refresh of a token this proxy itself just minted."""
82
+ payload_segment = token.split(".")[1]
83
+ padded = payload_segment + "=" * (-len(payload_segment) % 4)
84
+ claims = json.loads(base64.urlsafe_b64decode(padded))
85
+ return float(claims["exp"])
86
+
87
+
88
+ def credentials_path() -> Path:
89
+ """Cache location for OAuth credentials, resolved at call time from HOME."""
90
+ return Path.home() / ".config" / "trackly-mcp-proxy" / "credentials.json"
91
+
92
+
93
+ def _run_installed_app_flow() -> Credentials:
94
+ """Launch the browser-based installed-app OAuth flow and return fresh credentials."""
95
+ client_config = {
96
+ "installed": {
97
+ "client_id": OAUTH_CLIENT_ID,
98
+ "client_secret": OAUTH_CLIENT_SECRET,
99
+ "auth_uri": OAUTH_AUTH_URI,
100
+ "token_uri": OAUTH_TOKEN_URI,
101
+ }
102
+ }
103
+ flow = InstalledAppFlow.from_client_config(client_config, scopes=OAUTH_SCOPES)
104
+ return flow.run_local_server(port=0)
105
+
106
+
107
+ def _write_credentials_cache(path: Path, payload: str) -> None:
108
+ """Write payload with the directory at 0o700 and the file at 0o600, tightening pre-existing modes and refusing a
109
+ symlink at the cache path where the platform supports it — O_NOFOLLOW and fchmod are Unix-only, so on Windows the
110
+ write proceeds without those two hardenings instead of raising AttributeError."""
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ path.parent.chmod(CREDENTIALS_DIR_MODE)
113
+ with os.fdopen(
114
+ os.open(
115
+ path,
116
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0),
117
+ CREDENTIALS_FILE_MODE,
118
+ ),
119
+ "w",
120
+ ) as cache_file:
121
+ if hasattr(os, "fchmod"):
122
+ os.fchmod(cache_file.fileno(), CREDENTIALS_FILE_MODE)
123
+ cache_file.write(payload)
124
+
125
+
126
+ def fetch_id_token() -> tuple[str, float]:
127
+ """Mint a Google ID token via the installed-app OAuth flow, caching credentials
128
+ so sign-in happens once (THE Phase 0 (a) function; see module docstring)."""
129
+ path = credentials_path()
130
+ credentials = None
131
+
132
+ if path.exists():
133
+ try:
134
+ credentials = Credentials.from_authorized_user_info(
135
+ json.loads(path.read_text()), scopes=OAUTH_SCOPES
136
+ )
137
+ credentials.refresh(GoogleAuthRequest())
138
+ except (ValueError, RefreshError):
139
+ credentials = None
140
+
141
+ if credentials is None:
142
+ credentials = _run_installed_app_flow()
143
+ credentials.refresh(GoogleAuthRequest())
144
+
145
+ _write_credentials_cache(path, credentials.to_json())
146
+
147
+ if not credentials.id_token:
148
+ raise SystemExit(ID_TOKEN_MISSING_MESSAGE)
149
+
150
+ return credentials.id_token, _decode_jwt_exp(credentials.id_token)
151
+
152
+
153
+ class RefreshingBearerAuth(httpx.Auth):
154
+ """Bearer auth that refreshes the ID token shortly before it expires;
155
+ `fetcher`/`clock` default to `fetch_id_token`/`time.time` but are overridable for tests.
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ token: str,
161
+ exp: float,
162
+ fetcher: Callable[[], tuple[str, float]] = fetch_id_token,
163
+ clock: Callable[[], float] = time.time,
164
+ ) -> None:
165
+ """Store the initial token/expiry and the fetcher/clock used to refresh them."""
166
+ self._token = token
167
+ self._exp = exp
168
+ self._fetcher = fetcher
169
+ self._clock = clock
170
+
171
+ def auth_flow(
172
+ self, request: httpx.Request
173
+ ) -> Generator[httpx.Request, httpx.Response, None]:
174
+ """Refresh the cached token when within REFRESH_MARGIN_S of expiry, then attach it."""
175
+ if self._clock() >= self._exp - REFRESH_MARGIN_S:
176
+ self._token, self._exp = self._fetcher()
177
+ request.headers["Authorization"] = f"Bearer {self._token}"
178
+ yield request
179
+
180
+
181
+ def main(argv: list[str] | None = None) -> int:
182
+ """Mint an ID token, then bridge stdio to the Trackly MCP server over HTTP via create_proxy (FastMCP.as_proxy is deprecated in 3.4.7)."""
183
+ if not OAUTH_CLIENT_ID or not OAUTH_CLIENT_SECRET:
184
+ print(OAUTH_CLIENT_NOT_CONFIGURED_MESSAGE, file=sys.stderr)
185
+ return OAUTH_CLIENT_NOT_CONFIGURED_EXIT_CODE
186
+
187
+ args = parse_args(argv)
188
+ token, exp = fetch_id_token()
189
+
190
+ transport = StreamableHttpTransport(
191
+ normalize_url(args.url),
192
+ auth=RefreshingBearerAuth(token, exp),
193
+ headers={"X-Trackly-Client": args.client},
194
+ )
195
+ client = Client(transport)
196
+ create_proxy(client).run("stdio")
197
+ return 0
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main())
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: trackly-mcp-proxy
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: fastmcp==3.4.7
6
+ Requires-Dist: google-auth==2.30.0
7
+ Requires-Dist: google-auth-oauthlib==1.4.1
8
+ Requires-Dist: httpx==0.28.1
9
+ Requires-Dist: requests==2.34.2
@@ -0,0 +1,9 @@
1
+ pyproject.toml
2
+ trackly_mcp_proxy/__init__.py
3
+ trackly_mcp_proxy/main.py
4
+ trackly_mcp_proxy.egg-info/PKG-INFO
5
+ trackly_mcp_proxy.egg-info/SOURCES.txt
6
+ trackly_mcp_proxy.egg-info/dependency_links.txt
7
+ trackly_mcp_proxy.egg-info/entry_points.txt
8
+ trackly_mcp_proxy.egg-info/requires.txt
9
+ trackly_mcp_proxy.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ trackly-mcp-proxy = trackly_mcp_proxy.main:main
@@ -0,0 +1,5 @@
1
+ fastmcp==3.4.7
2
+ google-auth==2.30.0
3
+ google-auth-oauthlib==1.4.1
4
+ httpx==0.28.1
5
+ requests==2.34.2
@@ -0,0 +1 @@
1
+ trackly_mcp_proxy