start handling the rest of MCP
its a BIG THING god damn.
This commit is contained in:
+2
-2
@@ -14,5 +14,5 @@ dev = [
|
||||
"pytest>=8",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-p no:cacheprovider"
|
||||
# [tool.pytest.ini_options]
|
||||
# addopts = "-p no:cacheprovider"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# lda.chat - Running Design Notes
|
||||
|
||||
## Related docs
|
||||
|
||||
- [authoring_sketch.md](authoring_sketch.md): `wf_authoring` direction, including `@node`, `NodeSpec`, builder ergonomics, async registry seams, and subgraph-as-node.
|
||||
- [wf_mcp_plan.md](wf_mcp_plan.md): `wf_mcp` direction as a namespaced MCP capability broker plus workflow build/run layer.
|
||||
- [scratchpad.md](scratchpad.md): rougher design history and intermediate spec notes that fed the current model.
|
||||
|
||||
## What is this
|
||||
|
||||
`lda.chat` is an AI agent that turns natural language requests into executable
|
||||
|
||||
@@ -14,6 +14,8 @@ from wf_core import END, RuntimeContext, RunStatus
|
||||
from wf_mcp import (
|
||||
AuthRecord,
|
||||
ConnectionConfig,
|
||||
DiscoveredPrompt,
|
||||
DiscoveredResource,
|
||||
DiscoveredTool,
|
||||
FileStore,
|
||||
McpSdkAdapter,
|
||||
@@ -123,6 +125,52 @@ class FakeAdapter:
|
||||
)
|
||||
]
|
||||
|
||||
async def list_resources(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredResource]:
|
||||
return [
|
||||
DiscoveredResource(
|
||||
uri="demo://docs/welcome",
|
||||
name="resource.welcome",
|
||||
description="Welcome resource",
|
||||
mime_type="text/plain",
|
||||
metadata={"kind": "static"},
|
||||
)
|
||||
]
|
||||
|
||||
async def list_prompts(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredPrompt]:
|
||||
return [
|
||||
DiscoveredPrompt(
|
||||
name="prompt.summarize",
|
||||
description="Summarize text",
|
||||
arguments=[
|
||||
{
|
||||
"name": "text",
|
||||
"required": True,
|
||||
"description": "Text to summarize",
|
||||
}
|
||||
],
|
||||
metadata={"kind": "template"},
|
||||
)
|
||||
]
|
||||
|
||||
async def get_connection_metadata(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"server": connection.server,
|
||||
"account": connection.account,
|
||||
"auth_scheme": auth.scheme if auth is not None else None,
|
||||
}
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
@@ -262,6 +310,45 @@ def test_service_refreshes_catalog_from_adapter() -> None:
|
||||
},
|
||||
}
|
||||
]
|
||||
assert payload["resources"] == [
|
||||
{
|
||||
"qualified_name": "demo.personal.resource.welcome",
|
||||
"connection_id": "demo.personal",
|
||||
"local_name": "resource.welcome",
|
||||
"uri": "demo://docs/welcome",
|
||||
"description": "Welcome resource",
|
||||
"mime_type": "text/plain",
|
||||
"metadata": {"kind": "static"},
|
||||
}
|
||||
]
|
||||
assert payload["prompts"] == [
|
||||
{
|
||||
"qualified_name": "demo.personal.prompt.summarize",
|
||||
"connection_id": "demo.personal",
|
||||
"local_name": "prompt.summarize",
|
||||
"description": "Summarize text",
|
||||
"arguments": [
|
||||
{
|
||||
"name": "text",
|
||||
"required": True,
|
||||
"description": "Text to summarize",
|
||||
}
|
||||
],
|
||||
"metadata": {"kind": "template"},
|
||||
}
|
||||
]
|
||||
assert payload["connections"] == [
|
||||
{
|
||||
"connection_id": "demo.personal",
|
||||
"fetched_at_epoch_ms": payload["connections"][0]["fetched_at_epoch_ms"],
|
||||
"max_age_seconds": 300,
|
||||
"metadata": {
|
||||
"server": "demo",
|
||||
"account": "personal",
|
||||
"auth_scheme": "token",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_mcp_sdk_adapter_lists_and_calls_stdio_tool() -> None:
|
||||
@@ -328,3 +415,5 @@ def test_mcp_sdk_adapter_can_probe_everything_server() -> None:
|
||||
node["qualified_name"].startswith("everything.default.")
|
||||
for node in payload["nodes"]
|
||||
)
|
||||
assert "resources" in payload
|
||||
assert "prompts" in payload
|
||||
|
||||
+13
-1
@@ -1,9 +1,17 @@
|
||||
from .adapters import BackendAdapter, DiscoveredTool, ToolCallResult
|
||||
from .adapters import (
|
||||
BackendAdapter,
|
||||
DiscoveredPrompt,
|
||||
DiscoveredResource,
|
||||
DiscoveredTool,
|
||||
ToolCallResult,
|
||||
)
|
||||
from .catalog import CombinedCatalog
|
||||
from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name
|
||||
from .models import (
|
||||
AuthRecord,
|
||||
CatalogNodeEntry,
|
||||
CatalogPromptEntry,
|
||||
CatalogResourceEntry,
|
||||
CatalogSnapshot,
|
||||
ConnectionConfig,
|
||||
RawWorkflowPlan,
|
||||
@@ -17,10 +25,14 @@ __all__ = [
|
||||
"AuthRecord",
|
||||
"BackendAdapter",
|
||||
"CatalogNodeEntry",
|
||||
"CatalogPromptEntry",
|
||||
"CatalogResourceEntry",
|
||||
"CatalogSnapshot",
|
||||
"CombinedCatalog",
|
||||
"ConnectionConfig",
|
||||
"ConnectionRegistry",
|
||||
"DiscoveredPrompt",
|
||||
"DiscoveredResource",
|
||||
"DiscoveredTool",
|
||||
"FileStore",
|
||||
"McpSdkAdapter",
|
||||
|
||||
@@ -16,6 +16,23 @@ class DiscoveredTool:
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DiscoveredResource:
|
||||
uri: str
|
||||
name: str
|
||||
description: str | None
|
||||
mime_type: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DiscoveredPrompt:
|
||||
name: str
|
||||
description: str | None
|
||||
arguments: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolCallResult:
|
||||
outcome: str
|
||||
@@ -30,6 +47,24 @@ class BackendAdapter(Protocol):
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredTool]: ...
|
||||
|
||||
async def list_resources(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredResource]: ...
|
||||
|
||||
async def list_prompts(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredPrompt]: ...
|
||||
|
||||
async def get_connection_metadata(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
|
||||
+84
-2
@@ -5,14 +5,23 @@ from typing import Any
|
||||
|
||||
from wf_authoring import NodeCatalog, NodeSpec
|
||||
|
||||
from .adapters import DiscoveredPrompt, DiscoveredResource
|
||||
from .connections import qualify_node_name
|
||||
from .models import CatalogNodeEntry, CatalogSnapshot
|
||||
from .models import (
|
||||
CatalogNodeEntry,
|
||||
CatalogPromptEntry,
|
||||
CatalogResourceEntry,
|
||||
CatalogSnapshot,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_from_specs(
|
||||
connection_id: str,
|
||||
*,
|
||||
specs: dict[str, NodeSpec[Any, Any]],
|
||||
resources: list[DiscoveredResource] | None = None,
|
||||
prompts: list[DiscoveredPrompt] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
fetched_at_epoch_ms: int,
|
||||
max_age_seconds: int,
|
||||
) -> CatalogSnapshot:
|
||||
@@ -31,11 +40,37 @@ def snapshot_from_specs(
|
||||
)
|
||||
for entry in catalog.entries()
|
||||
]
|
||||
resource_entries = [
|
||||
CatalogResourceEntry(
|
||||
qualified_name=qualify_node_name(connection_id, resource.name),
|
||||
connection_id=connection_id,
|
||||
local_name=resource.name,
|
||||
uri=resource.uri,
|
||||
description=resource.description,
|
||||
mime_type=resource.mime_type,
|
||||
metadata=resource.metadata,
|
||||
)
|
||||
for resource in resources or []
|
||||
]
|
||||
prompt_entries = [
|
||||
CatalogPromptEntry(
|
||||
qualified_name=qualify_node_name(connection_id, prompt.name),
|
||||
connection_id=connection_id,
|
||||
local_name=prompt.name,
|
||||
description=prompt.description,
|
||||
arguments=prompt.arguments,
|
||||
metadata=prompt.metadata,
|
||||
)
|
||||
for prompt in prompts or []
|
||||
]
|
||||
return CatalogSnapshot(
|
||||
connection_id=connection_id,
|
||||
fetched_at_epoch_ms=fetched_at_epoch_ms,
|
||||
max_age_seconds=max_age_seconds,
|
||||
nodes=nodes,
|
||||
resources=resource_entries,
|
||||
prompts=prompt_entries,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -49,6 +84,18 @@ class CombinedCatalog:
|
||||
result.extend(snapshot.nodes)
|
||||
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||
|
||||
def resource_entries(self) -> list[CatalogResourceEntry]:
|
||||
result: list[CatalogResourceEntry] = []
|
||||
for snapshot in self.snapshots.values():
|
||||
result.extend(snapshot.resources)
|
||||
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||
|
||||
def prompt_entries(self) -> list[CatalogPromptEntry]:
|
||||
result: list[CatalogPromptEntry] = []
|
||||
for snapshot in self.snapshots.values():
|
||||
result.extend(snapshot.prompts)
|
||||
return sorted(result, key=lambda entry: entry.qualified_name)
|
||||
|
||||
def as_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"nodes": [
|
||||
@@ -62,5 +109,40 @@ class CombinedCatalog:
|
||||
"output_schema": entry.output_schema,
|
||||
}
|
||||
for entry in self.entries()
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"qualified_name": entry.qualified_name,
|
||||
"connection_id": entry.connection_id,
|
||||
"local_name": entry.local_name,
|
||||
"uri": entry.uri,
|
||||
"description": entry.description,
|
||||
"mime_type": entry.mime_type,
|
||||
"metadata": entry.metadata,
|
||||
}
|
||||
for entry in self.resource_entries()
|
||||
],
|
||||
"prompts": [
|
||||
{
|
||||
"qualified_name": entry.qualified_name,
|
||||
"connection_id": entry.connection_id,
|
||||
"local_name": entry.local_name,
|
||||
"description": entry.description,
|
||||
"arguments": entry.arguments,
|
||||
"metadata": entry.metadata,
|
||||
}
|
||||
for entry in self.prompt_entries()
|
||||
],
|
||||
"connections": [
|
||||
{
|
||||
"connection_id": snapshot.connection_id,
|
||||
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
|
||||
"max_age_seconds": snapshot.max_age_seconds,
|
||||
"metadata": snapshot.metadata,
|
||||
}
|
||||
for snapshot in sorted(
|
||||
self.snapshots.values(),
|
||||
key=lambda snapshot: snapshot.connection_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
@@ -8,9 +8,18 @@ from mcp.client.session import ClientSession
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import CallToolResult as McpCallToolResult
|
||||
from mcp.types import ListPromptsResult, ListResourcesResult
|
||||
from mcp.types import ListToolsResult, Tool as McpTool
|
||||
from mcp.types import Prompt as McpPrompt
|
||||
from mcp.types import Resource as McpResource
|
||||
|
||||
from .adapters import BackendAdapter, DiscoveredTool, ToolCallResult
|
||||
from .adapters import (
|
||||
BackendAdapter,
|
||||
DiscoveredPrompt,
|
||||
DiscoveredResource,
|
||||
DiscoveredTool,
|
||||
ToolCallResult,
|
||||
)
|
||||
from .models import AuthRecord, ConnectionConfig
|
||||
|
||||
|
||||
@@ -39,6 +48,28 @@ def _tool_to_discovered(tool: McpTool) -> DiscoveredTool:
|
||||
)
|
||||
|
||||
|
||||
def _resource_to_discovered(resource: McpResource) -> DiscoveredResource:
|
||||
return DiscoveredResource(
|
||||
uri=str(resource.uri),
|
||||
name=str(resource.uri),
|
||||
description=resource.description,
|
||||
mime_type=resource.mimeType,
|
||||
metadata=resource.model_dump(by_alias=True),
|
||||
)
|
||||
|
||||
|
||||
def _prompt_to_discovered(prompt: McpPrompt) -> DiscoveredPrompt:
|
||||
arguments = [
|
||||
argument.model_dump(by_alias=True) for argument in prompt.arguments or []
|
||||
]
|
||||
return DiscoveredPrompt(
|
||||
name=prompt.name,
|
||||
description=prompt.description,
|
||||
arguments=arguments,
|
||||
metadata=prompt.model_dump(by_alias=True),
|
||||
)
|
||||
|
||||
|
||||
def _tool_result_to_call_result(result: McpCallToolResult) -> ToolCallResult:
|
||||
if result.structuredContent is not None:
|
||||
output = result.structuredContent
|
||||
@@ -107,6 +138,34 @@ class McpSdkAdapter(BackendAdapter):
|
||||
result: ListToolsResult = await session.list_tools()
|
||||
return [_tool_to_discovered(tool) for tool in result.tools]
|
||||
|
||||
async def list_resources(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredResource]:
|
||||
async with self._session(connection, auth) as session:
|
||||
result: ListResourcesResult = await session.list_resources()
|
||||
return [_resource_to_discovered(resource) for resource in result.resources]
|
||||
|
||||
async def list_prompts(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredPrompt]:
|
||||
async with self._session(connection, auth) as session:
|
||||
result: ListPromptsResult = await session.list_prompts()
|
||||
return [_prompt_to_discovered(prompt) for prompt in result.prompts]
|
||||
|
||||
async def get_connection_metadata(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
auth: AuthRecord | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"server": connection.server,
|
||||
"transport": connection.metadata.get("transport", "stdio"),
|
||||
}
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
|
||||
@@ -31,12 +31,36 @@ class CatalogNodeEntry:
|
||||
output_schema: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CatalogResourceEntry:
|
||||
qualified_name: str
|
||||
connection_id: str
|
||||
local_name: str
|
||||
uri: str
|
||||
description: str | None
|
||||
mime_type: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CatalogPromptEntry:
|
||||
qualified_name: str
|
||||
connection_id: str
|
||||
local_name: str
|
||||
description: str | None
|
||||
arguments: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CatalogSnapshot:
|
||||
connection_id: str
|
||||
fetched_at_epoch_ms: int
|
||||
max_age_seconds: int
|
||||
nodes: list[CatalogNodeEntry] = field(default_factory=list)
|
||||
resources: list[CatalogResourceEntry] = field(default_factory=list)
|
||||
prompts: list[CatalogPromptEntry] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def is_stale(self, now_epoch_ms: int) -> bool:
|
||||
age_ms = now_epoch_ms - self.fetched_at_epoch_ms
|
||||
@@ -60,4 +84,7 @@ def dump_catalog_snapshot(snapshot: CatalogSnapshot) -> dict[str, Any]:
|
||||
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
|
||||
"max_age_seconds": snapshot.max_age_seconds,
|
||||
"nodes": [asdict(node) for node in snapshot.nodes],
|
||||
"resources": [asdict(resource) for resource in snapshot.resources],
|
||||
"prompts": [asdict(prompt) for prompt in snapshot.prompts],
|
||||
"metadata": snapshot.metadata,
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ class WfMcpService:
|
||||
|
||||
auth = self.load_auth(connection_id)
|
||||
tools = await adapter.list_tools(connection, auth)
|
||||
resources = await adapter.list_resources(connection, auth)
|
||||
prompts = await adapter.list_prompts(connection, auth)
|
||||
metadata = await adapter.get_connection_metadata(connection, auth)
|
||||
specs = [
|
||||
wrap_discovered_tool(
|
||||
connection=connection,
|
||||
@@ -107,6 +110,16 @@ class WfMcpService:
|
||||
*specs,
|
||||
max_age_seconds=max_age_seconds,
|
||||
)
|
||||
snapshot = snapshot_from_specs(
|
||||
connection_id,
|
||||
specs=self.specs_by_connection.get(connection_id, {}),
|
||||
resources=resources,
|
||||
prompts=prompts,
|
||||
metadata=metadata,
|
||||
fetched_at_epoch_ms=int(time.time() * 1000),
|
||||
max_age_seconds=max_age_seconds or self.default_catalog_max_age_seconds,
|
||||
)
|
||||
self.store.save_catalog(snapshot)
|
||||
|
||||
def compile_plan(self, plan: RawWorkflowPlan) -> Workflow:
|
||||
node_defs: dict[str, Any] = {}
|
||||
|
||||
+25
-10
@@ -1,10 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from .models import AuthRecord, CatalogNodeEntry, CatalogSnapshot
|
||||
from .models import (
|
||||
AuthRecord,
|
||||
CatalogNodeEntry,
|
||||
CatalogPromptEntry,
|
||||
CatalogResourceEntry,
|
||||
CatalogSnapshot,
|
||||
dump_catalog_snapshot,
|
||||
)
|
||||
|
||||
|
||||
class Store:
|
||||
@@ -44,7 +50,14 @@ class FileStore(Store):
|
||||
|
||||
def save_auth(self, record: AuthRecord) -> None:
|
||||
self._auth_path(record.connection_id).write_text(
|
||||
json.dumps(asdict(record), indent=2),
|
||||
json.dumps(
|
||||
{
|
||||
"connection_id": record.connection_id,
|
||||
"scheme": record.scheme,
|
||||
"payload": record.payload,
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -56,14 +69,8 @@ class FileStore(Store):
|
||||
return AuthRecord(**data)
|
||||
|
||||
def save_catalog(self, snapshot: CatalogSnapshot) -> None:
|
||||
payload = {
|
||||
"connection_id": snapshot.connection_id,
|
||||
"fetched_at_epoch_ms": snapshot.fetched_at_epoch_ms,
|
||||
"max_age_seconds": snapshot.max_age_seconds,
|
||||
"nodes": [asdict(node) for node in snapshot.nodes],
|
||||
}
|
||||
self._catalog_path(snapshot.connection_id).write_text(
|
||||
json.dumps(payload, indent=2),
|
||||
json.dumps(dump_catalog_snapshot(snapshot), indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -77,4 +84,12 @@ class FileStore(Store):
|
||||
fetched_at_epoch_ms=data["fetched_at_epoch_ms"],
|
||||
max_age_seconds=data["max_age_seconds"],
|
||||
nodes=[CatalogNodeEntry(**node) for node in data.get("nodes", [])],
|
||||
resources=[
|
||||
CatalogResourceEntry(**resource)
|
||||
for resource in data.get("resources", [])
|
||||
],
|
||||
prompts=[
|
||||
CatalogPromptEntry(**prompt) for prompt in data.get("prompts", [])
|
||||
],
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
+138
-101
@@ -162,6 +162,31 @@ Already present:
|
||||
|
||||
This is a good first vertical slice for tool execution.
|
||||
|
||||
## What "good proxy" means
|
||||
|
||||
For `wf_mcp`, "proxy" should not only mean "call tools on behalf of a client."
|
||||
|
||||
It should mean:
|
||||
|
||||
- discover backend MCP capabilities and expose them with stable namespacing
|
||||
- preserve enough metadata that a client can understand what exists and how to use it
|
||||
- forward or mirror backend-facing events and responses in a traceable way
|
||||
- keep auth/session boundaries explicit per connection
|
||||
- separate "what the backend offers" from "what becomes a workflow node"
|
||||
|
||||
So the proxy layer should eventually broker:
|
||||
|
||||
- tools
|
||||
- resources
|
||||
- prompts
|
||||
- notifications/events
|
||||
- auth-related state markers
|
||||
- tasks / long-running operations
|
||||
- elicitations / human-input capabilities
|
||||
- app/server metadata
|
||||
|
||||
Not all of these need workflow semantics immediately, but they should still have a place in the package model.
|
||||
|
||||
## Next capability expansion
|
||||
|
||||
The next architectural move should be broadening the capability model beyond tools.
|
||||
@@ -202,6 +227,81 @@ The client-facing catalog payload should be usable by:
|
||||
- an LLM that builds workflows
|
||||
- internal service code
|
||||
|
||||
## Near-term implementation order
|
||||
|
||||
This is the intended order of work from here.
|
||||
|
||||
### Milestone 1: Broaden discovery models
|
||||
|
||||
Add normalized discovery models for:
|
||||
|
||||
- resources
|
||||
- prompts
|
||||
- notifications
|
||||
- tasks
|
||||
- elicitations
|
||||
- app/server metadata
|
||||
|
||||
Outcome:
|
||||
|
||||
- `wf_mcp` can describe more of an MCP backend than just tools
|
||||
- namespacing rules apply uniformly across capability types
|
||||
|
||||
### Milestone 2: Unified capability snapshots
|
||||
|
||||
Move from tool-only snapshots to connection-scoped capability snapshots that can store:
|
||||
|
||||
- tools
|
||||
- resources
|
||||
- prompts
|
||||
- capability metadata
|
||||
- freshness timestamps
|
||||
- auth-state hints
|
||||
|
||||
Outcome:
|
||||
|
||||
- the catalog becomes useful to a real client or inspector
|
||||
- capability caching is no longer tool-specific
|
||||
|
||||
### Milestone 3: Service APIs for non-tool capabilities
|
||||
|
||||
Expose service methods that let a client:
|
||||
|
||||
- inspect resources
|
||||
- inspect prompts
|
||||
- read capability metadata
|
||||
- refresh one connection or all connections
|
||||
|
||||
Outcome:
|
||||
|
||||
- `wf_mcp` becomes a usable broker, not only a workflow launcher
|
||||
|
||||
### Milestone 4: Trace/event correlation
|
||||
|
||||
Add a light event model that can connect:
|
||||
|
||||
- client action
|
||||
- backend MCP request/response
|
||||
- workflow run/step trace
|
||||
|
||||
Outcome:
|
||||
|
||||
- better observability
|
||||
- cleaner debugging
|
||||
- future notification/task support has a home
|
||||
|
||||
### Milestone 5: Selective workflow mapping
|
||||
|
||||
Only after the above is stable:
|
||||
|
||||
- keep tools as workflow nodes
|
||||
- evaluate whether prompts/resources should become helper nodes or stay catalog-only
|
||||
- map elicitation to workflow interrupts carefully
|
||||
|
||||
Outcome:
|
||||
|
||||
- workflow integration grows from understood protocol behavior, not guesses
|
||||
|
||||
## Workflow integration policy
|
||||
|
||||
Not every capability becomes a workflow node right away.
|
||||
@@ -258,120 +358,57 @@ Traceability is a major requirement.
|
||||
We should eventually distinguish:
|
||||
|
||||
- workflow execution trace
|
||||
- MCP backend call trace
|
||||
- client-facing event/notification stream
|
||||
- backend MCP call trace
|
||||
- client-facing event stream
|
||||
|
||||
These are related but not identical.
|
||||
The important design rule is that these should be related, but not collapsed into one giant anonymous log.
|
||||
|
||||
The design should make it possible to correlate them through:
|
||||
## Concrete next files
|
||||
|
||||
- connection id
|
||||
- capability id
|
||||
- workflow run id
|
||||
- frame/node ids where appropriate
|
||||
If we follow the plan above, the next likely modules are:
|
||||
|
||||
## Auth and storage
|
||||
- `wf_mcp/discovery.py`
|
||||
- orchestrate refresh and cache policy for all capability types
|
||||
- `wf_mcp/capabilities.py`
|
||||
- broader normalized capability models if `models.py` starts getting crowded
|
||||
- `wf_mcp/events.py`
|
||||
- event / trace correlation shapes
|
||||
- `wf_mcp/resources.py`
|
||||
- resource-facing service helpers
|
||||
- `wf_mcp/prompts.py`
|
||||
- prompt-facing service helpers
|
||||
|
||||
Auth should remain behind a pluggable store interface.
|
||||
Whether these stay as separate files or fold back into `models.py` / `service.py` should be driven by clarity, not purity.
|
||||
|
||||
First implementation:
|
||||
## Test organization direction
|
||||
|
||||
- file-backed store
|
||||
The current test suite is still small enough to live in two top-level files, but it will get noisy if `wf_mcp` grows beyond tools.
|
||||
|
||||
Expected future replacements:
|
||||
Recommended direction once the next capability work starts:
|
||||
|
||||
- database-backed store
|
||||
- encrypted local store
|
||||
- secret-manager-backed store
|
||||
- `tests/wf_core/`
|
||||
- `tests/wf_authoring/`
|
||||
- `tests/wf_mcp/`
|
||||
- `tests/fixtures/`
|
||||
|
||||
The store should persist:
|
||||
For `wf_mcp`, likely split by concern:
|
||||
|
||||
- auth records
|
||||
- cached capability snapshots
|
||||
- `tests/wf_mcp/test_store.py`
|
||||
- `tests/wf_mcp/test_catalog.py`
|
||||
- `tests/wf_mcp/test_service.py`
|
||||
- `tests/wf_mcp/test_adapters.py`
|
||||
- `tests/wf_mcp/test_sdk_adapter.py`
|
||||
|
||||
It may later persist:
|
||||
This does not need to happen immediately, but it should happen before one `test_wf_mcp.py` turns into a junk drawer.
|
||||
|
||||
- saved plans/workflows
|
||||
- job specs
|
||||
- run metadata
|
||||
## Working rule for the next phase
|
||||
|
||||
## Execution model
|
||||
Before making a new MCP capability executable inside workflows, first answer:
|
||||
|
||||
Near-term execution stance:
|
||||
- is this primarily catalog/proxy surface, or workflow surface?
|
||||
- what is the stable namespaced identifier?
|
||||
- what is the trace/event story?
|
||||
- does it need auth/session context?
|
||||
- does it map cleanly to `wf_core`, or does it stay above it?
|
||||
|
||||
- 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
|
||||
That rule should keep us from forcing everything through the node model too early.
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 33 KiB |
Reference in New Issue
Block a user