docs enforcement; safe tool names for claude desktop / fragile ahh harnesses

This commit is contained in:
lda
2026-05-19 17:18:12 +07:00 Verified
parent 5ff3216d37
commit d22f3e64c8
12 changed files with 500 additions and 23 deletions
+7
View File
@@ -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
+24
View File
@@ -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`.
+65
View File
@@ -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"
]
}
}
+9
View File
@@ -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
+6
View File
@@ -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,
) )
) )
+11
View File
@@ -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,
) )
+118
View File
@@ -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
+78 -14
View File
@@ -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.",
+22 -7
View File
@@ -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(
+2
View File
@@ -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:
+56
View File
@@ -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
View File
@@ -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",