hot reloading the BRUTAL way

This commit is contained in:
lda
2026-04-30 06:53:32 +07:00 Verified
parent ef2f179110
commit 0d6f04b50e
12 changed files with 715 additions and 49 deletions
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+16
View File
@@ -0,0 +1,16 @@
# Project Overview
`lda-workflow-as-struct` is a Python 3.14 prototype for `lda.chat`: an AI-assisted workflow system where an LLM plans structured workflows and a deterministic executor validates/runs them.
Main packages live under `src/`:
- `wf_core`: workflow model, validation, runtime semantics, frames, trace, interrupts, foreach, async execution.
- `wf_authoring`: ergonomic authoring layer including `@node`, `NodeSpec`, `WorkflowBuilder`, conditions, paths, and subgraph wrapping.
- `wf_mcp`: MCP broker/proxy layer for managing multiple backend MCP connections, discovery/catalog snapshots, transparent FastMCP proxying, config/admin tools, and eventual workflow build/run integration.
Important docs:
- `readme.md`: running design notes and architecture.
- `authoring_sketch.md`: authoring API direction.
- `wf_mcp_plan.md`: MCP proxy/broker/workflow integration plan.
- `scratchpad.md`: rough design history.
Current MCP direction: transparent proxy mode is the main product path. Old broker tools remain useful for debugging/admin/catalog operations, but protocol-native FastMCP proxying exposes upstream tools/resources/prompts as first-class MCP capabilities.
+20
View File
@@ -0,0 +1,20 @@
# Style And Conventions
General:
- Python 3.14, `src/` layout, Pydantic v2 where external/boundary validation is useful.
- Prefer explicit dataclasses for runtime/internal models and Pydantic for config/wire-ish boundary validation.
- Async-first for MCP calls and workflow runtime interactions.
- Keep MCP proxy concepts separate from workflow-specific concepts like `outcome`.
- Do not leak workflow-only fields into MCP `tools/list`.
Code style:
- Use precise type hints and modern Python collection syntax (`list[str]`, `dict[str, Any]`).
- Keep modules layered by responsibility; avoid stuffing everything into service/runtime files.
- Prefer small helper modules when behavior becomes a boundary (`config_models.py`, `config_manager.py`, `proxy_validation.py`).
- Validation should fail early with clear errors.
- Tests should exercise behavior through public APIs/MCP calls where practical.
Editing rules from repo collaboration:
- Use `apply_patch` for manual code edits.
- Do not revert user-owned changes.
- Treat `wf_mcp.config.json` as user-owned live config unless explicitly asked.
+27
View File
@@ -0,0 +1,27 @@
# Suggested Commands
Use PowerShell on Windows from the repo root.
Testing:
- `uv run --with pytest pytest -q`
- Focused example: `uv run --with pytest pytest tests/test_wf_mcp_transparent_proxy.py -q`
Lint/type checks:
- `uv run ruff check src/wf_mcp tests`
- Focused basedpyright example: `uv run basedpyright src/wf_mcp/transparent_proxy.py --level error`
Formatting:
- `uv run ruff format`
CLI / MCP server:
- `uv run wf-mcp --config wf_mcp.config.json serve`
- Transparent proxy mode is default.
- Old broker mode: `uv run wf-mcp --config wf_mcp.config.json serve --mode broker`
- Optional compatibility/search flags: `--resources-as-tools`, `--prompts-as-tools`, `--search-tools`
Useful Windows shell commands:
- Fast search: `rg "pattern" path`
- List files: `Get-ChildItem -Force`
- Read file: `Get-Content -Path path`
- Git status: `git status --short`
- Diff: `git diff -- path`
@@ -0,0 +1,12 @@
# Task Completion Checklist
Before considering a code task done:
- Run focused tests for the touched area.
- Run full tests when the change affects shared behavior: `uv run --with pytest pytest -q`.
- Run ruff on touched source/tests, usually `uv run ruff check src/wf_mcp tests` for MCP work.
- Run focused basedpyright at error level for new or heavily changed files when type issues are likely.
- Check `git status --short` and distinguish user-owned config changes from code changes.
- Summarize functional changes and verification results concisely.
Known environment note:
- Windows sandbox may block commands with `CreateProcessAsUserW failed: 5`; retry important commands with escalation rather than working around via unsafe shell tricks.
+154
View File
@@ -0,0 +1,154 @@
# the name by which the project can be referenced within Serena
project_name: "lda-workflow-as-struct"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- python
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
+5
View File
@@ -19,6 +19,7 @@ from .capabilities import (
) )
from .catalog import CombinedCatalog from .catalog import CombinedCatalog
from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name from .connections import ConnectionRegistry, parse_connection_id, qualify_node_name
from .config_manager import BrokerConfigManager, ConfigMutationError
from .config_models import ( from .config_models import (
BrokerConfigFile, BrokerConfigFile,
ConnectionConfigFile, ConnectionConfigFile,
@@ -43,6 +44,7 @@ from .proxy_validation import ProxyConfigError, validate_transparent_proxy_confi
from .service import WfMcpService from .service import WfMcpService
from .store import FileStore, Store from .store import FileStore, Store
from .transparent_proxy import ( from .transparent_proxy import (
TransparentProxyRuntime,
broker_config_to_fastmcp_config, broker_config_to_fastmcp_config,
connection_to_fastmcp_server_config, connection_to_fastmcp_server_config,
create_proxy_admin_server, create_proxy_admin_server,
@@ -55,6 +57,7 @@ __all__ = [
"AuthRecord", "AuthRecord",
"BackendAdapter", "BackendAdapter",
"BrokerConfig", "BrokerConfig",
"BrokerConfigManager",
"CatalogNodeEntry", "CatalogNodeEntry",
"CatalogPromptEntry", "CatalogPromptEntry",
"CatalogResourceEntry", "CatalogResourceEntry",
@@ -63,6 +66,7 @@ __all__ = [
"ConnectionConfig", "ConnectionConfig",
"ConnectionConfigFile", "ConnectionConfigFile",
"ConnectionRegistry", "ConnectionRegistry",
"ConfigMutationError",
"DiscoveredConnectionCapabilities", "DiscoveredConnectionCapabilities",
"DiscoveredPrompt", "DiscoveredPrompt",
"DiscoveredResource", "DiscoveredResource",
@@ -77,6 +81,7 @@ __all__ = [
"Store", "Store",
"StdioConnectionMetadata", "StdioConnectionMetadata",
"ToolCallResult", "ToolCallResult",
"TransparentProxyRuntime",
"WfMcpService", "WfMcpService",
"build_service_from_config", "build_service_from_config",
"broker_config_to_fastmcp_config", "broker_config_to_fastmcp_config",
+1
View File
@@ -228,6 +228,7 @@ def run_transparent_proxy_server(
config = load_broker_config(config_path) config = load_broker_config(config_path)
server = create_transparent_proxy_server( server = create_transparent_proxy_server(
config, config,
config_path=config_path,
resources_as_tools=resources_as_tools, resources_as_tools=resources_as_tools,
prompts_as_tools=prompts_as_tools, prompts_as_tools=prompts_as_tools,
search_tools=search_tools, search_tools=search_tools,
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .config_models import BrokerConfigFile, ConnectionConfigFile
from .models import BrokerConfig
class ConfigMutationError(ValueError):
"""Raised when a requested config mutation cannot be applied."""
class BrokerConfigManager:
def __init__(self, config_path: str | Path) -> None:
self.config_path = Path(config_path)
def load_file(self) -> BrokerConfigFile:
data = json.loads(self.config_path.read_text(encoding="utf-8"))
return BrokerConfigFile.model_validate(data)
def load_runtime(self) -> BrokerConfig:
return self.load_file().to_runtime(config_path=self.config_path)
def write_file(self, config: BrokerConfigFile) -> None:
payload = config.model_dump(mode="json", exclude_none=True)
text = json.dumps(payload, indent=2) + "\n"
self.config_path.write_text(text, encoding="utf-8")
def get_payload(self) -> dict[str, Any]:
return self.load_file().model_dump(mode="json", exclude_none=True)
def add_connection(
self,
*,
connection_id: str,
server: str,
account: str,
metadata: dict[str, Any] | None = None,
enabled: bool = True,
) -> dict[str, Any]:
config = self.load_file()
if _find_connection(config, connection_id) is not None:
raise ConfigMutationError(f"connection {connection_id!r} already exists")
connection = ConnectionConfigFile(
id=connection_id,
server=server,
account=account,
enabled=enabled,
metadata={} if metadata is None else metadata,
)
config.connections.append(connection)
self.write_file(config)
return _mutation_payload("add_connection", connection_id)
def update_connection(
self,
*,
connection_id: str,
server: str | None = None,
account: str | None = None,
metadata: dict[str, Any] | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
config = self.load_file()
index = _find_connection_index(config, connection_id)
if index is None:
raise ConfigMutationError(f"connection {connection_id!r} does not exist")
existing = config.connections[index]
config.connections[index] = ConnectionConfigFile(
id=existing.id,
server=existing.server if server is None else server,
account=existing.account if account is None else account,
enabled=existing.enabled if enabled is None else enabled,
metadata=existing.metadata if metadata is None else metadata,
)
self.write_file(config)
return _mutation_payload("update_connection", connection_id)
def set_connection_enabled(
self,
connection_id: str,
*,
enabled: bool,
) -> dict[str, Any]:
return self.update_connection(connection_id=connection_id, enabled=enabled)
def remove_connection(self, connection_id: str) -> dict[str, Any]:
config = self.load_file()
index = _find_connection_index(config, connection_id)
if index is None:
raise ConfigMutationError(f"connection {connection_id!r} does not exist")
del config.connections[index]
self.write_file(config)
return _mutation_payload("remove_connection", connection_id)
def _find_connection(
config: BrokerConfigFile,
connection_id: str,
) -> ConnectionConfigFile | None:
index = _find_connection_index(config, connection_id)
if index is None:
return None
return config.connections[index]
def _find_connection_index(
config: BrokerConfigFile,
connection_id: str,
) -> int | None:
for index, connection in enumerate(config.connections):
if connection.id == connection_id:
return index
return None
def _mutation_payload(action: str, connection_id: str) -> dict[str, Any]:
return {
"action": action,
"connection_id": connection_id,
"ok": True,
"requires_reload": True,
}
+161 -36
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path
from typing import Any from typing import Any
from fastmcp import FastMCP from fastmcp import FastMCP
@@ -12,6 +13,7 @@ from fastmcp.server import create_proxy
from fastmcp.server.transforms import Namespace, PromptsAsTools, ResourcesAsTools from fastmcp.server.transforms import Namespace, PromptsAsTools, ResourcesAsTools
from fastmcp.server.transforms.search import BM25SearchTransform from fastmcp.server.transforms.search import BM25SearchTransform
from .config_manager import BrokerConfigManager, ConfigMutationError
from .models import BrokerConfig, ConnectionConfig from .models import BrokerConfig, ConnectionConfig
from .proxy_validation import validate_transparent_proxy_config from .proxy_validation import validate_transparent_proxy_config
@@ -19,10 +21,92 @@ _ADMIN_NAMESPACE = "wf.mcp"
_ADMIN_TOOL_NAMES = [ _ADMIN_TOOL_NAMES = [
f"{_ADMIN_NAMESPACE}_list_connections", f"{_ADMIN_NAMESPACE}_list_connections",
f"{_ADMIN_NAMESPACE}_get_connection_statuses", f"{_ADMIN_NAMESPACE}_get_connection_statuses",
f"{_ADMIN_NAMESPACE}_get_config",
f"{_ADMIN_NAMESPACE}_reload_config",
f"{_ADMIN_NAMESPACE}_add_connection",
f"{_ADMIN_NAMESPACE}_update_connection",
f"{_ADMIN_NAMESPACE}_enable_connection",
f"{_ADMIN_NAMESPACE}_disable_connection",
f"{_ADMIN_NAMESPACE}_remove_connection",
] ]
def create_proxy_admin_server(config: BrokerConfig) -> FastMCP[Any]: class TransparentProxyRuntime:
def __init__(
self,
config: BrokerConfig,
*,
config_path: str | Path | None = None,
resources_as_tools: bool = False,
prompts_as_tools: bool = False,
search_tools: bool = False,
) -> None:
self.config = config
self.manager = None if config_path is None else BrokerConfigManager(config_path)
self.server: FastMCP[Any] = FastMCP(
"wf-mcp-transparent-proxy",
instructions=(
"Transparent MCP proxy over configured upstream MCP connections. "
"Upstream tools, resources, and prompts are exposed as first-class "
"broker capabilities with connection-qualified names."
),
)
self.reload()
if resources_as_tools:
self.server.add_transform(ResourcesAsTools(self.server))
if prompts_as_tools:
self.server.add_transform(PromptsAsTools(self.server))
if search_tools:
self.server.add_transform(BM25SearchTransform(always_visible=_ADMIN_TOOL_NAMES))
def current_config(self) -> BrokerConfig:
if self.manager is None:
return self.config
self.config = self.manager.load_runtime()
return self.config
def require_manager(self) -> BrokerConfigManager:
if self.manager is None:
raise ConfigMutationError(
"config mutation tools require a config path-backed proxy"
)
return self.manager
def reload(self) -> dict[str, Any]:
config = self.current_config()
validate_transparent_proxy_config(config)
self.server.providers[:] = [self.server.local_provider]
admin = create_proxy_admin_server(self)
admin.add_transform(Namespace(_ADMIN_NAMESPACE))
self.server.mount(admin)
mounted_connections: list[str] = []
for connection in config.connections:
if not connection.enabled:
continue
server_config = broker_config_to_fastmcp_config(
BrokerConfig(store_root=config.store_root, connections=[connection])
)
transport = MCPConfigTransport(server_config, name_as_prefix=False)
client = Client(transport=transport, name=f"wf-mcp:{connection.id}")
proxy = create_proxy(client, name=f"Proxy-{connection.id}")
proxy.add_transform(Namespace(connection.id))
self.server.mount(proxy)
mounted_connections.append(connection.id)
return {
"ok": True,
"reloaded": True,
"mounted_connections": mounted_connections,
"connection_count": len(config.connections),
"enabled_connection_count": len(mounted_connections),
}
def create_proxy_admin_server(
runtime: TransparentProxyRuntime,
) -> FastMCP[Any]:
admin = FastMCP( admin = FastMCP(
"wf-mcp-admin", "wf-mcp-admin",
instructions="Administrative tools for this wf-mcp proxy instance.", instructions="Administrative tools for this wf-mcp proxy instance.",
@@ -33,7 +117,7 @@ def create_proxy_admin_server(config: BrokerConfig) -> FastMCP[Any]:
return [ return [
asdict(connection) asdict(connection)
for connection in sorted( for connection in sorted(
config.connections, runtime.current_config().connections,
key=lambda connection: connection.id, key=lambda connection: connection.id,
) )
] ]
@@ -49,11 +133,75 @@ def create_proxy_admin_server(config: BrokerConfig) -> FastMCP[Any]:
"transport": connection.metadata.get("transport"), "transport": connection.metadata.get("transport"),
} }
for connection in sorted( for connection in sorted(
config.connections, runtime.current_config().connections,
key=lambda connection: connection.id, key=lambda connection: connection.id,
) )
] ]
@admin.tool()
async def get_config() -> dict[str, Any]:
if runtime.manager is not None:
return runtime.manager.get_payload()
config = runtime.current_config()
return {
"store_root": str(config.store_root),
"connections": [asdict(connection) for connection in config.connections],
}
@admin.tool()
async def reload_config() -> dict[str, Any]:
return runtime.reload()
@admin.tool()
async def add_connection(
connection_id: str,
server: str,
account: str,
metadata: dict[str, Any] | None = None,
enabled: bool = True,
) -> dict[str, Any]:
return runtime.require_manager().add_connection(
connection_id=connection_id,
server=server,
account=account,
metadata=metadata,
enabled=enabled,
)
@admin.tool()
async def update_connection(
connection_id: str,
server: str | None = None,
account: str | None = None,
metadata: dict[str, Any] | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
return runtime.require_manager().update_connection(
connection_id=connection_id,
server=server,
account=account,
metadata=metadata,
enabled=enabled,
)
@admin.tool()
async def enable_connection(connection_id: str) -> dict[str, Any]:
return runtime.require_manager().set_connection_enabled(
connection_id,
enabled=True,
)
@admin.tool()
async def disable_connection(connection_id: str) -> dict[str, Any]:
return runtime.require_manager().set_connection_enabled(
connection_id,
enabled=False,
)
@admin.tool()
async def remove_connection(connection_id: str) -> dict[str, Any]:
return runtime.require_manager().remove_connection(connection_id)
return admin return admin
@@ -99,6 +247,7 @@ def broker_config_to_fastmcp_config(config: BrokerConfig) -> MCPConfig:
def create_transparent_proxy_server( def create_transparent_proxy_server(
config: BrokerConfig, config: BrokerConfig,
*, *,
config_path: str | Path | None = None,
resources_as_tools: bool = False, resources_as_tools: bool = False,
prompts_as_tools: bool = False, prompts_as_tools: bool = False,
search_tools: bool = False, search_tools: bool = False,
@@ -107,45 +256,20 @@ def create_transparent_proxy_server(
config, config,
resources_as_tools=resources_as_tools, resources_as_tools=resources_as_tools,
prompts_as_tools=prompts_as_tools, prompts_as_tools=prompts_as_tools,
# not yet idk codex help
) )
root = FastMCP( return TransparentProxyRuntime(
"wf-mcp-transparent-proxy", config,
instructions=( config_path=config_path,
"Transparent MCP proxy over configured upstream MCP connections. " resources_as_tools=resources_as_tools,
"Upstream tools, resources, and prompts are exposed as first-class " prompts_as_tools=prompts_as_tools,
"broker capabilities with connection-qualified names." search_tools=search_tools,
), ).server
)
admin = create_proxy_admin_server(config)
admin.add_transform(Namespace(_ADMIN_NAMESPACE))
root.mount(admin)
for connection in config.connections:
if not connection.enabled:
continue
server_config = broker_config_to_fastmcp_config(
BrokerConfig(store_root=config.store_root, connections=[connection])
)
transport = MCPConfigTransport(server_config, name_as_prefix=False)
client = Client(transport=transport, name=f"wf-mcp:{connection.id}")
proxy = create_proxy(client, name=f"Proxy-{connection.id}")
proxy.add_transform(Namespace(connection.id))
root.mount(proxy)
if resources_as_tools:
root.add_transform(ResourcesAsTools(root))
if prompts_as_tools:
root.add_transform(PromptsAsTools(root))
if search_tools:
root.add_transform(BM25SearchTransform(always_visible=_ADMIN_TOOL_NAMES))
return root
def create_transparent_proxy_client( def create_transparent_proxy_client(
config: BrokerConfig, config: BrokerConfig,
*, *,
config_path: str | Path | None = None,
resources_as_tools: bool = False, resources_as_tools: bool = False,
prompts_as_tools: bool = False, prompts_as_tools: bool = False,
search_tools: bool = False, search_tools: bool = False,
@@ -154,6 +278,7 @@ def create_transparent_proxy_client(
FastMCPTransport( FastMCPTransport(
create_transparent_proxy_server( create_transparent_proxy_server(
config, config,
config_path=config_path,
resources_as_tools=resources_as_tools, resources_as_tools=resources_as_tools,
prompts_as_tools=prompts_as_tools, prompts_as_tools=prompts_as_tools,
search_tools=search_tools, search_tools=search_tools,
+161
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import sys import sys
import pytest import pytest
@@ -12,6 +13,7 @@ from wf_mcp import (
create_transparent_proxy_client, create_transparent_proxy_client,
validate_transparent_proxy_config, validate_transparent_proxy_config,
) )
from wf_mcp.broker_server import load_broker_config
from tests.test_wf_mcp_support import fixture_server_path, local_temp_root from tests.test_wf_mcp_support import fixture_server_path, local_temp_root
@@ -185,3 +187,162 @@ def test_transparent_proxy_can_collapse_upstream_tools_behind_search() -> None:
assert "fixture.personal_echo_tool" in str(search_result) assert "fixture.personal_echo_tool" in str(search_result)
asyncio.run(run_proxy()) asyncio.run(run_proxy())
def test_transparent_proxy_admin_tools_mutate_config_file() -> None:
tmp_path = local_temp_root() / "transparent_proxy_admin_store"
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text(
json.dumps(
{
"store_root": ".wf_mcp_store",
"connections": [
{
"id": "fixture.personal",
"server": "fixture",
"account": "personal",
"enabled": False,
}
],
}
),
encoding="utf-8",
)
config = load_broker_config(config_path)
async def run_proxy() -> None:
client = create_transparent_proxy_client(config, config_path=config_path)
async with client:
add_result = await client.call_tool(
"wf.mcp_add_connection",
{
"connection_id": "fixture.work",
"server": "fixture",
"account": "work",
"enabled": False,
"metadata": {
"transport": "stdio",
"command": sys.executable,
"args": [fixture_server_path()],
},
},
)
assert add_result.structured_content == {
"action": "add_connection",
"connection_id": "fixture.work",
"ok": True,
"requires_reload": True,
}
disable_result = await client.call_tool(
"wf.mcp_disable_connection",
{"connection_id": "fixture.work"},
)
assert disable_result.structured_content == {
"action": "update_connection",
"connection_id": "fixture.work",
"ok": True,
"requires_reload": True,
}
update_result = await client.call_tool(
"wf.mcp_update_connection",
{
"connection_id": "fixture.work",
"metadata": {
"transport": "stdio",
"command": sys.executable,
"args": ["updated.py"],
},
},
)
assert update_result.structured_content == {
"action": "update_connection",
"connection_id": "fixture.work",
"ok": True,
"requires_reload": True,
}
config_result = await client.call_tool("wf.mcp_get_config")
assert "fixture.work" in str(config_result.structured_content)
remove_result = await client.call_tool(
"wf.mcp_remove_connection",
{"connection_id": "fixture.work"},
)
assert remove_result.structured_content == {
"action": "remove_connection",
"connection_id": "fixture.work",
"ok": True,
"requires_reload": True,
}
asyncio.run(run_proxy())
config_after = load_broker_config(config_path)
assert [connection.id for connection in config_after.connections] == [
"fixture.personal"
]
def test_transparent_proxy_admin_reload_remounts_connections() -> None:
tmp_path = local_temp_root() / "transparent_proxy_reload_store"
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text(
json.dumps(
{
"store_root": ".wf_mcp_store",
"connections": [],
}
),
encoding="utf-8",
)
config = load_broker_config(config_path)
async def run_proxy() -> None:
client = create_transparent_proxy_client(config, config_path=config_path)
async with client:
initial_tools = await client.list_tools()
initial_names = [tool.name for tool in initial_tools]
assert "fixture.personal_echo_tool" not in initial_names
await client.call_tool(
"wf.mcp_add_connection",
{
"connection_id": "fixture.personal",
"server": "fixture",
"account": "personal",
"metadata": {
"transport": "stdio",
"command": sys.executable,
"args": [fixture_server_path()],
},
},
)
before_reload_tools = await client.list_tools()
before_reload_names = [tool.name for tool in before_reload_tools]
assert "fixture.personal_echo_tool" not in before_reload_names
reload_result = await client.call_tool("wf.mcp_reload_config")
assert reload_result.structured_content == {
"ok": True,
"reloaded": True,
"mounted_connections": ["fixture.personal"],
"connection_count": 1,
"enabled_connection_count": 1,
}
after_reload_tools = await client.list_tools()
after_reload_names = [tool.name for tool in after_reload_tools]
assert "fixture.personal_echo_tool" in after_reload_names
result = await client.call_tool(
"fixture.personal_echo_tool",
{"text": "reloaded"},
)
assert result.structured_content == {"echoed": "reloaded"}
asyncio.run(run_proxy())
+31 -13
View File
@@ -1,6 +1,35 @@
{ {
"store_root": ".wf_mcp_store", "store_root": ".wf_mcp_store",
"connections": [ "connections": [
{
"id": "context7.default",
"server": "context7",
"account": "default",
"enabled": true,
"metadata": {
"transport": "stdio",
"command": "pnpx",
"args": [
"@upstash/context7-mcp"
],
"env": {}
}
},
{
"id": "serena.default",
"server": "serena",
"account": "default",
"enabled": true,
"metadata": {
"transport": "stdio",
"command": "serena",
"args": [
"start-mcp-server",
"--enable-web-dashboard=true"
],
"env": {}
}
},
{ {
"id": "everything.default", "id": "everything.default",
"server": "everything", "server": "everything",
@@ -11,19 +40,8 @@
"command": "pnpx", "command": "pnpx",
"args": [ "args": [
"@modelcontextprotocol/server-everything" "@modelcontextprotocol/server-everything"
] ],
} "env": {}
}, {
"id": "context7.default",
"server": "context7",
"account": "default",
"enabled": true,
"metadata": {
"transport": "stdio",
"command": "pnpx",
"args": [
"@upstash/context7-mcp"
]
} }
} }
] ]