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.
- extensible_mcp/__init__.py +97 -0
- extensible_mcp/__main__.py +6 -0
- extensible_mcp/admin.py +261 -0
- extensible_mcp/augment.py +189 -0
- extensible_mcp/bundle.py +301 -0
- extensible_mcp/cel_policy.py +238 -0
- extensible_mcp/client_manager.py +465 -0
- extensible_mcp/config.py +192 -0
- extensible_mcp/didweb.py +158 -0
- extensible_mcp/fetchplan.py +202 -0
- extensible_mcp/filters.py +395 -0
- extensible_mcp/guidance.py +113 -0
- extensible_mcp/issuer.py +62 -0
- extensible_mcp/policy_engine.py +36 -0
- extensible_mcp/replay.py +304 -0
- extensible_mcp/routing.py +112 -0
- extensible_mcp/selection.py +174 -0
- extensible_mcp/server.py +712 -0
- extensible_mcp/types.py +178 -0
- extensible_mcp/vector_store.py +67 -0
- extensible_mcp/wallet_bundle.py +220 -0
- extensible_mcp/wasm_filter.py +333 -0
- extensible_mcp/wasm_policy.py +529 -0
- extensible_mcp-0.3.4.dist-info/METADATA +502 -0
- extensible_mcp-0.3.4.dist-info/RECORD +28 -0
- extensible_mcp-0.3.4.dist-info/WHEEL +4 -0
- extensible_mcp-0.3.4.dist-info/entry_points.txt +2 -0
- extensible_mcp-0.3.4.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|
+
]
|
extensible_mcp/admin.py
ADDED
|
@@ -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)
|