Files
lda-wf/docs/historical/superpowers/plans/2026-06-05-legacy-mcp-config-migration.md
T

20 KiB

Legacy MCP Config Migration Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Convert old wf_mcp.config.json files into the new neutral WorkflowConfigFile shape and expose a CLI migration command.

Architecture: The migration is compatibility-only: old MCP broker config remains readable, but new output should use wf_config.server.store and wf_config.server.sources[]. Before adding the converter, fix the neutral MCP source bridge so it produces the flat connection metadata shape still expected by wf_mcp.sdk.adapter and wf_mcp.runtime.factory.

Tech Stack: Pydantic v2 config models, Typer CLI, existing wf_config / wf_mcp.control.models / wf_mcp.broker.config modules.


File Structure

  • Modify src/wf_mcp/source_registry.py: fix workflow_mcp_source_to_connection_config() to emit flat runtime metadata, not nested transport dicts.
  • Create or modify tests/wf_mcp/test_workflow_config_bridge.py: add runtime-metadata assertions.
  • Modify src/wf_mcp/broker/config.py: add workflow_config_from_broker_config_file() and migrate_broker_config_file().
  • Modify tests/wf_mcp/test_workflow_config_migration.py: test legacy config file conversion.
  • Modify src/wf_cli/commands/config.py and src/wf_cli/app.py: add wf config migrate-mcp.
  • Create tests/wf_cli/test_config_migration.py: test CLI conversion output.
  • Modify docs: docs/wf_cli.md, docs/current_roadmap.md, docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md.

Current Context

Neutral MCP source config exists in wf_config.models.McpSourceConfig.

MCP runtime code currently expects ConnectionConfig.metadata to be flat:

# src/wf_mcp/sdk/adapter.py and src/wf_mcp/runtime/factory.py
transport = connection.metadata.get("transport", "stdio")
command = connection.metadata["command"]
args = list(connection.metadata.get("args", []))
url = connection.metadata["url"]

The current workflow_mcp_source_to_connection_config() added by the previous slice stores:

"transport": transport.model_dump(mode="json")

That is fine for registry-like payloads, but wrong for actual runtime execution. Fix that before writing the migration converter.

Task 1: Lock Runtime Metadata Shape for Neutral MCP Sources

Files:

  • Modify: tests/wf_mcp/test_workflow_config_bridge.py

  • Modify: src/wf_mcp/source_registry.py

  • Step 1: Strengthen the stdio conversion test

In tests/wf_mcp/test_workflow_config_bridge.py, update the existing stdio assertion from nested transport dict:

assert connection.metadata["transport"] == {
    "kind": "stdio",
    "command": "uvx",
    "args": ["mcp-server-everything"],
    "env": {"DEBUG": "1"},
}

to flat runtime metadata:

assert connection.metadata["transport"] == "stdio"
assert connection.metadata["command"] == "uvx"
assert connection.metadata["args"] == ["mcp-server-everything"]
assert connection.metadata["env"] == {"DEBUG": "1"}
  • Step 2: Add HTTP conversion test

Append this test:

def test_broker_config_from_workflow_config_converts_mcp_http_source(tmp_path) -> None:
    workflow_config = WorkflowConfigFile.model_validate(
        {
            "version": 1,
            "server": {
                "store": {"kind": "filesystem", "root": str(tmp_path / "store")},
                "sources": [
                    {
                        "kind": "mcp",
                        "id": "context7.default",
                        "provider": "context7",
                        "account": "default",
                        "transport": {
                            "kind": "http",
                            "url": "http://127.0.0.1:3000/mcp",
                            "headers": {"X-Test": "yes"},
                        },
                    }
                ],
            },
        }
    )

    broker_config = broker_config_from_workflow_config(workflow_config)

    connection = broker_config.connections[0]
    assert connection.metadata["transport"] == "streamable_http"
    assert connection.metadata["url"] == "http://127.0.0.1:3000/mcp"
    assert connection.metadata["headers"] == {"X-Test": "yes"}
  • Step 3: Run tests and verify failure

Run:

uv run pytest tests/wf_mcp/test_workflow_config_bridge.py -q

Expected: fail because metadata is still nested under "transport".

  • Step 4: Fix runtime metadata conversion

In src/wf_mcp/source_registry.py, replace the metadata-building block inside workflow_mcp_source_to_connection_config() with:

    transport = getattr(source, "transport")
    metadata = dict(getattr(source, "metadata", {}))
    if transport.kind == "stdio":
        metadata.update(
            {
                "transport": "stdio",
                "command": transport.command,
                "args": list(transport.args),
                "env": dict(transport.env),
                "source_registry": False,
            }
        )
    elif transport.kind == "http":
        metadata.update(
            {
                "transport": "streamable_http",
                "url": str(transport.url),
                "headers": dict(transport.headers),
                "source_registry": False,
            }
        )
    else:
        raise ValueError(f"unsupported wf_config MCP transport {transport.kind!r}")

Keep the existing profile and auth_ref handling after this block.

  • Step 5: Run tests

Run:

uv run pytest tests/wf_mcp/test_workflow_config_bridge.py -q

Expected: pass.

Task 2: Add Legacy Config to WorkflowConfig Conversion Tests

Files:

  • Create: tests/wf_mcp/test_workflow_config_migration.py

  • Step 1: Create test file

Create tests/wf_mcp/test_workflow_config_migration.py:

from __future__ import annotations

from pathlib import Path

from wf_mcp.broker.config import migrate_broker_config_file


def test_migrate_broker_config_file_converts_stdio_connection(tmp_path: Path) -> None:
    legacy_path = tmp_path / "wf_mcp.config.json"
    legacy_path.write_text(
        """
{
  "store_root": ".wf_mcp_store",
  "connections": [
    {
      "id": "everything.default",
      "server": "everything",
      "account": "default",
      "enabled": true,
      "source_config_ownership": "seed",
      "metadata": {
        "transport": "stdio",
        "command": "uvx",
        "args": ["mcp-server-everything"],
        "env": {"DEBUG": "1"},
        "profile": "dev",
        "auth_ref": "auth.everything.default",
        "description": "Everything test server"
      }
    }
  ]
}
""",
        encoding="utf-8",
    )

    config = migrate_broker_config_file(legacy_path)

    assert config.server.store.kind == "filesystem"
    assert config.server.store.root == ".wf_mcp_store"
    assert len(config.server.sources) == 1
    source = config.server.sources[0]
    assert source.kind == "mcp"
    assert source.id == "everything.default"
    assert source.provider == "everything"
    assert source.account == "default"
    assert source.enabled is True
    assert source.ownership == "seed"
    assert source.profile == "dev"
    assert source.auth_ref == "auth.everything.default"
    assert source.transport.kind == "stdio"
    assert source.transport.command == "uvx"
    assert source.transport.args == ("mcp-server-everything",)
    assert source.transport.env == {"DEBUG": "1"}
    assert source.metadata["description"] == "Everything test server"


def test_migrate_broker_config_file_converts_streamable_http_connection(
    tmp_path: Path,
) -> None:
    legacy_path = tmp_path / "wf_mcp.config.json"
    legacy_path.write_text(
        """
{
  "store_root": "store",
  "connections": [
    {
      "id": "context7.default",
      "server": "context7",
      "account": "default",
      "metadata": {
        "transport": "streamable-http",
        "url": "http://127.0.0.1:3000/mcp",
        "headers": {"X-Test": "yes"},
        "description": "HTTP server"
      }
    }
  ]
}
""",
        encoding="utf-8",
    )

    config = migrate_broker_config_file(legacy_path)

    source = config.server.sources[0]
    assert source.kind == "mcp"
    assert source.transport.kind == "http"
    assert str(source.transport.url) == "http://127.0.0.1:3000/mcp"
    assert source.transport.headers == {"X-Test": "yes"}
    assert source.metadata["description"] == "HTTP server"
    assert source.metadata["legacy_transport"] == "streamable-http"


def test_migrate_broker_config_file_converts_sse_connection(tmp_path: Path) -> None:
    legacy_path = tmp_path / "wf_mcp.config.json"
    legacy_path.write_text(
        """
{
  "store_root": "store",
  "connections": [
    {
      "id": "legacy.default",
      "server": "legacy",
      "account": "default",
      "metadata": {
        "transport": "sse",
        "url": "http://127.0.0.1:3000/sse"
      }
    }
  ]
}
""",
        encoding="utf-8",
    )

    config = migrate_broker_config_file(legacy_path)

    source = config.server.sources[0]
    assert source.transport.kind == "http"
    assert str(source.transport.url) == "http://127.0.0.1:3000/sse"
    assert source.metadata["legacy_transport"] == "sse"
  • Step 2: Run tests and verify failure

Run:

uv run pytest tests/wf_mcp/test_workflow_config_migration.py -q

Expected: fail because migrate_broker_config_file does not exist.

Task 3: Implement Legacy Config Conversion Library

Files:

  • Modify: src/wf_mcp/broker/config.py

  • Step 1: Add imports

Update imports:

from wf_config import WorkflowConfigFile
from wf_config.models import FilesystemStoreConfig, McpSourceConfig

If WorkflowConfigFile is already imported, only add the model imports.

  • Step 2: Add metadata conversion helpers

Add these helpers above load_broker_config():

_HTTP_TRANSPORTS = {"http", "streamable-http", "streamable_http", "sse"}


def _source_metadata_without_transport(metadata: dict[str, object]) -> dict[str, object]:
    return {
        key: value
        for key, value in metadata.items()
        if key
        not in {
            "transport",
            "command",
            "args",
            "env",
            "cwd",
            "url",
            "headers",
            "profile",
            "auth_ref",
        }
    }


def _mcp_source_from_connection(connection) -> McpSourceConfig:
    metadata = dict(connection.metadata)
    transport_kind = str(metadata.get("transport", "stdio"))
    profile = metadata.get("profile")
    auth_ref = metadata.get("auth_ref")
    source_metadata = _source_metadata_without_transport(metadata)
    if transport_kind == "stdio":
        command = metadata.get("command")
        if not isinstance(command, str) or not command:
            raise ValueError(
                f"legacy stdio connection {connection.id!r} requires metadata.command"
            )
        transport = {
            "kind": "stdio",
            "command": command,
            "args": list(metadata.get("args", [])),
            "env": dict(metadata.get("env", {})),
        }
        cwd = metadata.get("cwd")
        if cwd is not None:
            source_metadata["cwd"] = cwd
    elif transport_kind in _HTTP_TRANSPORTS:
        url = metadata.get("url")
        if not isinstance(url, str) or not url:
            raise ValueError(
                f"legacy HTTP connection {connection.id!r} requires metadata.url"
            )
        transport = {
            "kind": "http",
            "url": url,
            "headers": dict(metadata.get("headers", {})),
        }
        source_metadata["legacy_transport"] = transport_kind
    else:
        raise ValueError(
            f"legacy connection {connection.id!r} uses unsupported transport "
            f"{transport_kind!r}"
        )

    return McpSourceConfig.model_validate(
        {
            "kind": "mcp",
            "id": connection.id,
            "enabled": connection.enabled,
            "provider": connection.server,
            "account": connection.account,
            "profile": profile if isinstance(profile, str) else None,
            "ownership": connection.source_config_ownership,
            "transport": transport,
            "auth_ref": auth_ref if isinstance(auth_ref, str) else None,
            "metadata": source_metadata,
        }
    )
  • Step 3: Add public conversion function

Add after load_broker_config():

def migrate_broker_config_file(path: str | Path) -> WorkflowConfigFile:
    """Convert legacy wf_mcp.config.json into neutral workflow config.

    This does not write files. Callers choose whether to serialize the returned
    config to disk or inspect it first.
    """
    config_path = Path(path)
    data = json.loads(config_path.read_text(encoding="utf-8"))
    legacy = BrokerConfigFile.model_validate(data)
    return WorkflowConfigFile(
        server={
            "store": FilesystemStoreConfig(root=legacy.store_root),
            "sources": [
                _mcp_source_from_connection(connection)
                for connection in legacy.connections
            ],
        }
    )
  • Step 4: Export it

Add "migrate_broker_config_file" to __all__.

  • Step 5: Run migration tests

Run:

uv run pytest tests/wf_mcp/test_workflow_config_migration.py tests/wf_mcp/test_workflow_config_bridge.py -q

Expected: pass.

Task 4: Add CLI Migration Command

Files:

  • Create: src/wf_cli/commands/config.py

  • Modify: src/wf_cli/app.py

  • Create: tests/wf_cli/test_config_migration.py

  • Step 1: Create CLI command module

Create src/wf_cli/commands/config.py:

from __future__ import annotations

from pathlib import Path
from typing import Annotated

import typer

from wf_mcp.broker.config import migrate_broker_config_file

from wf_cli.io import emit_json

app = typer.Typer(
    name="config",
    help="Inspect and migrate workflow config files.",
    no_args_is_help=True,
)


@app.command("migrate-mcp")
def migrate_mcp_config(
    input_path: Annotated[
        Path,
        typer.Argument(help="Legacy wf_mcp.config.json path."),
    ],
    output_path: Annotated[
        Path | None,
        typer.Option("--output", help="Write neutral workflow config JSON here."),
    ] = None,
) -> None:
    """Convert legacy MCP broker config into neutral workflow config."""
    config = migrate_broker_config_file(input_path)
    payload = config.model_dump(mode="json")
    if output_path is None:
        emit_json(payload)
        return
    output_path.write_text(
        config.model_dump_json(indent=2),
        encoding="utf-8",
    )
    emit_json({"status": "written", "path": str(output_path)})
  • Step 2: Register command in app

In src/wf_cli/app.py, add config to the command import tuple:

from .commands import (
    admin,
    artifacts,
    caps,
    config,
    deployments,
    docs,
    drafts,
    explain,
    runs,
    schema,
    sources,
)

Add:

app.add_typer(config.app, name="config")

Place it near schema/docs.

  • Step 3: Add CLI tests

Create tests/wf_cli/test_config_migration.py:

from __future__ import annotations

import json

from typer.testing import CliRunner

from wf_cli.app import app


def test_wf_config_migrate_mcp_prints_neutral_config(tmp_path) -> None:
    legacy_path = tmp_path / "wf_mcp.config.json"
    legacy_path.write_text(
        """
{
  "store_root": ".wf_mcp_store",
  "connections": [
    {
      "id": "everything.default",
      "server": "everything",
      "account": "default",
      "metadata": {
        "transport": "stdio",
        "command": "uvx",
        "args": ["mcp-server-everything"]
      }
    }
  ]
}
""",
        encoding="utf-8",
    )

    result = CliRunner().invoke(app, ["config", "migrate-mcp", str(legacy_path)])

    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["server"]["store"] == {
        "kind": "filesystem",
        "root": ".wf_mcp_store",
    }
    source = payload["server"]["sources"][0]
    assert source["kind"] == "mcp"
    assert source["id"] == "everything.default"
    assert source["transport"]["kind"] == "stdio"
    assert source["transport"]["command"] == "uvx"


def test_wf_config_migrate_mcp_writes_output_file(tmp_path) -> None:
    legacy_path = tmp_path / "wf_mcp.config.json"
    output_path = tmp_path / "wf.json"
    legacy_path.write_text(
        """
{
  "store_root": "store",
  "connections": [
    {
      "id": "context7.default",
      "server": "context7",
      "account": "default",
      "metadata": {
        "transport": "streamable_http",
        "url": "http://127.0.0.1:3000/mcp"
      }
    }
  ]
}
""",
        encoding="utf-8",
    )

    result = CliRunner().invoke(
        app,
        ["config", "migrate-mcp", str(legacy_path), "--output", str(output_path)],
    )

    assert result.exit_code == 0, result.output
    status = json.loads(result.output)
    assert status["status"] == "written"
    payload = json.loads(output_path.read_text(encoding="utf-8"))
    assert payload["server"]["sources"][0]["transport"]["kind"] == "http"
  • Step 4: Run CLI tests

Run:

uv run pytest tests/wf_cli/test_config_migration.py -q

Expected: pass.

Task 5: Update Docs and Mark Slice Complete

Files:

  • Modify: docs/wf_cli.md

  • Modify: docs/current_roadmap.md

  • Modify: docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md

  • Step 1: Update CLI docs

In docs/wf_cli.md, replace the future migration note:

Future migration support should let users convert that legacy shape into the
neutral config above. The old `store_root` field maps to
`server.store: {"kind": "filesystem", "root": ...}`; old `connections[]` map to
`server.sources[]` entries with `kind: "mcp"`.

with:

Convert a legacy broker config into the neutral config shape:

```bash
wf config migrate-mcp wf_mcp.config.json --output wf.json

The old store_root field maps to server.store: {"kind": "filesystem", "root": ...}; old connections[] map to server.sources[] entries with kind: "mcp".


- [ ] **Step 2: Update roadmap**

In `docs/current_roadmap.md`, under "Legacy config migration", append:

```markdown
    Completed: `wf config migrate-mcp` converts legacy broker config files into
    neutral workflow config files without mutating the original.
  • Step 3: Update long-lived API spec

In docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md, under "Legacy MCP config migration", append:

   Completed when `wf config migrate-mcp <legacy> --output <workflow-config>`
   writes a neutral config that can be used by `wf-rpc-server --config`.

Task 6: Final Verification and Commit

Files:

  • All touched files from prior tasks.

  • Step 1: Run focused tests

Run:

uv run pytest tests/wf_mcp/test_workflow_config_bridge.py tests/wf_mcp/test_workflow_config_migration.py tests/wf_cli/test_config_migration.py tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py -q

Expected: pass.

  • Step 2: Run lint/type checks

Run:

uv run ruff check src/wf_mcp src/wf_cli tests/wf_mcp tests/wf_cli
uv run basedpyright --level error src/wf_mcp src/wf_cli tests/wf_mcp tests/wf_cli

Expected: pass with 0 errors.

  • Step 3: Commit

Run:

git add src/wf_mcp src/wf_cli tests/wf_mcp tests/wf_cli docs/wf_cli.md docs/current_roadmap.md docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md
git commit -m "feat: migrate legacy mcp config"

Self-Review Checklist

  • Converter does not mutate the legacy input file.
  • server.store remains the only store destination; no parallel store_root is added to neutral config.
  • stdio conversion preserves command/args/env.
  • http, streamable-http, streamable_http, and sse legacy transports normalize to neutral HTTP source transport.
  • sse remains supported only as migration compatibility metadata.
  • Runtime connection metadata is flat so MCP SDK/runtime adapters still work.