Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/recipes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
227 changes: 227 additions & 0 deletions src/contextweaver/_mcp_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,41 @@ class _ReportFormat(str, Enum):
markdown = "markdown"


class _ConfigTarget(str, Enum):
copilot = "copilot"
cursor = "cursor"
claude_desktop = "claude_desktop"
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",
_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=(
Expand Down Expand Up @@ -277,6 +312,89 @@ 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:
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 "
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.

Expand Down Expand Up @@ -944,6 +1062,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",
Expand Down
Loading
Loading