extensible-mcp 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ # Copyright (c) 2026 Matthew Fuchs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """MCP proxy that replaces tool definitions with semantic search."""
5
+
6
+ from .augment import BundleAugmenter
7
+ from .bundle import PolicyBundle
8
+ from .didweb import (
9
+ DidResolutionError,
10
+ DidWebResolver,
11
+ did_web_to_url,
12
+ ed25519_jwk_from_document,
13
+ )
14
+ from .fetchplan import FetchContext, FetchError, FetchExecutor, WalletLookup
15
+ from .filters import CallFilter, ResponseFilter, ServerLoadFilter, ToolFilter
16
+ from .guidance import render_denial
17
+ from .replay import (
18
+ InMemorySpentStore,
19
+ SingleUseEvidenceFilter,
20
+ SpentStore,
21
+ wallet_bundle_jti,
22
+ )
23
+ from .issuer import IssuerRegistry
24
+ from .routing import BundleRouter
25
+ from .selection import (
26
+ LayeredBundleSelector,
27
+ RegoClassifier,
28
+ SelectionResult,
29
+ provenance_descriptor,
30
+ )
31
+ from .types import (
32
+ CallFilterResult,
33
+ CallRequest,
34
+ CallResponse,
35
+ LocalTool,
36
+ ResponseFilterResult,
37
+ SearchResult,
38
+ ServerLoadRequest,
39
+ ServerLoadResult,
40
+ ToolRecord,
41
+ )
42
+ from .wallet_bundle import AdaptedCall, WalletBundleAdapter, WalletBundleError, make_membership_lookup
43
+ from .wasm_filter import VCPolicyFilter, WasmPolicyFilter, policy_deny_reason
44
+ from .wasm_policy import (
45
+ HostBuiltinError,
46
+ OpaWasmPolicy,
47
+ PolicyEvaluationError,
48
+ default_builtins,
49
+ )
50
+
51
+ __all__ = [
52
+ "BundleAugmenter",
53
+ "BundleRouter",
54
+ "CallFilter",
55
+ "CallFilterResult",
56
+ "CallRequest",
57
+ "CallResponse",
58
+ "DidResolutionError",
59
+ "DidWebResolver",
60
+ "FetchContext",
61
+ "FetchError",
62
+ "FetchExecutor",
63
+ "HostBuiltinError",
64
+ "IssuerRegistry",
65
+ "LayeredBundleSelector",
66
+ "LocalTool",
67
+ "OpaWasmPolicy",
68
+ "PolicyBundle",
69
+ "PolicyEvaluationError",
70
+ "RegoClassifier",
71
+ "ResponseFilter",
72
+ "ResponseFilterResult",
73
+ "SearchResult",
74
+ "SelectionResult",
75
+ "ServerLoadFilter",
76
+ "ServerLoadRequest",
77
+ "ServerLoadResult",
78
+ "ToolFilter",
79
+ "ToolRecord",
80
+ "AdaptedCall",
81
+ "VCPolicyFilter",
82
+ "WalletBundleAdapter",
83
+ "WalletBundleError",
84
+ "WalletLookup",
85
+ "WasmPolicyFilter",
86
+ "default_builtins",
87
+ "did_web_to_url",
88
+ "ed25519_jwk_from_document",
89
+ "make_membership_lookup",
90
+ "policy_deny_reason",
91
+ "provenance_descriptor",
92
+ "render_denial",
93
+ "InMemorySpentStore",
94
+ "SingleUseEvidenceFilter",
95
+ "SpentStore",
96
+ "wallet_bundle_jti",
97
+ ]
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2026 Matthew Fuchs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from extensible_mcp.server import main
5
+
6
+ main()
@@ -0,0 +1,261 @@
1
+ # Copyright (c) 2026 Matthew Fuchs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Adding an authenticated server without handing the credential to the model.
5
+
6
+ `load_mcp_server` lets the LLM name both a server and a URL, and a token is
7
+ keyed by name — so a prompt-injected model could point a name that has a token
8
+ at a host it controls and be handed the bearer. The proxy closed that by
9
+ sending credentials only to URLs an operator configured, which also removed the
10
+ documented "drop the token in the tokens file, then load at runtime" flow.
11
+
12
+ This restores it from the other side. The distinction that makes it safe is
13
+ *who possesses the credential*: the bug was the model referencing a secret it
14
+ had never seen, and the proxy fetching it on the model's behalf. Here the
15
+ operator passes the token in, so only a caller who already holds it can use it
16
+ — and the model holds none.
17
+
18
+ Note this is the opposite direction from moving *signed evidence* by handle
19
+ rather than by value, and both are right. Evidence should travel by reference
20
+ because the LLM is a lossy courier of bytes it legitimately carries; a
21
+ downstream auth token should travel by value because the LLM must never be
22
+ able to invoke one it cannot hold.
23
+
24
+ Deliberately not an MCP tool. A `token` parameter on `load_mcp_server` would
25
+ be safe from exfiltration — the model has nothing to pass — but it shares the
26
+ endpoint the model drives, and any user who pasted a token into a chat to make
27
+ it work would break the rule that tokens never transit the conversation.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import signal
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+
38
+ from mcp import ClientSession
39
+ from mcp.client.streamable_http import streamable_http_client
40
+
41
+ PID_FILENAME = "extensible-mcp.pid"
42
+
43
+
44
+ class AdminError(Exception):
45
+ """Something the operator needs to fix, reported without a traceback."""
46
+
47
+
48
+ @dataclass
49
+ class VerifiedServer:
50
+ name: str
51
+ url: str
52
+ tool_names: list[str]
53
+
54
+
55
+ async def verify_server(name: str, url: str, token: str | None) -> VerifiedServer:
56
+ """Connect as an MCP client and list tools, so a bad URL or a wrong token
57
+ fails here rather than at the proxy's next start.
58
+
59
+ Verification is the point of the command: writing a token into a file is
60
+ trivial, and finding out hours later that it was wrong is the expensive
61
+ part.
62
+
63
+ Built to behave exactly as ``ClientManager`` will, which means **not**
64
+ following redirects. An earlier version did, and so accepted a URL the
65
+ proxy then could not use -- a `/mcp/` that 307s to `/mcp` verified here and
66
+ failed at the proxy, which is worse than not checking, because it reports
67
+ success for something broken. The runtime does not follow redirects either,
68
+ and should not: a redirect carrying an Authorization header is a way to
69
+ hand a bearer somewhere it was not meant to go.
70
+ """
71
+ import httpx
72
+
73
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
74
+ try:
75
+ async with httpx.AsyncClient(headers=headers) as http:
76
+ async with streamable_http_client(url, http_client=http) as (r, w, _):
77
+ async with ClientSession(r, w) as session:
78
+ await session.initialize()
79
+ listed = await session.list_tools()
80
+ except Exception as e: # noqa: BLE001 - every failure is the same advice
81
+ detail = _leaf(e)
82
+ hint = (
83
+ "Check the URL, that the server is running, and that the token is "
84
+ "the one it expects."
85
+ )
86
+ if "Redirect" in detail or "307" in detail or "308" in detail:
87
+ # Overwhelmingly a trailing slash, and the message httpx gives names
88
+ # the target, so point straight at it.
89
+ hint = (
90
+ "That URL redirects. Use the URL it redirects to -- usually this "
91
+ "is a trailing slash: try the form without one, or with one, to "
92
+ "match what the server serves. The proxy does not follow "
93
+ "redirects, so the exact URL matters."
94
+ )
95
+ raise AdminError(f"could not use {url} as an MCP server: {detail}\n{hint}") from e
96
+ return VerifiedServer(name=name, url=url, tool_names=[t.name for t in listed.tools])
97
+
98
+
99
+ def _leaf(exc: BaseException) -> str:
100
+ """The innermost message. anyio task groups wrap the real failure."""
101
+ if isinstance(exc, BaseExceptionGroup):
102
+ return "; ".join(_leaf(e) for e in exc.exceptions)
103
+ return str(exc).strip() or type(exc).__name__
104
+
105
+
106
+ def add_server_to_config(config_path: Path, name: str, url: str) -> None:
107
+ """Add ``name`` to the config's ``mcpServers``, refusing to redefine one.
108
+
109
+ Rewriting an existing entry is how a name could be re-pointed at another
110
+ host, which is the substitution the token binding exists to prevent -- so
111
+ it is an error here rather than an overwrite.
112
+ """
113
+ raw = json.loads(config_path.read_text())
114
+ servers = raw.setdefault("mcpServers", {})
115
+ if name in servers:
116
+ raise AdminError(
117
+ f"'{name}' is already in {config_path}. Remove it by hand if you "
118
+ "really mean to point that name somewhere else; this command will "
119
+ "not repoint an existing server."
120
+ )
121
+ servers[name] = {"url": url}
122
+ config_path.write_text(json.dumps(raw, indent=2) + "\n")
123
+
124
+
125
+ def write_token(tokens_path: Path, name: str, token: str) -> None:
126
+ """Upsert ``name=token``, leaving every other line untouched.
127
+
128
+ Rewritten through a 0600 temp file and replaced atomically: a reader that
129
+ catches the file mid-write would see a truncated token and send it.
130
+ """
131
+ lines: list[str] = []
132
+ replaced = False
133
+ if tokens_path.exists():
134
+ for line in tokens_path.read_text().splitlines():
135
+ key = line.split("=", 1)[0].strip()
136
+ if key == name and not line.strip().startswith("#"):
137
+ lines.append(f"{name}={token}")
138
+ replaced = True
139
+ else:
140
+ lines.append(line)
141
+ if not replaced:
142
+ lines.append(f"{name}={token}")
143
+
144
+ tmp = tokens_path.with_name(tokens_path.name + ".tmp")
145
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
146
+ with os.fdopen(fd, "w") as fh:
147
+ fh.write("\n".join(lines) + "\n")
148
+ os.replace(tmp, tokens_path)
149
+ os.chmod(tokens_path, 0o600)
150
+
151
+
152
+ def pid_file_for(config_path: Path) -> Path:
153
+ """Beside the config, so one proxy per config is the unit."""
154
+ return config_path.parent / PID_FILENAME
155
+
156
+
157
+ def _is_locked(pid_file: Path) -> bool:
158
+ """Whether a live process holds the pid file's lock.
159
+
160
+ The pid alone is not safe to act on. Python does not run ``finally`` on
161
+ SIGTERM, which is how a daemon is normally stopped, so a stale pid file is
162
+ the common case rather than the exceptional one -- and pids get reused.
163
+ Signalling a recycled pid would send SIGHUP to an unrelated process, whose
164
+ default action is to terminate it.
165
+
166
+ An advisory lock answers the question the pid cannot: the running proxy
167
+ holds it for its whole life, and the kernel drops it when that process
168
+ dies, however it died. If we can take the lock, nobody is there.
169
+ """
170
+ try:
171
+ import fcntl
172
+ except ImportError: # pragma: no cover - Windows
173
+ return True # cannot tell; fall back to trusting the pid
174
+ try:
175
+ fd = os.open(str(pid_file), os.O_RDWR)
176
+ except OSError:
177
+ return False
178
+ try:
179
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
180
+ except OSError:
181
+ return True # someone holds it: a live proxy
182
+ else:
183
+ fcntl.flock(fd, fcntl.LOCK_UN)
184
+ return False
185
+ finally:
186
+ os.close(fd)
187
+
188
+
189
+ def signal_reload(config_path: Path) -> int | None:
190
+ """SIGHUP a proxy running against this config. Returns the pid, or None.
191
+
192
+ A stale pid file is normal, so it is reported rather than treated as a
193
+ failure: the config and token are already written, and the next start picks
194
+ them up.
195
+ """
196
+ pid_file = pid_file_for(config_path)
197
+ if not pid_file.exists():
198
+ return None
199
+ try:
200
+ pid = int(pid_file.read_text().strip())
201
+ except ValueError:
202
+ return None
203
+ if not hasattr(signal, "SIGHUP"):
204
+ return None
205
+ if not _is_locked(pid_file):
206
+ return None
207
+ try:
208
+ os.kill(pid, signal.SIGHUP)
209
+ except (ProcessLookupError, PermissionError):
210
+ return None
211
+ return pid
212
+
213
+
214
+ # The running proxy's lock fd. Held for the process lifetime: closing it would
215
+ # release the lock and make a live proxy look stale.
216
+ _pid_lock_fd: int | None = None
217
+
218
+
219
+ def write_pid_file(config_path: Path) -> Path:
220
+ """Record this process's pid and hold an advisory lock on the file."""
221
+ global _pid_lock_fd
222
+ path = pid_file_for(config_path)
223
+ # Open without O_TRUNC and truncate only after the lock is ours. O_TRUNC
224
+ # empties the file at open(), which happens before the lock is tested -- so
225
+ # a second proxy that is about to be refused would first wipe the running
226
+ # proxy's pid, leaving add-server unable to find it.
227
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT, 0o644)
228
+ try:
229
+ import fcntl
230
+
231
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
232
+ except ImportError: # pragma: no cover - Windows
233
+ pass
234
+ except OSError as e:
235
+ os.close(fd)
236
+ raise AdminError(
237
+ f"another proxy is already running against {config_path} "
238
+ f"(see {path})"
239
+ ) from e
240
+ os.ftruncate(fd, 0)
241
+ os.write(fd, f"{os.getpid()}\n".encode())
242
+ _pid_lock_fd = fd
243
+ return path
244
+
245
+
246
+ def remove_pid_file(path: Path) -> None:
247
+ """Best effort, on the graceful path only. The lock is what actually
248
+ tells a later ``add-server`` whether anyone is there, so a file left by a
249
+ SIGTERM is untidy rather than wrong."""
250
+ global _pid_lock_fd
251
+ if _pid_lock_fd is not None:
252
+ try:
253
+ os.close(_pid_lock_fd)
254
+ except OSError:
255
+ pass
256
+ _pid_lock_fd = None
257
+ try:
258
+ if int(path.read_text().strip()) == os.getpid():
259
+ path.unlink()
260
+ except (OSError, ValueError):
261
+ pass
@@ -0,0 +1,189 @@
1
+ # Copyright (c) 2026 Matthew Fuchs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Search-side augmentation: tell the LLM which credentials a governed tool
5
+ needs.
6
+
7
+ When ``search_tools`` surfaces a tool governed by a policy bundle, the LLM must
8
+ be told to supply the bundle's credential fields (e.g. ``requestVC``) — and
9
+ *when*, since some are required only on certain paths (e.g. an authorization VC
10
+ only above a spend threshold). :class:`BundleAugmenter` is a ``ToolFilter`` that,
11
+ per result, looks up the governing bundle (via the :class:`BundleRouter` the call
12
+ path already uses) and rewrites the surfaced tool definition:
13
+
14
+ - injects each credential field into the tool's input schema, typed from the
15
+ bundle's manifest; an always-required credential is marked ``required``, a
16
+ conditional one is optional;
17
+ - extends the description with a generated "what you need" note — the
18
+ *scaffolding* ("always / if … also", the dependency ordering) is the
19
+ proxy's; the *meaningful words* (the named-check sentences, issuer roles,
20
+ credential names) come from the bundle's ``guidance.json``.
21
+
22
+ Credentials are listed in **dependency order** (a credential that signs over
23
+ another comes after it), each annotated with its **issuer role** — the
24
+ policy's enforced tag ("a parent must sign"), which the deployment's
25
+ ``IssuerRegistry`` maps to a concrete wallet. Roles and edges are fail-soft
26
+ derivations from the bundle's own authoring toolchain: when a guidance file
27
+ predates them the note is merely vaguer, never wrong.
28
+
29
+ This is the proactive half of the message-is-the-interface design: the
30
+ enforcement path re-checks everything regardless, so a tool that misreads this
31
+ guidance is denied, not a security hole.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from copy import deepcopy
37
+ from typing import Any
38
+
39
+ from collections.abc import Callable
40
+
41
+ from .bundle import PolicyBundle
42
+ from .routing import BundleRouter
43
+ from .types import SearchResult, ToolRecord
44
+
45
+
46
+ def _vc_bundle_phrase(role: str | None) -> str:
47
+ wallet = f"the {role}'s wallet" if role else "the appropriate wallet"
48
+ return (
49
+ "A signed verifiable-credential bundle ({token, membership}) "
50
+ f"obtained from {wallet}."
51
+ )
52
+
53
+
54
+ def _unqualified_action(tool_name: str) -> str:
55
+ return tool_name.split("__", 1)[1] if "__" in tool_name else tool_name
56
+
57
+
58
+ class BundleAugmenter:
59
+ """A ``ToolFilter`` that surfaces a governed tool's required credentials.
60
+
61
+ ``tool_action`` must match the call filter's mapping (the policy's
62
+ ``input.tool``): the note tells the LLM the exact action identifier to
63
+ put in the signed request, because the policy binds on it and the LLM
64
+ cannot guess it — each wrong guess costs a human wallet approval.
65
+
66
+ ``wire_schemas`` (field → JSON Schema) overrides the injected schema for
67
+ a credential field. The manifest's shape is what the *policy* reads; when
68
+ the call path runs a translating adapter (e.g. ``WalletBundleAdapter``
69
+ turning ``{token, membership}`` into ``{jws, claims}``), the LLM must be
70
+ shown the **wire** shape it actually sends — advertising the policy shape
71
+ misleads it into "fixing" a working call.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ router: BundleRouter,
77
+ *,
78
+ tool_action: Callable[[str], str] = _unqualified_action,
79
+ wire_schemas: dict[str, dict[str, Any]] | None = None,
80
+ ) -> None:
81
+ self._router = router
82
+ self._tool_action = tool_action
83
+ self._wire_schemas = dict(wire_schemas or {})
84
+
85
+ def filter(self, results: list[SearchResult], query: str) -> list[SearchResult]:
86
+ out: list[SearchResult] = []
87
+ for r in results:
88
+ bundle = self._router.bundle_for(r.tool.server_name)
89
+ if bundle is None or not bundle.credential_fields():
90
+ out.append(r)
91
+ continue
92
+ out.append(SearchResult(tool=self._augment(r.tool, bundle), score=r.score))
93
+ return out
94
+
95
+ # -- augmentation ------------------------------------------------------- #
96
+
97
+ def _augment(self, tool: ToolRecord, bundle: PolicyBundle) -> ToolRecord:
98
+ schema = deepcopy(tool.input_schema) if tool.input_schema else {"type": "object"}
99
+ properties: dict[str, Any] = schema.setdefault("properties", {})
100
+ required: list[str] = schema.setdefault("required", [])
101
+
102
+ for field in self._dependency_order(bundle):
103
+ properties[field] = self._credential_schema(field, bundle)
104
+ if bundle.is_always_required(field) and field not in required:
105
+ required.append(field)
106
+
107
+ return ToolRecord(
108
+ name=tool.name,
109
+ qualified_name=tool.qualified_name,
110
+ description=self._augment_description(
111
+ tool.description, bundle, action=self._tool_action(tool.qualified_name)
112
+ ),
113
+ input_schema=schema,
114
+ server_name=tool.server_name,
115
+ # Keep the original embedding text: augmentation is presentation,
116
+ # it must not change how the tool is found.
117
+ embedding_text=tool.embedding_text,
118
+ )
119
+
120
+ def _dependency_order(self, bundle: PolicyBundle) -> list[str]:
121
+ """Credential fields with each one's dependencies before it (a
122
+ credential signs over what it depends on, so that must exist first).
123
+ Fail-soft: an edge to a non-credential field is ignored, and a cycle —
124
+ which a well-formed bundle's dependency edges should never contain —
125
+ degrades to the given order."""
126
+ fields = bundle.credential_fields()
127
+ ordered: list[str] = []
128
+ visiting: set[str] = set()
129
+
130
+ def visit(field: str) -> None:
131
+ if field in ordered or field in visiting:
132
+ return
133
+ visiting.add(field)
134
+ for dep in bundle.credential_dependencies(field):
135
+ if dep in fields:
136
+ visit(dep)
137
+ visiting.discard(field)
138
+ ordered.append(field)
139
+
140
+ for field in fields:
141
+ visit(field)
142
+ return ordered
143
+
144
+ def _credential_schema(self, field: str, bundle: PolicyBundle) -> dict[str, Any]:
145
+ base = self._wire_schemas.get(field) or bundle.field_schema(field)
146
+ schema = deepcopy(base) if isinstance(base, dict) else {"type": "object"}
147
+ note = self._requirement_phrase(field, bundle)
148
+ existing = schema.get("description")
149
+ schema["description"] = f"{existing} {note}".strip() if existing else note
150
+ return schema
151
+
152
+ def _requirement_phrase(self, field: str, bundle: PolicyBundle) -> str:
153
+ phrase = _vc_bundle_phrase(bundle.issuer_role(field))
154
+ if bundle.is_always_required(field):
155
+ return f"Required: {phrase}"
156
+ deciding = bundle.deciding_sentences(field)
157
+ when = "; ".join(deciding) if deciding else "certain conditions hold"
158
+ return f"Required only when {when}. {phrase}"
159
+
160
+ def _augment_description(
161
+ self, description: str, bundle: PolicyBundle, *, action: str
162
+ ) -> str:
163
+ lines = ["", "This tool is policy-governed and requires signed credentials:"]
164
+ for field in self._dependency_order(bundle):
165
+ role = bundle.issuer_role(field)
166
+ signed = f", signed by the {role}," if role else ""
167
+ if bundle.is_always_required(field):
168
+ head = f"- Always provide `{field}`{signed}".rstrip(",")
169
+ else:
170
+ deciding = bundle.deciding_sentences(field)
171
+ when = "; ".join(deciding) if deciding else "the policy's conditions hold"
172
+ head = f"- Provide `{field}`{signed} when {when}"
173
+ deps = [d for d in bundle.credential_dependencies(field)
174
+ if d in bundle.credential_fields()]
175
+ if deps:
176
+ listed = ", ".join(f"`{d}`" for d in deps)
177
+ head += f"; obtain {listed} first — this credential signs over it"
178
+ lines.append(head + ".")
179
+ lines.append(
180
+ f'The policy evaluates this tool\'s action as `"{action}"` — when '
181
+ "requesting a credential, name the action exactly so (the signed "
182
+ f'request\'s type/action field must equal `"{action}"`, not the '
183
+ "qualified tool name)."
184
+ )
185
+ lines.append(
186
+ "Calls missing a required credential are denied; the denial says "
187
+ "what is still needed."
188
+ )
189
+ return description + "\n".join(lines)