This commit is contained in:
lda
2026-05-22 00:58:53 +07:00 Verified
parent 617eb90ed9
commit ef3434fc64
22 changed files with 623 additions and 516 deletions
+55 -53
View File
@@ -6,61 +6,63 @@ from wf_core.run_state import RunState
def build_raw_canonical_workflow() -> Workflow: def build_raw_canonical_workflow() -> Workflow:
"""Build a raw core workflow using the canonical post-migration shape.""" """Build a raw core workflow using the canonical post-migration shape."""
return Workflow.model_validate({ return Workflow.model_validate(
"name": "raw_canonical_echo", {
"input_schema": { "name": "raw_canonical_echo",
"type": "object", "input_schema": {
"properties": {"text": {"type": "string"}}, "type": "object",
"required": ["text"], "properties": {"text": {"type": "string"}},
}, "required": ["text"],
"state_schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"reducer": "wf.std.replace",
}
}, },
}, "state_schema": {
"output_schema": { "type": "object",
"type": "object", "properties": {
"properties": {"message": {"type": "string"}}, "message": {
"required": ["message"], "type": "string",
}, "reducer": "wf.std.replace",
"node_defs": [ }
{ },
"name": "format_text", },
"input_schema": { "output_schema": {
"type": "object", "type": "object",
"properties": { "properties": {"message": {"type": "string"}},
"text": {"type": "string"}, "required": ["message"],
"prefix": {"type": "string"}, },
"node_defs": [
{
"name": "format_text",
"input_schema": {
"type": "object",
"properties": {
"text": {"type": "string"},
"prefix": {"type": "string"},
},
"required": ["text", "prefix"],
}, },
"required": ["text", "prefix"], "output_schema": {
}, "type": "object",
"output_schema": { "properties": {"message": {"type": "string"}},
"type": "object", "required": ["message"],
"properties": {"message": {"type": "string"}}, },
"required": ["message"], "outcomes": ["ok"],
}, }
"outcomes": ["ok"], ],
} "start": "format",
], "nodes": [
"start": "format", {
"nodes": [ "id": "format",
{ "type": "node",
"id": "format", "node": "format_text",
"type": "node", "input": [
"node": "format_text", {"target": "text", "path": "input.text"},
"input": [ {"target": "prefix", "value": "raw:"},
{"target": "text", "path": "input.text"}, ],
{"target": "prefix", "value": "raw:"}, "output": [{"source": "message", "target": "state.message"}],
], }
"output": [{"source": "message", "target": "state.message"}], ],
} "edges": [{"from": "format", "outcome": "ok", "to": END}],
], }
"edges": [{"from": "format", "outcome": "ok", "to": END}], )
})
def build_raw_canonical_registry(): def build_raw_canonical_registry():
+6 -4
View File
@@ -38,10 +38,12 @@ def artifact_catalog_entry(
diagnostics: list[DependencyDiagnostic] | tuple[DependencyDiagnostic, ...] = (), diagnostics: list[DependencyDiagnostic] | tuple[DependencyDiagnostic, ...] = (),
) -> WorkflowArtifactCatalogEntry: ) -> WorkflowArtifactCatalogEntry:
"""Project an artifact as a catalog entry without exposing its internal plan.""" """Project an artifact as a catalog entry without exposing its internal plan."""
required_sources = sorted({ required_sources = sorted(
capability.logical_source {
for capability in artifact.required_capability_map().values() capability.logical_source
}) for capability in artifact.required_capability_map().values()
}
)
return WorkflowArtifactCatalogEntry( return WorkflowArtifactCatalogEntry(
name=artifact_node_name(artifact), name=artifact_node_name(artifact),
artifact_id=artifact.id, artifact_id=artifact.id,
+20 -16
View File
@@ -319,22 +319,26 @@ class WfMcpService:
statuses: list[dict[str, Any]] = [] statuses: list[dict[str, Any]] = []
for connection in self.connections.list_all(): for connection in self.connections.list_all():
snapshot = self.store.load_catalog(connection.id) snapshot = self.store.load_catalog(connection.id)
statuses.append({ statuses.append(
"connection_id": connection.id, {
"server": connection.server, "connection_id": connection.id,
"account": connection.account, "server": connection.server,
"enabled": connection.enabled, "account": connection.account,
"has_snapshot": snapshot is not None, "enabled": connection.enabled,
"fetched_at_epoch_ms": None "has_snapshot": snapshot is not None,
if snapshot is None "fetched_at_epoch_ms": None
else snapshot.fetched_at_epoch_ms, if snapshot is None
"max_age_seconds": None else snapshot.fetched_at_epoch_ms,
if snapshot is None "max_age_seconds": None
else snapshot.max_age_seconds, if snapshot is None
"node_count": 0 if snapshot is None else len(snapshot.nodes), else snapshot.max_age_seconds,
"resource_count": 0 if snapshot is None else len(snapshot.resources), "node_count": 0 if snapshot is None else len(snapshot.nodes),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts), "resource_count": 0
}) if snapshot is None
else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
}
)
return statuses return statuses
def list_resources( def list_resources(
+37 -27
View File
@@ -94,20 +94,26 @@ async def _refresh_all(service, connection_id: str | None) -> list[dict[str, Any
try: try:
await service.refresh_connection_catalog(target_id) await service.refresh_connection_catalog(target_id)
snapshot = service.get_connection_snapshot(target_id) snapshot = service.get_connection_snapshot(target_id)
results.append({ results.append(
"connection_id": target_id, {
"refreshed": snapshot is not None, "connection_id": target_id,
"node_count": 0 if snapshot is None else len(snapshot.nodes), "refreshed": snapshot is not None,
"resource_count": 0 if snapshot is None else len(snapshot.resources), "node_count": 0 if snapshot is None else len(snapshot.nodes),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts), "resource_count": 0
}) if snapshot is None
else len(snapshot.resources),
"prompt_count": 0 if snapshot is None else len(snapshot.prompts),
}
)
except Exception as exc: except Exception as exc:
results.append({ results.append(
"connection_id": target_id, {
"refreshed": False, "connection_id": target_id,
"error_type": type(exc).__name__, "refreshed": False,
"error": str(exc), "error_type": type(exc).__name__,
}) "error": str(exc),
}
)
return results return results
@@ -132,16 +138,18 @@ def main(argv: list[str] | None = None) -> int:
service = _service_from_config(args.config) service = _service_from_config(args.config)
if args.command == "connections": if args.command == "connections":
_json_dump([ _json_dump(
{ [
"id": connection.id, {
"server": connection.server, "id": connection.id,
"account": connection.account, "server": connection.server,
"enabled": connection.enabled, "account": connection.account,
"metadata": connection.metadata, "enabled": connection.enabled,
} "metadata": connection.metadata,
for connection in service.connections.list_all() }
]) for connection in service.connections.list_all()
]
)
return 0 return 0
if args.command == "status": if args.command == "status":
@@ -154,10 +162,12 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "refresh": if args.command == "refresh":
results = asyncio.run(_refresh_all(service, args.connection_id)) results = asyncio.run(_refresh_all(service, args.connection_id))
_json_dump({ _json_dump(
"results": results, {
"catalog": service.get_catalog().as_payload(), "results": results,
}) "catalog": service.get_catalog().as_payload(),
}
)
if any(not result["refreshed"] for result in results): if any(not result["refreshed"] for result in results):
return 1 return 1
return 0 return 0
+8 -6
View File
@@ -38,10 +38,12 @@ def connection_to_fastmcp_server_config(
def broker_config_to_fastmcp_config(config: BrokerConfig) -> MCPConfig: def broker_config_to_fastmcp_config(config: BrokerConfig) -> MCPConfig:
"""Convert broker config into FastMCP's multi-server config object.""" """Convert broker config into FastMCP's multi-server config object."""
validate_proxy_config(config) validate_proxy_config(config)
return MCPConfig.from_dict({ return MCPConfig.from_dict(
"mcpServers": { {
connection.id: connection_to_fastmcp_server_config(connection) "mcpServers": {
for connection in config.connections connection.id: connection_to_fastmcp_server_config(connection)
if connection.enabled for connection in config.connections
if connection.enabled
}
} }
}) )
@@ -46,11 +46,13 @@ class ResourceLinkRewritingTool(Tool):
rewrite_uri: Callable[[str], str], rewrite_uri: Callable[[str], str],
) -> ResourceLinkRewritingTool: ) -> ResourceLinkRewritingTool:
"""Copy one tool's public schema while replacing only execution.""" """Copy one tool's public schema while replacing only execution."""
return cls.model_validate({ return cls.model_validate(
**tool.model_dump(), {
"parent_tool": tool, **tool.model_dump(),
"rewrite_uri": rewrite_uri, "parent_tool": tool,
}) "rewrite_uri": rewrite_uri,
}
)
class ResourceLinkNamespace(Transform): class ResourceLinkNamespace(Transform):
+1 -3
View File
@@ -174,9 +174,7 @@ class CapabilitySource:
reducers=_preview_names( reducers=_preview_names(
self.capabilities.reducers, SOURCE_PREVIEW_LIMIT self.capabilities.reducers, SOURCE_PREVIEW_LIMIT
), ),
prompts=_preview_names( prompts=_preview_names(self.capabilities.prompts, SOURCE_PREVIEW_LIMIT),
self.capabilities.prompts, SOURCE_PREVIEW_LIMIT
),
resources=_preview_names( resources=_preview_names(
self.capabilities.resources, SOURCE_PREVIEW_LIMIT self.capabilities.resources, SOURCE_PREVIEW_LIMIT
), ),
+25 -19
View File
@@ -126,16 +126,18 @@ def test_output_bindings_validate_exact_state_schema_before_mutation() -> None:
def test_output_bindings_validate_declared_parent_schema_before_mutation() -> None: def test_output_bindings_validate_declared_parent_schema_before_mutation() -> None:
workflow = _workflow_from_state_schema( workflow = _workflow_from_state_schema(
StateSchema.model_validate({ StateSchema.model_validate(
"type": "object", {
"properties": { "type": "object",
"person": { "properties": {
"type": "object", "person": {
"properties": {"name": {"type": "string"}}, "type": "object",
"additionalProperties": False, "properties": {"name": {"type": "string"}},
} "additionalProperties": False,
}, }
}) },
}
)
) )
state = {"person": {"name": "old"}} state = {"person": {"name": "old"}}
@@ -204,9 +206,9 @@ def _workflow_with_node() -> Workflow:
return Workflow( return Workflow(
name="canonical_output", name="canonical_output",
input_schema=SchemaRef(type="object", properties={}), input_schema=SchemaRef(type="object", properties={}),
state_schema=StateSchema.from_field_map({ state_schema=StateSchema.from_field_map(
"person.name": StateField(type="string") {"person.name": StateField(type="string")}
}), ),
output_schema=SchemaRef( output_schema=SchemaRef(
type="object", properties={"person": {"type": "object"}} type="object", properties={"person": {"type": "object"}}
), ),
@@ -223,12 +225,16 @@ def _workflow_with_node() -> Workflow:
], ],
start="rename", start="rename",
nodes=[ nodes=[
NodeUse.model_validate({ NodeUse.model_validate(
"id": "rename", {
"type": "node", "id": "rename",
"node": "rename", "type": "node",
"output": [{"source": "person.name", "target": "state.person.name"}], "node": "rename",
}) "output": [
{"source": "person.name", "target": "state.person.name"}
],
}
)
], ],
edges=[Edge.model_validate({"from": "rename", "outcome": "ok", "to": END})], edges=[Edge.model_validate({"from": "rename", "outcome": "ok", "to": END})],
) )
+9 -12
View File
@@ -118,18 +118,15 @@ def test_canonical_binding_json_schema_describes_nested_fields():
input_value = defs["InputValueBinding"] input_value = defs["InputValueBinding"]
output = defs["OutputBinding"] output = defs["OutputBinding"]
assert "whole node input payload" in input_path["properties"]["target"][ assert (
"description" "whole node input payload" in input_path["properties"]["target"]["description"]
] )
assert "input, state, or context" in input_path["properties"]["path"][ assert "input, state, or context" in input_path["properties"]["path"]["description"]
"description" assert (
] "Literal JSON-compatible value"
assert "Literal JSON-compatible value" in input_value["properties"]["value"][ in input_value["properties"]["value"]["description"]
"description" )
] assert "whole node output payload" in output["properties"]["source"]["description"]
assert "whole node output payload" in output["properties"]["source"][
"description"
]
assert "Bare state is invalid" in output["properties"]["target"]["description"] assert "Bare state is invalid" in output["properties"]["target"]["description"]
+18 -12
View File
@@ -178,10 +178,12 @@ def _workflow(
return Workflow( return Workflow(
name="mapping_validation", name="mapping_validation",
input_schema=SchemaRef.model_validate({ input_schema=SchemaRef.model_validate(
"type": "object", {
"properties": {"person": {"type": "object"}}, "type": "object",
}), "properties": {"person": {"type": "object"}},
}
),
state_schema=StateSchema.from_field_map( state_schema=StateSchema.from_field_map(
state_fields or {"person": StateField(type="object")} state_fields or {"person": StateField(type="object")}
), ),
@@ -189,14 +191,18 @@ def _workflow(
node_defs=[ node_defs=[
NodeDef( NodeDef(
name="tool", name="tool",
input_schema=SchemaRef.model_validate({ input_schema=SchemaRef.model_validate(
"type": "object", {
"properties": {"user": {"type": "object"}}, "type": "object",
}), "properties": {"user": {"type": "object"}},
output_schema=SchemaRef.model_validate({ }
"type": "object", ),
"properties": {"user": {"type": "object"}}, output_schema=SchemaRef.model_validate(
}), {
"type": "object",
"properties": {"user": {"type": "object"}},
}
),
outcomes=["ok"], outcomes=["ok"],
) )
], ],
+131 -109
View File
@@ -19,52 +19,54 @@ from wf_core import (
def test_canonical_bindings_resolve_input_values_paths_and_explicit_null() -> None: def test_canonical_bindings_resolve_input_values_paths_and_explicit_null() -> None:
workflow = Workflow.model_validate({ workflow = Workflow.model_validate(
"name": "canonical", {
"input_schema": { "name": "canonical",
"type": "object", "input_schema": {
"properties": {"message": {"type": "string"}}, "type": "object",
}, "properties": {"message": {"type": "string"}},
"state_schema": {"fields": {"echoed": {"type": "string"}}}, },
"output_schema": { "state_schema": {"fields": {"echoed": {"type": "string"}}},
"type": "object", "output_schema": {
"properties": {"echoed": {"type": "string"}}, "type": "object",
}, "properties": {"echoed": {"type": "string"}},
"start": "echo", },
"node_defs": [ "start": "echo",
{ "node_defs": [
"name": "echo", {
"input_schema": { "name": "echo",
"type": "object", "input_schema": {
"properties": { "type": "object",
"message": {"type": "string"}, "properties": {
"mode": {"type": "string"}, "message": {"type": "string"},
"maybe": {"type": "null"}, "mode": {"type": "string"},
"maybe": {"type": "null"},
},
"required": ["message", "mode", "maybe"],
}, },
"required": ["message", "mode", "maybe"], "output_schema": {
}, "type": "object",
"output_schema": { "properties": {"echoed": {"type": "string"}},
"type": "object", },
"properties": {"echoed": {"type": "string"}}, "outcomes": ["ok"],
}, }
"outcomes": ["ok"], ],
} "nodes": [
], {
"nodes": [ "id": "echo",
{ "type": "node",
"id": "echo", "node": "echo",
"type": "node", "input": [
"node": "echo", {"target": "message", "path": "input.message"},
"input": [ {"target": "mode", "value": "fast"},
{"target": "message", "path": "input.message"}, {"target": "maybe", "value": None},
{"target": "mode", "value": "fast"}, ],
{"target": "maybe", "value": None}, "output": [{"source": "echoed", "target": "state.echoed"}],
], }
"output": [{"source": "echoed", "target": "state.echoed"}], ],
} "edges": [{"from": "echo", "outcome": "ok", "to": END}],
], }
"edges": [{"from": "echo", "outcome": "ok", "to": END}], )
})
run = execute_workflow( run = execute_workflow(
workflow, workflow,
{"message": "hi"}, {"message": "hi"},
@@ -128,10 +130,12 @@ def test_missing_nested_node_output_path_fails() -> None:
def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None: def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
workflow = Workflow( workflow = Workflow(
name="root_mapping", name="root_mapping",
input_schema=SchemaRef.model_validate({ input_schema=SchemaRef.model_validate(
"type": "object", {
"properties": {"rates": {"type": "object"}}, "type": "object",
}), "properties": {"rates": {"type": "object"}},
}
),
state_schema=StateSchema.from_field_map({"rates": StateField(type="object")}), state_schema=StateSchema.from_field_map({"rates": StateField(type="object")}),
output_schema=SchemaRef(type="object", properties={}), output_schema=SchemaRef(type="object", properties={}),
node_defs=[ node_defs=[
@@ -152,13 +156,15 @@ def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
nodes=[ nodes=[
cast( cast(
Any, Any,
NodeUse.model_validate({ NodeUse.model_validate(
"id": "force", {
"type": "node", "id": "force",
"node": "force_rates", "type": "node",
"in_map": {"input.rates": "."}, "node": "force_rates",
"out_map": {".": "state.rates"}, "in_map": {"input.rates": "."},
}), "out_map": {".": "state.rates"},
}
),
) )
], ],
edges=[Edge.model_validate({"from": "force", "outcome": "ok", "to": END})], edges=[Edge.model_validate({"from": "force", "outcome": "ok", "to": END})],
@@ -190,16 +196,20 @@ def test_static_input_values_are_merged_into_node_local_input() -> None:
node_defs=[ node_defs=[
NodeDef( NodeDef(
name="constant", name="constant",
input_schema=SchemaRef.model_validate({ input_schema=SchemaRef.model_validate(
"type": "object", {
"properties": {"value": {"type": "string"}}, "type": "object",
"required": ["value"], "properties": {"value": {"type": "string"}},
}), "required": ["value"],
output_schema=SchemaRef.model_validate({ }
"type": "object", ),
"properties": {"value": {"type": "string"}}, output_schema=SchemaRef.model_validate(
"required": ["value"], {
}), "type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
}
),
outcomes=["ok"], outcomes=["ok"],
) )
], ],
@@ -207,13 +217,15 @@ def test_static_input_values_are_merged_into_node_local_input() -> None:
nodes=[ nodes=[
cast( cast(
Any, Any,
NodeUse.model_validate({ NodeUse.model_validate(
"id": "constant", {
"type": "node", "id": "constant",
"node": "constant", "type": "node",
"input_values": {"value": "CLICKED"}, "node": "constant",
"out_map": {"value": "state.message"}, "input_values": {"value": "CLICKED"},
}), "out_map": {"value": "state.message"},
}
),
) )
], ],
edges=[Edge.model_validate({"from": "constant", "outcome": "ok", "to": END})], edges=[Edge.model_validate({"from": "constant", "outcome": "ok", "to": END})],
@@ -232,32 +244,40 @@ def test_static_input_values_are_merged_into_node_local_input() -> None:
def _nested_mapping_workflow() -> Workflow: def _nested_mapping_workflow() -> Workflow:
return Workflow( return Workflow(
name="nested_mapping", name="nested_mapping",
input_schema=SchemaRef.model_validate({ input_schema=SchemaRef.model_validate(
"type": "object", {
"properties": { "type": "object",
"person": {"type": "object"}, "properties": {
"digital": {"type": "object"}, "person": {"type": "object"},
}, "digital": {"type": "object"},
}), },
state_schema=StateSchema.from_field_map({ }
"person": StateField(type="object"), ),
"experience": StateField(type="object"), state_schema=StateSchema.from_field_map(
}), {
"person": StateField(type="object"),
"experience": StateField(type="object"),
}
),
output_schema=SchemaRef(type="object", properties={}), output_schema=SchemaRef(type="object", properties={}),
node_defs=[ node_defs=[
NodeDef( NodeDef(
name="big_tool", name="big_tool",
input_schema=SchemaRef.model_validate({ input_schema=SchemaRef.model_validate(
"type": "object", {
"properties": {"user": {"type": "object"}}, "type": "object",
}), "properties": {"user": {"type": "object"}},
output_schema=SchemaRef.model_validate({ }
"type": "object", ),
"properties": { output_schema=SchemaRef.model_validate(
"user": {"type": "object"}, {
"job": {"type": "object"}, "type": "object",
}, "properties": {
}), "user": {"type": "object"},
"job": {"type": "object"},
},
}
),
outcomes=["ok"], outcomes=["ok"],
) )
], ],
@@ -265,20 +285,22 @@ def _nested_mapping_workflow() -> Workflow:
nodes=[ nodes=[
cast( cast(
Any, Any,
NodeUse.model_validate({ NodeUse.model_validate(
"id": "big", {
"type": "node", "id": "big",
"node": "big_tool", "type": "node",
"in_map": { "node": "big_tool",
"input.person.name": "user.name", "in_map": {
"input.digital.email": "user.email", "input.person.name": "user.name",
}, "input.digital.email": "user.email",
"out_map": { },
"user.age": "state.person.age", "out_map": {
"user.gender": "state.person.gender", "user.age": "state.person.age",
"job.years": "state.experience.years", "user.gender": "state.person.gender",
}, "job.years": "state.experience.years",
}), },
}
),
) )
], ],
edges=[Edge.model_validate({"from": "big", "outcome": "ok", "to": END})], edges=[Edge.model_validate({"from": "big", "outcome": "ok", "to": END})],
+97 -79
View File
@@ -10,14 +10,16 @@ from wf_core.runtime.ops.schemas import validate_payload_against_schema
def test_schema_validation_rejects_wrong_property_type() -> None: def test_schema_validation_rejects_wrong_property_type() -> None:
schema = SchemaRef.model_validate({ schema = SchemaRef.model_validate(
"type": "object", {
"properties": { "type": "object",
"name": {"type": "string"}, "properties": {
"count": {"type": "integer"}, "name": {"type": "string"},
}, "count": {"type": "integer"},
"required": ["name", "count"], },
}) "required": ["name", "count"],
}
)
with pytest.raises(WorkflowExecutionError, match=r"count.*not of type 'integer'"): with pytest.raises(WorkflowExecutionError, match=r"count.*not of type 'integer'"):
validate_payload_against_schema( validate_payload_against_schema(
@@ -28,17 +30,19 @@ def test_schema_validation_rejects_wrong_property_type() -> None:
def test_schema_validation_rejects_nested_missing_required_field() -> None: def test_schema_validation_rejects_nested_missing_required_field() -> None:
schema = SchemaRef.model_validate({ schema = SchemaRef.model_validate(
"type": "object", {
"properties": { "type": "object",
"profile": { "properties": {
"type": "object", "profile": {
"properties": {"email": {"type": "string"}}, "type": "object",
"required": ["email"], "properties": {"email": {"type": "string"}},
} "required": ["email"],
}, }
"required": ["profile"], },
}) "required": ["profile"],
}
)
with pytest.raises(WorkflowExecutionError, match=r"profile.*email.*required"): with pytest.raises(WorkflowExecutionError, match=r"profile.*email.*required"):
validate_payload_against_schema( validate_payload_against_schema(
@@ -49,31 +53,35 @@ def test_schema_validation_rejects_nested_missing_required_field() -> None:
def test_schema_validation_accepts_valid_payload() -> None: def test_schema_validation_accepts_valid_payload() -> None:
schema = SchemaRef.model_validate({ schema = SchemaRef.model_validate(
"type": "object", {
"properties": { "type": "object",
"tags": {"type": "array", "items": {"type": "string"}}, "properties": {
}, "tags": {"type": "array", "items": {"type": "string"}},
"required": ["tags"], },
}) "required": ["tags"],
}
)
validate_payload_against_schema(schema, {"tags": ["a", "b"]}, "node input") validate_payload_against_schema(schema, {"tags": ["a", "b"]}, "node input")
def test_schema_ref_accepts_and_preserves_schema_with_defs_and_ref() -> None: def test_schema_ref_accepts_and_preserves_schema_with_defs_and_ref() -> None:
schema = SchemaRef.model_validate({ schema = SchemaRef.model_validate(
"$schema": "https://json-schema.org/draft/2020-12/schema", {
"$defs": { "$schema": "https://json-schema.org/draft/2020-12/schema",
"tag": { "$defs": {
"type": "object", "tag": {
"properties": {"name": {"type": "string"}}, "type": "object",
"required": ["name"], "properties": {"name": {"type": "string"}},
} "required": ["name"],
}, }
"type": "object", },
"properties": {"tag": {"$ref": "#/$defs/tag"}}, "type": "object",
"required": ["tag"], "properties": {"tag": {"$ref": "#/$defs/tag"}},
}) "required": ["tag"],
}
)
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
@@ -89,11 +97,13 @@ def test_schema_ref_rejects_invalid_json_schema_shape() -> None:
def test_schema_ref_defaults_to_draft_2020_12_without_schema_keyword() -> None: def test_schema_ref_defaults_to_draft_2020_12_without_schema_keyword() -> None:
schema = SchemaRef.model_validate({ schema = SchemaRef.model_validate(
"type": "object", {
"properties": {"count": {"type": "integer"}}, "type": "object",
"required": ["count"], "properties": {"count": {"type": "integer"}},
}) "required": ["count"],
}
)
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
@@ -103,11 +113,13 @@ def test_schema_ref_defaults_to_draft_2020_12_without_schema_keyword() -> None:
def test_schema_ref_preserves_extra_json_schema_keywords() -> None: def test_schema_ref_preserves_extra_json_schema_keywords() -> None:
schema = SchemaRef.model_validate({ schema = SchemaRef.model_validate(
"type": "object", {
"properties": {"name": {"type": "string"}}, "type": "object",
"additionalProperties": False, "properties": {"name": {"type": "string"}},
}) "additionalProperties": False,
}
)
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
@@ -125,10 +137,12 @@ def test_schema_ref_dump_omits_none_fields_and_stays_valid_json_schema() -> None
def test_state_field_decl_dump_omits_nested_schema_none_fields() -> None: def test_state_field_decl_dump_omits_nested_schema_none_fields() -> None:
field = StateFieldDecl.model_validate({ field = StateFieldDecl.model_validate(
"path": "state.person", {
"schema": {"type": "object"}, "path": "state.person",
}) "schema": {"type": "object"},
}
)
dumped = field.model_dump(mode="json") dumped = field.model_dump(mode="json")
@@ -140,16 +154,18 @@ def test_state_field_decl_dump_omits_nested_schema_none_fields() -> None:
def test_state_schema_dump_is_valid_json_schema_with_reducer_keyword() -> None: def test_state_schema_dump_is_valid_json_schema_with_reducer_keyword() -> None:
from wf_core import StateSchema from wf_core import StateSchema
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"type": "object", {
"properties": { "type": "object",
"count": { "properties": {
"type": "integer", "count": {
"description": "Running count", "type": "integer",
"reducer": "wf.std.add", "description": "Running count",
} "reducer": "wf.std.add",
}, }
}) },
}
)
dumped = schema.model_dump(mode="json") dumped = schema.model_dump(mode="json")
assert dumped["type"] == "object" assert dumped["type"] == "object"
@@ -161,22 +177,24 @@ def test_state_schema_dump_is_valid_json_schema_with_reducer_keyword() -> None:
def test_state_field_validation_schema_preserves_root_defs_for_local_refs() -> None: def test_state_field_validation_schema_preserves_root_defs_for_local_refs() -> None:
from wf_core import StateSchema from wf_core import StateSchema
schema = StateSchema.model_validate({ schema = StateSchema.model_validate(
"type": "object", {
"$defs": { "type": "object",
"PoolByCategory": { "$defs": {
"type": "object", "PoolByCategory": {
"properties": {"category": {"type": "string"}}, "type": "object",
"required": ["category"], "properties": {"category": {"type": "string"}},
} "required": ["category"],
}, }
"properties": { },
"current_pools": { "properties": {
"type": "array", "current_pools": {
"items": {"$ref": "#/$defs/PoolByCategory"}, "type": "array",
} "items": {"$ref": "#/$defs/PoolByCategory"},
}, }
}) },
}
)
field_schema = schema.field_map()["current_pools"].validation_schema field_schema = schema.field_map()["current_pools"].validation_schema
+8 -6
View File
@@ -26,12 +26,14 @@ async def echo_tool(
async def resource_link_tool() -> list[mcp_types.ResourceLink]: async def resource_link_tool() -> list[mcp_types.ResourceLink]:
"""Return a link to a fixture resource so proxy URI rewriting is testable.""" """Return a link to a fixture resource so proxy URI rewriting is testable."""
return [ return [
mcp_types.ResourceLink.model_validate({ mcp_types.ResourceLink.model_validate(
"type": "resource_link", {
"name": "resource.welcome", "type": "resource_link",
"uri": "fixture://docs/welcome", "name": "resource.welcome",
"mimeType": "text/plain", "uri": "fixture://docs/welcome",
}) "mimeType": "text/plain",
}
)
] ]
+53 -41
View File
@@ -109,33 +109,37 @@ class RateChange:
@node(name="force 6* rating") @node(name="force 6* rating")
@staticmethod @staticmethod
def r80(r: Rates) -> Rates: def r80(r: Rates) -> Rates:
return Rates.model_validate({ return Rates.model_validate(
"rates": { {
"r_1": 0, "rates": {
"r_10": 0, "r_1": 0,
"r_80": r.rates["r_80"], "r_10": 0,
"r_240": r.rates["r_240"], "r_80": r.rates["r_80"],
"r_240": r.rates["r_240"],
}
} }
}) )
@node(name="force banner rating") @node(name="force banner rating")
@staticmethod @staticmethod
def r240(_: Nothing) -> Rates: def r240(_: Nothing) -> Rates:
return Rates.model_validate({ return Rates.model_validate(
"rates": {"r_1": 0, "r_10": 0, "r_80": 0, "r_240": 1} {"rates": {"r_1": 0, "r_10": 0, "r_80": 0, "r_240": 1}}
}) )
@node(name="force 5*+ rating") @node(name="force 5*+ rating")
@staticmethod @staticmethod
def r10(r: Rates) -> Rates: def r10(r: Rates) -> Rates:
return Rates.model_validate({ return Rates.model_validate(
"rates": { {
"r_1": 0, "rates": {
"r_10": r.rates["r_10"], "r_1": 0,
"r_80": r.rates["r_80"], "r_10": r.rates["r_10"],
"r_240": r.rates["r_240"], "r_80": r.rates["r_80"],
"r_240": r.rates["r_240"],
}
} }
}) )
@node(name="buff 6* rating") @node(name="buff 6* rating")
@staticmethod @staticmethod
@@ -153,14 +157,16 @@ class RateChange:
r80 = br["r_80"] * (1 + rpn) r80 = br["r_80"] * (1 + rpn)
r10 = br["r_10"] # use initial rates because i dont know how this works r10 = br["r_10"] # use initial rates because i dont know how this works
r1 = 1 - r240 - r80 - r10 r1 = 1 - r240 - r80 - r10
return Rates.model_validate({ return Rates.model_validate(
"rates": { {
"r_1": r1, "rates": {
"r_10": r10, "r_1": r1,
"r_80": r80, "r_10": r10,
"r_240": r240, "r_80": r80,
"r_240": r240,
}
} }
}) )
@node(name="reset rating") @node(name="reset rating")
@staticmethod @staticmethod
@@ -172,24 +178,28 @@ class CounterUp:
@node(name="counter 6* reset") @node(name="counter 6* reset")
@staticmethod @staticmethod
def c80(_: Nothing) -> Counters: def c80(_: Nothing) -> Counters:
return Counters.model_validate({ return Counters.model_validate(
"counter": { {
"c_80": 0, "counter": {
"c_10": 0, "c_80": 0,
}, "c_10": 0,
"simple_counter": 0, },
# this is influenced by the add reducer. "simple_counter": 0,
# its top level. it doesnt reset. its a miracle. i hate this. # this is influenced by the add reducer.
}) # its top level. it doesnt reset. its a miracle. i hate this.
}
)
@node(name="counter 5* reset") @node(name="counter 5* reset")
@staticmethod @staticmethod
def c10(c: Counters) -> Counters: def c10(c: Counters) -> Counters:
c80 = c.counter["c_80"] c80 = c.counter["c_80"]
return Counters.model_validate({ return Counters.model_validate(
"counter": {"c_10": 0, "c_80": c80}, # merge with or_! {
"simple_counter": 0, "counter": {"c_10": 0, "c_80": c80}, # merge with or_!
}) "simple_counter": 0,
}
)
@node(name="counting up") @node(name="counting up")
@staticmethod @staticmethod
@@ -228,10 +238,12 @@ def roll(state: CurrentPools) -> ThisStorage:
r = state.current_pools r = state.current_pools
(t,) = random.choices(r, weights=[*map(lambda p: p["rates"], r)]) (t,) = random.choices(r, weights=[*map(lambda p: p["rates"], r)])
this = Entity(category=t["category"], name=random.choice(t["pool"])) this = Entity(category=t["category"], name=random.choice(t["pool"]))
return ThisStorage.model_validate({ return ThisStorage.model_validate(
"this": this, {
"storage": [this], # I NEED MERGE "this": this,
}) "storage": [this], # I NEED MERGE
}
)
@node(outcomes=("240", "80", "10", "1")) # missed this! good job. @node(outcomes=("240", "80", "10", "1")) # missed this! good job.
+11 -9
View File
@@ -72,15 +72,17 @@ def test():
assert d.status == RunStatus.COMPLETED, "oops" assert d.status == RunStatus.COMPLETED, "oops"
state = State.model_validate(d.state) state = State.model_validate(d.state)
pprint(state.storage) pprint(state.storage)
pprint([ pprint(
t [
for t in d.trace t
if t.node_id for t in d.trace
in ( if t.node_id
"counter_up", in (
"tick", "counter_up",
) "tick",
]) )
]
)
pprint(d.state) pprint(d.state)
assert any( assert any(
i["name"] in context["pool"]["n_240"] i["name"] in context["pool"]["n_240"]
+9 -7
View File
@@ -84,13 +84,15 @@ class FakeManager:
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
enabled: bool = True, enabled: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
self.added.append({ self.added.append(
"connection_id": connection_id, {
"server": server, "connection_id": connection_id,
"account": account, "server": server,
"metadata": metadata, "account": account,
"enabled": enabled, "metadata": metadata,
}) "enabled": enabled,
}
)
return {"action": "add_connection", "ok": True} return {"action": "add_connection", "ok": True}
def update_connection( def update_connection(
+78 -64
View File
@@ -14,29 +14,33 @@ from .test_support import local_temp_root
def _write_config(path: Path) -> None: def _write_config(path: Path) -> None:
path.write_text( path.write_text(
json.dumps({ json.dumps(
"store_root": ".wf_mcp_store", {
"connections": [ "store_root": ".wf_mcp_store",
{ "connections": [
"id": "demo.personal", {
"server": "demo", "id": "demo.personal",
"account": "personal", "server": "demo",
} "account": "personal",
], }
}), ],
}
),
encoding="utf-8", encoding="utf-8",
) )
def test_build_parser_accepts_serve_transport() -> None: def test_build_parser_accepts_serve_transport() -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args([ args = parser.parse_args(
"--config", [
"wf_mcp.config.json", "--config",
"serve", "wf_mcp.config.json",
"--transport", "serve",
"streamable_http", "--transport",
]) "streamable_http",
]
)
assert args.command == "serve" assert args.command == "serve"
assert args.transport == "streamable_http" assert args.transport == "streamable_http"
@@ -47,15 +51,17 @@ def test_build_parser_accepts_serve_transport() -> None:
def test_build_parser_accepts_proxy_compatibility_flags() -> None: def test_build_parser_accepts_proxy_compatibility_flags() -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args([ args = parser.parse_args(
"--config", [
"wf_mcp.config.json", "--config",
"serve", "wf_mcp.config.json",
"--resources-as-tools", "serve",
"--prompts-as-tools", "--resources-as-tools",
"--search-tools", "--prompts-as-tools",
"--safe-tool-names", "--search-tools",
]) "--safe-tool-names",
]
)
assert args.command == "serve" assert args.command == "serve"
assert args.resources_as_tools is True assert args.resources_as_tools is True
@@ -68,23 +74,27 @@ def test_build_parser_rejects_legacy_mode_flag() -> None:
parser = build_parser() parser = build_parser()
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
parser.parse_args([ parser.parse_args(
"--config", [
"wf_mcp.config.json", "--config",
"serve", "wf_mcp.config.json",
"--mode", "serve",
"unified", "--mode",
]) "unified",
]
)
def test_build_parser_accepts_no_admin_tools_flag() -> None: def test_build_parser_accepts_no_admin_tools_flag() -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args([ args = parser.parse_args(
"--config", [
"wf_mcp.config.json", "--config",
"serve", "wf_mcp.config.json",
"--no-admin-tools", "serve",
]) "--no-admin-tools",
]
)
assert args.command == "serve" assert args.command == "serve"
assert args.admin_tools is False assert args.admin_tools is False
@@ -154,21 +164,23 @@ def test_load_broker_config_normalizes_typed_stdio_metadata() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps({ json.dumps(
"store_root": ".wf_mcp_store", {
"connections": [ "store_root": ".wf_mcp_store",
{ "connections": [
"id": "demo.personal", {
"server": "demo", "id": "demo.personal",
"account": "personal", "server": "demo",
"metadata": { "account": "personal",
"command": "python", "metadata": {
"args": ["server.py"], "command": "python",
"env": {"TOKEN": "secret"}, "args": ["server.py"],
}, "env": {"TOKEN": "secret"},
} },
], }
}), ],
}
),
encoding="utf-8", encoding="utf-8",
) )
@@ -188,16 +200,18 @@ def test_load_broker_config_rejects_bad_metadata_shape() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps({ json.dumps(
"connections": [ {
{ "connections": [
"id": "demo.personal", {
"server": "demo", "id": "demo.personal",
"account": "personal", "server": "demo",
"metadata": {"transport": "stdio", "args": "server.py"}, "account": "personal",
} "metadata": {"transport": "stdio", "args": "server.py"},
], }
}), ],
}
),
encoding="utf-8", encoding="utf-8",
) )
@@ -38,9 +38,7 @@ def test_mcp_workflow_surface_example_runs_happy_path(tmp_path) -> None:
assert payload["diagnostics"] == [] assert payload["diagnostics"] == []
def test_mcp_wrapper_authoring_flow_example_creates_and_calls_wrapper(tmp_path) -> ( def test_mcp_wrapper_authoring_flow_example_creates_and_calls_wrapper(tmp_path) -> None:
None
):
payload = asyncio.run(author_echo_wrapper_from_capability(tmp_path)) payload = asyncio.run(author_echo_wrapper_from_capability(tmp_path))
assert payload["inspected_hints"]["capability_name"] == "demo.personal.echo_tool" assert payload["inspected_hints"]["capability_name"] == "demo.personal.echo_tool"
+31 -23
View File
@@ -389,17 +389,19 @@ def test_proxy_admin_tools_mutate_config_file() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps({ json.dumps(
"store_root": ".wf_mcp_store", {
"connections": [ "store_root": ".wf_mcp_store",
{ "connections": [
"id": "fixture.personal", {
"server": "fixture", "id": "fixture.personal",
"account": "personal", "server": "fixture",
"enabled": False, "account": "personal",
} "enabled": False,
], }
}), ],
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
@@ -484,10 +486,12 @@ def test_proxy_admin_reload_remounts_connections() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps({ json.dumps(
"store_root": ".wf_mcp_store", {
"connections": [], "store_root": ".wf_mcp_store",
}), "connections": [],
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
@@ -544,10 +548,12 @@ def test_proxy_admin_reload_sends_list_changed_notifications() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps({ json.dumps(
"store_root": ".wf_mcp_store", {
"connections": [], "store_root": ".wf_mcp_store",
}), "connections": [],
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
@@ -576,10 +582,12 @@ def test_proxy_config_mutation_does_not_notify_before_reload() -> None:
tmp_path.mkdir(parents=True, exist_ok=True) tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json" config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text( config_path.write_text(
json.dumps({ json.dumps(
"store_root": ".wf_mcp_store", {
"connections": [], "store_root": ".wf_mcp_store",
}), "connections": [],
}
),
encoding="utf-8", encoding="utf-8",
) )
config = load_broker_config(config_path) config = load_broker_config(config_path)
+8 -6
View File
@@ -65,9 +65,11 @@ def test_rewrites_resource_links_inside_call_tool_result() -> None:
def _resource_link(uri: str) -> mcp_types.ResourceLink: def _resource_link(uri: str) -> mcp_types.ResourceLink:
"""Build ResourceLink through validation because Pydantic accepts URI strings.""" """Build ResourceLink through validation because Pydantic accepts URI strings."""
return mcp_types.ResourceLink.model_validate({ return mcp_types.ResourceLink.model_validate(
"type": "resource_link", {
"name": "dynamic-text", "type": "resource_link",
"uri": uri, "name": "dynamic-text",
"mimeType": "text/plain", "uri": uri,
}) "mimeType": "text/plain",
}
)
+9 -9
View File
@@ -133,13 +133,15 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
assert "workspace_id" in create_workspace_schema["properties"] assert "workspace_id" in create_workspace_schema["properties"]
assert "revision" in create_workspace_schema["properties"] assert "revision" in create_workspace_schema["properties"]
list_sources_schema = tools_by_name["wf.admin.list_sources"].inputSchema list_sources_schema = tools_by_name["wf.admin.list_sources"].inputSchema
assert "inspect_source" in list_sources_schema["properties"]["limit"][ assert (
"description" "inspect_source"
] in list_sources_schema["properties"]["limit"]["description"]
)
inspect_source_schema = tools_by_name["wf.admin.inspect_source"].inputSchema inspect_source_schema = tools_by_name["wf.admin.inspect_source"].inputSchema
assert "Exact source id" in inspect_source_schema["properties"][ assert (
"source_id" "Exact source id"
]["description"] in inspect_source_schema["properties"]["source_id"]["description"]
)
minimal_workspace_input = tools_by_name[ minimal_workspace_input = tools_by_name[
"wf.workflow.create_minimal_draft_workspace" "wf.workflow.create_minimal_draft_workspace"
].inputSchema ].inputSchema
@@ -399,9 +401,7 @@ def test_workflow_tools_have_human_metadata() -> None:
assert run_deployment.title == "Run Workflow Deployment" assert run_deployment.title == "Run Workflow Deployment"
assert "deployment_id" in (run_deployment.description or "") assert "deployment_id" in (run_deployment.description or "")
assert "trace_range" in run_deployment.inputSchema["properties"] assert "trace_range" in run_deployment.inputSchema["properties"]
trace_range_schema = run_deployment.inputSchema["properties"][ trace_range_schema = run_deployment.inputSchema["properties"]["trace_range"]
"trace_range"
]
assert "Debug traces" in trace_range_schema.get("description", "") assert "Debug traces" in trace_range_schema.get("description", "")
assert "null" in [ assert "null" in [
option.get("type") for option in trace_range_schema["anyOf"] option.get("type") for option in trace_range_schema["anyOf"]
+1 -3
View File
@@ -284,9 +284,7 @@ def test_workflow_surface_lists_compact_deployment_summaries_and_inspects_detail
artifact_store.save_deployment(deployment) artifact_store.save_deployment(deployment)
listed = asyncio.run(handlers.list_deployments()) listed = asyncio.run(handlers.list_deployments())
inspected = asyncio.run( inspected = asyncio.run(handlers.inspect_deployment(deployment_id="echo.personal"))
handlers.inspect_deployment(deployment_id="echo.personal")
)
assert listed["deployments"][0]["id"] == "echo.personal" assert listed["deployments"][0]["id"] == "echo.personal"
assert listed["deployments"][0]["binding_count"] == 1 assert listed["deployments"][0]["binding_count"] == 1