tis is the greatest thing ever
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user