docs enforcement; safe tool names for claude desktop / fragile ahh harnesses
This commit is contained in:
@@ -206,3 +206,10 @@ poetry.toml
|
|||||||
|
|
||||||
# LSP config files
|
# LSP config files
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
|
||||||
|
|
||||||
|
# local config file
|
||||||
|
wf_mcp.config.json
|
||||||
|
|
||||||
|
# Generated MCPB packages
|
||||||
|
*.mcpb
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# wf-mcp Local MCPB
|
||||||
|
|
||||||
|
This is a local-development Claude Desktop bundle for `wf-mcp`.
|
||||||
|
|
||||||
|
It is intentionally not self-contained. The manifest runs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
uv run --directory C:/Users/Admin/Documents/lda.chat/lda-workflow-as-struct wf-mcp --config C:/Users/Admin/Documents/lda.chat/lda-workflow-as-struct/wf_mcp.config.json serve --transport stdio --resources-as-tools --prompts-as-tools --search-tools --safe-tool-names
|
||||||
|
```
|
||||||
|
|
||||||
|
Important behavior:
|
||||||
|
|
||||||
|
- `--config` points to the broker config file.
|
||||||
|
- `store_root` is read from that config file.
|
||||||
|
- `wf.admin.reload_config` reloads the same config file, so changing
|
||||||
|
`store_root` should happen in the config, not in a separate CLI override.
|
||||||
|
- Upstream stdio tools can set their own working directory with connection
|
||||||
|
metadata field `cwd`.
|
||||||
|
- `--resources-as-tools` and `--prompts-as-tools` are enabled because some
|
||||||
|
MCPB/Claude paths expose tools more reliably than native resources/prompts.
|
||||||
|
- `--safe-tool-names` maps runtime MCP tool ids to Claude-safe names such as
|
||||||
|
`wf_workflow_list_capabilities` while payload values keep dotted names.
|
||||||
|
|
||||||
|
This package assumes `uv` is available on `PATH`.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": "0.3",
|
||||||
|
"name": "wf-mcp-local",
|
||||||
|
"display_name": "wf-mcp Local",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Local development bundle for the wf-mcp workflow/proxy server.",
|
||||||
|
"long_description": "Runs the wf-mcp server from the local lda-workflow-as-struct checkout over stdio. This bundle is for local testing with Claude Desktop, not a self-contained distribution. It uses the config file in the checkout; that config owns store_root, connections, and reload behavior.",
|
||||||
|
"author": {
|
||||||
|
"name": "lda"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"type": "binary",
|
||||||
|
"entry_point": "uv",
|
||||||
|
"mcp_config": {
|
||||||
|
"command": "uv",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--directory",
|
||||||
|
"C:/Users/Admin/Documents/lda.chat/lda-workflow-as-struct",
|
||||||
|
"wf-mcp",
|
||||||
|
"--config",
|
||||||
|
"C:/Users/Admin/Documents/lda.chat/lda-workflow-as-struct/wf_mcp.config.json",
|
||||||
|
"serve",
|
||||||
|
"--transport",
|
||||||
|
"stdio",
|
||||||
|
"--resources-as-tools",
|
||||||
|
"--prompts-as-tools",
|
||||||
|
"--search-tools",
|
||||||
|
"--safe-tool-names"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "search_tools",
|
||||||
|
"description": "Search the wf-mcp tool catalog when search mode is enabled."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "call_tool",
|
||||||
|
"description": "Call a discovered proxied tool by name when search mode is enabled."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wf_workflow_list_capabilities",
|
||||||
|
"description": "MCPB-safe preview name for runtime tool wf.workflow.list_capabilities."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wf_workflow_call_capability",
|
||||||
|
"description": "MCPB-safe preview name for runtime tool wf.workflow.call_capability."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tools_generated": true,
|
||||||
|
"prompts_generated": true,
|
||||||
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"workflow",
|
||||||
|
"proxy",
|
||||||
|
"local-dev"
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"platforms": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Collapse a large tool catalog into a search interface, for discovery on demand",
|
help="Collapse a large tool catalog into a search interface, for discovery on demand",
|
||||||
)
|
)
|
||||||
|
serve.add_argument(
|
||||||
|
"--safe-tool-names",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Expose runtime tool names using only letters, numbers, underscore, "
|
||||||
|
"and dash for strict clients such as Claude Desktop MCPB."
|
||||||
|
),
|
||||||
|
)
|
||||||
serve.add_argument(
|
serve.add_argument(
|
||||||
"--no-admin-tools",
|
"--no-admin-tools",
|
||||||
dest="admin_tools",
|
dest="admin_tools",
|
||||||
@@ -122,6 +130,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
resources_as_tools=args.resources_as_tools,
|
resources_as_tools=args.resources_as_tools,
|
||||||
prompts_as_tools=args.prompts_as_tools,
|
prompts_as_tools=args.prompts_as_tools,
|
||||||
search_tools=args.search_tools,
|
search_tools=args.search_tools,
|
||||||
|
safe_tool_names=args.safe_tool_names,
|
||||||
admin_tools=args.admin_tools,
|
admin_tools=args.admin_tools,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def create_server(
|
|||||||
resources_as_tools: bool = False,
|
resources_as_tools: bool = False,
|
||||||
prompts_as_tools: bool = False,
|
prompts_as_tools: bool = False,
|
||||||
search_tools: bool = False,
|
search_tools: bool = False,
|
||||||
|
safe_tool_names: bool = False,
|
||||||
admin_tools: bool = True,
|
admin_tools: bool = True,
|
||||||
) -> FastMCP[Any]:
|
) -> FastMCP[Any]:
|
||||||
"""Create the public MCP server with proxy, admin, and workflow tools."""
|
"""Create the public MCP server with proxy, admin, and workflow tools."""
|
||||||
@@ -43,6 +44,7 @@ def create_server(
|
|||||||
resources_as_tools=resources_as_tools,
|
resources_as_tools=resources_as_tools,
|
||||||
prompts_as_tools=prompts_as_tools,
|
prompts_as_tools=prompts_as_tools,
|
||||||
search_tools=search_tools,
|
search_tools=search_tools,
|
||||||
|
safe_tool_names=safe_tool_names,
|
||||||
admin_tools=admin_tools,
|
admin_tools=admin_tools,
|
||||||
event_bus=service.event_bus,
|
event_bus=service.event_bus,
|
||||||
on_reload=sync_service,
|
on_reload=sync_service,
|
||||||
@@ -69,6 +71,7 @@ def run_server(
|
|||||||
resources_as_tools: bool = False,
|
resources_as_tools: bool = False,
|
||||||
prompts_as_tools: bool = False,
|
prompts_as_tools: bool = False,
|
||||||
search_tools: bool = False,
|
search_tools: bool = False,
|
||||||
|
safe_tool_names: bool = False,
|
||||||
admin_tools: bool = True,
|
admin_tools: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
server = create_server(
|
server = create_server(
|
||||||
@@ -77,6 +80,7 @@ def run_server(
|
|||||||
resources_as_tools=resources_as_tools,
|
resources_as_tools=resources_as_tools,
|
||||||
prompts_as_tools=prompts_as_tools,
|
prompts_as_tools=prompts_as_tools,
|
||||||
search_tools=search_tools,
|
search_tools=search_tools,
|
||||||
|
safe_tool_names=safe_tool_names,
|
||||||
admin_tools=admin_tools,
|
admin_tools=admin_tools,
|
||||||
)
|
)
|
||||||
server.run(transport=normalize_transport(transport), show_banner=False)
|
server.run(transport=normalize_transport(transport), show_banner=False)
|
||||||
@@ -89,6 +93,7 @@ def create_server_client(
|
|||||||
resources_as_tools: bool = False,
|
resources_as_tools: bool = False,
|
||||||
prompts_as_tools: bool = False,
|
prompts_as_tools: bool = False,
|
||||||
search_tools: bool = False,
|
search_tools: bool = False,
|
||||||
|
safe_tool_names: bool = False,
|
||||||
admin_tools: bool = True,
|
admin_tools: bool = True,
|
||||||
) -> Client[FastMCPTransport]:
|
) -> Client[FastMCPTransport]:
|
||||||
return Client(
|
return Client(
|
||||||
@@ -99,6 +104,7 @@ def create_server_client(
|
|||||||
resources_as_tools=resources_as_tools,
|
resources_as_tools=resources_as_tools,
|
||||||
prompts_as_tools=prompts_as_tools,
|
prompts_as_tools=prompts_as_tools,
|
||||||
search_tools=search_tools,
|
search_tools=search_tools,
|
||||||
|
safe_tool_names=safe_tool_names,
|
||||||
admin_tools=admin_tools,
|
admin_tools=admin_tools,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .tools import (
|
|||||||
proxy_tools_page,
|
proxy_tools_page,
|
||||||
)
|
)
|
||||||
from .reload_events import ProxyReloadResult, reload_change_events
|
from .reload_events import ProxyReloadResult, reload_change_events
|
||||||
|
from .safe_names import SafeToolNames
|
||||||
|
|
||||||
_SEARCH_ALWAYS_VISIBLE_TOOL_NAMES = [
|
_SEARCH_ALWAYS_VISIBLE_TOOL_NAMES = [
|
||||||
# Stable discovery/control spine.
|
# Stable discovery/control spine.
|
||||||
@@ -75,6 +76,7 @@ class ProxyRuntime:
|
|||||||
resources_as_tools: bool = False,
|
resources_as_tools: bool = False,
|
||||||
prompts_as_tools: bool = False,
|
prompts_as_tools: bool = False,
|
||||||
search_tools: bool = False,
|
search_tools: bool = False,
|
||||||
|
safe_tool_names: bool = False,
|
||||||
admin_tools: bool = True,
|
admin_tools: bool = True,
|
||||||
event_bus: EventBus | None = None,
|
event_bus: EventBus | None = None,
|
||||||
on_reload: Callable[[BrokerConfig], None] | None = None,
|
on_reload: Callable[[BrokerConfig], None] | None = None,
|
||||||
@@ -106,6 +108,11 @@ class ProxyRuntime:
|
|||||||
self.server.add_transform(
|
self.server.add_transform(
|
||||||
BM25SearchTransform(always_visible=_SEARCH_ALWAYS_VISIBLE_TOOL_NAMES)
|
BM25SearchTransform(always_visible=_SEARCH_ALWAYS_VISIBLE_TOOL_NAMES)
|
||||||
)
|
)
|
||||||
|
if safe_tool_names:
|
||||||
|
# Keep this outermost so every previous tool projection, including
|
||||||
|
# search mode's synthetic tools and always-visible controls, is
|
||||||
|
# adapted for clients with stricter name patterns.
|
||||||
|
self.server.add_transform(SafeToolNames())
|
||||||
|
|
||||||
def current_config(self) -> BrokerConfig:
|
def current_config(self) -> BrokerConfig:
|
||||||
if self.manager is None:
|
if self.manager is None:
|
||||||
@@ -222,6 +229,7 @@ def create_transparent_proxy_server(
|
|||||||
resources_as_tools: bool = False,
|
resources_as_tools: bool = False,
|
||||||
prompts_as_tools: bool = False,
|
prompts_as_tools: bool = False,
|
||||||
search_tools: bool = False,
|
search_tools: bool = False,
|
||||||
|
safe_tool_names: bool = False,
|
||||||
admin_tools: bool = True,
|
admin_tools: bool = True,
|
||||||
event_bus: EventBus | None = None,
|
event_bus: EventBus | None = None,
|
||||||
) -> FastMCP[Any]:
|
) -> FastMCP[Any]:
|
||||||
@@ -236,6 +244,7 @@ def create_transparent_proxy_server(
|
|||||||
resources_as_tools=resources_as_tools,
|
resources_as_tools=resources_as_tools,
|
||||||
prompts_as_tools=prompts_as_tools,
|
prompts_as_tools=prompts_as_tools,
|
||||||
search_tools=search_tools,
|
search_tools=search_tools,
|
||||||
|
safe_tool_names=safe_tool_names,
|
||||||
admin_tools=admin_tools,
|
admin_tools=admin_tools,
|
||||||
event_bus=event_bus,
|
event_bus=event_bus,
|
||||||
).server
|
).server
|
||||||
@@ -248,6 +257,7 @@ def create_transparent_proxy_client(
|
|||||||
resources_as_tools: bool = False,
|
resources_as_tools: bool = False,
|
||||||
prompts_as_tools: bool = False,
|
prompts_as_tools: bool = False,
|
||||||
search_tools: bool = False,
|
search_tools: bool = False,
|
||||||
|
safe_tool_names: bool = False,
|
||||||
admin_tools: bool = True,
|
admin_tools: bool = True,
|
||||||
event_bus: EventBus | None = None,
|
event_bus: EventBus | None = None,
|
||||||
) -> Client[FastMCPTransport]:
|
) -> Client[FastMCPTransport]:
|
||||||
@@ -259,6 +269,7 @@ def create_transparent_proxy_client(
|
|||||||
resources_as_tools=resources_as_tools,
|
resources_as_tools=resources_as_tools,
|
||||||
prompts_as_tools=prompts_as_tools,
|
prompts_as_tools=prompts_as_tools,
|
||||||
search_tools=search_tools,
|
search_tools=search_tools,
|
||||||
|
safe_tool_names=safe_tool_names,
|
||||||
admin_tools=admin_tools,
|
admin_tools=admin_tools,
|
||||||
event_bus=event_bus,
|
event_bus=event_bus,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from fastmcp.server.transforms import GetToolNext, Transform
|
||||||
|
from fastmcp.tools.base import Tool
|
||||||
|
from fastmcp.utilities.versions import VersionSpec
|
||||||
|
|
||||||
|
_SAFE_TOOL_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
||||||
|
_MAX_TOOL_NAME_LENGTH = 64
|
||||||
|
|
||||||
|
|
||||||
|
class SafeToolNames(Transform):
|
||||||
|
"""Expose MCP tools with client-safe names while preserving internal names.
|
||||||
|
|
||||||
|
Some clients, including Claude Desktop's MCPB frontend path, reject tool
|
||||||
|
names outside `^[a-zA-Z0-9_-]{1,64}$`. wf-mcp's native names are dotted
|
||||||
|
(`wf.workflow.run_deployment`, `everything.default.echo`) because they are
|
||||||
|
better for humans and source ownership. This transform is a boundary
|
||||||
|
adapter: `tools/list` shows safe names, and `tools/call` maps them back.
|
||||||
|
|
||||||
|
This is intentionally lookup-backed rather than fully reversible. Claude's
|
||||||
|
normal flow is `tools/list` followed by `tools/call` using one listed name,
|
||||||
|
so public names should optimize for readability.
|
||||||
|
|
||||||
|
Ambiguous or long mappings receive a deterministic hash suffix. The mapping
|
||||||
|
remains one-to-one inside this transform instance, while common names stay
|
||||||
|
readable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._safe_to_original: dict[str, str] = {}
|
||||||
|
self._original_to_safe: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
|
||||||
|
return [
|
||||||
|
tool.model_copy(update={"name": self._safe_name(tool.name)})
|
||||||
|
for tool in tools
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_tool(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
call_next: GetToolNext,
|
||||||
|
*,
|
||||||
|
version: VersionSpec | None = None,
|
||||||
|
) -> Tool | None:
|
||||||
|
original_name = self._safe_to_original.get(name) or decode_safe_tool_name(name)
|
||||||
|
tool = await call_next(original_name, version=version)
|
||||||
|
return None if tool is None else tool.model_copy(update={"name": name})
|
||||||
|
|
||||||
|
def _safe_name(self, original_name: str) -> str:
|
||||||
|
cached = self._original_to_safe.get(original_name)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
candidate = encode_safe_tool_name(original_name)
|
||||||
|
if len(candidate) > _MAX_TOOL_NAME_LENGTH:
|
||||||
|
candidate = self._hashed_safe_name(original_name)
|
||||||
|
if _SAFE_TOOL_NAME_PATTERN.fullmatch(candidate) is None:
|
||||||
|
candidate = self._hashed_safe_name(original_name)
|
||||||
|
|
||||||
|
existing = self._safe_to_original.get(candidate)
|
||||||
|
if existing is not None and existing != original_name:
|
||||||
|
candidate = self._hashed_safe_name(original_name)
|
||||||
|
existing = self._safe_to_original.get(candidate)
|
||||||
|
if existing is not None and existing != original_name:
|
||||||
|
raise ValueError(
|
||||||
|
f"safe tool name collision: {existing!r} and {original_name!r} "
|
||||||
|
f"both project to {candidate!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._original_to_safe[original_name] = candidate
|
||||||
|
self._safe_to_original[candidate] = original_name
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def _hashed_safe_name(self, original_name: str) -> str:
|
||||||
|
digest = hashlib.sha1(original_name.encode("utf-8")).hexdigest()[:10]
|
||||||
|
prefix = encode_safe_tool_name(original_name)[: _MAX_TOOL_NAME_LENGTH - 12]
|
||||||
|
prefix = prefix.rstrip("_-") or "tool"
|
||||||
|
return f"{prefix}_h{digest}"
|
||||||
|
|
||||||
|
def assert_consistent(self) -> None:
|
||||||
|
"""Fail if the bidirectional lookup tables are not exact inverses."""
|
||||||
|
original_to_safe = self._original_to_safe
|
||||||
|
safe_to_original = self._safe_to_original
|
||||||
|
if len(original_to_safe) != len(safe_to_original):
|
||||||
|
raise AssertionError("safe tool name maps have different sizes")
|
||||||
|
for original, safe in original_to_safe.items():
|
||||||
|
if safe_to_original.get(safe) != original:
|
||||||
|
raise AssertionError(
|
||||||
|
f"safe tool name reverse map is stale for {original!r}"
|
||||||
|
)
|
||||||
|
for safe, original in safe_to_original.items():
|
||||||
|
if original_to_safe.get(original) != safe:
|
||||||
|
raise AssertionError(
|
||||||
|
f"safe tool name forward map is stale for {safe!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_safe_tool_name(name: str) -> str:
|
||||||
|
"""Return the preferred readable safe spelling for one runtime tool name."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for char in name:
|
||||||
|
if char.isascii() and (char.isalnum() or char in {"_", "-"}):
|
||||||
|
parts.append(char)
|
||||||
|
elif char == ".":
|
||||||
|
parts.append("_")
|
||||||
|
else:
|
||||||
|
parts.append("_")
|
||||||
|
return "".join(parts) or "tool"
|
||||||
|
|
||||||
|
|
||||||
|
def decode_safe_tool_name(name: str) -> str:
|
||||||
|
"""Fallback for already-safe names not seen in `tools/list` first."""
|
||||||
|
return name
|
||||||
@@ -38,7 +38,12 @@ JsonPatchOperations = Annotated[
|
|||||||
]
|
]
|
||||||
SourceBindings = Annotated[
|
SourceBindings = Annotated[
|
||||||
dict[str, str],
|
dict[str, str],
|
||||||
Field(description="Map logical source ids to concrete source ids."),
|
Field(
|
||||||
|
description=(
|
||||||
|
"Map logical source ids in the draft/artifact to concrete runtime "
|
||||||
|
"sources, for example {'demo': 'demo.personal', 'wf.std': 'wf.std'}."
|
||||||
|
)
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -103,7 +108,12 @@ class CreateDraftWorkspaceRequest(BaseModel):
|
|||||||
"""Typed MCP request payload for creating a stored draft workspace."""
|
"""Typed MCP request payload for creating a stored draft workspace."""
|
||||||
|
|
||||||
workspace_id: WorkspaceId
|
workspace_id: WorkspaceId
|
||||||
draft: dict[str, Any] = Field(description="WorkflowDraft JSON document.")
|
draft: dict[str, Any] = Field(
|
||||||
|
description=(
|
||||||
|
"WorkflowDraft JSON document. Prefer create_minimal_draft_workspace "
|
||||||
|
"for a one-capability bootstrap, then patch this workspace by revision."
|
||||||
|
)
|
||||||
|
)
|
||||||
title: str | None = Field(default=None, description="Optional workspace title.")
|
title: str | None = Field(default=None, description="Optional workspace title.")
|
||||||
|
|
||||||
|
|
||||||
@@ -112,7 +122,13 @@ class PatchDraftWorkspaceRequest(BaseModel):
|
|||||||
|
|
||||||
workspace_id: WorkspaceId
|
workspace_id: WorkspaceId
|
||||||
revision: int = Field(ge=1, description="Expected current workspace revision.")
|
revision: int = Field(ge=1, description="Expected current workspace revision.")
|
||||||
patch: JsonPatchOperations
|
patch: JsonPatchOperations = Field(
|
||||||
|
description=(
|
||||||
|
"RFC 6902 JSON Patch operations against the stored WorkflowDraft. "
|
||||||
|
"Use focused helpers such as set_draft_route or set_step_input_map "
|
||||||
|
"when possible."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ValidateDraftWorkspaceRequest(BaseModel):
|
class ValidateDraftWorkspaceRequest(BaseModel):
|
||||||
@@ -177,13 +193,36 @@ class CreateMinimalDraftWorkspaceRequest(BaseModel):
|
|||||||
workspace_id: WorkspaceId
|
workspace_id: WorkspaceId
|
||||||
name: str = Field(description="Workflow draft name.")
|
name: str = Field(description="Workflow draft name.")
|
||||||
capability_name: str = Field(
|
capability_name: str = Field(
|
||||||
description="Workflow capability to call, such as demo.default.echo_tool."
|
description=(
|
||||||
|
"Workflow capability to call, such as demo.default.echo_tool or "
|
||||||
|
"workflow.echo_wrapper.v1. Inspect it first when unsure."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
input_schema: JsonSchemaObject = Field(
|
||||||
|
description="Public input JSON Schema for the workflow or wrapper being drafted."
|
||||||
|
)
|
||||||
|
state_schema: JsonSchemaObject = Field(
|
||||||
|
description=(
|
||||||
|
"Workflow state schema. The current core state schema uses a fields "
|
||||||
|
"object; keep it small and explicit."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output_schema: JsonSchemaObject = Field(
|
||||||
|
description="Public output JSON Schema for the workflow or wrapper being drafted."
|
||||||
|
)
|
||||||
|
input_map: DraftPathMap = Field(
|
||||||
|
description=(
|
||||||
|
"Map public workflow input paths to local capability input paths. "
|
||||||
|
"Example: {'input.text': 'message'} sends workflow input.text to "
|
||||||
|
"capability field message."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output_map: DraftPathMap = Field(
|
||||||
|
description=(
|
||||||
|
"Map local capability output paths to workflow state paths. Example: "
|
||||||
|
"{'echoed': 'state.echoed'} stores capability output echoed in state.echoed."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
input_schema: JsonSchemaObject
|
|
||||||
state_schema: JsonSchemaObject
|
|
||||||
output_schema: JsonSchemaObject
|
|
||||||
input_map: DraftPathMap
|
|
||||||
output_map: DraftPathMap
|
|
||||||
error_message_source: str | None = Field(
|
error_message_source: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
@@ -198,10 +237,20 @@ class CreateArtifactFromWorkspaceRequest(BaseModel):
|
|||||||
"""Typed MCP request payload for saving a draft workspace as an artifact."""
|
"""Typed MCP request payload for saving a draft workspace as an artifact."""
|
||||||
|
|
||||||
workspace_id: WorkspaceId
|
workspace_id: WorkspaceId
|
||||||
artifact_id: str = Field(description="Immutable artifact id to write.")
|
artifact_id: str = Field(
|
||||||
|
description=(
|
||||||
|
"Immutable artifact id to write. Use a stable snake_case name; each "
|
||||||
|
"version is saved separately."
|
||||||
|
)
|
||||||
|
)
|
||||||
version: int = Field(ge=1, description="Artifact version to write.")
|
version: int = Field(ge=1, description="Artifact version to write.")
|
||||||
title: str = Field(description="Human-readable artifact title.")
|
title: str = Field(description="Human-readable artifact title.")
|
||||||
outcomes: list[str] = Field(description="Artifact-level outcomes.")
|
outcomes: list[str] = Field(
|
||||||
|
description=(
|
||||||
|
"Public outcomes for this saved artifact, for example ['completed'] "
|
||||||
|
"or ['completed', 'failed']."
|
||||||
|
)
|
||||||
|
)
|
||||||
kind: ArtifactKind = Field(default="workflow", description="Artifact kind.")
|
kind: ArtifactKind = Field(default="workflow", description="Artifact kind.")
|
||||||
description: str | None = Field(default=None, description="Optional description.")
|
description: str | None = Field(default=None, description="Optional description.")
|
||||||
required_capabilities: dict[str, dict[str, Any]] | None = Field(
|
required_capabilities: dict[str, dict[str, Any]] | None = Field(
|
||||||
@@ -222,11 +271,26 @@ class CreateWrapperFromWorkspaceRequest(BaseModel):
|
|||||||
"""Typed MCP request for saving a draft workspace as a wrapper artifact."""
|
"""Typed MCP request for saving a draft workspace as a wrapper artifact."""
|
||||||
|
|
||||||
workspace_id: WorkspaceId
|
workspace_id: WorkspaceId
|
||||||
artifact_id: str = Field(description="Immutable wrapper artifact id to write.")
|
artifact_id: str = Field(
|
||||||
|
description=(
|
||||||
|
"Immutable wrapper artifact id to write. The callable capability name "
|
||||||
|
"will be workflow.<artifact_id>.v<version>."
|
||||||
|
)
|
||||||
|
)
|
||||||
version: int = Field(ge=1, description="Wrapper artifact version to write.")
|
version: int = Field(ge=1, description="Wrapper artifact version to write.")
|
||||||
title: str = Field(description="Human-readable wrapper title.")
|
title: str = Field(description="Human-readable wrapper title.")
|
||||||
outcomes: list[str] = Field(description="Wrapper-level outcomes.")
|
outcomes: list[str] = Field(
|
||||||
description: str | None = Field(default=None, description="Optional description.")
|
description=(
|
||||||
|
"Wrapper-level outcomes exposed to graphs and call_capability. Use "
|
||||||
|
"explicit outcomes when the wrapper normalizes provider status/error shapes."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
description: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Optional description of what raw capability shape this wrapper normalizes."
|
||||||
|
),
|
||||||
|
)
|
||||||
required_capabilities: dict[str, dict[str, Any]] | None = Field(
|
required_capabilities: dict[str, dict[str, Any]] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional explicit dependency contract override.",
|
description="Optional explicit dependency contract override.",
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
@server.tool(
|
@server.tool(
|
||||||
name="wf.workflow.list_capabilities",
|
name="wf.workflow.list_capabilities",
|
||||||
title="List Workflow Capabilities",
|
title="List Workflow Capabilities",
|
||||||
description="List compact planner-visible workflow-ready node capabilities.",
|
description=(
|
||||||
|
"List compact planner-visible workflow-ready capabilities. Use this "
|
||||||
|
"before inspecting schemas; saved wrappers appear with kind "
|
||||||
|
"wrapper_artifact under source_id workflow."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def list_capabilities(
|
async def list_capabilities(
|
||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
@@ -61,7 +65,10 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
@server.tool(
|
@server.tool(
|
||||||
name="wf.workflow.inspect_capability",
|
name="wf.workflow.inspect_capability",
|
||||||
title="Inspect Workflow Capability",
|
title="Inspect Workflow Capability",
|
||||||
description="Return one planner-visible workflow capability contract.",
|
description=(
|
||||||
|
"Return one workflow capability contract with schemas and outcomes. "
|
||||||
|
"Use after list_capabilities selects one candidate."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def inspect_capability(qualified_name: str) -> dict[str, Any]:
|
async def inspect_capability(qualified_name: str) -> dict[str, Any]:
|
||||||
return await handlers.inspect_capability(qualified_name=qualified_name)
|
return await handlers.inspect_capability(qualified_name=qualified_name)
|
||||||
@@ -219,7 +226,10 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
@server.tool(
|
@server.tool(
|
||||||
name="wf.workflow.create_draft_workspace",
|
name="wf.workflow.create_draft_workspace",
|
||||||
title="Create Draft Workspace",
|
title="Create Draft Workspace",
|
||||||
description="Store a mutable workflow draft workspace for iterative patching.",
|
description=(
|
||||||
|
"Store a mutable workflow draft workspace for iterative patching. "
|
||||||
|
"Prefer create_minimal_draft_workspace for one-capability starts."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def create_draft_workspace(
|
async def create_draft_workspace(
|
||||||
request: CreateDraftWorkspaceRequest,
|
request: CreateDraftWorkspaceRequest,
|
||||||
@@ -265,7 +275,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
title="Patch Draft Workspace",
|
title="Patch Draft Workspace",
|
||||||
description=(
|
description=(
|
||||||
"Apply an RFC 6902 JSON Patch to a stored workflow draft workspace "
|
"Apply an RFC 6902 JSON Patch to a stored workflow draft workspace "
|
||||||
"when the expected revision matches."
|
"when the expected revision matches. Prefer focused helpers for common "
|
||||||
|
"field edits."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def patch_draft_workspace(
|
async def patch_draft_workspace(
|
||||||
@@ -361,7 +372,10 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
@server.tool(
|
@server.tool(
|
||||||
name="wf.workflow.create_minimal_draft_workspace",
|
name="wf.workflow.create_minimal_draft_workspace",
|
||||||
title="Create Minimal Draft Workspace",
|
title="Create Minimal Draft Workspace",
|
||||||
description="Bootstrap a patchable draft workspace around one capability.",
|
description=(
|
||||||
|
"Bootstrap a patchable draft workspace around one inspected capability. "
|
||||||
|
"Use this before patch helpers when authoring from MCP clients."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def create_minimal_draft_workspace(
|
async def create_minimal_draft_workspace(
|
||||||
request: CreateMinimalDraftWorkspaceRequest,
|
request: CreateMinimalDraftWorkspaceRequest,
|
||||||
@@ -386,7 +400,7 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
title="Create Workflow Artifact From Workspace",
|
title="Create Workflow Artifact From Workspace",
|
||||||
description=(
|
description=(
|
||||||
"Validate the current draft workspace and save it as a versioned "
|
"Validate the current draft workspace and save it as a versioned "
|
||||||
"workflow artifact."
|
"workflow artifact for deployment/run_deployment."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def create_artifact_from_workspace(
|
async def create_artifact_from_workspace(
|
||||||
@@ -418,7 +432,8 @@ def register_workflow_tools(server: FastMCP[Any], service: WfMcpService) -> None
|
|||||||
title="Create Wrapper From Workspace",
|
title="Create Wrapper From Workspace",
|
||||||
description=(
|
description=(
|
||||||
"Validate the current draft workspace and save it as a callable "
|
"Validate the current draft workspace and save it as a callable "
|
||||||
"wrapper artifact."
|
"wrapper artifact. The result appears as workflow.<artifact_id>.v<version> "
|
||||||
|
"in list_capabilities and can be tested with call_capability."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def create_wrapper_from_workspace(
|
async def create_wrapper_from_workspace(
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def test_build_parser_accepts_proxy_compatibility_flags() -> None:
|
|||||||
"--resources-as-tools",
|
"--resources-as-tools",
|
||||||
"--prompts-as-tools",
|
"--prompts-as-tools",
|
||||||
"--search-tools",
|
"--search-tools",
|
||||||
|
"--safe-tool-names",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ def test_build_parser_accepts_proxy_compatibility_flags() -> None:
|
|||||||
assert args.resources_as_tools is True
|
assert args.resources_as_tools is True
|
||||||
assert args.prompts_as_tools is True
|
assert args.prompts_as_tools is True
|
||||||
assert args.search_tools is True
|
assert args.search_tools is True
|
||||||
|
assert args.safe_tool_names is True
|
||||||
|
|
||||||
|
|
||||||
def test_build_parser_rejects_legacy_mode_flag() -> None:
|
def test_build_parser_rejects_legacy_mode_flag() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from wf_mcp.transparent_proxy.safe_names import (
|
||||||
|
SafeToolNames,
|
||||||
|
encode_safe_tool_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_safe_tool_name_keeps_readable_names() -> None:
|
||||||
|
assert encode_safe_tool_name("wf.workflow.list_artifacts") == (
|
||||||
|
"wf_workflow_list_artifacts"
|
||||||
|
)
|
||||||
|
assert encode_safe_tool_name("search_tools") == "search_tools"
|
||||||
|
assert encode_safe_tool_name("some-tool") == "some-tool"
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_tool_names_hashes_collisions_and_preserves_lookup_invariants() -> None:
|
||||||
|
transform = SafeToolNames()
|
||||||
|
server = _server_with_tools("demo.echo", "demo_echo", transform=transform)
|
||||||
|
|
||||||
|
tools = asyncio.run(server.list_tools())
|
||||||
|
names = [tool.name for tool in tools]
|
||||||
|
|
||||||
|
assert "demo_echo" in names
|
||||||
|
assert any(name.startswith("demo_echo_h") for name in names)
|
||||||
|
transform.assert_consistent()
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_tool_names_hashes_overlength_names() -> None:
|
||||||
|
transform = SafeToolNames()
|
||||||
|
server = _server_with_tools("x" * 65, transform=transform)
|
||||||
|
|
||||||
|
tools = asyncio.run(server.list_tools())
|
||||||
|
|
||||||
|
assert len(tools[0].name) <= 64
|
||||||
|
assert "_h" in tools[0].name
|
||||||
|
transform.assert_consistent()
|
||||||
|
|
||||||
|
|
||||||
|
def _server_with_tools(
|
||||||
|
*names: str,
|
||||||
|
transform: SafeToolNames | None = None,
|
||||||
|
) -> FastMCP[object]:
|
||||||
|
server: FastMCP[object] = FastMCP("safe-name-test")
|
||||||
|
for name in names:
|
||||||
|
|
||||||
|
def handler() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
server.tool(name=name)(handler)
|
||||||
|
server.add_transform(transform or SafeToolNames())
|
||||||
|
return server
|
||||||
+102
-2
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -20,6 +21,23 @@ def _structured(result: Any) -> dict[str, Any]:
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
async def _assert_safe_tool_maps(
|
||||||
|
client: Any,
|
||||||
|
*,
|
||||||
|
original_name: str,
|
||||||
|
safe_name: str,
|
||||||
|
arguments: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assert one safe public tool name maps back to its original tool."""
|
||||||
|
tools = await client.list_tools()
|
||||||
|
names = [tool.name for tool in tools]
|
||||||
|
assert safe_name in names
|
||||||
|
assert original_name not in names
|
||||||
|
assert all(re.fullmatch(r"^[a-zA-Z0-9_-]{1,64}$", name) for name in names)
|
||||||
|
assert len(names) == len(set(names))
|
||||||
|
return _structured(await client.call_tool(safe_name, arguments or {}))
|
||||||
|
|
||||||
|
|
||||||
def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
||||||
config = BrokerConfig(
|
config = BrokerConfig(
|
||||||
store_root=local_temp_root() / "unified_server_store",
|
store_root=local_temp_root() / "unified_server_store",
|
||||||
@@ -99,8 +117,8 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
|||||||
assert "error_message_source" in minimal_request["properties"]
|
assert "error_message_source" in minimal_request["properties"]
|
||||||
assert (
|
assert (
|
||||||
minimal_request["properties"]["input_schema"]["description"]
|
minimal_request["properties"]["input_schema"]["description"]
|
||||||
== "JSON Schema object. Keep this as ordinary JSON; "
|
== "Public input JSON Schema for the workflow or wrapper being "
|
||||||
"nested schema fields are passed through unchanged."
|
"drafted."
|
||||||
)
|
)
|
||||||
wrapper_workspace_input = tools_by_name[
|
wrapper_workspace_input = tools_by_name[
|
||||||
"wf.workflow.create_wrapper_from_workspace"
|
"wf.workflow.create_wrapper_from_workspace"
|
||||||
@@ -232,6 +250,88 @@ def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
|
|||||||
asyncio.run(run_proxy())
|
asyncio.run(run_proxy())
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_search_mode_can_use_safe_tool_names() -> None:
|
||||||
|
config = BrokerConfig(
|
||||||
|
store_root=local_temp_root() / "server_search_safe_names_store",
|
||||||
|
connections=[
|
||||||
|
ConnectionConfig(
|
||||||
|
id="fixture.personal",
|
||||||
|
server="fixture",
|
||||||
|
account="personal",
|
||||||
|
metadata={
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": sys.executable,
|
||||||
|
"args": [fixture_server_path()],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_proxy() -> None:
|
||||||
|
client = create_server_client(
|
||||||
|
config,
|
||||||
|
search_tools=True,
|
||||||
|
safe_tool_names=True,
|
||||||
|
)
|
||||||
|
async with client:
|
||||||
|
tools = await client.list_tools()
|
||||||
|
names = [tool.name for tool in tools]
|
||||||
|
|
||||||
|
assert "search_tools" in names
|
||||||
|
assert "call_tool" in names
|
||||||
|
assert "wf_admin_list_sources" in names
|
||||||
|
assert "wf_workflow_call_capability" in names
|
||||||
|
assert "wf.admin.list_sources" not in names
|
||||||
|
|
||||||
|
result = await _assert_safe_tool_maps(
|
||||||
|
client,
|
||||||
|
original_name="wf.admin.list_sources",
|
||||||
|
safe_name="wf_admin_list_sources",
|
||||||
|
)
|
||||||
|
source_ids = {source["id"] for source in result["sources"]}
|
||||||
|
assert "wf.std" in source_ids
|
||||||
|
|
||||||
|
asyncio.run(run_proxy())
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_safe_tool_names_adapts_dotted_runtime_names() -> None:
|
||||||
|
config = BrokerConfig(
|
||||||
|
store_root=local_temp_root() / "server_safe_tool_names_store",
|
||||||
|
connections=[
|
||||||
|
ConnectionConfig(
|
||||||
|
id="fixture.personal",
|
||||||
|
server="fixture",
|
||||||
|
account="personal",
|
||||||
|
metadata={
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": sys.executable,
|
||||||
|
"args": [fixture_server_path()],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_proxy() -> None:
|
||||||
|
client = create_server_client(config, safe_tool_names=True)
|
||||||
|
async with client:
|
||||||
|
artifacts = await _assert_safe_tool_maps(
|
||||||
|
client,
|
||||||
|
original_name="wf.workflow.list_artifacts",
|
||||||
|
safe_name="wf_workflow_list_artifacts",
|
||||||
|
)
|
||||||
|
echo = await _assert_safe_tool_maps(
|
||||||
|
client,
|
||||||
|
original_name="fixture.personal.echo_tool",
|
||||||
|
safe_name="fixture_personal_echo_tool",
|
||||||
|
arguments={"text": "hello"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert artifacts["nodes"] == []
|
||||||
|
assert echo["echoed"] == "hello"
|
||||||
|
|
||||||
|
asyncio.run(run_proxy())
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_tools_have_human_metadata() -> None:
|
def test_workflow_tools_have_human_metadata() -> None:
|
||||||
config = BrokerConfig(
|
config = BrokerConfig(
|
||||||
store_root=local_temp_root() / "unified_metadata_store",
|
store_root=local_temp_root() / "unified_metadata_store",
|
||||||
|
|||||||
Reference in New Issue
Block a user