From 450d2163b5f9d0abd78436f55206675d924e388f Mon Sep 17 00:00:00 2001 From: Diogo Andre Santos Date: Tue, 23 Jun 2026 20:09:14 +0100 Subject: [PATCH 1/4] feat(mcp): generate multi-client recipe configs Add mcp generate-configs to render Copilot, Cursor, Claude Desktop, and Claude Code files from one serve config. Reuse serve config validation, emit target warnings, and block overwrite unless --force. Keep output deterministic and fixture-compatible. Update recipe docs, changelog, and AGENTS module-map entry. Add focused CLI tests for command surface and generated shape. --- AGENTS.md | 2 +- CHANGELOG.md | 10 ++ api/public_api.txt | 5 +- docs/recipes/index.md | 21 +++ llms-full.txt | 21 +++ src/contextweaver/_mcp_cli.py | 225 +++++++++++++++++++++++++ tests/test_mcp_generate_configs_cli.py | 153 +++++++++++++++++ 7 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 tests/test_mcp_generate_configs_cli.py diff --git a/AGENTS.md b/AGENTS.md index 7da8c1ef..b9b72da5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,7 @@ It prepares context and routes tools but never calls models or executes tools. | `eval/consolidation.py` | Consolidation quality evaluation harness (issue #683): `evaluate_consolidation` → `ConsolidationEvalReport` (precision / coverage against an optional gold set + dedup ratio). Pure-stdlib, offline, deterministic. | | `eval/metrics.py` | Canonical rank-based routing metrics — `recall_at_k` (classic fractional recall@k), `precision_at_k`, `reciprocal_rank` (issue #354). Single source of truth imported by both `eval/routing.py` and `benchmarks/benchmark.py` so the harness and the benchmark script can no longer define the same names with different semantics. | | `__main__.py` | CLI: 11 subcommands (`demo`, `build`, `route`, `print-tree`, `init`, `ingest`, `replay`, `stats`, `inspect`, `budget-check`, `eval`) plus the `mcp` and `catalog` Typer sub-apps. `inspect` renders payload-safe context/routing/artifact JSON or Markdown (issue #398); `catalog lint` surfaces `NormalizationReport` + reference findings with `--json` and CI exit codes (issue #538). | -| `_mcp_cli.py` | Backs the `mcp` Typer sub-app. Hosts `mcp serve`, `mcp inspect`, and `mcp stats`; accepts native contextweaver, raw MCP `tools/list`, and `{tools:[...]}` catalog shapes. `mcp serve --diagnostics FILE` appends sanitized JSONL and `--quiet` suppresses lifecycle stderr; both are config-file keys. `mcp serve --state-dir DIR` (config key `state_dir`) persists gateway state — `events.sqlite3` + `artifacts/` — so artifact handles and event history survive a restart (issue #511); omit it for the in-memory default. The packaged serve path remains a static catalog + stub upstream. | +| `_mcp_cli.py` | Backs the `mcp` Typer sub-app. Hosts `mcp serve`, `mcp inspect`, `mcp stats`, and `mcp generate-configs`; accepts native contextweaver, raw MCP `tools/list`, and `{tools:[...]}` catalog shapes. `mcp serve --diagnostics FILE` appends sanitized JSONL and `--quiet` suppresses lifecycle stderr; both are config-file keys. `mcp serve --state-dir DIR` (config key `state_dir`) persists gateway state — `events.sqlite3` + `artifacts/` — so artifact handles and event history survive a restart (issue #511); omit it for the in-memory default. `mcp generate-configs` emits deterministic multi-client recipe artifacts from one canonical `mcp serve --config` input (issue #659). | | `data/` | Packaged data files shipped inside the wheel via `[tool.setuptools.package-data]`. Exposes `gateway_catalog_path()` (resolves `mcp_gateway_catalog.yaml` to a concrete `Path` for both editable installs and zipped wheels — falls back to a persistent cache under `tempfile.gettempdir()/contextweaver/` for zipimport). Issue #264. | | `examples/recipes/` | MCP-client integration recipes: installed-CLI configs for Claude Desktop, Claude Code, GitHub Copilot, and Cursor plus `gateway_config.yaml`; `serve_gateway.py` remains a legacy/custom-runtime launcher (issues #278, #279, #346, #371, #429, #437). | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1bbb88..62871d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Multi-client MCP config-pack generator (#659).** + Added `contextweaver mcp generate-configs` to render client recipe files + (`copilot_mcp.json`, `cursor_mcp.json`, `claude_desktop_config.json`, + `claude_code_mcp.json`) from one canonical `mcp serve --config` input. + The command reuses `mcp serve` config validation, supports target selection, + fails closed on unknown/invalid target values, blocks overwriting unless + `--force`, emits target-specific compatibility warnings, and produces + deterministic JSON artifacts suitable for committing. Added CLI tests for + generation behavior and fixture-shape pinning. + - **Supply-chain & security CI hardening (#443, #689, #690, #691, #692, #468, #552).** A coordinated security-posture pass under the supply-chain hardening umbrella (#443): - **CodeQL** code scanning (`.github/workflows/codeql.yml`) with the diff --git a/api/public_api.txt b/api/public_api.txt index cd7eb891..e9ef425d 100644 --- a/api/public_api.txt +++ b/api/public_api.txt @@ -138,7 +138,10 @@ class FirewallStats(triggered: 'bool', strategy: 'str', threshold_chars: 'int' = ..., original_chars: 'int' = ..., original_tokens: 'int' = ..., summary_chars: 'int' = ..., summary_tokens: 'int' = ..., artifact_ref: 'str | None' = ..., summarized_by_llm: 'bool' = ...) -> None def from_dict(cls, data: 'dict[str, Any]') -> 'FirewallStats' def to_dict(self) -> 'dict[str, Any]' - FuzzyScorer: NoneType + class FuzzyScorer() -> 'None' + def fit(self, documents: 'list[str]') -> 'None' + def score(self, query: 'str', doc_index: 'int') -> 'float' + def score_all(self, query: 'str') -> 'list[float]' class GraphBuildError(ContextWeaverError)(message: 'str', *, cycle: 'list[str] | None' = ..., edge: 'tuple[str, str] | None' = ..., missing_root: 'str | None' = ..., hint: 'str | None' = ...) -> 'None' class GraphManifest(manifest_version: 'int' = ..., build_hash: 'str' = ..., seed: 'int | None' = ..., engine_versions: 'dict[str, str]' = ..., timestamp: 'float' = ..., item_count: 'int' = ..., strategy: 'str' = ..., max_depth: 'int' = ..., extra: 'dict[str, Any]' = ...) -> None def for_build(cls, items: 'list[SelectableItem]', *, strategy: 'str' = ..., max_depth: 'int' = ..., seed: 'int | None' = ..., engine_versions: 'dict[str, str] | None' = ..., extra: 'dict[str, Any] | None' = ..., timestamp: 'float | None' = ...) -> 'GraphManifest' diff --git a/docs/recipes/index.md b/docs/recipes/index.md index 0726ef64..344b137a 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -59,6 +59,27 @@ Relative catalog paths are resolved from the gateway config file's directory. This keeps project-scoped client configs portable even when the client starts the server from a different working directory. +## Generate Multi-Client Config Packs + +Generate every supported client config from one gateway source file: + +```bash +contextweaver mcp generate-configs \ + --config examples/recipes/gateway_config.yaml \ + --out-dir ./generated-recipes +``` + +By default this emits: + +- `copilot_mcp.json` +- `cursor_mcp.json` +- `claude_desktop_config.json` +- `claude_code_mcp.json` + +Use `--target` repeatedly to generate only selected clients. The command +validates the gateway config before writing files, fails if outputs already +exist (unless `--force`), and prints target-specific compatibility warnings. + ## What the client sees ```text diff --git a/llms-full.txt b/llms-full.txt index 3565a867..e24c59c9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2060,6 +2060,27 @@ Relative catalog paths are resolved from the gateway config file's directory. This keeps project-scoped client configs portable even when the client starts the server from a different working directory. +## Generate Multi-Client Config Packs + +Generate every supported client config from one gateway source file: + +```bash +contextweaver mcp generate-configs \ + --config examples/recipes/gateway_config.yaml \ + --out-dir ./generated-recipes +``` + +By default this emits: + +- `copilot_mcp.json` +- `cursor_mcp.json` +- `claude_desktop_config.json` +- `claude_code_mcp.json` + +Use `--target` repeatedly to generate only selected clients. The command +validates the gateway config before writing files, fails if outputs already +exist (unless `--force`), and prints target-specific compatibility warnings. + ## What the client sees ```text diff --git a/src/contextweaver/_mcp_cli.py b/src/contextweaver/_mcp_cli.py index ab970b9d..a226e262 100644 --- a/src/contextweaver/_mcp_cli.py +++ b/src/contextweaver/_mcp_cli.py @@ -72,6 +72,38 @@ class _ReportFormat(str, Enum): markdown = "markdown" +class _ConfigTarget(str, Enum): + copilot = "copilot" + cursor = "cursor" + claude_desktop = "claude_desktop" + claude_code = "claude_code" + + +_CONFIG_PACK_INPUT_SCHEMA = "mcp-serve-config/v1" +_CONFIG_PACK_FILES: dict[_ConfigTarget, str] = { + _ConfigTarget.copilot: "copilot_mcp.json", + _ConfigTarget.cursor: "cursor_mcp.json", + _ConfigTarget.claude_desktop: "claude_desktop_config.json", + _ConfigTarget.claude_code: "claude_code_mcp.json", +} +_CONFIG_PACK_WARNINGS: dict[_ConfigTarget, str] = { + _ConfigTarget.copilot: ( + "VS Code expects top-level 'servers' with stdio entries under '.vscode/mcp.json'." + ), + _ConfigTarget.cursor: ( + "Cursor workspace configs can use ${workspaceFolder}; global configs " + "typically require absolute paths." + ), + _ConfigTarget.claude_desktop: ( + "Replace /ABSOLUTE/PATH/TO placeholders before use; Claude Desktop " + "does not reliably expand variables in this file." + ), + _ConfigTarget.claude_code: ( + "Place this at project root as .mcp.json; Claude Code resolves ${CLAUDE_PROJECT_DIR:-.}." + ), +} + + mcp_app = typer.Typer( name="mcp", help=( @@ -277,6 +309,90 @@ def _build_dispatch_controls( return retry_policy, rate_limiter, result_cache +def _relative_to_cwd(path: Path) -> Path | None: + """Return *path* relative to cwd when possible; otherwise ``None``.""" + resolved = path.expanduser().resolve() + cwd = Path.cwd().resolve() + try: + return resolved.relative_to(cwd) + except ValueError: + return None + + +def _workspace_path(path: Path, root_token: str) -> tuple[str, str | None]: + """Render a workspace-scoped path reference for a target config.""" + rel = _relative_to_cwd(path) + if rel is not None: + return f"${{{root_token}}}/{rel.as_posix()}", None + abs_path = path.expanduser().resolve().as_posix() + return ( + abs_path, + f"config path is outside the current workspace; emitted absolute path: {abs_path}", + ) + + +def _absolute_placeholder(path: Path) -> tuple[str, str | None]: + """Render a deterministic absolute-path placeholder for Claude Desktop.""" + rel = _relative_to_cwd(path) + if rel is not None: + repo_name = Path.cwd().resolve().name + return f"/ABSOLUTE/PATH/TO/{repo_name}/{rel.as_posix()}", None + fallback = f"/ABSOLUTE/PATH/TO/{path.name}" + return fallback, ( + "path is outside the current workspace; desktop placeholder was " + f"reduced to basename: {fallback}" + ) + + +def _render_config_payload( + target: _ConfigTarget, + *, + config_arg: str, + desktop_catalog_arg: str, +) -> dict[str, object]: + """Render one client config payload from the canonical gateway config path.""" + base_args: list[str] = ["contextweaver", "mcp", "serve", "--config", config_arg] + if target == _ConfigTarget.copilot: + return { + "$schema": "https://aka.ms/vscode-mcp-schema", + "servers": { + "contextweaver-gateway": { + "type": "stdio", + "command": "uvx", + "args": base_args, + } + }, + } + if target == _ConfigTarget.cursor: + return { + "mcpServers": { + "contextweaver-gateway": { + "command": "uvx", + "args": base_args, + } + } + } + if target == _ConfigTarget.claude_code: + return { + "mcpServers": { + "contextweaver-gateway": { + "type": "stdio", + "command": "uvx", + "args": base_args, + } + } + } + return { + "mcpServers": { + "contextweaver-gateway": { + "command": "uvx", + "args": [*base_args, "--catalog", desktop_catalog_arg], + "env": {}, + } + } + } + + def _parse_catalog_file(catalog_path: Path) -> Any: # noqa: ANN401 — JSON/YAML payload """Read and parse a JSON/YAML catalog file into its raw Python object. @@ -944,6 +1060,115 @@ async def _serve() -> None: loop.close() +@mcp_app.command("generate-configs") +def generate_configs( + config: Annotated[ + Path, + typer.Option( + ..., + "--config", + help=( + "Path to the canonical mcp serve JSON/YAML config. " + "Validated with the same schema as mcp serve --config." + ), + ), + ], + out_dir: Annotated[ + Path, + typer.Option( + "--out-dir", + help="Directory where generated client config files are written.", + ), + ] = Path("."), + target: Annotated[ + list[_ConfigTarget] | None, + typer.Option( + "--target", + help=( + "Target client to generate (repeat for multiple). " + "Defaults to all supported targets." + ), + ), + ] = None, + force: Annotated[ + bool, + typer.Option( + "--force/--no-force", + help="Overwrite existing generated files in --out-dir.", + ), + ] = False, +) -> None: + """Generate multi-client MCP config files from one gateway source-of-truth.""" + loaded = _load_serve_config(config) + config_path = config.expanduser().resolve() + catalog_path = Path(str(loaded["catalog"])).expanduser().resolve() + + selected_targets = list(dict.fromkeys(target)) if target else list(_ConfigTarget) + out_dir = out_dir.expanduser() + + planned_paths = [out_dir / _CONFIG_PACK_FILES[item] for item in selected_targets] + existing = [path for path in planned_paths if path.exists()] + if existing and not force: + existing_names = ", ".join(path.name for path in existing) + raise typer.BadParameter( + f"refusing to overwrite existing file(s): {existing_names}; pass --force", + param_hint="--out-dir", + ) + + out_dir.mkdir(parents=True, exist_ok=True) + + copilot_path, copilot_note = _workspace_path(config_path, "workspaceFolder") + cursor_path, cursor_note = _workspace_path(config_path, "workspaceFolder") + claude_code_path, claude_code_note = _workspace_path(config_path, "CLAUDE_PROJECT_DIR:-.") + desktop_config_path, desktop_config_note = _absolute_placeholder(config_path) + desktop_catalog_path, desktop_catalog_note = _absolute_placeholder(catalog_path) + + target_paths: dict[_ConfigTarget, str] = { + _ConfigTarget.copilot: copilot_path, + _ConfigTarget.cursor: cursor_path, + _ConfigTarget.claude_code: claude_code_path, + _ConfigTarget.claude_desktop: desktop_config_path, + } + + notes_by_target: dict[_ConfigTarget, list[str]] = { + item: [_CONFIG_PACK_WARNINGS[item]] for item in _ConfigTarget + } + if copilot_note is not None: + notes_by_target[_ConfigTarget.copilot].append(copilot_note) + if cursor_note is not None: + notes_by_target[_ConfigTarget.cursor].append(cursor_note) + if claude_code_note is not None: + notes_by_target[_ConfigTarget.claude_code].append(claude_code_note) + if desktop_config_note is not None: + notes_by_target[_ConfigTarget.claude_desktop].append(desktop_config_note) + if desktop_catalog_note is not None: + notes_by_target[_ConfigTarget.claude_desktop].append(desktop_catalog_note) + + written_paths: list[Path] = [] + for item in selected_targets: + payload = _render_config_payload( + item, + config_arg=target_paths[item], + desktop_catalog_arg=desktop_catalog_path, + ) + out_path = out_dir / _CONFIG_PACK_FILES[item] + out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + written_paths.append(out_path) + + typer.echo( + ( + f"generated {len(written_paths)} config file(s) from {config_path} " + f"(input schema: {_CONFIG_PACK_INPUT_SCHEMA})" + ), + err=True, + ) + for path in written_paths: + typer.echo(f"wrote: {path}", err=True) + for item in selected_targets: + for note in notes_by_target[item]: + typer.echo(f"warning [{item.value}]: {note}", err=True) + + # Re-exported for tests / advanced wiring. __all__ = [ "mcp_app", diff --git a/tests/test_mcp_generate_configs_cli.py b/tests/test_mcp_generate_configs_cli.py new file mode 100644 index 00000000..065d91dc --- /dev/null +++ b/tests/test_mcp_generate_configs_cli.py @@ -0,0 +1,153 @@ +"""Tests for the ``contextweaver mcp generate-configs`` subcommand.""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_RECIPES_DIR = _REPO_ROOT / "examples" / "recipes" +_ALL_CONFIG_FILES = [ + "copilot_mcp.json", + "cursor_mcp.json", + "claude_desktop_config.json", + "claude_code_mcp.json", +] + +# ANSI escape code regex for stripping color codes from help output +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes from text.""" + return _ANSI_ESCAPE.sub("", text) + + +def _run(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + env["NO_COLOR"] = "1" # Disable ANSI color codes for test assertions + return subprocess.run( + [sys.executable, "-m", "contextweaver", *args], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(cwd or _REPO_ROOT), + env=env, + ) + + +def test_mcp_help_lists_generate_configs_subcommand() -> None: + result = _run("mcp", "--help") + assert result.returncode == 0 + out = _strip_ansi(result.stdout + result.stderr) + assert "generate-configs" in out + + +def test_generate_configs_help_lists_core_options() -> None: + result = _run("mcp", "generate-configs", "--help") + assert result.returncode == 0 + out = _strip_ansi(result.stdout + result.stderr) + assert "--config" in out + assert "--out-dir" in out + assert "--target" in out + assert "--force" in out + + +def test_generate_configs_defaults_emit_all_artifacts(tmp_path: Path) -> None: + config = (_RECIPES_DIR / "gateway_config.yaml").resolve() + result = _run( + "mcp", + "generate-configs", + "--config", + str(config), + "--out-dir", + str(tmp_path), + ) + assert result.returncode == 0, result.stderr + + for filename in _ALL_CONFIG_FILES: + assert (tmp_path / filename).is_file() + + out = result.stdout + result.stderr + for target in ("copilot", "cursor", "claude_desktop", "claude_code"): + assert f"warning [{target}]" in out + + +def test_generate_configs_selected_targets_only(tmp_path: Path) -> None: + config = (_RECIPES_DIR / "gateway_config.yaml").resolve() + result = _run( + "mcp", + "generate-configs", + "--config", + str(config), + "--out-dir", + str(tmp_path), + "--target", + "copilot", + "--target", + "cursor", + ) + assert result.returncode == 0, result.stderr + + actual = sorted(path.name for path in tmp_path.iterdir()) + assert actual == ["copilot_mcp.json", "cursor_mcp.json"] + + +def test_generate_configs_rejects_existing_without_force(tmp_path: Path) -> None: + existing = tmp_path / "copilot_mcp.json" + existing.write_text("sentinel\n", encoding="utf-8") + + config = (_RECIPES_DIR / "gateway_config.yaml").resolve() + result = _run( + "mcp", + "generate-configs", + "--config", + str(config), + "--out-dir", + str(tmp_path), + "--target", + "copilot", + ) + assert result.returncode != 0 + assert "overwrite" in (result.stdout + result.stderr).lower() + assert existing.read_text(encoding="utf-8") == "sentinel\n" + + +def test_generate_configs_validates_config_before_writing(tmp_path: Path) -> None: + bad = tmp_path / "bad.yaml" + bad.write_text("mode: gateway\n", encoding="utf-8") + out_dir = tmp_path / "out" + + result = _run( + "mcp", + "generate-configs", + "--config", + str(bad), + "--out-dir", + str(out_dir), + ) + assert result.returncode != 0 + assert "catalog" in (result.stdout + result.stderr).lower() + assert not out_dir.exists() + + +def test_generate_configs_matches_shipped_recipe_fixtures(tmp_path: Path) -> None: + config = (_RECIPES_DIR / "gateway_config.yaml").resolve() + result = _run( + "mcp", + "generate-configs", + "--config", + str(config), + "--out-dir", + str(tmp_path), + ) + assert result.returncode == 0, result.stderr + + for filename in _ALL_CONFIG_FILES: + generated = (tmp_path / filename).read_text(encoding="utf-8") + expected = (_RECIPES_DIR / filename).read_text(encoding="utf-8") + assert generated == expected From 8eb80cf1e89ed3a2d073a6e6ababdbb68505d159 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 20:55:32 +0000 Subject: [PATCH 2/4] fix(mcp): derive Claude Desktop placeholder from package name _absolute_placeholder() baked Path.cwd().name into the generated Claude Desktop placeholder, so `mcp generate-configs` output varied by checkout directory name and the fixture-pinning test failed when the repo was not in a folder named "contextweaver". Derive the slug from the top-level package name instead, keeping output deterministic and fixture-identical. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AmsfKnjsxEEhpecTyZqFcN --- src/contextweaver/_mcp_cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/contextweaver/_mcp_cli.py b/src/contextweaver/_mcp_cli.py index a226e262..5850cbab 100644 --- a/src/contextweaver/_mcp_cli.py +++ b/src/contextweaver/_mcp_cli.py @@ -79,6 +79,9 @@ class _ConfigTarget(str, Enum): claude_code = "claude_code" +# Stable project slug for generated placeholders. Derived from the top-level +# package name so output never depends on the checkout directory name. +_PROJECT_SLUG = __name__.split(".")[0] _CONFIG_PACK_INPUT_SCHEMA = "mcp-serve-config/v1" _CONFIG_PACK_FILES: dict[_ConfigTarget, str] = { _ConfigTarget.copilot: "copilot_mcp.json", @@ -335,8 +338,7 @@ def _absolute_placeholder(path: Path) -> tuple[str, str | None]: """Render a deterministic absolute-path placeholder for Claude Desktop.""" rel = _relative_to_cwd(path) if rel is not None: - repo_name = Path.cwd().resolve().name - return f"/ABSOLUTE/PATH/TO/{repo_name}/{rel.as_posix()}", None + return f"/ABSOLUTE/PATH/TO/{_PROJECT_SLUG}/{rel.as_posix()}", None fallback = f"/ABSOLUTE/PATH/TO/{path.name}" return fallback, ( "path is outside the current workspace; desktop placeholder was " From c0d8d2b612704831864aac95d7c4d23df7b45427 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 21:37:21 +0000 Subject: [PATCH 3/4] fix: regenerate public-API manifest without optional rapidfuzz The committed api/public_api.txt listed the full FuzzyScorer class, which only exists when the optional rapidfuzz dependency (contextweaver[retrieval]) is installed. The canonical CI environment installs ".[dev,langchain]" (no rapidfuzz), so its generator emits "FuzzyScorer: NoneType", and the manifest drift gate (gen_api_manifest --check / drift_check --check) failed across the test matrix. Regenerate the manifest in the rapidfuzz-less environment so it matches main and the CI generator. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AmsfKnjsxEEhpecTyZqFcN --- api/public_api.txt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/public_api.txt b/api/public_api.txt index e9ef425d..cd7eb891 100644 --- a/api/public_api.txt +++ b/api/public_api.txt @@ -138,10 +138,7 @@ class FirewallStats(triggered: 'bool', strategy: 'str', threshold_chars: 'int' = ..., original_chars: 'int' = ..., original_tokens: 'int' = ..., summary_chars: 'int' = ..., summary_tokens: 'int' = ..., artifact_ref: 'str | None' = ..., summarized_by_llm: 'bool' = ...) -> None def from_dict(cls, data: 'dict[str, Any]') -> 'FirewallStats' def to_dict(self) -> 'dict[str, Any]' - class FuzzyScorer() -> 'None' - def fit(self, documents: 'list[str]') -> 'None' - def score(self, query: 'str', doc_index: 'int') -> 'float' - def score_all(self, query: 'str') -> 'list[float]' + FuzzyScorer: NoneType class GraphBuildError(ContextWeaverError)(message: 'str', *, cycle: 'list[str] | None' = ..., edge: 'tuple[str, str] | None' = ..., missing_root: 'str | None' = ..., hint: 'str | None' = ...) -> 'None' class GraphManifest(manifest_version: 'int' = ..., build_hash: 'str' = ..., seed: 'int | None' = ..., engine_versions: 'dict[str, str]' = ..., timestamp: 'float' = ..., item_count: 'int' = ..., strategy: 'str' = ..., max_depth: 'int' = ..., extra: 'dict[str, Any]' = ...) -> None def for_build(cls, items: 'list[SelectableItem]', *, strategy: 'str' = ..., max_depth: 'int' = ..., seed: 'int | None' = ..., engine_versions: 'dict[str, str] | None' = ..., extra: 'dict[str, Any] | None' = ..., timestamp: 'float | None' = ...) -> 'GraphManifest' From e81b20bb23951b14ac56ce82c68c912efc2ba41c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 21:59:58 +0000 Subject: [PATCH 4/4] test(mcp): cover generate-configs --force and outside-workspace paths Audit-grade review flagged two untested branches of `mcp generate-configs`: - `--force` overwrite-success (only the refuse-without-force path was covered) - the outside-workspace fallback in _workspace_path/_absolute_placeholder (absolute-path emission + compatibility warning) Add focused CLI tests for both so the warning path and overwrite semantics are pinned. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AmsfKnjsxEEhpecTyZqFcN --- tests/test_mcp_generate_configs_cli.py | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_mcp_generate_configs_cli.py b/tests/test_mcp_generate_configs_cli.py index 065d91dc..6f29dbd0 100644 --- a/tests/test_mcp_generate_configs_cli.py +++ b/tests/test_mcp_generate_configs_cli.py @@ -151,3 +151,54 @@ def test_generate_configs_matches_shipped_recipe_fixtures(tmp_path: Path) -> Non generated = (tmp_path / filename).read_text(encoding="utf-8") expected = (_RECIPES_DIR / filename).read_text(encoding="utf-8") assert generated == expected + + +def test_generate_configs_force_overwrites_existing(tmp_path: Path) -> None: + existing = tmp_path / "copilot_mcp.json" + existing.write_text("sentinel\n", encoding="utf-8") + + config = (_RECIPES_DIR / "gateway_config.yaml").resolve() + result = _run( + "mcp", + "generate-configs", + "--config", + str(config), + "--out-dir", + str(tmp_path), + "--target", + "copilot", + "--force", + ) + assert result.returncode == 0, result.stderr + + overwritten = existing.read_text(encoding="utf-8") + assert overwritten != "sentinel\n" + assert "contextweaver-gateway" in overwritten + + +def test_generate_configs_outside_workspace_emits_absolute_paths(tmp_path: Path) -> None: + # A config that lives outside the invocation cwd cannot be expressed as a + # ${workspaceFolder}-relative path, so the command must fall back to an + # absolute path and surface a compatibility warning. + config = tmp_path / "gateway_config.yaml" + config.write_text("catalog: ./catalog.json\nmode: gateway\n", encoding="utf-8") + out_dir = tmp_path / "out" + + result = _run( + "mcp", + "generate-configs", + "--config", + str(config), + "--out-dir", + str(out_dir), + "--target", + "copilot", + cwd=_REPO_ROOT, + ) + assert result.returncode == 0, result.stderr + assert "outside the current workspace" in _strip_ansi(result.stdout + result.stderr) + + copilot = (out_dir / "copilot_mcp.json").read_text(encoding="utf-8") + # Absolute config path is emitted verbatim instead of a ${workspaceFolder} token. + assert str(config.resolve()) in copilot + assert "${workspaceFolder}" not in copilot