remove just about everything bad coming from broker
This commit is contained in:
@@ -17,9 +17,9 @@
|
||||
`PersistentSessionFactory` exist, and config-built services use the runtime
|
||||
pool for generated workflow node execution while discovery/catalog refreshes
|
||||
still use short-lived SDK adapter sessions.
|
||||
- Remaining work starts at hiding/removing unsafe raw public `call_tool`
|
||||
surfaces and then renaming the legacy `transparent_proxy` package to the
|
||||
clearer proxy/provider-layer package.
|
||||
- Unsafe raw public `call_tool` surfaces have been deleted. Remaining work starts
|
||||
at renaming the legacy `transparent_proxy` package to the clearer
|
||||
proxy/provider-layer package.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -41,13 +41,12 @@ The broker keeps two related catalog views:
|
||||
- `get_catalog()` is the backend MCP catalog. It only includes enabled upstream
|
||||
connection snapshots loaded from storage.
|
||||
- `get_planner_catalog()` is the workflow-planning catalog. It includes backend
|
||||
connection snapshots plus broker-local system sources such as `wf.std` and
|
||||
`wf.mcp`.
|
||||
connection snapshots plus broker-local system sources such as `wf.std`.
|
||||
|
||||
Broker-local sources are not fake MCP backend connections. They are registered
|
||||
as service spec sources so raw workflow plans can address nodes like
|
||||
`wf.std.runtime_error` and `wf.mcp.call_tool` without polluting connection status,
|
||||
auth, adapter lookup, or persisted backend catalog snapshots.
|
||||
`wf.std.runtime_error` without polluting connection status, auth, adapter lookup,
|
||||
or persisted backend catalog snapshots.
|
||||
|
||||
The longer-term source model is described in
|
||||
[`wf_mcp_capability_sources.md`](wf_mcp_capability_sources.md). In that model,
|
||||
|
||||
@@ -20,7 +20,7 @@ connections into that model rather than owning the model itself.
|
||||
|
||||
```text
|
||||
CapabilitySource
|
||||
id: "wf.std" | "wf.docs" | "wf.mcp" | "wf.admin" | "<server>.<account>"
|
||||
id: "wf.std" | "wf.docs" | "wf.admin" | "<server>.<account>"
|
||||
kind: "system" | "connection"
|
||||
enabled: bool
|
||||
visibility:
|
||||
@@ -94,25 +94,14 @@ Expected capabilities:
|
||||
|
||||
### `wf.mcp`
|
||||
|
||||
Workflow runtime helpers for interacting with MCP backends.
|
||||
Reserved for future workflow-safe MCP helpers.
|
||||
|
||||
- Planner-visible: yes, for workflow node specs.
|
||||
- MCP-client-visible: maybe, for docs/prompts/resources, not admin mutation.
|
||||
- Admin-dashboard-visible: yes, for inspection and source toggling.
|
||||
- MCP tools: normally none.
|
||||
- Workflow safety: mixed. Individual capabilities must be marked.
|
||||
|
||||
Expected capabilities:
|
||||
|
||||
- `node_specs`: currently `wf.mcp.call_tool`.
|
||||
- Near-term node specs may include `wf.mcp.read_resource` and
|
||||
`wf.mcp.get_prompt`.
|
||||
- Advanced/escape-hatch node specs may include:
|
||||
`wf.mcp.invoke_method`, `wf.mcp.send_notification`.
|
||||
- `prompts/resources`: docs for building MCP-backed workflows.
|
||||
|
||||
`wf.mcp` is not the admin namespace. It should mean "workflow can interact with
|
||||
MCP capabilities."
|
||||
`wf.mcp` currently owns no public NodeSpecs. The previous raw
|
||||
`wf.mcp.call_tool` helper was deleted because it duplicated the proxy tool
|
||||
surface and used the wrong abstraction for stateful servers. Workflow authors
|
||||
should use generated connection NodeSpecs, saved wrappers, or
|
||||
`wf.workflow.call_capability` when they need to test a workflow-facing
|
||||
capability.
|
||||
|
||||
### `wf.admin`
|
||||
|
||||
@@ -128,7 +117,7 @@ Expected capabilities:
|
||||
|
||||
- `tools`: list sources, enable source, disable source, refresh catalog, list
|
||||
connections, add/update/remove connections, view config, reload config, inspect
|
||||
events, inspect proxy tools, call/debug upstream capabilities.
|
||||
events, and inspect proxy tools.
|
||||
- `prompts/resources`: admin documentation may be useful later.
|
||||
|
||||
This source is privileged. A normal client LLM should not automatically see
|
||||
@@ -174,7 +163,6 @@ and capability is safe or explicitly allowed for workflow
|
||||
Examples:
|
||||
|
||||
- include `wf.std.runtime_error`
|
||||
- include `wf.mcp.call_tool`
|
||||
- include `everything.default.echo`
|
||||
- exclude `wf.admin.disable_source`
|
||||
|
||||
@@ -200,7 +188,7 @@ Examples:
|
||||
|
||||
- `wf.std.workflow_manual`
|
||||
- `wf.std.error_handling_guide`
|
||||
- `wf.mcp.mcp_workflow_guide`
|
||||
- MCP workflow guides under `wf.docs`
|
||||
|
||||
These are MCP-visible without implying the source exposes MCP tools.
|
||||
|
||||
@@ -241,8 +229,8 @@ The code now has the first capability-source layer in place.
|
||||
directly; the old `SpecSource` compatibility layer has been removed.
|
||||
- `wf.std` owns current `wf_authoring.ops` workflow node specs under
|
||||
`wf.std.*`.
|
||||
- `wf.mcp` owns workflow MCP runtime node specs, currently
|
||||
`wf.mcp.call_tool`.
|
||||
- `wf.mcp` is reserved for future workflow-safe MCP helpers and currently owns
|
||||
no public NodeSpecs.
|
||||
- `wf.admin` owns privileged admin capability metadata and is not planner-visible
|
||||
by default.
|
||||
- Transparent proxy admin tools now use dotted `wf.admin.*` names through
|
||||
@@ -266,7 +254,7 @@ Current code has several useful pieces but the boundaries are blurred.
|
||||
| --- | --- | --- |
|
||||
| `wf_authoring.ops` | reusable workflow node specs | `wf.std.node_specs` |
|
||||
| `wf_core` built-in reducers | reusable workflow state reducers | `wf.std.reducers` |
|
||||
| `wf_mcp.broker.service.builtins` | local workflow specs | `wf.std`, `wf.mcp` |
|
||||
| `wf_mcp.broker.service.builtins` | local workflow specs | `wf.std` |
|
||||
| `wf_mcp.broker.tools` | compatibility wrapper over shared service-admin registration | `wf.admin.tools` |
|
||||
| `wf_mcp.admin_surface.tools` | shared service-backed admin tool registration | `wf.admin.tools` |
|
||||
| `wf_mcp.transparent_proxy.admin` | proxy-backed public admin tools | `wf.admin.tools` |
|
||||
|
||||
+5
-4
@@ -191,10 +191,9 @@ Not all of these need workflow semantics immediately, but they should still have
|
||||
|
||||
The broker also needs a more protocol-native mode than the current generic broker tools.
|
||||
|
||||
The current broker tools are useful:
|
||||
The current broker tools are useful for catalog and control-plane inspection:
|
||||
|
||||
- `get_catalog`
|
||||
- `call_broker_tool`
|
||||
- `read_broker_resource`
|
||||
- `render_broker_prompt`
|
||||
- `invoke_broker_method`
|
||||
@@ -235,7 +234,9 @@ Example desired tool entry:
|
||||
}
|
||||
```
|
||||
|
||||
This is different from `call_broker_tool`. `call_broker_tool` is a convenience wrapper. Mirrored tools are actual MCP tools exposed by the broker.
|
||||
Mirrored tools are actual MCP tools exposed by the broker/proxy surface. Do not
|
||||
reintroduce a generic `call_tool` wrapper for upstream tools; it duplicates the
|
||||
proxy plane and is the wrong abstraction for stateful MCP servers.
|
||||
|
||||
### Official protocol boundary
|
||||
|
||||
@@ -333,7 +334,7 @@ For a client like Inspector to show an elicitation form from an upstream server,
|
||||
Near-term stance:
|
||||
|
||||
- mirror normal tools/resources/prompts first
|
||||
- keep `call_broker_tool` and raw method invocation as debugging fallbacks
|
||||
- keep raw method invocation only for explicit protocol/admin diagnostics
|
||||
- test the Everything Reference Server elicitation tool as the first serious proxy pressure test
|
||||
- only claim elicitation/sampling support once a live Inspector flow works end to end
|
||||
|
||||
|
||||
@@ -401,15 +401,12 @@ Local system sources use the same binding mechanism. For example:
|
||||
```text
|
||||
artifact reference: wf.std.replace
|
||||
runtime binding: wf.std -> wf.std
|
||||
|
||||
artifact reference: wf.mcp.call_tool
|
||||
runtime binding: wf.mcp -> wf.mcp
|
||||
```
|
||||
|
||||
These self-bindings are not external account choices. They keep dependency
|
||||
resolution uniform across local system sources and upstream connection sources.
|
||||
They may become implicit later, but today deployments should include them when
|
||||
validation reports `binding_missing` for `wf.std` or `wf.mcp`.
|
||||
validation reports `binding_missing` for `wf.std`.
|
||||
|
||||
or:
|
||||
|
||||
|
||||
@@ -88,30 +88,5 @@ class BrokerAdminHandlers:
|
||||
**error_payload(exc),
|
||||
}
|
||||
|
||||
async def call_broker_tool(
|
||||
self,
|
||||
connection_id: str,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return {
|
||||
"connection_id": connection_id,
|
||||
"tool_name": tool_name,
|
||||
"ok": True,
|
||||
**await self.service.call_tool(
|
||||
connection_id,
|
||||
tool_name,
|
||||
arguments=arguments,
|
||||
),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"connection_id": connection_id,
|
||||
"tool_name": tool_name,
|
||||
"ok": False,
|
||||
**error_payload(exc),
|
||||
}
|
||||
|
||||
def get_broker_events(self) -> list[dict[str, Any]]:
|
||||
return [asdict(event) for event in self.service.list_events()]
|
||||
|
||||
@@ -27,7 +27,6 @@ def register_service_admin_tools(
|
||||
"read_resource": "read_broker_resource",
|
||||
"render_prompt": "render_broker_prompt",
|
||||
"invoke_method": "invoke_broker_method",
|
||||
"call_tool": "call_broker_tool",
|
||||
"get_events": "get_broker_events",
|
||||
}
|
||||
|
||||
@@ -142,22 +141,6 @@ def register_service_admin_tools(
|
||||
params=params,
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name=name("call_tool"),
|
||||
title="Call Tool",
|
||||
description="Call one upstream MCP tool through the broker service layer.",
|
||||
)
|
||||
async def call_tool(
|
||||
connection_id: str,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await handlers.call_broker_tool(
|
||||
connection_id,
|
||||
tool_name,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name=name("get_events"),
|
||||
title="Get Events",
|
||||
|
||||
@@ -10,8 +10,8 @@ Build workflows from current capabilities instead of assuming a stale catalog.
|
||||
Use `get_planner_catalog` when you need the current workflow-capability view.
|
||||
Use `list_sources` when you need to understand source ownership, visibility,
|
||||
and capability kinds.
|
||||
Use `call_broker_tool` to test an upstream MCP tool manually before wrapping it
|
||||
into a workflow.
|
||||
Use source-projected workflow capabilities or directly exposed proxy tools to
|
||||
test the smallest reusable piece before wrapping it into a workflow.
|
||||
|
||||
Prefer namespaced capabilities, inspect before you rely on them, and test the
|
||||
smallest reusable piece before saving a larger workflow artifact.
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wf_authoring import (
|
||||
NodeReturn,
|
||||
NodeSpec,
|
||||
coalesce,
|
||||
constant,
|
||||
default_if_none,
|
||||
first_item,
|
||||
first_item_maybe,
|
||||
first_item_or_none,
|
||||
is_empty,
|
||||
last_item,
|
||||
last_item_or_none,
|
||||
length,
|
||||
node,
|
||||
pick_key,
|
||||
pick_path,
|
||||
project_fields,
|
||||
rename_fields,
|
||||
runtime_error,
|
||||
truthy,
|
||||
)
|
||||
from wf_authoring import NodeSpec, coalesce, constant, default_if_none, first_item
|
||||
from wf_authoring import first_item_maybe, first_item_or_none, is_empty, last_item
|
||||
from wf_authoring import last_item_or_none, length, node, pick_key, pick_path
|
||||
from wf_authoring import project_fields, rename_fields, runtime_error, truthy
|
||||
from wf_core.runtime.ops.merges import DEFAULT_REDUCER_DEFINITIONS
|
||||
|
||||
from wf_platform import (
|
||||
@@ -42,7 +23,7 @@ BUILTIN_CONNECTION_ID = "wf.std"
|
||||
"""Internal source id for workflow standard-library node specs."""
|
||||
|
||||
MCP_SOURCE_ID = "wf.mcp"
|
||||
"""Internal source id for broker MCP utility node specs."""
|
||||
"""Reserved source id for future workflow-safe MCP utility node specs."""
|
||||
|
||||
|
||||
AUTHORING_STD_SPECS: tuple[NodeSpec[Any, Any], ...] = (
|
||||
@@ -66,43 +47,6 @@ AUTHORING_STD_SPECS: tuple[NodeSpec[Any, Any], ...] = (
|
||||
"""Existing authoring ops that are also exposed through the workflow stdlib."""
|
||||
|
||||
|
||||
class ToolCaller(Protocol):
|
||||
"""Small service boundary needed by the broker-local MCP utility nodes."""
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
connection_id: str,
|
||||
tool_name: str,
|
||||
*,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class McpCallToolInput(BaseModel):
|
||||
"""Input for calling a proxied MCP tool from inside a workflow."""
|
||||
|
||||
connection_id: str = Field(description="Connection id that owns the MCP tool.")
|
||||
tool_name: str = Field(description="Local tool name on the upstream MCP server.")
|
||||
arguments: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="JSON-compatible arguments passed to the upstream tool.",
|
||||
)
|
||||
|
||||
|
||||
class McpCallToolOutput(BaseModel):
|
||||
"""Normalized output returned by a proxied MCP tool call."""
|
||||
|
||||
outcome: str = Field(description="Workflow outcome reported by the upstream tool.")
|
||||
output: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="JSON-compatible tool result payload.",
|
||||
)
|
||||
meta: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Adapter metadata returned with the tool result.",
|
||||
)
|
||||
|
||||
|
||||
def builtin_specs() -> dict[str, NodeSpec[Any, Any]]:
|
||||
"""Return built-in NodeSpecs available to raw broker workflow plans."""
|
||||
specs = [
|
||||
@@ -126,30 +70,7 @@ def builtin_reducer_definitions():
|
||||
return dict(DEFAULT_REDUCER_DEFINITIONS)
|
||||
|
||||
|
||||
def mcp_specs(service: ToolCaller) -> dict[str, NodeSpec[Any, Any]]:
|
||||
"""Return service-bound MCP utility specs available to raw plans."""
|
||||
|
||||
@node(
|
||||
name="call_tool",
|
||||
outcomes=("ok", "error"),
|
||||
input_model=McpCallToolInput,
|
||||
output_model=McpCallToolOutput,
|
||||
description="Call a tool on a registered MCP connection.",
|
||||
)
|
||||
async def call_tool(payload: McpCallToolInput) -> NodeReturn[McpCallToolOutput]:
|
||||
result = await service.call_tool(
|
||||
payload.connection_id,
|
||||
payload.tool_name,
|
||||
arguments=payload.arguments,
|
||||
)
|
||||
output = McpCallToolOutput.model_validate(result)
|
||||
return NodeReturn(outcome=output.outcome, output=output)
|
||||
|
||||
qualified_specs = [qualify_spec(MCP_SOURCE_ID, call_tool)]
|
||||
return {spec.name: spec for spec in qualified_specs}
|
||||
|
||||
|
||||
def builtin_sources(service: ToolCaller) -> dict[str, CapabilitySource]:
|
||||
def builtin_sources() -> dict[str, CapabilitySource]:
|
||||
"""Return all broker-local capability sources."""
|
||||
return {
|
||||
BUILTIN_CONNECTION_ID: CapabilitySource(
|
||||
@@ -168,12 +89,4 @@ def builtin_sources(service: ToolCaller) -> dict[str, CapabilitySource]:
|
||||
permissions=SourcePermissions(safe_for_workflow=True),
|
||||
description="Workflow standard-library nodes.",
|
||||
),
|
||||
MCP_SOURCE_ID: CapabilitySource(
|
||||
id=MCP_SOURCE_ID,
|
||||
kind="system",
|
||||
capabilities=CapabilityBuckets(node_specs=mcp_specs(service)),
|
||||
visibility=SourceVisibility(planner=True, admin_dashboard=True),
|
||||
permissions=SourcePermissions(calls_upstream=True),
|
||||
description="Broker MCP utility nodes.",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class WfMcpService:
|
||||
_store_root(self.store)
|
||||
)
|
||||
if self.include_builtin_specs:
|
||||
for source in builtin_sources(self).values():
|
||||
for source in builtin_sources().values():
|
||||
self.register_capability_source(source)
|
||||
self.register_capability_source(admin_source())
|
||||
|
||||
@@ -449,41 +449,6 @@ class WfMcpService:
|
||||
)
|
||||
return result
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
connection_id: str,
|
||||
tool_name: str,
|
||||
*,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
connection = self.connections.get(connection_id)
|
||||
adapter = require_adapter(connection, self.adapters)
|
||||
auth = self.load_auth(connection_id)
|
||||
capability_id = qualify_node_name(connection_id, tool_name)
|
||||
payload = arguments or {}
|
||||
self._record_event(
|
||||
make_event(
|
||||
"tool_call_started",
|
||||
connection_id=connection_id,
|
||||
capability_id=capability_id,
|
||||
payload={"argument_keys": sorted(payload.keys())},
|
||||
)
|
||||
)
|
||||
result = await adapter.call_tool(connection, auth, tool_name, payload)
|
||||
self._record_event(
|
||||
make_event(
|
||||
"tool_call_completed",
|
||||
connection_id=connection_id,
|
||||
capability_id=capability_id,
|
||||
payload={"outcome": result.outcome},
|
||||
)
|
||||
)
|
||||
return {
|
||||
"outcome": result.outcome,
|
||||
"output": result.output,
|
||||
"meta": result.meta,
|
||||
}
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
connection_id: str,
|
||||
@@ -794,20 +759,29 @@ class WfMcpService:
|
||||
self,
|
||||
entry: CatalogNodeEntry,
|
||||
) -> NodeSpec[Any, Any]:
|
||||
"""Rebuild an executable tool wrapper from a stored catalog node entry."""
|
||||
"""Rebuild an executable tool wrapper from a stored catalog node entry.
|
||||
|
||||
Snapshot entries store schema/name metadata, not Python functions. This
|
||||
helper reconstructs the same generated NodeSpec shape and routes calls
|
||||
through `_tool_executor_for()`, so hydrated specs use the persistent MCP
|
||||
runtime when the service has one configured.
|
||||
"""
|
||||
model_prefix = entry.qualified_name.replace(".", "_").replace("-", "_")
|
||||
input_model = _model_from_schema(f"{model_prefix}_Input", entry.input_schema)
|
||||
output_model = _model_from_schema(f"{model_prefix}_Output", entry.output_schema)
|
||||
|
||||
async def invoke_tool(payload: BaseModel) -> NodeReturn[BaseModel]:
|
||||
result = await self.call_tool(
|
||||
entry.connection_id,
|
||||
connection = self.connections.get(entry.connection_id)
|
||||
auth = self.load_auth(entry.connection_id)
|
||||
result = await self._tool_executor_for(connection).call_tool(
|
||||
connection,
|
||||
auth,
|
||||
entry.local_name,
|
||||
arguments=payload.model_dump(),
|
||||
payload.model_dump(exclude_unset=True),
|
||||
)
|
||||
return NodeReturn(
|
||||
outcome=result["outcome"],
|
||||
output=output_model.model_validate(result["output"]),
|
||||
outcome=result.outcome,
|
||||
output=output_model.model_validate(result.output),
|
||||
)
|
||||
|
||||
return NodeSpec(
|
||||
|
||||
@@ -24,7 +24,13 @@ def _auth_headers(auth: AuthRecord | None) -> dict[str, str]:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PersistentSessionFactory:
|
||||
"""Create initialized persistent MCP sessions for configured connections."""
|
||||
"""Create initialized persistent MCP sessions for configured connections.
|
||||
|
||||
Input connection metadata must describe either stdio transport
|
||||
(`command`, optional `args`/`env`/`cwd`) or streamable HTTP transport
|
||||
(`url`). The returned session owns its transport stack and closes it through
|
||||
the `PersistentMcpSession.close_callback`.
|
||||
"""
|
||||
|
||||
async def create(
|
||||
self,
|
||||
|
||||
@@ -40,7 +40,13 @@ def connection_runtime_fingerprint(
|
||||
|
||||
@dataclass(slots=True)
|
||||
class McpRuntimePool:
|
||||
"""Cache one persistent MCP runtime per unchanged connection fingerprint."""
|
||||
"""Cache one persistent MCP runtime per unchanged connection fingerprint.
|
||||
|
||||
Callers provide full `ConnectionConfig` and optional `AuthRecord` on every
|
||||
call. The pool decides whether that identity still maps to the existing
|
||||
upstream MCP session. If command, URL, account, or auth changes, the old
|
||||
session is closed and replaced.
|
||||
"""
|
||||
|
||||
session_factory: SessionFactory
|
||||
_sessions: dict[str, tuple[str, PersistentMcpSession]] = field(default_factory=dict)
|
||||
|
||||
@@ -13,7 +13,12 @@ from ..sdk.converters import tool_result_to_call_result
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PersistentMcpSession:
|
||||
"""Long-lived MCP execution handle for one configured connection."""
|
||||
"""Long-lived MCP execution handle for one configured connection.
|
||||
|
||||
`client` is an initialized MCP SDK `ClientSession`. `call_tool()` returns
|
||||
this project's normalized `ToolCallResult`, not the SDK result object, so
|
||||
generated workflow nodes do not need to know MCP wire result shapes.
|
||||
"""
|
||||
|
||||
connection: ConnectionConfig
|
||||
auth: AuthRecord | None
|
||||
|
||||
@@ -25,7 +25,13 @@ _JSON_TYPE_MAP: dict[str, object] = {
|
||||
|
||||
|
||||
def _python_type_from_schema(schema: object) -> object:
|
||||
"""Map a small JSON Schema subset into a Pydantic field annotation."""
|
||||
"""Map the supported JSON Schema subset into a Pydantic annotation.
|
||||
|
||||
Input is expected to be an MCP tool property schema. This helper is not a
|
||||
full JSON Schema compiler; unsupported shapes intentionally become `Any`
|
||||
so discovery remains tolerant while the original schema contract is still
|
||||
preserved on the generated NodeSpec.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return Any
|
||||
|
||||
@@ -65,14 +71,25 @@ def _field_default(
|
||||
property_schema: object,
|
||||
required: set[str],
|
||||
) -> object:
|
||||
"""Return the Pydantic field default for a JSON Schema property."""
|
||||
"""Return the Pydantic default for one MCP tool input property.
|
||||
|
||||
Required fields use `...`; optional fields default to `None`; explicit JSON
|
||||
Schema defaults win. Runtime calls later use `exclude_unset=True`, so absent
|
||||
optional fields are omitted instead of being sent upstream as explicit null.
|
||||
"""
|
||||
if isinstance(property_schema, dict) and "default" in property_schema:
|
||||
return property_schema["default"]
|
||||
return ... if field_name in required else None
|
||||
|
||||
|
||||
def _model_from_schema(name: str, schema: dict[str, Any]) -> type[BaseModel]:
|
||||
"""Create a loose Pydantic adapter model for an MCP JSON Schema object."""
|
||||
"""Create a loose Pydantic adapter model for an MCP JSON Schema object.
|
||||
|
||||
Input should be an object-like JSON Schema with `properties` and optional
|
||||
`required`. The returned model is only the Python-call boundary for a
|
||||
generated NodeSpec; the original JSON Schema remains the public contract via
|
||||
`input_schema_contract` / `output_schema_contract`.
|
||||
"""
|
||||
properties = cast(dict[str, Any], schema.get("properties", {}))
|
||||
required = set(cast(list[str], schema.get("required", [])))
|
||||
field_defs: dict[str, tuple[object, object]] = {}
|
||||
|
||||
@@ -76,7 +76,6 @@ def test_create_broker_server_exposes_tools_resources_and_prompts() -> None:
|
||||
assert "get_planner_catalog" in tool_names
|
||||
assert "list_sources" in tool_names
|
||||
assert "invoke_broker_method" in tool_names
|
||||
assert "call_broker_tool" in tool_names
|
||||
assert "catalog.all" in resource_names
|
||||
assert "events.all" in resource_names
|
||||
assert "status.all" in resource_names
|
||||
@@ -89,7 +88,6 @@ def test_create_broker_server_exposes_tools_resources_and_prompts() -> None:
|
||||
planner_catalog = cast(dict[str, Any], cast(object, planner_catalog_raw))
|
||||
planner_names = [node["qualified_name"] for node in planner_catalog["nodes"]]
|
||||
assert "demo.personal.echo_tool" in planner_names
|
||||
assert "wf.mcp.call_tool" in planner_names
|
||||
assert "wf.std.runtime_error" in planner_names
|
||||
|
||||
_content, all_sources_payload_raw = asyncio.run(
|
||||
@@ -153,35 +151,6 @@ def test_broker_refresh_tool_returns_structured_error() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_broker_call_tool_returns_structured_result() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "broker_tool_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
server = create_broker_server(service)
|
||||
|
||||
_content, structured = asyncio.run(
|
||||
server.call_tool(
|
||||
"call_broker_tool",
|
||||
{
|
||||
"connection_id": "demo.personal",
|
||||
"tool_name": "echo_tool",
|
||||
"arguments": {"text": "hello"},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert structured == {
|
||||
"connection_id": "demo.personal",
|
||||
"tool_name": "echo_tool",
|
||||
"ok": True,
|
||||
"outcome": "ok",
|
||||
"output": {"echoed": "hello"},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
|
||||
def test_broker_lists_workflow_artifacts_from_artifact_store() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "broker_artifacts")
|
||||
artifact_store.save_artifact(_artifact())
|
||||
|
||||
@@ -31,4 +31,4 @@ def test_namespaced_tool_parser_rejects_unknown_and_admin_names() -> None:
|
||||
def test_admin_namespace_is_distinct_from_wf_mcp_runtime_source() -> None:
|
||||
assert ADMIN_NAMESPACE == "wf.admin"
|
||||
assert is_admin_tool_name("wf.admin.list_connections") is True
|
||||
assert is_admin_tool_name("wf.mcp.call_tool") is False
|
||||
assert is_admin_tool_name("wf.std.runtime_error") is False
|
||||
|
||||
@@ -95,7 +95,6 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
||||
assert "wf.admin.read_resource" in names
|
||||
assert "wf.admin.render_prompt" in names
|
||||
assert "wf.admin.invoke_method" in names
|
||||
assert "wf.admin.call_tool" in names
|
||||
assert "wf.admin.get_events" in names
|
||||
assert "wf.workflow.list_artifacts" in names
|
||||
assert "wf.workflow.list_capabilities" in names
|
||||
@@ -173,7 +172,6 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
||||
}
|
||||
assert "wf.admin" in source_ids
|
||||
assert "wf.docs" in source_ids
|
||||
assert "wf.mcp" in source_ids
|
||||
assert "wf.std" in source_ids
|
||||
|
||||
asyncio.run(run_proxy())
|
||||
|
||||
@@ -101,10 +101,7 @@ def test_service_installs_builtin_stdlib_specs_by_default() -> None:
|
||||
"wf.std.runtime_error"
|
||||
in service.capability_sources["wf.std"].capabilities.node_specs
|
||||
)
|
||||
assert (
|
||||
"wf.mcp.call_tool"
|
||||
in service.capability_sources["wf.mcp"].capabilities.node_specs
|
||||
)
|
||||
assert "wf.mcp" not in service.capability_sources
|
||||
|
||||
|
||||
def test_service_installs_default_draft_workspace_store() -> None:
|
||||
@@ -147,9 +144,6 @@ def test_service_lists_all_capability_sources_with_owned_capability_names() -> N
|
||||
assert std_source["capabilities"]["tools"] == []
|
||||
assert std_source["reducer_count"] == 6
|
||||
|
||||
mcp_source = sources_by_id["wf.mcp"]
|
||||
assert mcp_source["capabilities"]["node_specs"] == ["wf.mcp.call_tool"]
|
||||
|
||||
admin_source = sources_by_id["wf.admin"]
|
||||
assert admin_source["visibility"]["planner"] is False
|
||||
assert "wf.admin.list_sources" in admin_source["capabilities"]["tools"]
|
||||
@@ -158,11 +152,11 @@ def test_service_lists_all_capability_sources_with_owned_capability_names() -> N
|
||||
def test_service_lists_compact_source_summaries() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "source_summaries"))
|
||||
|
||||
payload = service.list_source_summaries(limit=2)
|
||||
payload = service.list_source_summaries(limit=1)
|
||||
|
||||
assert len(payload["sources"]) == 2
|
||||
assert len(payload["sources"]) == 1
|
||||
assert payload["total"] >= 2
|
||||
assert payload["next_cursor"] == "2"
|
||||
assert payload["next_cursor"] == "1"
|
||||
assert "capabilities" not in payload["sources"][0]
|
||||
|
||||
full_page = service.list_source_summaries(limit=100)
|
||||
@@ -215,7 +209,6 @@ def test_service_sources_have_visibility_and_capability_buckets() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "source_shape_store"))
|
||||
|
||||
std_source = service.capability_sources["wf.std"]
|
||||
mcp_source = service.capability_sources["wf.mcp"]
|
||||
|
||||
assert std_source.id == "wf.std"
|
||||
assert std_source.kind == "system"
|
||||
@@ -225,11 +218,6 @@ def test_service_sources_have_visibility_and_capability_buckets() -> None:
|
||||
assert "wf.std.runtime_error" in std_source.capabilities.node_specs
|
||||
assert not std_source.capabilities.tools
|
||||
|
||||
assert mcp_source.id == "wf.mcp"
|
||||
assert mcp_source.visibility.planner is True
|
||||
assert mcp_source.permissions.calls_upstream is True
|
||||
assert "wf.mcp.call_tool" in mcp_source.capabilities.node_specs
|
||||
|
||||
|
||||
def test_wf_admin_source_exists_but_is_not_planner_visible() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "admin_source_store"))
|
||||
@@ -256,7 +244,6 @@ def test_service_can_disable_builtin_stdlib_specs() -> None:
|
||||
)
|
||||
|
||||
assert "wf.std" not in service.capability_sources
|
||||
assert "wf.mcp" not in service.capability_sources
|
||||
|
||||
|
||||
def test_service_planner_catalog_excludes_hidden_sources() -> None:
|
||||
@@ -298,10 +285,8 @@ def test_service_catalog_split_keeps_system_specs_out_of_backend_catalog() -> No
|
||||
|
||||
assert backend_payload["nodes"] == []
|
||||
planner_node_names = {node["qualified_name"] for node in planner_payload["nodes"]}
|
||||
assert "wf.mcp.call_tool" in planner_node_names
|
||||
assert "wf.std.runtime_error" in planner_node_names
|
||||
available_names = {entry.qualified_name for entry in service.list_available_specs()}
|
||||
assert "wf.mcp.call_tool" in available_names
|
||||
assert "wf.std.runtime_error" in available_names
|
||||
|
||||
|
||||
@@ -813,69 +798,6 @@ def test_service_records_tool_call_events() -> None:
|
||||
assert tool_events[1].payload["outcome"] == "ok"
|
||||
|
||||
|
||||
def test_service_can_call_upstream_tool_through_wf_mcp_system_node() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "system_tool_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
plan = _raw_plan(
|
||||
name="system_tool_plan",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connection_id": {"type": "string"},
|
||||
"tool_name": {"type": "string"},
|
||||
"arguments": {"type": "object"},
|
||||
},
|
||||
"required": ["connection_id", "tool_name", "arguments"],
|
||||
},
|
||||
state_schema={
|
||||
"fields": {
|
||||
"tool_result": {"type": "object"},
|
||||
}
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"tool_result": {"type": "object"}},
|
||||
"required": ["tool_result"],
|
||||
},
|
||||
start="call_tool",
|
||||
nodes=[
|
||||
{
|
||||
"id": "call_tool",
|
||||
"type": "node",
|
||||
"node": "wf.mcp.call_tool",
|
||||
"in_map": {
|
||||
"input.connection_id": "connection_id",
|
||||
"input.tool_name": "tool_name",
|
||||
"input.arguments": "arguments",
|
||||
},
|
||||
"out_map": {"output": "state.tool_result"},
|
||||
}
|
||||
],
|
||||
edges=[
|
||||
{"from": "call_tool", "outcome": "ok", "to": END},
|
||||
{"from": "call_tool", "outcome": "error", "to": END},
|
||||
],
|
||||
)
|
||||
|
||||
run = asyncio.run(
|
||||
service.run_workflow_from_plan(
|
||||
plan,
|
||||
{
|
||||
"connection_id": "demo.personal",
|
||||
"tool_name": "echo_tool",
|
||||
"arguments": {"text": "hello"},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert run.status == RunStatus.COMPLETED
|
||||
assert run.output["tool_result"]["echoed"] == "hello"
|
||||
|
||||
|
||||
def test_service_can_inspect_resources_and_prompts() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "inspect_store"))
|
||||
service.register_connection(
|
||||
@@ -1000,31 +922,6 @@ def test_service_can_invoke_raw_method_and_notification() -> None:
|
||||
assert "raw_notification_completed" in event_kinds
|
||||
|
||||
|
||||
def test_service_can_call_upstream_tool_directly() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "direct_tool_store"))
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
|
||||
result = asyncio.run(
|
||||
service.call_tool(
|
||||
"demo.personal",
|
||||
"echo_tool",
|
||||
arguments={"text": "hello"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"outcome": "ok",
|
||||
"output": {"echoed": "hello"},
|
||||
"meta": {},
|
||||
}
|
||||
event_kinds = [event.kind for event in service.list_events()]
|
||||
assert "tool_call_started" in event_kinds
|
||||
assert "tool_call_completed" in event_kinds
|
||||
|
||||
|
||||
def test_generated_specs_use_injected_tool_executor() -> None:
|
||||
class RecordingExecutor:
|
||||
def __init__(self) -> None:
|
||||
|
||||
@@ -99,17 +99,17 @@ def test_workflow_surface_lists_planner_visible_capabilities() -> None:
|
||||
assert "wf.admin.list_sources" not in names
|
||||
|
||||
|
||||
def test_workflow_surface_filters_capabilities_by_source() -> None:
|
||||
def test_workflow_surface_filters_stdlib_capabilities_by_source() -> None:
|
||||
handlers = _handlers(
|
||||
FileWorkflowArtifactStore(local_temp_root() / "surface_filtered_caps")
|
||||
)
|
||||
|
||||
payload = asyncio.run(handlers.list_capabilities(source_id="wf.mcp"))
|
||||
payload = asyncio.run(handlers.list_capabilities(source_id="wf.std", query="truthy"))
|
||||
|
||||
assert [capability["name"] for capability in payload["capabilities"]] == [
|
||||
"wf.mcp.call_tool"
|
||||
"wf.std.truthy"
|
||||
]
|
||||
assert payload["capabilities"][0]["source_id"] == "wf.mcp"
|
||||
assert payload["capabilities"][0]["source_id"] == "wf.std"
|
||||
|
||||
|
||||
def test_workflow_surface_lists_saved_wrapper_capabilities() -> None:
|
||||
|
||||
Reference in New Issue
Block a user