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.
- trackly_mcp_proxy-0.0.1/PKG-INFO +9 -0
- trackly_mcp_proxy-0.0.1/pyproject.toml +18 -0
- trackly_mcp_proxy-0.0.1/setup.cfg +4 -0
- trackly_mcp_proxy-0.0.1/trackly_mcp_proxy/__init__.py +1 -0
- trackly_mcp_proxy-0.0.1/trackly_mcp_proxy/main.py +201 -0
- trackly_mcp_proxy-0.0.1/trackly_mcp_proxy.egg-info/PKG-INFO +9 -0
- trackly_mcp_proxy-0.0.1/trackly_mcp_proxy.egg-info/SOURCES.txt +9 -0
- trackly_mcp_proxy-0.0.1/trackly_mcp_proxy.egg-info/dependency_links.txt +1 -0
- trackly_mcp_proxy-0.0.1/trackly_mcp_proxy.egg-info/entry_points.txt +2 -0
- trackly_mcp_proxy-0.0.1/trackly_mcp_proxy.egg-info/requires.txt +5 -0
- trackly_mcp_proxy-0.0.1/trackly_mcp_proxy.egg-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trackly_mcp_proxy
|