plan + fmt

This commit is contained in:
lda
2026-04-29 19:04:02 +07:00 Verified
parent d227a892f3
commit 6bff79c304
10 changed files with 444 additions and 61 deletions
+14 -11
View File
@@ -364,14 +364,17 @@ def test_foreach_stress_with_many_documents() -> None:
assert run.status == RunStatus.COMPLETED assert run.status == RunStatus.COMPLETED
assert len(run.state["documents"]) == document_count assert len(run.state["documents"]) == document_count
assert len(run.state["item_summaries"]) == document_count assert len(run.state["item_summaries"]) == document_count
assert len( assert (
[ len(
frame [
for frame in run.frames.values() frame
if frame.kind == "foreach_iteration" for frame in run.frames.values()
and frame.status == FrameStatus.COMPLETED if frame.kind == "foreach_iteration"
] and frame.status == FrameStatus.COMPLETED
) == document_count ]
)
== document_count
)
assert len([entry for entry in run.trace if entry.step_type == "foreach"]) == ( assert len([entry for entry in run.trace if entry.step_type == "foreach"]) == (
document_count + 1 document_count + 1
) )
@@ -381,9 +384,9 @@ def test_builder_compiles_same_workflow_as_declared_demo() -> None:
declared = build_demo_workflow() declared = build_demo_workflow()
built, _registry = build_authoring_demo_workflow() built, _registry = build_authoring_demo_workflow()
assert _strip_schema_titles(built.model_dump(by_alias=True)) == _strip_schema_titles( assert _strip_schema_titles(
declared.model_dump(by_alias=True) built.model_dump(by_alias=True)
) ) == _strip_schema_titles(declared.model_dump(by_alias=True))
def test_builder_compiled_workflow_executes_like_declared_demo() -> None: def test_builder_compiled_workflow_executes_like_declared_demo() -> None:
+15 -13
View File
@@ -65,21 +65,22 @@ def _fixture_server_path() -> str:
def _everything_server_connection() -> ConnectionConfig | None: def _everything_server_connection() -> ConnectionConfig | None:
command = os.environ.get("MCP_EVERYTHING_COMMAND")
if not command:
return None
raw_args = os.environ.get("MCP_EVERYTHING_ARGS", "")
args = [arg for arg in raw_args.split(" ") if arg]
transport = os.environ.get("MCP_EVERYTHING_TRANSPORT", "stdio") transport = os.environ.get("MCP_EVERYTHING_TRANSPORT", "stdio")
if transport == "stdio":
command = os.environ.get("MCP_EVERYTHING_COMMAND")
if not command:
return None
metadata: dict[str, Any] = { raw_args = os.environ.get("MCP_EVERYTHING_ARGS", "")
"transport": transport, args = [arg for arg in raw_args.split(" ") if arg]
"command": command,
"args": args,
}
if transport == "streamable_http": metadata: dict[str, Any] = {
"transport": transport,
"command": command,
"args": args,
}
elif transport == "streamable_http":
url = os.environ.get("MCP_EVERYTHING_URL") url = os.environ.get("MCP_EVERYTHING_URL")
if not url: if not url:
raise AssertionError( raise AssertionError(
@@ -89,7 +90,8 @@ def _everything_server_connection() -> ConnectionConfig | None:
"transport": "streamable_http", "transport": "streamable_http",
"url": url, "url": url,
} }
else:
return None
return ConnectionConfig( return ConnectionConfig(
id="everything.default", id="everything.default",
server="everything", server="everything",
+2 -4
View File
@@ -16,15 +16,13 @@ def normalize_path(path: PathArg) -> str:
def bind_fields(**mapping: PathArg) -> dict[str, str]: def bind_fields(**mapping: PathArg) -> dict[str, str]:
return { return {
normalize_path(source): destination normalize_path(source): destination for destination, source in mapping.items()
for destination, source in mapping.items()
} }
def bind_state(**mapping: PathArg) -> dict[str, str]: def bind_state(**mapping: PathArg) -> dict[str, str]:
return { return {
destination: normalize_path(target) destination: normalize_path(target) for destination, target in mapping.items()
for destination, target in mapping.items()
} }
+23 -21
View File
@@ -3,7 +3,17 @@ from __future__ import annotations
from inspect import Parameter, iscoroutinefunction, signature from inspect import Parameter, iscoroutinefunction, signature
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Generic, Literal, TypeVar, cast, get_args, get_origin, get_type_hints, overload from typing import (
Any,
Generic,
Literal,
TypeVar,
cast,
get_args,
get_origin,
get_type_hints,
overload,
)
from pydantic import BaseModel from pydantic import BaseModel
@@ -67,9 +77,7 @@ def _infer_models(
hints = get_type_hints(fn, include_extras=True) hints = get_type_hints(fn, include_extras=True)
params = list(signature(fn).parameters.values()) params = list(signature(fn).parameters.values())
if len(params) < 2: if len(params) < 2:
raise TypeError( raise TypeError("node function must accept at least (payload, ctx) parameters")
"node function must accept at least (payload, ctx) parameters"
)
payload_param = params[0] payload_param = params[0]
ctx_param = params[1] ctx_param = params[1]
@@ -87,15 +95,11 @@ def _infer_models(
input_model = hints.get(payload_param.name) input_model = hints.get(payload_param.name)
if not _is_basemodel_subclass(input_model): if not _is_basemodel_subclass(input_model):
raise TypeError( raise TypeError("node payload annotation must be a pydantic BaseModel subclass")
"node payload annotation must be a pydantic BaseModel subclass"
)
ctx_type = hints.get(ctx_param.name) ctx_type = hints.get(ctx_param.name)
if ctx_type is not RuntimeContext: if ctx_type is not RuntimeContext:
raise TypeError( raise TypeError("node context annotation must be wf_core.RuntimeContext")
"node context annotation must be wf_core.RuntimeContext"
)
return_type = hints.get("return") return_type = hints.get("return")
if return_type is None: if return_type is None:
@@ -184,12 +188,12 @@ class NodeSpec(Generic[InputT, OutputT]):
return handler return handler
@overload @overload
def node( def node(
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT], fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
/, /,
) -> NodeSpec[InputT, OutputT]: ) -> NodeSpec[InputT, OutputT]: ...
...
@overload @overload
@@ -199,8 +203,7 @@ def node(
) -> Callable[ ) -> Callable[
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]], [NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
NodeSpec[InputT, OutputT], NodeSpec[InputT, OutputT],
]: ]: ...
...
@overload @overload
@@ -217,12 +220,13 @@ def node(
) -> Callable[ ) -> Callable[
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]], [NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
NodeSpec[InputT, OutputT], NodeSpec[InputT, OutputT],
]: ]: ...
...
def node( def node(
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT] | None = None, fn: NodeCallable[InputT, OutputT]
| AsyncNodeCallable[InputT, OutputT]
| None = None,
*, *,
name: str | None = None, name: str | None = None,
input_model: type[InputT] | None = None, input_model: type[InputT] | None = None,
@@ -276,8 +280,7 @@ def _build_registry(
specs: tuple[NodeSpec[Any, Any], ...], specs: tuple[NodeSpec[Any, Any], ...],
*, *,
export: Literal["sync"], export: Literal["sync"],
) -> dict[str, SyncRegistryHandler]: ) -> dict[str, SyncRegistryHandler]: ...
...
@overload @overload
@@ -285,8 +288,7 @@ def _build_registry(
specs: tuple[NodeSpec[Any, Any], ...], specs: tuple[NodeSpec[Any, Any], ...],
*, *,
export: Literal["async"], export: Literal["async"],
) -> dict[str, AsyncRegistryHandler]: ) -> dict[str, AsyncRegistryHandler]: ...
...
def _build_registry( def _build_registry(
+5 -1
View File
@@ -192,7 +192,11 @@ def build_demo_workflow() -> Workflow:
"edges": [ "edges": [
{"from": "list_files", "outcome": "ok", "to": "summarize_each"}, {"from": "list_files", "outcome": "ok", "to": "summarize_each"},
{"from": "summarize_each", "outcome": "loop", "to": "summarize_one"}, {"from": "summarize_each", "outcome": "loop", "to": "summarize_one"},
{"from": "summarize_each", "outcome": "done", "to": "combine_summaries"}, {
"from": "summarize_each",
"outcome": "done",
"to": "combine_summaries",
},
{"from": "summarize_one", "outcome": "ok", "to": END}, {"from": "summarize_one", "outcome": "ok", "to": END},
{"from": "combine_summaries", "outcome": "ok", "to": "should_email"}, {"from": "combine_summaries", "outcome": "ok", "to": "should_email"},
{"from": "should_email", "outcome": "true", "to": "approve_email"}, {"from": "should_email", "outcome": "true", "to": "approve_email"},
+5 -8
View File
@@ -7,14 +7,10 @@ from .models import ConnectionConfig
def parse_connection_id(connection_id: str) -> tuple[str, str]: def parse_connection_id(connection_id: str) -> tuple[str, str]:
if "." not in connection_id: if "." not in connection_id:
raise ValueError( raise ValueError("connection id must look like '<server>.<account>'")
"connection id must look like '<server>.<account>'"
)
server, account = connection_id.split(".", 1) server, account = connection_id.split(".", 1)
if not server or not account: if not server or not account:
raise ValueError( raise ValueError("connection id must look like '<server>.<account>'")
"connection id must look like '<server>.<account>'"
)
return server, account return server, account
@@ -37,5 +33,6 @@ class ConnectionRegistry:
return self.connections[connection_id] return self.connections[connection_id]
def list_enabled(self) -> list[ConnectionConfig]: def list_enabled(self) -> list[ConnectionConfig]:
return [connection for connection in self.connections.values() if connection.enabled] return [
connection for connection in self.connections.values() if connection.enabled
]
-1
View File
@@ -61,4 +61,3 @@ def dump_catalog_snapshot(snapshot: CatalogSnapshot) -> dict[str, Any]:
"max_age_seconds": snapshot.max_age_seconds, "max_age_seconds": snapshot.max_age_seconds,
"nodes": [asdict(node) for node in snapshot.nodes], "nodes": [asdict(node) for node in snapshot.nodes],
} }
+3 -1
View File
@@ -58,7 +58,9 @@ class WfMcpService:
) -> None: ) -> None:
self.connections.get(connection_id) self.connections.get(connection_id)
qualified_specs = { qualified_specs = {
qualify_node_name(connection_id, spec.name): _qualify_spec(connection_id, spec) qualify_node_name(connection_id, spec.name): _qualify_spec(
connection_id, spec
)
for spec in specs for spec in specs
} }
self.specs_by_connection[connection_id] = qualified_specs self.specs_by_connection[connection_id] = qualified_specs
-1
View File
@@ -78,4 +78,3 @@ class FileStore(Store):
max_age_seconds=data["max_age_seconds"], max_age_seconds=data["max_age_seconds"],
nodes=[CatalogNodeEntry(**node) for node in data.get("nodes", [])], nodes=[CatalogNodeEntry(**node) for node in data.get("nodes", [])],
) )
+377
View File
@@ -0,0 +1,377 @@
# `wf_mcp` Plan
This document describes the intended direction of `wf_mcp`.
The important change in scope is this:
- `wf_mcp` is not just a tool wrapper layer
- `wf_mcp` is a namespaced MCP capability broker plus workflow build/run layer
That means it should be able to face:
- human users
- client LLMs
- workflow execution services
without forcing every MCP capability to immediately become a workflow node.
## Goals
`wf_mcp` should:
- manage multiple named MCP backend connections
- persist auth/session state
- discover and cache MCP capabilities per connection
- expose namespaced catalogs to humans and LLMs
- wrap callable tool capabilities into workflow-executable `NodeSpec`s
- compile and run workflows against those capabilities
- preserve traceability between client actions, MCP backend calls, and workflow runs
## Non-goals for the first phase
- full persistence of workflow runs/jobs
- parallel workflow execution semantics beyond what `wf_core` already supports
- turning every MCP capability type into a workflow node immediately
- hiding protocol complexity by inventing vague magic abstractions
## Layering
### `wf_core`
Owns:
- workflow model
- validation
- runtime semantics
- frames
- trace
- interrupts
- foreach
Does not own:
- MCP connections
- auth/session persistence
- discovery caching
- capability brokerage
### `wf_authoring`
Owns:
- `@node`
- `NodeSpec`
- `WorkflowBuilder`
- condition DSL
- subgraph wrapping
Does not own:
- MCP transport/client logic
### `wf_mcp`
Owns:
- connection registry
- auth store
- capability discovery/cache
- namespaced capability catalog
- MCP backend adapter layer
- tool-to-`NodeSpec` wrapping
- raw plan compilation and workflow execution entrypoints
- future user-facing service/API surface
## Mental model
Think of `wf_mcp` as having two planes.
### 1. Capability proxy plane
This plane exposes what a backend MCP connection offers.
Capabilities include:
- tools
- resources
- prompts
- notifications/events
- auth metadata
- tasks
- elicitations
- app/server metadata
This plane is about discovery, namespacing, caching, and proxying.
### 2. Workflow execution plane
This plane decides which capabilities can be used inside workflows and how.
In the first phase:
- tools become executable workflow nodes
- resources and prompts are exposed in catalog/proxy APIs first
- tasks, notifications, and elicitations are modeled and surfaced first, then integrated into workflows later
## Connection identity
Connections must be first-class and stable.
Expected shape:
- `<server>.<account>`
Examples:
- `google.personal`
- `google.work`
- `github.main`
- `everything.default`
The same backend/server type may have multiple configured connections with different auth, tool availability, or environment.
## Namespacing
All exposed capabilities should be explicitly namespaced.
Examples:
- `google.personal.tool.list_files`
- `google.personal.prompt.summarize_folder`
- `google.personal.resource.drive://folder/abc`
- `everything.default.tool.echo_tool`
The exact string shape can evolve, but these properties should hold:
- globally unique
- reversible back to connection id + local capability id
- understandable by both humans and LLMs
## Current implemented slice
Already present:
- connection config and registry
- pluggable file-backed auth/catalog store
- tool catalog snapshots
- raw workflow plan compilation
- workflow execution via async runtime
- lightweight adapter protocol
- fake adapter tests
- real MCP SDK adapter for `stdio` and `streamable_http`
This is a good first vertical slice for tool execution.
## Next capability expansion
The next architectural move should be broadening the capability model beyond tools.
### Add models for
- `DiscoveredResource`
- `DiscoveredPrompt`
- `DiscoveredNotification`
- `DiscoveredTaskCapability`
- `DiscoveredElicitationCapability`
- maybe `DiscoveredAppMetadata`
These do not all need execution semantics immediately.
They do need:
- namespacing
- storage/caching
- catalog exposure
## Catalog direction
We should move from a tool-only catalog to a unified capability catalog.
The catalog should be able to expose, per connection:
- tools
- resources
- prompts
- capability metadata
- auth requirements or state markers
- freshness metadata
The client-facing catalog payload should be usable by:
- a human inspector UI
- an LLM that builds workflows
- internal service code
## Workflow integration policy
Not every capability becomes a workflow node right away.
### Immediate workflow nodes
- tools
### Catalog/proxy first, workflow later
- resources
- prompts
- notifications
- tasks
- elicitations
Why:
- tools already match the node call model well
- the others need more careful semantic mapping
## Elicitations and interrupts
Elicitations are especially interesting because they align with the workflow interrupt model.
Planned stance:
- expose MCP elicitation capability in catalogs first
- understand the backend semantics first
- later map appropriate elicitation flows into workflow `InterruptNode` behavior
This should be done deliberately, because workflow interrupt semantics are stronger and more structured than generic protocol-level elicitation.
## Tasks and long-running work
Tasks likely align with future run/job persistence, but they should not force that design immediately.
Near-term stance:
- surface task capability in capability catalogs
- understand the task API shape
- leave run persistence mostly out of scope for now
Future direction:
- scheduled jobs
- persisted runs
- polling or event-driven task monitoring
## Notifications and traceability
Traceability is a major requirement.
We should eventually distinguish:
- workflow execution trace
- MCP backend call trace
- client-facing event/notification stream
These are related but not identical.
The design should make it possible to correlate them through:
- connection id
- capability id
- workflow run id
- frame/node ids where appropriate
## Auth and storage
Auth should remain behind a pluggable store interface.
First implementation:
- file-backed store
Expected future replacements:
- database-backed store
- encrypted local store
- secret-manager-backed store
The store should persist:
- auth records
- cached capability snapshots
It may later persist:
- saved plans/workflows
- job specs
- run metadata
## Execution model
Near-term execution stance:
- async-first at the MCP layer
- use existing async workflow runtime
- keep workflow runs mostly in memory
- leave room for future scheduled/offline execution
The important offline use case is:
- build workflow once
- execute it later without the LLM in the loop
This is closer to scheduled automation than to interactive planning.
## Public API direction
`wf_mcp` should expose two explicit entrypoints.
### 1. Convenient/build-style API
For human or higher-level service use.
Examples:
- build workflow from selected catalog items
- helper methods around namespaced tools/resources/prompts
### 2. Raw plan API
For client LLM use.
This should accept plans that:
- reference namespaced capabilities directly
- avoid raw `NodeDef` authoring
- still compile down to `wf_core.Workflow`
## Proposed modules
Existing:
- `models.py`
- `connections.py`
- `store.py`
- `catalog.py`
- `service.py`
- `adapters.py`
- `wrappers.py`
- `mcp_sdk_adapter.py`
Likely next:
- `discovery.py`
- `capabilities.py`
- `events.py`
- `auth.py`
- `plans.py`
- `jobs.py`
## Recommended next implementation order
1. Broaden capability models beyond tools
2. Introduce unified capability snapshots/catalog payloads
3. Add discovery/cache orchestration policy
4. Expose prompt/resource inspection through service APIs
5. Add trace/event correlation hooks
6. Revisit workflow integration for elicitation/tasks
## Guiding rule
`wf_mcp` should not collapse protocol richness into fake simplicity too early.
It should:
- preserve namespacing
- preserve capability boundaries
- preserve traceability
- only turn protocol features into workflow features when the semantic mapping is clear