tis is the greatest thing ever
This commit is contained in:
@@ -29,7 +29,7 @@ wf-mcp serve --mode unified
|
||||
### Working
|
||||
|
||||
- `tools/list` shows Everything tools through `wf-mcp`.
|
||||
- `tools/call` works for normal tools such as `everything.default_echo`.
|
||||
- `tools/call` works for normal tools such as `everything.default.echo`.
|
||||
- Tool metadata is preserved for proxy inventory, including title, description,
|
||||
and JSON input schema.
|
||||
- Annotated text content is preserved.
|
||||
@@ -47,7 +47,7 @@ Example listed resource mapping:
|
||||
|
||||
```text
|
||||
upstream: demo://resource/static/document/instructions.md
|
||||
proxied: demo://everything.default/resource/static/document/instructions.md
|
||||
proxied: demo://everything/default/resource/static/document/instructions.md
|
||||
```
|
||||
|
||||
### Gaps
|
||||
@@ -63,7 +63,7 @@ demo://resource/dynamic/text/2
|
||||
normal dynamic resources:
|
||||
|
||||
```text
|
||||
demo://everything.default/resource/dynamic/text/2
|
||||
demo://everything/default/resource/dynamic/text/2
|
||||
```
|
||||
|
||||
- Session resource links from `gzip-file-as-resource` are not currently usable
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
@@ -11,8 +10,7 @@ from pydantic import ConfigDict
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from .resource_links import rewrite_resource_link_content
|
||||
|
||||
_URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
|
||||
from ..shared.names import connection_id_to_resource_path
|
||||
|
||||
|
||||
class ResourceLinkRewritingTool(Tool):
|
||||
@@ -55,7 +53,7 @@ class ResourceLinkNamespace(Transform):
|
||||
"""Rewrite resource links returned by tools into one namespace."""
|
||||
|
||||
def __init__(self, prefix: str) -> None:
|
||||
self._prefix = prefix
|
||||
self._prefix = connection_id_to_resource_path(prefix)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ResourceLinkNamespace({self._prefix!r})"
|
||||
@@ -80,8 +78,7 @@ class ResourceLinkNamespace(Transform):
|
||||
|
||||
def _transform_uri(self, uri: str) -> str:
|
||||
"""Match FastMCP Namespace URI projection for tool-returned links."""
|
||||
match = _URI_PATTERN.match(uri)
|
||||
if match is None:
|
||||
protocol, separator, path = uri.partition("://")
|
||||
if not separator:
|
||||
return uri
|
||||
protocol, path = match.groups()
|
||||
return f"{protocol}{self._prefix}/{path}"
|
||||
return f"{protocol}://{self._prefix}/{path}"
|
||||
|
||||
@@ -53,11 +53,6 @@ def _validate_connection_ids(
|
||||
continue
|
||||
if connection_id in RESERVED_CONNECTION_IDS:
|
||||
errors.append(f"connection id {connection_id!r} is reserved by wf-mcp")
|
||||
if "_" in connection_id:
|
||||
errors.append(
|
||||
f"connection id {connection_id!r} must not contain '_' because "
|
||||
"FastMCP Namespace uses '_' as the tool-name separator"
|
||||
)
|
||||
if not _NAMESPACE_ID_RE.fullmatch(connection_id):
|
||||
errors.append(
|
||||
f"connection id {connection_id!r} must contain only letters, "
|
||||
|
||||
@@ -2,7 +2,9 @@ from .errors import error_payload, root_exception
|
||||
from .names import (
|
||||
ADMIN_NAMESPACE,
|
||||
LdaNamespace,
|
||||
ProxyNamespace,
|
||||
ProxyToolName,
|
||||
connection_id_to_resource_path,
|
||||
is_admin_tool_name,
|
||||
namespaced_tool_name,
|
||||
parse_namespaced_tool_name,
|
||||
@@ -12,7 +14,9 @@ from .pagination import clamp_limit, make_cursor, paginate_items, parse_cursor
|
||||
__all__ = [
|
||||
"ADMIN_NAMESPACE",
|
||||
"LdaNamespace",
|
||||
"ProxyNamespace",
|
||||
"ProxyToolName",
|
||||
"connection_id_to_resource_path",
|
||||
"clamp_limit",
|
||||
"error_payload",
|
||||
"is_admin_tool_name",
|
||||
|
||||
+147
-3
@@ -2,7 +2,25 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastmcp.server.transforms import Namespace
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp.server.transforms import (
|
||||
GetPromptNext,
|
||||
GetResourceNext,
|
||||
GetResourceTemplateNext,
|
||||
GetToolNext,
|
||||
Namespace,
|
||||
Transform,
|
||||
)
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.prompts.base import Prompt
|
||||
from fastmcp.resources.base import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.tools.base import Tool
|
||||
|
||||
ADMIN_NAMESPACE = "wf.admin"
|
||||
RESERVED_CONNECTION_IDS = frozenset({ADMIN_NAMESPACE, "wf.mcp"})
|
||||
@@ -17,7 +35,7 @@ class ProxyToolName:
|
||||
|
||||
|
||||
def namespaced_tool_name(connection_id: str, local_name: str) -> str:
|
||||
return f"{connection_id}_{local_name}"
|
||||
return f"{connection_id}.{local_name}"
|
||||
|
||||
|
||||
def parse_namespaced_tool_name(
|
||||
@@ -27,7 +45,7 @@ def parse_namespaced_tool_name(
|
||||
matches = [
|
||||
connection_id
|
||||
for connection_id in connection_ids
|
||||
if proxy_name.startswith(f"{connection_id}_")
|
||||
if proxy_name.startswith(f"{connection_id}.")
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
@@ -54,3 +72,129 @@ class LdaNamespace(Namespace):
|
||||
# FastMCP's public Namespace transform uses underscores; override its
|
||||
# private prefix so admin tools keep their dotted wf.admin.* names.
|
||||
self._name_prefix = f"{prefix}."
|
||||
|
||||
|
||||
_URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
|
||||
|
||||
|
||||
def connection_id_to_resource_path(connection_id: str) -> str:
|
||||
"""Project a dotted connection id into URI path segments."""
|
||||
return connection_id.replace(".", "/")
|
||||
|
||||
|
||||
class ProxyNamespace(Transform):
|
||||
"""Project MCP proxy names with dots for callables and slashes for URIs."""
|
||||
|
||||
def __init__(self, connection_id: str) -> None:
|
||||
self._connection_id = connection_id
|
||||
self._name_prefix = f"{connection_id}."
|
||||
self._resource_prefix = f"{connection_id_to_resource_path(connection_id)}/"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ProxyNamespace({self._connection_id!r})"
|
||||
|
||||
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
|
||||
return [tool.model_copy(update={"name": self._name(tool.name)}) for tool in tools]
|
||||
|
||||
async def get_tool(
|
||||
self,
|
||||
name: str,
|
||||
call_next: GetToolNext,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
) -> Tool | None:
|
||||
local_name = self._local_name(name)
|
||||
if local_name is None:
|
||||
return None
|
||||
tool = await call_next(local_name, version=version)
|
||||
return None if tool is None else tool.model_copy(update={"name": name})
|
||||
|
||||
async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]:
|
||||
return [
|
||||
prompt.model_copy(update={"name": self._name(prompt.name)})
|
||||
for prompt in prompts
|
||||
]
|
||||
|
||||
async def get_prompt(
|
||||
self,
|
||||
name: str,
|
||||
call_next: GetPromptNext,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
) -> Prompt | None:
|
||||
local_name = self._local_name(name)
|
||||
if local_name is None:
|
||||
return None
|
||||
prompt = await call_next(local_name, version=version)
|
||||
return None if prompt is None else prompt.model_copy(update={"name": name})
|
||||
|
||||
async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]:
|
||||
return [
|
||||
resource.model_copy(update={"uri": self._uri(str(resource.uri))})
|
||||
for resource in resources
|
||||
]
|
||||
|
||||
async def get_resource(
|
||||
self,
|
||||
uri: str,
|
||||
call_next: GetResourceNext,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
) -> Resource | None:
|
||||
local_uri = self._local_uri(uri)
|
||||
if local_uri is None:
|
||||
return None
|
||||
resource = await call_next(local_uri, version=version)
|
||||
return None if resource is None else resource.model_copy(update={"uri": uri})
|
||||
|
||||
async def list_resource_templates(
|
||||
self,
|
||||
templates: Sequence[ResourceTemplate],
|
||||
) -> Sequence[ResourceTemplate]:
|
||||
return [
|
||||
template.model_copy(
|
||||
update={"uri_template": self._uri(template.uri_template)}
|
||||
)
|
||||
for template in templates
|
||||
]
|
||||
|
||||
async def get_resource_template(
|
||||
self,
|
||||
uri: str,
|
||||
call_next: GetResourceTemplateNext,
|
||||
*,
|
||||
version: VersionSpec | None = None,
|
||||
) -> ResourceTemplate | None:
|
||||
local_uri = self._local_uri(uri)
|
||||
if local_uri is None:
|
||||
return None
|
||||
template = await call_next(local_uri, version=version)
|
||||
return (
|
||||
None
|
||||
if template is None
|
||||
else template.model_copy(update={"uri_template": self._uri(template.uri_template)})
|
||||
)
|
||||
|
||||
def _name(self, name: str) -> str:
|
||||
return f"{self._name_prefix}{name}"
|
||||
|
||||
def _local_name(self, name: str) -> str | None:
|
||||
if not name.startswith(self._name_prefix):
|
||||
return None
|
||||
return name[len(self._name_prefix) :]
|
||||
|
||||
def _uri(self, uri: str) -> str:
|
||||
match = _URI_PATTERN.match(uri)
|
||||
if match is None:
|
||||
return uri
|
||||
protocol, path = match.groups()
|
||||
return f"{protocol}{self._resource_prefix}{path}"
|
||||
|
||||
def _local_uri(self, uri: str) -> str | None:
|
||||
match = _URI_PATTERN.match(uri)
|
||||
if match is None:
|
||||
return None
|
||||
protocol, path = match.groups()
|
||||
if not path.startswith(self._resource_prefix):
|
||||
return None
|
||||
return f"{protocol}{path[len(self._resource_prefix):]}"
|
||||
|
||||
@@ -10,11 +10,10 @@ from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports.config import MCPConfigTransport
|
||||
from fastmcp.server import create_proxy
|
||||
from fastmcp.server.transforms import Namespace
|
||||
|
||||
from ..models import BrokerConfig, ConnectionConfig
|
||||
from ..proxy_results import ResourceLinkNamespace
|
||||
from ..proxy_config import broker_config_to_fastmcp_config
|
||||
from ..shared.names import ProxyNamespace
|
||||
|
||||
ProxyT = TypeVar("ProxyT")
|
||||
ProxyMountFactory = Callable[[ConnectionConfig, Path], "ProxyMount[ProxyT]"]
|
||||
@@ -98,7 +97,7 @@ def create_proxy_mount(
|
||||
transport = MCPConfigTransport(server_config, name_as_prefix=False)
|
||||
client = Client(transport=transport, name=f"wf-mcp:{connection.id}")
|
||||
proxy: FastMCP[Any] = create_proxy(client, name=f"Proxy-{connection.id}")
|
||||
proxy.add_transform(Namespace(connection.id))
|
||||
proxy.add_transform(ProxyNamespace(connection.id))
|
||||
proxy.add_transform(ResourceLinkNamespace(connection.id))
|
||||
return ProxyMount(
|
||||
connection_id=connection.id,
|
||||
|
||||
@@ -17,15 +17,15 @@ def test_namespaced_tool_names_are_reversible_with_known_connections() -> None:
|
||||
)
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed.proxy_name == "everything.default_get-sum"
|
||||
assert parsed.proxy_name == "everything.default.get-sum"
|
||||
assert parsed.connection_id == "everything.default"
|
||||
assert parsed.local_name == "get-sum"
|
||||
|
||||
|
||||
def test_namespaced_tool_parser_rejects_unknown_and_admin_names() -> None:
|
||||
assert parse_namespaced_tool_name("missing_echo", {"everything.default"}) is None
|
||||
assert parse_namespaced_tool_name("missing.echo", {"everything.default"}) is None
|
||||
assert is_admin_tool_name("wf.admin.list_connections") is True
|
||||
assert is_admin_tool_name("everything.default_echo") is False
|
||||
assert is_admin_tool_name("everything.default.echo") is False
|
||||
|
||||
|
||||
def test_admin_namespace_is_distinct_from_wf_mcp_runtime_source() -> None:
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_transparent_proxy_lists_and_calls_upstream_tools() -> None:
|
||||
assert "wf.admin.get_connection_statuses" in names
|
||||
assert "wf.admin.list_proxy_tools" in names
|
||||
assert "wf.admin.get_proxy_tool" in names
|
||||
assert "fixture.personal_echo_tool" in names
|
||||
assert "fixture.personal.echo_tool" in names
|
||||
|
||||
connections_result = await client.call_tool("wf.admin.list_connections")
|
||||
assert _structured(connections_result) == {
|
||||
@@ -74,7 +74,7 @@ def test_transparent_proxy_lists_and_calls_upstream_tools() -> None:
|
||||
}
|
||||
|
||||
result = await client.call_tool(
|
||||
"fixture.personal_echo_tool",
|
||||
"fixture.personal.echo_tool",
|
||||
{"text": "hello"},
|
||||
)
|
||||
assert _structured(result) == {"echoed": "hello"}
|
||||
@@ -85,21 +85,21 @@ def test_transparent_proxy_lists_and_calls_upstream_tools() -> None:
|
||||
assert proxy_tools_payload["nextCursor"] is None
|
||||
assert proxy_tools_payload["total"] == 2
|
||||
assert len(proxy_tools) == 2
|
||||
assert proxy_tools[0]["proxy_name"] == "fixture.personal_echo_tool"
|
||||
assert proxy_tools[0]["proxy_name"] == "fixture.personal.echo_tool"
|
||||
assert proxy_tools[0]["connection_id"] == "fixture.personal"
|
||||
assert proxy_tools[0]["local_name"] == "echo_tool"
|
||||
assert proxy_tools[0]["enabled"] is True
|
||||
assert (
|
||||
proxy_tools[1]["proxy_name"]
|
||||
== "fixture.personal_resource_link_tool"
|
||||
== "fixture.personal.resource_link_tool"
|
||||
)
|
||||
|
||||
proxy_tool_result = await client.call_tool(
|
||||
"wf.admin.get_proxy_tool",
|
||||
{"proxy_name": "fixture.personal_echo_tool"},
|
||||
{"proxy_name": "fixture.personal.echo_tool"},
|
||||
)
|
||||
proxy_tool = _structured(proxy_tool_result)
|
||||
assert proxy_tool["proxy_name"] == "fixture.personal_echo_tool"
|
||||
assert proxy_tool["proxy_name"] == "fixture.personal.echo_tool"
|
||||
assert proxy_tool["connection_id"] == "fixture.personal"
|
||||
assert proxy_tool["local_name"] == "echo_tool"
|
||||
assert proxy_tool["input_schema"]["properties"]["text"]["type"] == "string"
|
||||
@@ -127,10 +127,10 @@ def test_transparent_proxy_rewrites_resource_links_returned_by_tools() -> None:
|
||||
async def run_proxy() -> None:
|
||||
client = create_transparent_proxy_client(config)
|
||||
async with client:
|
||||
result = await client.call_tool("fixture.personal_resource_link_tool")
|
||||
result = await client.call_tool("fixture.personal.resource_link_tool")
|
||||
link = result.content[0]
|
||||
assert isinstance(link, mcp_types.ResourceLink)
|
||||
assert str(link.uri) == "fixture://fixture.personal/docs/welcome"
|
||||
assert str(link.uri) == "fixture://fixture/personal/docs/welcome"
|
||||
|
||||
contents = await client.read_resource(str(link.uri))
|
||||
assert isinstance(contents[0], mcp_types.TextResourceContents)
|
||||
@@ -189,7 +189,6 @@ def test_transparent_proxy_rejects_invalid_connection_config() -> None:
|
||||
assert "duplicate connection id 'fixture.personal'" in message
|
||||
assert "fixture.personal: stdio transport requires metadata.command" in message
|
||||
assert "fixture.personal: unsupported MCP transport 'websocket'" in message
|
||||
assert "connection id 'bad_scope.personal' must not contain '_'" in message
|
||||
assert "fixture.http: http transport requires metadata.url" in message
|
||||
assert "connection id 'wf.mcp' is reserved by wf-mcp" in message
|
||||
assert "connection id 'wf.admin' is reserved by wf-mcp" in message
|
||||
@@ -255,13 +254,13 @@ def test_transparent_proxy_can_collapse_upstream_tools_behind_search() -> None:
|
||||
assert "wf.admin.list_connections" in names
|
||||
assert "wf.admin.get_connection_statuses" in names
|
||||
assert "wf.admin.list_proxy_tools" in names
|
||||
assert "fixture.personal_echo_tool" not in names
|
||||
assert "fixture.personal.echo_tool" not in names
|
||||
|
||||
search_result = await client.call_tool(
|
||||
"search_tools",
|
||||
{"query": "echo text back"},
|
||||
)
|
||||
assert "fixture.personal_echo_tool" in str(search_result)
|
||||
assert "fixture.personal.echo_tool" in str(search_result)
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
@@ -327,7 +326,7 @@ def test_transparent_proxy_proxy_tool_listing_supports_filters_and_cursor() -> N
|
||||
filtered = _structured(filtered_result)
|
||||
assert filtered["nextCursor"] is None
|
||||
assert filtered["total"] == 1
|
||||
assert filtered["tools"][0]["proxy_name"] == "fixture.personal_echo_tool"
|
||||
assert filtered["tools"][0]["proxy_name"] == "fixture.personal.echo_tool"
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
@@ -449,7 +448,7 @@ def test_transparent_proxy_admin_reload_remounts_connections() -> None:
|
||||
async with client:
|
||||
initial_tools = await client.list_tools()
|
||||
initial_names = [tool.name for tool in initial_tools]
|
||||
assert "fixture.personal_echo_tool" not in initial_names
|
||||
assert "fixture.personal.echo_tool" not in initial_names
|
||||
|
||||
await client.call_tool(
|
||||
"wf.admin.add_connection",
|
||||
@@ -467,7 +466,7 @@ def test_transparent_proxy_admin_reload_remounts_connections() -> None:
|
||||
|
||||
before_reload_tools = await client.list_tools()
|
||||
before_reload_names = [tool.name for tool in before_reload_tools]
|
||||
assert "fixture.personal_echo_tool" not in before_reload_names
|
||||
assert "fixture.personal.echo_tool" not in before_reload_names
|
||||
|
||||
reload_result = await client.call_tool("wf.admin.reload_config")
|
||||
assert _structured(reload_result) == {
|
||||
@@ -480,10 +479,10 @@ def test_transparent_proxy_admin_reload_remounts_connections() -> None:
|
||||
|
||||
after_reload_tools = await client.list_tools()
|
||||
after_reload_names = [tool.name for tool in after_reload_tools]
|
||||
assert "fixture.personal_echo_tool" in after_reload_names
|
||||
assert "fixture.personal.echo_tool" in after_reload_names
|
||||
|
||||
result = await client.call_tool(
|
||||
"fixture.personal_echo_tool",
|
||||
"fixture.personal.echo_tool",
|
||||
{"text": "reloaded"},
|
||||
)
|
||||
assert _structured(result) == {"echoed": "reloaded"}
|
||||
@@ -648,7 +647,7 @@ def test_proxy_reload_result_serializes_and_drives_reload_events() -> None:
|
||||
|
||||
def test_proxy_tool_payload_serializes_admin_tool_metadata() -> None:
|
||||
payload = ProxyToolPayload(
|
||||
proxy_name="fixture.personal_echo_tool",
|
||||
proxy_name="fixture.personal.echo_tool",
|
||||
connection_id="fixture.personal",
|
||||
local_name="echo_tool",
|
||||
title="Echo Tool",
|
||||
@@ -660,7 +659,7 @@ def test_proxy_tool_payload_serializes_admin_tool_metadata() -> None:
|
||||
minimal = payload.to_payload(include_schema=False)
|
||||
with_schema = payload.to_payload(include_schema=True)
|
||||
|
||||
assert minimal["proxy_name"] == "fixture.personal_echo_tool"
|
||||
assert minimal["proxy_name"] == "fixture.personal.echo_tool"
|
||||
assert minimal["connection_id"] == "fixture.personal"
|
||||
assert minimal["local_name"] == "echo_tool"
|
||||
assert minimal["enabled"] is True
|
||||
@@ -671,7 +670,7 @@ def test_proxy_tool_payload_serializes_admin_tool_metadata() -> None:
|
||||
|
||||
def test_proxy_tools_page_serializes_paginated_payload() -> None:
|
||||
tool = ProxyToolPayload(
|
||||
proxy_name="fixture.personal_echo_tool",
|
||||
proxy_name="fixture.personal.echo_tool",
|
||||
connection_id="fixture.personal",
|
||||
local_name="echo_tool",
|
||||
)
|
||||
@@ -685,4 +684,4 @@ def test_proxy_tools_page_serializes_paginated_payload() -> None:
|
||||
|
||||
assert payload["nextCursor"] == "cursor-1"
|
||||
assert payload["total"] == 3
|
||||
assert payload["tools"][0]["proxy_name"] == "fixture.personal_echo_tool"
|
||||
assert payload["tools"][0]["proxy_name"] == "fixture.personal.echo_tool"
|
||||
|
||||
@@ -38,13 +38,13 @@ def test_unified_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
||||
async with client:
|
||||
tools = await client.list_tools()
|
||||
names = [tool.name for tool in tools]
|
||||
assert "fixture.personal_echo_tool" in names
|
||||
assert "fixture.personal.echo_tool" in names
|
||||
assert "wf.admin.list_connections" in names
|
||||
assert "wf.workflow.list_artifacts" in names
|
||||
assert "wf.workflow.run_deployment" in names
|
||||
|
||||
echo_result = await client.call_tool(
|
||||
"fixture.personal_echo_tool",
|
||||
"fixture.personal.echo_tool",
|
||||
{"text": "hello"},
|
||||
)
|
||||
artifacts_result = await client.call_tool("wf.workflow.list_artifacts")
|
||||
@@ -77,7 +77,7 @@ def test_unified_server_can_hide_admin_tools() -> None:
|
||||
async with client:
|
||||
tools = await client.list_tools()
|
||||
names = [tool.name for tool in tools]
|
||||
assert "fixture.personal_echo_tool" in names
|
||||
assert "fixture.personal.echo_tool" in names
|
||||
assert "wf.workflow.list_artifacts" in names
|
||||
assert "wf.admin.list_connections" not in names
|
||||
|
||||
|
||||
Reference in New Issue
Block a user