tsugite-mcp 0.27.0__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,201 @@
1
+ # ---> Python
2
+ # Byte-compiled / optimized / DLL files
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+
7
+ # C extensions
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ /lib/
19
+ /lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ #Pipfile.lock
97
+
98
+ # poetry
99
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
103
+ #poetry.lock
104
+
105
+ # pdm
106
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
107
+ #pdm.lock
108
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
109
+ # in version control.
110
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
111
+ .pdm.toml
112
+ .pdm-python
113
+ .pdm-build/
114
+
115
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116
+ __pypackages__/
117
+
118
+ # Celery stuff
119
+ celerybeat-schedule
120
+ celerybeat.pid
121
+
122
+ # SageMath parsed files
123
+ *.sage.py
124
+
125
+ # Environments
126
+ .env
127
+ .venv
128
+ env/
129
+ venv/
130
+ ENV/
131
+ env.bak/
132
+ venv.bak/
133
+
134
+ # Spyder project settings
135
+ .spyderproject
136
+ .spyproject
137
+
138
+ # Rope project settings
139
+ .ropeproject
140
+
141
+ # mkdocs documentation
142
+ /site
143
+
144
+ # mypy
145
+ .mypy_cache/
146
+ .dmypy.json
147
+ dmypy.json
148
+
149
+ # Pyre type checker
150
+ .pyre/
151
+
152
+ # pytype static type analyzer
153
+ .pytype/
154
+
155
+ # Cython debug symbols
156
+ cython_debug/
157
+
158
+ # PyCharm
159
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
162
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163
+ #.idea/
164
+
165
+ .env
166
+ .env
167
+ benchmark_results/
168
+ test_output/
169
+ .claude/settings.local.json
170
+ std*.txt
171
+ secrets/*
172
+
173
+
174
+ # TODO: temp - I need to clean up the docs
175
+ docs-old/
176
+ docs/design/
177
+ examples/*
178
+ !examples/tsugite-example-plugin/
179
+ /agents/
180
+ .claude/
181
+ .tsugite/
182
+ benchmarks/
183
+ docker-compose.test.yml
184
+ #### TODO ^^^
185
+
186
+ # Vite dep-optimizer cache. Normally under node_modules/.vite, but lands at the
187
+ # cwd's `.vite/` when vite/vitest is run from the repo root, so ignore it anywhere.
188
+ .vite/
189
+ # Built web UI - generated by `mise run web-build`; the wheel bundles it via
190
+ # hatch `artifacts`, git never carries it.
191
+ plugins/tsugite-daemon/tsugite_daemon/web/
192
+ # Re-include frontend source that generic lib/ rules (incl. contributors' global
193
+ # gitignores with Python-style `lib/`) would otherwise silently exclude.
194
+ !plugins/tsugite-daemon/frontend/src/lib/
195
+ !plugins/tsugite-daemon/frontend/src/lib/**
196
+ # Per-job git worktrees provisioned by the daemon.
197
+ .tsugite-jobs/
198
+ /packaging/runtime/.cache/
199
+ # Corporate CA certs dropped in for docker build.
200
+ /docker/ca-certificates/*
201
+ !/docker/ca-certificates/.gitkeep
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: tsugite-mcp
3
+ Version: 0.27.0
4
+ Summary: Tsugite plugin: remote MCP servers over Streamable HTTP with OAuth sign-in
5
+ Author: Justyn Shull
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: mcp<2,>=1.30
8
+ Requires-Dist: tsugite-cli==0.27.0
9
+ Requires-Dist: tsugite-daemon==0.27.0
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "tsugite-mcp"
3
+ version = "0.27.0"
4
+ description = "Tsugite plugin: remote MCP servers over Streamable HTTP with OAuth sign-in"
5
+ authors = [{ name = "Justyn Shull" }]
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "tsugite-cli==0.27.0",
9
+ "tsugite-daemon==0.27.0",
10
+ "mcp>=1.30,<2",
11
+ ]
12
+
13
+ [project.entry-points."tsugite.plugins"]
14
+ mcp = "tsugite_mcp.tools"
15
+
16
+ [project.entry-points."tsugite.adapters"]
17
+ mcp = "tsugite_mcp.adapter:create_adapter"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["tsugite_mcp"]
25
+
26
+ [tool.hatch.build.targets.sdist]
27
+ include = [
28
+ "/tsugite_mcp",
29
+ "/pyproject.toml",
30
+ ]
31
+
32
+ [tool.uv.sources]
33
+ tsugite-cli = { workspace = true }
34
+ tsugite-daemon = { workspace = true }
@@ -0,0 +1 @@
1
+ """Tsugite plugin: remote MCP servers over Streamable HTTP with OAuth sign-in."""
@@ -0,0 +1,473 @@
1
+ """The MCP plugin's daemon adapter: the server list, OAuth sign-in, tool calls and HTTP routes."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import logging
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from ipaddress import ip_address
10
+ from pathlib import Path
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ import httpx
14
+ from mcp import ClientSession
15
+ from mcp.client.auth import OAuthClientProvider, OAuthFlowError
16
+ from mcp.client.auth.utils import (
17
+ build_oauth_authorization_server_metadata_discovery_urls,
18
+ build_protected_resource_metadata_discovery_urls,
19
+ handle_auth_metadata_response,
20
+ handle_protected_resource_response,
21
+ )
22
+ from mcp.client.streamable_http import streamable_http_client
23
+ from mcp.shared._httpx_utils import create_mcp_http_client
24
+ from mcp.shared.auth import OAuthClientMetadata
25
+ from starlette.datastructures import QueryParams
26
+ from starlette.requests import Request
27
+ from starlette.responses import HTMLResponse, JSONResponse
28
+ from starlette.routing import Route
29
+ from tsugite_daemon.adapters.base import BaseAdapter
30
+
31
+ from tsugite.config import get_xdg_data_path
32
+ from tsugite.secrets.registry import get_registry
33
+ from tsugite_mcp import tools
34
+ from tsugite_mcp.storage import NO_REDIRECT, SecretStorage
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ NAME_PATTERN = re.compile(r"[a-z][a-z0-9]{0,23}")
39
+ CALLBACK_PATH = "/api/plugins/mcp/callback"
40
+ # Registered with servers that reject the daemon's own redirect. Nothing listens on it.
41
+ LOOPBACK_REDIRECT = "http://127.0.0.1" + CALLBACK_PATH
42
+ LIST_TIMEOUT = 30
43
+ SIGNIN_TIMEOUT = 300
44
+ NEEDS_CLIENT = (
45
+ "This server does not offer dynamic client registration. Register Tsugite in the provider's developer "
46
+ "console with the redirect URL shown above, then edit this server and enter the client ID and secret."
47
+ )
48
+ HTML = "<!doctype html><meta charset=utf-8><title>Tsugite</title><p>{}</p>"
49
+
50
+
51
+ @dataclass
52
+ class Server:
53
+ name: str
54
+ url: str
55
+ status: str = "connecting"
56
+ error: str | None = None
57
+ tools: list = field(default_factory=list)
58
+
59
+
60
+ def _leaves(exc: BaseException):
61
+ """The exceptions inside nested exception groups, which the SDK's task groups wrap errors in."""
62
+ if isinstance(exc, BaseExceptionGroup):
63
+ for inner in exc.exceptions:
64
+ yield from _leaves(inner)
65
+ else:
66
+ yield exc
67
+
68
+
69
+ def _needs_signin(exc: BaseException) -> bool:
70
+ return any(
71
+ type(leaf) is OAuthFlowError or (isinstance(leaf, httpx.HTTPStatusError) and leaf.response.status_code == 401)
72
+ for leaf in _leaves(exc)
73
+ )
74
+
75
+
76
+ def _describe(exc: BaseException) -> str:
77
+ text = "; ".join(f"{type(leaf).__name__}: {leaf}" for leaf in _leaves(exc))
78
+ return get_registry().mask(text)
79
+
80
+
81
+ def pick_auth_method(supported: list[str] | None, has_secret: bool) -> str:
82
+ """The token endpoint auth method for a user-entered client, from the server's metadata."""
83
+ supported = supported or ["client_secret_basic"]
84
+ for method in ("client_secret_basic", "client_secret_post") if has_secret else ("none",):
85
+ if method in supported:
86
+ return method
87
+ kind = "with" if has_secret else "without"
88
+ raise RuntimeError(
89
+ f"the server's token endpoint accepts {', '.join(supported)}, which does not fit a client {kind} a secret"
90
+ )
91
+
92
+
93
+ class _Provider(OAuthClientProvider):
94
+ async def _exchange_token_authorization_code(self, *args, **kwargs):
95
+ storage = self.context.storage
96
+ if storage.using_preset and self.context.oauth_metadata:
97
+ info = self.context.client_info
98
+ method = pick_auth_method(
99
+ self.context.oauth_metadata.token_endpoint_auth_methods_supported, bool(info.client_secret)
100
+ )
101
+ info.token_endpoint_auth_method = method
102
+ await storage.set_preset({**(await storage.get_preset()), "auth_method": method})
103
+ return await super()._exchange_token_authorization_code(*args, **kwargs)
104
+
105
+
106
+ def validate_server(name: str, url: str) -> None:
107
+ if not NAME_PATTERN.fullmatch(name):
108
+ raise ValueError("name must be 1-24 lowercase letters and digits, starting with a letter")
109
+ parsed = urlparse(url)
110
+ loopback = parsed.hostname == "localhost"
111
+ if parsed.hostname and not loopback:
112
+ with contextlib.suppress(ValueError):
113
+ loopback = ip_address(parsed.hostname).is_loopback
114
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
115
+ raise ValueError("url must be https (http is allowed for localhost)")
116
+ if not parsed.hostname:
117
+ raise ValueError("url needs a host")
118
+
119
+
120
+ class McpAdapter(BaseAdapter):
121
+ def __init__(self, path: Path, client_factory=create_mcp_http_client):
122
+ # Not agent-scoped, so BaseAdapter.__init__ is skipped.
123
+ self.event_bus = None
124
+ self.loop: asyncio.AbstractEventLoop | None = None
125
+ self.path = path
126
+ self.client_factory = client_factory
127
+ self.servers = {name: Server(name, url) for name, url in self._read().items()}
128
+ self._signins: dict[str, asyncio.Task] = {}
129
+ # state -> the future the OAuth callback route resolves with (code, state)
130
+ self._flows: dict[str, asyncio.Future] = {}
131
+ self._startup: asyncio.Future | None = None
132
+
133
+ def _read(self) -> dict[str, str]:
134
+ return json.loads(self.path.read_text()) if self.path.exists() else {}
135
+
136
+ def _write(self) -> None:
137
+ self.path.parent.mkdir(parents=True, exist_ok=True)
138
+ self.path.write_text(json.dumps({s.name: s.url for s in self.servers.values()}, indent=2))
139
+
140
+ async def start(self) -> None:
141
+ self.loop = asyncio.get_running_loop()
142
+ tools.set_adapter(self)
143
+ self._startup = asyncio.gather(*(self.refresh(name) for name in self.servers))
144
+
145
+ async def stop(self) -> None:
146
+ tools.set_adapter(None)
147
+ for task in [self._startup, *self._signins.values()]:
148
+ if task is not None:
149
+ task.cancel()
150
+ for name in self.servers:
151
+ tools.clear_tools(name)
152
+
153
+ @contextlib.asynccontextmanager
154
+ async def _session(self, server: Server, signin=None):
155
+ """An initialized session. `signin` is (redirect_uri, redirect_handler, callback_handler).
156
+
157
+ Without it, auth is attached only when tokens exist, and a server that needs a new
158
+ sign-in fails with an error `_needs_signin` recognizes.
159
+ """
160
+ storage = SecretStorage(server.name)
161
+ auth = None
162
+ if signin or await storage.get_tokens():
163
+ redirect_uri, redirect_handler, callback_handler = signin or (NO_REDIRECT, None, None)
164
+ auth = _Provider(
165
+ server.url,
166
+ OAuthClientMetadata(
167
+ client_name="Tsugite",
168
+ redirect_uris=[redirect_uri],
169
+ grant_types=["authorization_code", "refresh_token"],
170
+ response_types=["code"],
171
+ token_endpoint_auth_method="none",
172
+ ),
173
+ storage,
174
+ redirect_handler,
175
+ callback_handler,
176
+ )
177
+ auth.context.token_expiry_time = await storage.expires_at()
178
+ async with self.client_factory(auth=auth) as http_client:
179
+ async with streamable_http_client(server.url, http_client=http_client) as streams:
180
+ async with ClientSession(streams[0], streams[1]) as session:
181
+ await session.initialize()
182
+ yield session
183
+
184
+ def _cancel_signin(self, name: str) -> None:
185
+ if task := self._signins.pop(name, None):
186
+ task.cancel()
187
+
188
+ async def refresh(self, name: str, signin=None) -> None:
189
+ """Connect to the server and replace its status and tools."""
190
+ if not signin:
191
+ self._cancel_signin(name)
192
+ server = self.servers[name]
193
+ server.status = "connecting"
194
+ try:
195
+ async with asyncio.timeout(SIGNIN_TIMEOUT if signin else LIST_TIMEOUT):
196
+ async with self._session(server, signin) as session:
197
+ listed = (await session.list_tools()).tools
198
+ if self.servers.get(name) is not server:
199
+ return
200
+ registered = tools.set_tools(name, listed)
201
+ except Exception as exc:
202
+ server.status, server.error, server.tools = "error", _describe(exc), []
203
+ if _needs_signin(exc):
204
+ server.status, server.error = "needs_signin", None
205
+ await self._check_client(server)
206
+ tools.clear_tools(name)
207
+ return
208
+ server.status, server.error, server.tools = "connected", None, registered
209
+
210
+ async def _check_client(self, server: Server) -> None:
211
+ """Flag a server that needs a client the user has not entered, because it cannot register one itself."""
212
+ storage = SecretStorage(server.name)
213
+ if await storage.get_preset() or await storage.get_registration():
214
+ return
215
+ try:
216
+ dynamic = await self._registers_dynamically(server)
217
+ except Exception as exc:
218
+ server.status, server.error = "error", _describe(exc)
219
+ return
220
+ if not dynamic:
221
+ server.status, server.error = "needs_client", NEEDS_CLIENT
222
+
223
+ async def _registers_dynamically(self, server: Server) -> bool:
224
+ """Whether the authorization server advertises a registration endpoint. True when no metadata is found."""
225
+ async with self.client_factory() as http:
226
+ auth_server = None
227
+ for url in build_protected_resource_metadata_discovery_urls(None, server.url):
228
+ if resource := await handle_protected_resource_response(await http.get(url)):
229
+ auth_server = str(resource.authorization_servers[0])
230
+ break
231
+ for url in build_oauth_authorization_server_metadata_discovery_urls(auth_server, server.url):
232
+ ok, metadata = await handle_auth_metadata_response(await http.get(url))
233
+ if metadata:
234
+ return metadata.registration_endpoint is not None
235
+ if not ok:
236
+ break
237
+ return True
238
+
239
+ async def call_tool(self, name: str, tool: str, arguments: dict) -> str:
240
+ server = self.servers[name]
241
+ try:
242
+ async with self._session(server) as session:
243
+ result = await session.call_tool(tool, arguments)
244
+ except Exception as exc:
245
+ if _needs_signin(exc):
246
+ server.status, server.error = "needs_signin", None
247
+ raise RuntimeError(f"{name} needs sign-in, open Connectors") from None
248
+ raise RuntimeError(_describe(exc)) from None
249
+ text = "\n".join(c.text if c.type == "text" else f"[{c.type} content omitted]" for c in result.content)
250
+ text = get_registry().mask(text)
251
+ if result.isError:
252
+ raise RuntimeError(text)
253
+ return text
254
+
255
+ async def add(self, name: str, url: str, client_id: str = "", client_secret: str = "") -> Server:
256
+ validate_server(name, url)
257
+ if name in self.servers:
258
+ raise ValueError(f"{name} is already connected")
259
+ if client_secret and not client_id:
260
+ raise ValueError("a client secret needs a client ID")
261
+ self.servers[name] = Server(name, url)
262
+ self._write()
263
+ if client_id:
264
+ await self._save_client(name, client_id, client_secret, False)
265
+ await self.refresh(name)
266
+ return self.servers[name]
267
+
268
+ async def _save_client(self, name: str, client_id: str, client_secret: str, clear_secret: bool) -> None:
269
+ """Store the user's client, or remove it when `client_id` is empty. An empty secret keeps the saved one for the same ID."""
270
+ storage = SecretStorage(name)
271
+ old = await storage.get_preset() or {}
272
+ record = {"client_id": client_id} if client_id else None
273
+ kept = "" if clear_secret or client_id != old.get("client_id") else old.get("client_secret", "")
274
+ secret = client_secret or kept
275
+ if record and secret:
276
+ record["client_secret"] = secret
277
+ if (record or {}) == {k: v for k, v in old.items() if k != "auth_method"}:
278
+ return
279
+ await storage.delete_registration()
280
+ await storage.set_preset(record)
281
+
282
+ async def update(self, name: str, client_id: str, client_secret: str = "", clear_secret: bool = False) -> None:
283
+ if client_secret and not client_id:
284
+ raise ValueError("a client secret needs a client ID")
285
+ await self._save_client(name, client_id, client_secret, clear_secret)
286
+ await self.refresh(name)
287
+
288
+ async def remove(self, name: str) -> None:
289
+ self._cancel_signin(name)
290
+ del self.servers[name]
291
+ self._write()
292
+ tools.clear_tools(name)
293
+ await SecretStorage(name).delete_all()
294
+
295
+ async def signin(self, name: str, redirect_base: str) -> tuple[str, bool]:
296
+ """Start the OAuth flow and return the URL the user opens and whether the user must paste the redirect back.
297
+
298
+ It finishes after the callback. A server that rejects the daemon's own redirect at registration gets the loopback one.
299
+ """
300
+ self._cancel_signin(name)
301
+ if self.servers[name].status == "needs_client":
302
+ raise RuntimeError(NEEDS_CLIENT)
303
+ storage = SecretStorage(name)
304
+ registered = await storage.get_registration()
305
+ uris = [str(uri) for uri in registered.redirect_uris] if registered else []
306
+ redirect_uri = LOOPBACK_REDIRECT if uris == [LOOPBACK_REDIRECT] else redirect_base.rstrip("/") + CALLBACK_PATH
307
+ if registered and uris != [redirect_uri]:
308
+ await storage.delete_registration()
309
+ try:
310
+ return await self._begin(name, redirect_uri), redirect_uri == LOOPBACK_REDIRECT
311
+ except RuntimeError as exc:
312
+ if redirect_uri == LOOPBACK_REDIRECT or "invalid_redirect_uri" not in str(exc):
313
+ raise
314
+ return await self._begin(name, LOOPBACK_REDIRECT), True
315
+
316
+ async def _begin(self, name: str, redirect_uri: str) -> str:
317
+ url_ready: asyncio.Future = self.loop.create_future()
318
+ state = None
319
+
320
+ async def redirect_handler(url: str) -> None:
321
+ nonlocal state
322
+ state = parse_qs(urlparse(url).query)["state"][0]
323
+ self._flows[state] = self.loop.create_future()
324
+ url_ready.set_result(url)
325
+
326
+ async def callback_handler() -> tuple[str, str]:
327
+ return await self._flows[state]
328
+
329
+ task = asyncio.create_task(self.refresh(name, (redirect_uri, redirect_handler, callback_handler)))
330
+ task.add_done_callback(lambda _: self._flows.pop(state, None))
331
+ self._signins[name] = task
332
+ await asyncio.wait({url_ready, task}, return_when=asyncio.FIRST_COMPLETED)
333
+ if not url_ready.done():
334
+ raise RuntimeError(self.servers[name].error or "the server did not ask for sign-in")
335
+ return url_ready.result()
336
+
337
+ def get_http_routes(self) -> list:
338
+ return [
339
+ Route("/servers", self._list, methods=["GET"]),
340
+ Route("/servers", self._add, methods=["POST"]),
341
+ Route("/servers/{name}", self._update, methods=["PUT"]),
342
+ Route("/servers/{name}", self._remove, methods=["DELETE"]),
343
+ Route("/servers/{name}/refresh", self._refresh, methods=["POST"]),
344
+ Route("/servers/{name}/signin", self._signin, methods=["POST"]),
345
+ Route("/paste", self._paste, methods=["POST"]),
346
+ ]
347
+
348
+ def get_public_http_routes(self) -> list:
349
+ # The browser's redirect cannot send the daemon token. A request is accepted only
350
+ # when its `state` matches a sign-in this process started.
351
+ return [Route("/callback", self._callback, methods=["GET"])]
352
+
353
+ async def _status(self) -> dict:
354
+ """The server list for the page. The client secret is reported only as `has_secret`."""
355
+ servers = []
356
+ for s in self.servers.values():
357
+ preset = await SecretStorage(s.name).get_preset() or {}
358
+ servers.append(
359
+ {
360
+ "name": s.name,
361
+ "url": s.url,
362
+ "status": s.status,
363
+ "error": s.error,
364
+ "client_id": preset.get("client_id", ""),
365
+ "has_secret": bool(preset.get("client_secret")),
366
+ "tools": s.tools,
367
+ }
368
+ )
369
+ return {"servers": servers}
370
+
371
+ async def _list(self, request: Request) -> JSONResponse:
372
+ return JSONResponse(await self._status())
373
+
374
+ async def _add(self, request: Request) -> JSONResponse:
375
+ try:
376
+ body = await _body(request)
377
+ await self.add(
378
+ str(body.get("name", "")),
379
+ str(body.get("url", "")),
380
+ str(body.get("client_id", "")).strip(),
381
+ str(body.get("client_secret", "")).strip(),
382
+ )
383
+ except ValueError as exc:
384
+ return JSONResponse({"error": str(exc)}, status_code=400)
385
+ return JSONResponse(await self._status())
386
+
387
+ async def _update(self, request: Request) -> JSONResponse:
388
+ if (name := self._known(request)) is None:
389
+ return JSONResponse({"error": "no such server"}, status_code=404)
390
+ try:
391
+ body = await _body(request)
392
+ await self.update(
393
+ name,
394
+ str(body.get("client_id", "")).strip(),
395
+ str(body.get("client_secret", "")).strip(),
396
+ bool(body.get("clear_secret")),
397
+ )
398
+ except ValueError as exc:
399
+ return JSONResponse({"error": str(exc)}, status_code=400)
400
+ return JSONResponse(await self._status())
401
+
402
+ def _known(self, request: Request) -> str | None:
403
+ name = request.path_params["name"]
404
+ return name if name in self.servers else None
405
+
406
+ async def _remove(self, request: Request) -> JSONResponse:
407
+ if (name := self._known(request)) is None:
408
+ return JSONResponse({"error": "no such server"}, status_code=404)
409
+ await self.remove(name)
410
+ return JSONResponse(await self._status())
411
+
412
+ async def _refresh(self, request: Request) -> JSONResponse:
413
+ if (name := self._known(request)) is None:
414
+ return JSONResponse({"error": "no such server"}, status_code=404)
415
+ await self.refresh(name)
416
+ return JSONResponse(await self._status())
417
+
418
+ async def _signin(self, request: Request) -> JSONResponse:
419
+ if (name := self._known(request)) is None:
420
+ return JSONResponse({"error": "no such server"}, status_code=404)
421
+ try:
422
+ body = await _body(request)
423
+ except ValueError as exc:
424
+ return JSONResponse({"error": str(exc)}, status_code=400)
425
+ try:
426
+ url, paste_back = await self.signin(name, str(body.get("redirect_base", "")))
427
+ except Exception as exc:
428
+ return JSONResponse({"error": _describe(exc)}, status_code=502)
429
+ return JSONResponse({"authorization_url": url, "paste_back": paste_back})
430
+
431
+ def _finish(self, params) -> str | None:
432
+ """Give the callback's `code` to the sign-in waiting on its `state`. Returns an error message, or None."""
433
+ flow = self._flows.get(params.get("state", ""))
434
+ if flow is None or flow.done():
435
+ return "This sign-in link is unknown or expired."
436
+ if "code" in params:
437
+ flow.set_result((params["code"], params["state"]))
438
+ return None
439
+ flow.set_exception(RuntimeError(f"authorization denied: {params.get('error', 'no code returned')}"))
440
+ return "Sign-in was not completed."
441
+
442
+ async def _callback(self, request: Request) -> HTMLResponse:
443
+ if error := self._finish(request.query_params):
444
+ return HTMLResponse(HTML.format(f"{error} You can close this tab."), status_code=400)
445
+ return HTMLResponse(HTML.format("Signed in. You can close this tab."))
446
+
447
+ async def _paste(self, request: Request) -> JSONResponse:
448
+ try:
449
+ body = await _body(request)
450
+ except ValueError as exc:
451
+ return JSONResponse({"error": str(exc)}, status_code=400)
452
+ url = str(body.get("url", ""))
453
+ if error := self._finish(QueryParams(urlparse(url).query)):
454
+ return JSONResponse({"error": error}, status_code=400)
455
+ return JSONResponse(await self._status())
456
+
457
+
458
+ async def _body(request: Request) -> dict:
459
+ try:
460
+ body = await request.json()
461
+ except json.JSONDecodeError:
462
+ body = None
463
+ if not isinstance(body, dict):
464
+ raise ValueError("expected a JSON object")
465
+ return body
466
+
467
+
468
+ def create_adapter(*, config, runtime, session_store, identity_map):
469
+ """Adapter-plugin factory (the tsugite.adapters entry point). Returns None unless plugins.mcp.enabled is true."""
470
+ if not (config or {}).get("enabled"):
471
+ logger.info("mcp plugin installed but disabled (set plugins.mcp.enabled: true to activate)")
472
+ return None
473
+ return McpAdapter(get_xdg_data_path("mcp") / "servers.json")
@@ -0,0 +1,110 @@
1
+ """OAuth tokens, client registrations and user-entered clients for one server, kept in tsugite's secret store."""
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+
7
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
8
+
9
+ from tsugite.secrets import get_backend
10
+ from tsugite.secrets.registry import get_registry
11
+
12
+ NO_REDIRECT = "http://localhost/"
13
+
14
+
15
+ class SecretStorage:
16
+ """The SDK's `TokenStorage` for server `name`.
17
+
18
+ Tokens are saved with the time they expire, because the SDK keeps expiry only in memory.
19
+ Every token handed out is registered with the masking registry first.
20
+ """
21
+
22
+ def __init__(self, name: str):
23
+ self.name = name
24
+ self.tokens_key = f"mcp-{name}-tokens"
25
+ self.client_key = f"mcp-{name}-client"
26
+ # A client the user registered in the provider's console: client_id, client_secret, auth_method.
27
+ self.preset_key = f"mcp-{name}-client-preset"
28
+ self.using_preset = False
29
+
30
+ async def _get(self, key: str) -> dict | None:
31
+ raw = await asyncio.to_thread(get_backend().get, key)
32
+ return json.loads(raw) if raw else None
33
+
34
+ async def _set(self, key: str, record: dict) -> None:
35
+ await asyncio.to_thread(get_backend().set, key, json.dumps(record))
36
+
37
+ async def delete_all(self) -> None:
38
+ for key in (self.tokens_key, self.client_key, self.preset_key):
39
+ await asyncio.to_thread(get_backend().delete, key)
40
+
41
+ async def delete_registration(self) -> None:
42
+ for key in (self.tokens_key, self.client_key):
43
+ await asyncio.to_thread(get_backend().delete, key)
44
+
45
+ async def get_preset(self) -> dict | None:
46
+ record = await self._get(self.preset_key)
47
+ if record and record.get("client_secret"):
48
+ get_registry().register(f"{self.preset_key}-secret", record["client_secret"], agent="mcp")
49
+ return record
50
+
51
+ async def set_preset(self, record: dict | None) -> None:
52
+ if record is None:
53
+ await asyncio.to_thread(get_backend().delete, self.preset_key)
54
+ else:
55
+ if record.get("client_secret"):
56
+ get_registry().register(f"{self.preset_key}-secret", record["client_secret"], agent="mcp")
57
+ await self._set(self.preset_key, record)
58
+
59
+ async def expires_at(self) -> float | None:
60
+ record = await self._get(self.tokens_key)
61
+ return record and record.get("expires_at")
62
+
63
+ async def get_tokens(self) -> OAuthToken | None:
64
+ record = await self._get(self.tokens_key)
65
+ if record is None:
66
+ return None
67
+ self._mask(record)
68
+ return OAuthToken.model_validate(record)
69
+
70
+ async def set_tokens(self, tokens: OAuthToken) -> None:
71
+ previous = await self._get(self.tokens_key)
72
+ record = tokens.model_dump(mode="json", exclude_none=True)
73
+ # A refresh response may leave out the refresh token, which then stays valid.
74
+ if previous and "refresh_token" not in record and "refresh_token" in previous:
75
+ record["refresh_token"] = previous["refresh_token"]
76
+ if tokens.expires_in is not None:
77
+ record["expires_at"] = time.time() + tokens.expires_in
78
+ self._mask(record)
79
+ await self._set(self.tokens_key, record)
80
+
81
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
82
+ """The user's own client when one is set, else the dynamic registration."""
83
+ preset = await self.get_preset()
84
+ self.using_preset = preset is not None
85
+ if preset:
86
+ return OAuthClientInformationFull(
87
+ client_id=preset["client_id"],
88
+ client_secret=preset.get("client_secret"),
89
+ redirect_uris=[NO_REDIRECT],
90
+ token_endpoint_auth_method=preset.get("auth_method")
91
+ or ("client_secret_basic" if preset.get("client_secret") else "none"),
92
+ )
93
+ return await self.get_registration()
94
+
95
+ async def get_registration(self) -> OAuthClientInformationFull | None:
96
+ record = await self._get(self.client_key)
97
+ if record is None:
98
+ return None
99
+ info = OAuthClientInformationFull.model_validate(record)
100
+ if info.client_secret:
101
+ get_registry().register(f"{self.client_key}-secret", info.client_secret, agent="mcp")
102
+ return info
103
+
104
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
105
+ await self._set(self.client_key, client_info.model_dump(mode="json", exclude_none=True))
106
+
107
+ def _mask(self, record: dict) -> None:
108
+ for field in ("access_token", "refresh_token"):
109
+ if field in record:
110
+ get_registry().register(f"{self.tokens_key}-{field}", record[field], agent="mcp")
@@ -0,0 +1,92 @@
1
+ """Agent-facing tools for connected MCP servers (the `tsugite.plugins` entry point).
2
+
3
+ Each tool of a connected server is registered as `mcp_<server>_<tool>` in the category
4
+ `mcp_<server>`, so an agent enables one server with `@mcp_<server>` or all with `mcp_*`.
5
+ """
6
+
7
+ import inspect
8
+ import keyword
9
+ import logging
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from tsugite.exceptions import ToolUnavailableError
14
+ from tsugite.tools import _tools, call_on_loop, tool
15
+ from tsugite.ui_surfaces import register_ui_surface
16
+
17
+ CALL_TIMEOUT = 120
18
+ JSON_TYPES = {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ register_ui_surface(
23
+ kind="connectors",
24
+ label="Connectors",
25
+ icon="plug",
26
+ entry="ui/connectors.html",
27
+ assets=Path(__file__).parent / "ui",
28
+ nav=True,
29
+ )
30
+
31
+ _adapter = None
32
+ _registered: dict[str, list[str]] = {}
33
+
34
+
35
+ def set_adapter(adapter) -> None:
36
+ global _adapter
37
+ _adapter = adapter
38
+
39
+
40
+ def clear_tools(server: str) -> None:
41
+ for name in _registered.pop(server, []):
42
+ _tools.pop(name, None)
43
+
44
+
45
+ def _caller(server: str, name: str):
46
+ def call(**kwargs):
47
+ if _adapter is None:
48
+ raise ToolUnavailableError("MCP tools need the tsugite daemon with the mcp plugin")
49
+ arguments = {key: value for key, value in kwargs.items() if value is not None}
50
+ return call_on_loop(_adapter.loop, _adapter.call_tool, server, name, arguments, timeout=CALL_TIMEOUT)
51
+
52
+ return call
53
+
54
+
55
+ def set_tools(server: str, listed) -> list[dict]:
56
+ """Replace `server`'s registered tools with the MCP tools in `listed`. Returns what registered."""
57
+ clear_tools(server)
58
+ registered = []
59
+ for spec in listed:
60
+ schema = spec.inputSchema or {}
61
+ properties = schema.get("properties", {})
62
+ bad = [
63
+ key
64
+ for key, prop in properties.items()
65
+ if not (isinstance(prop, dict) and key.isidentifier() and not keyword.iskeyword(key))
66
+ ]
67
+ if bad:
68
+ logger.warning("mcp %s: skipping tool %s, argument %r is not usable", server, spec.name, bad[0])
69
+ continue
70
+ safe = re.sub(r"\W", "_", spec.name)
71
+ name = f"mcp_{server}_{safe}"
72
+ if name in _registered.get(server, []):
73
+ logger.warning("mcp %s: skipping tool %s, its name collides with another tool", server, spec.name)
74
+ continue
75
+ required = set(schema.get("required", []))
76
+ parameters = [
77
+ inspect.Parameter(
78
+ key,
79
+ inspect.Parameter.KEYWORD_ONLY,
80
+ default=inspect.Parameter.empty if key in required else None,
81
+ annotation=JSON_TYPES.get(str(prop.get("type")), inspect.Parameter.empty),
82
+ )
83
+ for key, prop in sorted(properties.items(), key=lambda item: item[0] not in required)
84
+ ]
85
+ call = _caller(server, spec.name)
86
+ call.__name__ = name
87
+ call.__doc__ = spec.description or f"{spec.name} on MCP server {server}"
88
+ call.__signature__ = inspect.Signature(parameters)
89
+ tool(category=f"mcp_{server}")(call)
90
+ _registered.setdefault(server, []).append(call.__name__)
91
+ registered.append({"name": call.__name__, "description": call.__doc__.split("\n")[0].strip()})
92
+ return registered
@@ -0,0 +1,205 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Connectors</title>
7
+ <style>
8
+ body { margin: 0; padding: var(--sp-3, 12px); background: var(--bg1); color: var(--tx0); font: var(--fs-md, 13px) var(--font-ui, sans-serif); }
9
+ h1 { font-size: 1.2em; margin: 0 0 var(--sp-3, 12px); }
10
+ form { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: var(--sp-3, 12px); }
11
+ input, button { background: var(--bg2); color: var(--tx0); border: 1px solid var(--bd1); border-radius: var(--r-sm, 4px); padding: 6px 10px; font: inherit; }
12
+ input[name="url"] { flex: 1; min-width: 16em; }
13
+ button { cursor: pointer; }
14
+ section { border: 1px solid var(--bd0); border-radius: var(--r-sm, 4px); padding: 10px; margin-bottom: 10px; }
15
+ header { display: flex; align-items: center; gap: 8px; }
16
+ header strong { flex: 1; }
17
+ .meta, .error { color: var(--tx2); font-size: 0.9em; }
18
+ .error { color: var(--st-err); }
19
+ ul { margin: 6px 0 0; padding-left: 1.2em; }
20
+ code { user-select: all; }
21
+ details { margin-top: 6px; }
22
+ details form { margin: 6px 0 0; align-items: center; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <h1>Connectors</h1>
27
+ <form id="add">
28
+ <input name="name" placeholder="name" pattern="[a-z][a-z0-9]{0,23}" required aria-label="Name" />
29
+ <input name="url" type="url" placeholder="https://mcp.example.com/mcp" required aria-label="Server URL" />
30
+ <input name="client_id" placeholder="Client ID (optional)" autocomplete="off" aria-label="Client ID" />
31
+ <input name="client_secret" type="password" placeholder="Client secret (optional)" autocomplete="off" aria-label="Client secret" />
32
+ <button type="submit">Add</button>
33
+ </form>
34
+ <p class="meta">
35
+ With no client set, the plugin registers one itself when the server allows it. Otherwise create a client in the
36
+ provider's developer console with this redirect URL:
37
+ <code id="redirect"></code> <button type="button" id="copy">Copy</button>
38
+ </p>
39
+ <p class="error" id="error"></p>
40
+ <main id="list"></main>
41
+ <script>
42
+ const BASE = '/api/plugins/mcp';
43
+ let token = '';
44
+ let timer = null;
45
+ const pasting = new Set();
46
+ const REDIRECT = location.origin + BASE + '/callback';
47
+
48
+ async function api(path, method = 'GET', body) {
49
+ const headers = { Authorization: 'Bearer ' + token };
50
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
51
+ const resp = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
52
+ const data = await resp.json();
53
+ if (!resp.ok) throw new Error(data.error || `${path} returned ${resp.status}`);
54
+ return data;
55
+ }
56
+
57
+ function el(tag, text, className) {
58
+ const node = document.createElement(tag);
59
+ if (text !== undefined) node.textContent = text;
60
+ if (className) node.className = className;
61
+ return node;
62
+ }
63
+
64
+ function button(label, onclick) {
65
+ const b = el('button', label);
66
+ b.type = 'button';
67
+ b.onclick = () => run(onclick);
68
+ return b;
69
+ }
70
+
71
+ async function run(action) {
72
+ document.getElementById('error').textContent = '';
73
+ try {
74
+ render(await action());
75
+ } catch (err) {
76
+ document.getElementById('error').textContent = err.message;
77
+ }
78
+ }
79
+
80
+ async function signin(name) {
81
+ // Opened before the await, while the click still counts as a user gesture for popup blockers.
82
+ const w = window.open('', '_blank');
83
+ w.opener = null;
84
+ try {
85
+ const { authorization_url, paste_back } = await api(`/servers/${name}/signin`, 'POST', { redirect_base: location.origin });
86
+ if (paste_back) pasting.add(name);
87
+ w.location = authorization_url;
88
+ } catch (err) {
89
+ w.close();
90
+ throw err;
91
+ }
92
+ return api('/servers');
93
+ }
94
+
95
+ function input(name, type, placeholder, value = '') {
96
+ const i = el('input');
97
+ Object.assign(i, { name, type, placeholder, value, autocomplete: 'off' });
98
+ i.setAttribute('aria-label', placeholder);
99
+ return i;
100
+ }
101
+
102
+ function clientForm(s) {
103
+ const form = el('form');
104
+ const id = input('client_id', 'text', 'Client ID', s.client_id);
105
+ const secret = input('client_secret', 'password', s.has_secret ? 'Secret is set - type to replace' : 'Client secret (optional)');
106
+ const clear = el('input');
107
+ clear.type = 'checkbox';
108
+ const label = el('label', ' Clear secret');
109
+ label.prepend(clear);
110
+ label.hidden = !s.has_secret;
111
+ const save = el('button', 'Save client');
112
+ form.append(id, secret, label, save);
113
+ form.onsubmit = (event) => {
114
+ event.preventDefault();
115
+ run(() => api(`/servers/${s.name}`, 'PUT', { client_id: id.value, client_secret: secret.value, clear_secret: clear.checked }));
116
+ };
117
+ const details = el('details');
118
+ details.append(el('summary', s.client_id ? 'OAuth client' : 'OAuth client (registered automatically)'), form);
119
+ return details;
120
+ }
121
+
122
+ function pasteForm(name) {
123
+ const form = el('form', undefined, 'paste');
124
+ const url = input('url', 'text', 'Paste the address the browser ended on after you approved');
125
+ url.size = 40;
126
+ form.append(url, el('button', 'Finish sign-in'));
127
+ form.onsubmit = (event) => {
128
+ event.preventDefault();
129
+ run(async () => {
130
+ const data = await api('/paste', 'POST', { url: url.value });
131
+ pasting.delete(name);
132
+ return data;
133
+ });
134
+ };
135
+ return form;
136
+ }
137
+
138
+ function poll() {
139
+ if (document.querySelector('#list details[open], #list .paste:focus-within')) timer = setTimeout(poll, 2000);
140
+ else run(() => api('/servers'));
141
+ }
142
+
143
+ function render({ servers }) {
144
+ clearTimeout(timer);
145
+ if (servers.some((s) => s.status === 'connecting')) timer = setTimeout(poll, 2000);
146
+ const list = document.getElementById('list');
147
+ list.replaceChildren(...servers.map((s) => {
148
+ const box = el('section');
149
+ const head = el('header');
150
+ head.append(el('strong', s.name), el('span', s.status.replace('_', ' '), 'meta'));
151
+ if (s.status === 'needs_signin') head.append(button('Sign in', () => signin(s.name)));
152
+ head.append(button('Refresh', () => api(`/servers/${s.name}/refresh`, 'POST')));
153
+ head.append(button('Disconnect', () => api(`/servers/${s.name}`, 'DELETE')));
154
+ box.append(head, el('div', s.url, 'meta'));
155
+ if (s.error) box.append(el('div', s.error, 'error'));
156
+ if (s.status !== 'connecting') pasting.delete(s.name);
157
+ if (pasting.has(s.name)) box.append(pasteForm(s.name));
158
+ box.append(clientForm(s));
159
+ if (s.tools.length) {
160
+ const tools = el('ul');
161
+ tools.append(...s.tools.map((t) => el('li', t.description ? `${t.name} - ${t.description}` : t.name)));
162
+ box.append(tools);
163
+ }
164
+ return box;
165
+ }));
166
+ }
167
+
168
+ document.getElementById('add').onsubmit = (event) => {
169
+ event.preventDefault();
170
+ const form = new FormData(event.target);
171
+ run(async () => {
172
+ const data = await api('/servers', 'POST', {
173
+ name: form.get('name'),
174
+ url: form.get('url'),
175
+ client_id: form.get('client_id'),
176
+ client_secret: form.get('client_secret'),
177
+ });
178
+ event.target.reset();
179
+ return data;
180
+ });
181
+ };
182
+
183
+ document.getElementById('redirect').textContent = REDIRECT;
184
+ document.getElementById('copy').onclick = () =>
185
+ navigator.clipboard.writeText(REDIRECT).then(
186
+ () => (document.getElementById('copy').textContent = 'Copied'),
187
+ (err) => (document.getElementById('error').textContent = err.message),
188
+ );
189
+
190
+ addEventListener('message', (event) => {
191
+ if (event.source !== parent) return;
192
+ const msg = event.data;
193
+ if (!msg || typeof msg !== 'object') return;
194
+ if (msg.type === 'tsugite:init' || msg.type === 'tsugite:theme') {
195
+ for (const [name, value] of Object.entries(msg.theme.tokens)) document.documentElement.style.setProperty(name, value);
196
+ }
197
+ if (msg.type === 'tsugite:init') {
198
+ token = msg.token;
199
+ parent.postMessage({ type: 'tsugite:ready' }, location.origin);
200
+ run(() => api('/servers'));
201
+ }
202
+ });
203
+ </script>
204
+ </body>
205
+ </html>