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.
- tsugite_mcp-0.27.0/.gitignore +201 -0
- tsugite_mcp-0.27.0/PKG-INFO +9 -0
- tsugite_mcp-0.27.0/pyproject.toml +34 -0
- tsugite_mcp-0.27.0/tsugite_mcp/__init__.py +1 -0
- tsugite_mcp-0.27.0/tsugite_mcp/adapter.py +473 -0
- tsugite_mcp-0.27.0/tsugite_mcp/storage.py +110 -0
- tsugite_mcp-0.27.0/tsugite_mcp/tools.py +92 -0
- tsugite_mcp-0.27.0/tsugite_mcp/ui/connectors.html +205 -0
|
@@ -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>
|