diff --git a/.github/scripts/mcp_smoke.py b/.github/scripts/mcp_smoke.py new file mode 100755 index 0000000..ea643f9 --- /dev/null +++ b/.github/scripts/mcp_smoke.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Drive a real MCP session against a built server and assert CodeMode behavior. + +This is the release smoke test: it speaks the actual stdio JSON-RPC protocol to a +server binary (or container) and exercises the full CodeMode loop — discover, +describe, execute — instead of only probing `--version`/`--help`. Anything that +breaks the worker re-exec, the capability catalog, or the Starlark execution path +fails here rather than in a user's client. + +The target is passed as a literal argv after `--`, so the same script covers every +release artifact: + + uv run .github/scripts/mcp_smoke.py -- ./bin/template-mcp-codemode stdio + uv run .github/scripts/mcp_smoke.py -- dist/release-assets/BINARY stdio + uv run .github/scripts/mcp_smoke.py -- docker run -i --rm IMAGE stdio + +The server identity assertion is exact, so pointing the script at the dev proxy +(`mcp-devproxy`) or at the bare CodeMode library server (`codemode`) is reported as +a failure rather than silently passing. +""" + +from __future__ import annotations + +import argparse +import json +import queue +import subprocess +import sys +import threading +from typing import Any + +# EXPECTED_TOOLS is the exact CodeMode tool surface: no more, no fewer. +EXPECTED_TOOLS = frozenset({"search_api", "describe_api", "execute"}) + +# DETERMINISTIC_PROGRAM composes several capability calls whose results are pinned by +# min == max, so a passing run proves real execution rather than a lucky random draw. +DETERMINISTIC_PROGRAM = """ +def main(): + total = 0 + for _ in range(3): + total = total + {capability}(min=7, max=7)["value"] + return {{"total": total, "single": {capability}(min=-2, max=-2)["value"]}} +""" + +# DETERMINISTIC_RESULT is the only correct envelope for DETERMINISTIC_PROGRAM. +DETERMINISTIC_RESULT = {"result": {"total": 21, "single": -2}} + +# INVALID_RANGE_PROGRAM asks for an impossible range, which the capability must reject. +INVALID_RANGE_PROGRAM = """ +def main(): + return {capability}(min=5, max=1) +""" + + +class SmokeError(RuntimeError): + """Raised when the smoke test fails; the message is the failure reason.""" + + +def parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + """Split argv on the first `--` so the target argv keeps its own flags.""" + if "--" in argv: + index = argv.index("--") + own, command = argv[:index], argv[index + 1 :] + else: + own, command = argv, [] + + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + usage="%(prog)s [options] -- COMMAND [ARG ...]", + ) + parser.add_argument( + "--server-name", + default="template-mcp-codemode", + help="exact initialize serverInfo.name the target must report", + ) + parser.add_argument( + "--capability", + default="random.int", + help="exact capability name to discover, describe, and execute", + ) + parser.add_argument( + "--expect-error-text", + default="capability failed", + help="exact tool error text for the rejected out-of-range call", + ) + parser.add_argument( + "--protocol-version", + # The newest version the legacy `initialize` handshake can negotiate: the + # go-sdk caps initialize at 2025-11-25 because 2026-07-28 replaces it with + # `discover`. The negotiated version the server answers with is accepted + # either way, so a newer server does not break this check. + default="2025-11-25", + help="protocol version requested at initialize", + ) + parser.add_argument( + "--timeout", + default=60.0, + type=float, + help="seconds to wait for any single response", + ) + args = parser.parse_args(own) + if not command: + parser.error("a target argv is required after `--`") + return args, command + + +def main(argv: list[str] | None = None) -> int: + args, command = parse_args(sys.argv[1:] if argv is None else argv) + try: + run_smoke( + command=command, + server_name=args.server_name, + capability=args.capability, + expect_error_text=args.expect_error_text, + protocol_version=args.protocol_version, + timeout=args.timeout, + ) + except SmokeError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print("[smoke] PASS") + return 0 + + +def run_smoke( + *, + command: list[str], + server_name: str, + capability: str, + expect_error_text: str, + protocol_version: str, + timeout: float, +) -> None: + print(f"[smoke] target: {' '.join(command)}") + with Session(command, timeout=timeout) as session: + check_initialize(session, server_name=server_name, protocol_version=protocol_version) + check_tools(session) + signature = check_search(session, capability=capability) + check_describe(session, capability=capability, signature=signature) + check_execute(session, capability=capability) + check_invalid_range(session, capability=capability, expect_error_text=expect_error_text) + # The rejected call runs in its own worker process; a healthy server keeps + # serving afterwards. Re-running the deterministic program proves it. + check_execute(session, capability=capability) + + +def check_initialize(session: Session, *, server_name: str, protocol_version: str) -> None: + result = session.request( + "initialize", + { + "protocolVersion": protocol_version, + "capabilities": {}, + "clientInfo": {"name": "mcp-smoke", "version": "1"}, + }, + ) + negotiated = result.get("protocolVersion") + if not isinstance(negotiated, str) or not negotiated: + raise SmokeError(f"initialize returned no protocol version: {result}") + + info = result.get("serverInfo") + if not isinstance(info, dict): + raise SmokeError(f"initialize returned no serverInfo: {result}") + actual = info.get("name") + if actual != server_name: + raise SmokeError(f"serverInfo.name is {actual!r}, want exactly {server_name!r}") + if not isinstance(result.get("capabilities"), dict) or "tools" not in result["capabilities"]: + raise SmokeError(f"server does not advertise the tools capability: {result}") + + session.notify("notifications/initialized", {}) + print( + f"[smoke] initialize: {actual} " + f"{info.get('version', '?')} (protocol {negotiated})" + ) + + +def check_tools(session: Session) -> None: + result = session.request("tools/list", {}) + tools = result.get("tools") + if not isinstance(tools, list): + raise SmokeError(f"tools/list returned no tools array: {result}") + names = {tool.get("name") for tool in tools if isinstance(tool, dict)} + if names != set(EXPECTED_TOOLS): + raise SmokeError(f"tools are {sorted(map(str, names))}, want {sorted(EXPECTED_TOOLS)}") + print(f"[smoke] tools/list: {sorted(EXPECTED_TOOLS)}") + + +def check_search(session: Session, *, capability: str) -> str: + payload = session.call_tool("search_api", {"query": capability}) + results = payload.get("results") + if not isinstance(results, list): + raise SmokeError(f"search_api returned no results array: {payload}") + matches = [ + entry + for entry in results + if isinstance(entry, dict) and entry.get("name") == capability + ] + if not matches: + found = sorted( + str(entry.get("name")) for entry in results if isinstance(entry, dict) + ) + raise SmokeError(f"search_api did not return {capability!r}; got {found}") + signature = matches[0].get("signature") + if not isinstance(signature, str) or not signature.startswith(capability + "("): + raise SmokeError(f"search_api signature for {capability!r} is {signature!r}") + print(f"[smoke] search_api: {signature}") + return signature + + +def check_describe(session: Session, *, capability: str, signature: str) -> None: + payload = session.call_tool("describe_api", {"name": capability}) + if payload.get("name") != capability: + raise SmokeError(f"describe_api returned {payload.get('name')!r}, want {capability!r}") + if payload.get("signature") != signature: + raise SmokeError( + f"describe_api signature {payload.get('signature')!r} " + f"disagrees with search_api {signature!r}" + ) + inputs = field_names(payload, "input") + outputs = field_names(payload, "output") + if not {"min", "max"} <= inputs: + raise SmokeError(f"describe_api input fields are {sorted(inputs)}, want min and max") + if "value" not in outputs: + raise SmokeError(f"describe_api output fields are {sorted(outputs)}, want value") + print(f"[smoke] describe_api: input {sorted(inputs)} output {sorted(outputs)}") + + +def check_execute(session: Session, *, capability: str) -> None: + program = DETERMINISTIC_PROGRAM.format(capability=capability) + payload = session.call_tool("execute", {"source": program}) + if payload != DETERMINISTIC_RESULT: + raise SmokeError(f"execute returned {payload}, want {DETERMINISTIC_RESULT}") + print(f"[smoke] execute: {payload}") + + +def check_invalid_range(session: Session, *, capability: str, expect_error_text: str) -> None: + program = INVALID_RANGE_PROGRAM.format(capability=capability) + result = session.request("tools/call", {"name": "execute", "arguments": {"source": program}}) + if not result.get("isError"): + raise SmokeError(f"execute accepted an impossible range: {result}") + text = result_text(result) + if text != expect_error_text: + raise SmokeError(f"execute error text is {text!r}, want exactly {expect_error_text!r}") + print(f"[smoke] execute rejected min>max: {text}") + + +def field_names(payload: dict[str, Any], key: str) -> set[str]: + fields = payload.get(key) + if not isinstance(fields, list): + raise SmokeError(f"describe_api returned no {key} array: {payload}") + return { + field["name"] + for field in fields + if isinstance(field, dict) and isinstance(field.get("name"), str) + } + + +def result_text(result: dict[str, Any]) -> str: + blocks = result.get("content") + if not isinstance(blocks, list): + return "" + texts = [ + block["text"] + for block in blocks + if isinstance(block, dict) and isinstance(block.get("text"), str) + ] + return "\n".join(texts).strip() + + +class Session: + """One MCP stdio session: newline-delimited JSON-RPC over a child process.""" + + def __init__(self, command: list[str], *, timeout: float) -> None: + self._command = command + self._timeout = timeout + self._next_id = 0 + try: + self._process = subprocess.Popen( # noqa: S603 - argv comes from the caller + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + bufsize=1, + ) + except OSError as exc: + raise SmokeError(f"could not start {command[0]!r}: {exc}") from exc + # A reader thread keeps every wait bounded: a hung or dead server surfaces as + # a timeout or EOF instead of blocking the release job forever. + self._lines: queue.Queue[str | None] = queue.Queue() + self._reader = threading.Thread(target=self._read_lines, daemon=True) + self._reader.start() + + def __enter__(self) -> Session: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + self._next_id += 1 + request_id = self._next_id + self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + while True: + message = self._receive() + if message.get("id") != request_id: + # Server-initiated requests and notifications are not part of this + # smoke test; skip anything that is not our response. + continue + if "error" in message: + raise SmokeError(f"{method} failed: {message['error']}") + result = message.get("result") + if not isinstance(result, dict): + raise SmokeError(f"{method} returned no result object: {message}") + return result + + def notify(self, method: str, params: dict[str, Any]) -> None: + self._send({"jsonrpc": "2.0", "method": method, "params": params}) + + def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Call one tool and return its structured content, failing on a tool error.""" + result = self.request("tools/call", {"name": name, "arguments": arguments}) + if result.get("isError"): + raise SmokeError(f"{name} reported a tool error: {result_text(result)!r}") + payload = result.get("structuredContent") + if not isinstance(payload, dict): + raise SmokeError(f"{name} returned no structured content: {result}") + return payload + + def close(self) -> None: + process = self._process + if process.stdin is not None: + try: + process.stdin.close() + except OSError: + pass + try: + process.wait(timeout=self._timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise SmokeError("server did not exit after its stdin was closed") from None + if process.returncode not in (0, -15): + raise SmokeError(f"server exited with status {process.returncode}") + + def _send(self, message: dict[str, Any]) -> None: + stdin = self._process.stdin + if stdin is None: + raise SmokeError("server stdin is not available") + try: + stdin.write(json.dumps(message) + "\n") + stdin.flush() + except OSError as exc: + raise SmokeError(f"could not write to the server: {exc}") from exc + + def _receive(self) -> dict[str, Any]: + try: + line = self._lines.get(timeout=self._timeout) + except queue.Empty: + raise SmokeError(f"no response within {self._timeout:g}s") from None + if line is None: + status = self._process.poll() + raise SmokeError(f"server closed stdout (exit status {status})") + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + raise SmokeError(f"server wrote non-JSON to stdout: {line!r} ({exc})") from exc + if not isinstance(message, dict): + raise SmokeError(f"server wrote a non-object JSON-RPC message: {line!r}") + return message + + def _read_lines(self) -> None: + stdout = self._process.stdout + if stdout is not None: + for line in stdout: + if line.strip(): + self._lines.put(line) + self._lines.put(None) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/stage_ghd_release_assets.py b/.github/scripts/stage_ghd_release_assets.py index c8de272..a17e69d 100644 --- a/.github/scripts/stage_ghd_release_assets.py +++ b/.github/scripts/stage_ghd_release_assets.py @@ -41,7 +41,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--artifacts", default=Path("dist/artifacts.json"), type=Path) parser.add_argument("--config", default=Path("ghd.toml"), type=Path) parser.add_argument("--output", default=Path("dist/release-assets"), type=Path) - parser.add_argument("--binary-name", default="template-mcp") + parser.add_argument("--binary-name", default="template-mcp-codemode") return parser.parse_args(argv) diff --git a/.github/scripts/test_configure_github_repo.py b/.github/scripts/test_configure_github_repo.py index 5b8f6b9..a9c9c62 100644 --- a/.github/scripts/test_configure_github_repo.py +++ b/.github/scripts/test_configure_github_repo.py @@ -103,7 +103,7 @@ def test_plan_creates_workflow_pages_site(self) -> None: plan = configure.build_plan( api, "meigma", - "template-mcp", + "template-mcp-codemode", base_config({"build_type": "workflow", "https_enforced": True}), mode="plan", hostname="github.com", @@ -120,7 +120,7 @@ def test_plan_updates_existing_pages_site(self) -> None: plan = configure.build_plan( api, "meigma", - "template-mcp", + "template-mcp-codemode", base_config({"build_type": "workflow", "https_enforced": True}), mode="plan", hostname="github.com", @@ -133,7 +133,7 @@ def test_plan_updates_existing_pages_site(self) -> None: def test_apply_create_pages_runs_follow_up_update(self) -> None: api = FakeGitHubApi() plan = configure.PlanResult( - repo="meigma/template-mcp", + repo="meigma/template-mcp-codemode", hostname="github.com", mode="apply", changes=[ @@ -153,7 +153,7 @@ def test_apply_create_pages_runs_follow_up_update(self) -> None: warnings=[], ) - applied = configure.apply_plan(api, "meigma", "template-mcp", plan) + applied = configure.apply_plan(api, "meigma", "template-mcp-codemode", plan) self.assertEqual(applied, ["Create GitHub Pages site"]) self.assertEqual(api.created_pages, [{"build_type": "workflow"}]) diff --git a/.github/scripts/test_stage_ghd_release_assets.py b/.github/scripts/test_stage_ghd_release_assets.py index 35ac743..84bdc0e 100644 --- a/.github/scripts/test_stage_ghd_release_assets.py +++ b/.github/scripts/test_stage_ghd_release_assets.py @@ -61,36 +61,36 @@ def test_stages_expected_assets(self) -> None: staged, [ "checksums.txt", - "template-mcp_1.2.3_darwin_amd64", - "template-mcp_1.2.3_darwin_amd64.sbom.json", - "template-mcp_1.2.3_darwin_arm64", - "template-mcp_1.2.3_darwin_arm64.sbom.json", - "template-mcp_1.2.3_linux_amd64", - "template-mcp_1.2.3_linux_amd64.sbom.json", - "template-mcp_1.2.3_linux_arm64", - "template-mcp_1.2.3_linux_arm64.sbom.json", + "template-mcp-codemode_1.2.3_darwin_amd64", + "template-mcp-codemode_1.2.3_darwin_amd64.sbom.json", + "template-mcp-codemode_1.2.3_darwin_arm64", + "template-mcp-codemode_1.2.3_darwin_arm64.sbom.json", + "template-mcp-codemode_1.2.3_linux_amd64", + "template-mcp-codemode_1.2.3_linux_amd64.sbom.json", + "template-mcp-codemode_1.2.3_linux_arm64", + "template-mcp-codemode_1.2.3_linux_arm64.sbom.json", ], ) - linux_binary = root / "dist/release-assets/template-mcp_1.2.3_linux_amd64" + linux_binary = root / "dist/release-assets/template-mcp-codemode_1.2.3_linux_amd64" mode = linux_binary.stat().st_mode self.assertTrue(mode & stat.S_IXUSR) - self.assertIn("dist/release-assets/template-mcp_1.2.3_linux_arm64", stdout) + self.assertIn("dist/release-assets/template-mcp-codemode_1.2.3_linux_arm64", stdout) def test_fails_on_missing_checksum_entry(self) -> None: - with fixture(missing_checksum="template-mcp_1.2.3_linux_arm64") as root: + with fixture(missing_checksum="template-mcp-codemode_1.2.3_linux_arm64") as root: result, _, stderr = run_script(root) self.assertEqual(result, 1) self.assertIn("missing checksum entry", stderr) - self.assertIn("template-mcp_1.2.3_linux_arm64", stderr) + self.assertIn("template-mcp-codemode_1.2.3_linux_arm64", stderr) def test_fails_on_checksum_mismatch(self) -> None: - override = ("template-mcp_1.2.3_linux_amd64", "0" * 64) + override = ("template-mcp-codemode_1.2.3_linux_amd64", "0" * 64) with fixture(checksum_override=override) as root: result, _, stderr = run_script(root) self.assertEqual(result, 1) - self.assertIn("checksum mismatch for template-mcp_1.2.3_linux_amd64", stderr) + self.assertIn("checksum mismatch for template-mcp-codemode_1.2.3_linux_amd64", stderr) def test_fails_on_wrong_signer_workflow(self) -> None: with fixture(signer="other/repo/.github/workflows/attest.yml") as root: @@ -104,7 +104,7 @@ def test_fails_on_missing_os_arch_asset(self) -> None: result, _, stderr = run_script(root) self.assertEqual(result, 1) - self.assertIn("missing expected binary asset template-mcp_1.2.3_linux_arm64", stderr) + self.assertIn("missing expected binary asset template-mcp-codemode_1.2.3_linux_arm64", stderr) def test_fails_on_unexpected_asset_count(self) -> None: with fixture(extra_binary=True) as root: @@ -117,7 +117,7 @@ def test_fails_on_unexpected_asset_count(self) -> None: def run_script(root: Path) -> tuple[int, str, str]: stdout = io.StringIO() stderr = io.StringIO() - with working_directory(root), github_repository("meigma/template-mcp"): + with working_directory(root), github_repository("meigma/template-mcp-codemode"): with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): result = stage_ghd_release_assets.main(["--tag", "v1.2.3"]) return result, stdout.getvalue(), stderr.getvalue() @@ -126,7 +126,7 @@ def run_script(root: Path) -> tuple[int, str, str]: @contextlib.contextmanager def fixture( *, - signer: str = "meigma/template-mcp/.github/workflows/attest.yml", + signer: str = "meigma/template-mcp-codemode/.github/workflows/attest.yml", missing_checksum: str | None = None, checksum_override: tuple[str, str] | None = None, omit_artifact: tuple[str, str, str] | None = None, @@ -140,7 +140,7 @@ def fixture( artifacts: list[dict[str, str]] = [] checksum_entries: dict[str, str] = {} for goos, goarch in PLATFORMS: - binary_name = f"template-mcp_1.2.3_{goos}_{goarch}" + binary_name = f"template-mcp-codemode_1.2.3_{goos}_{goarch}" sbom_name = f"{binary_name}.sbom.json" binary_path = root / "dist" / binary_name @@ -163,7 +163,7 @@ def fixture( }) if extra_binary: - extra_name = "template-mcp_1.2.3_freebsd_amd64" + extra_name = "template-mcp-codemode_1.2.3_freebsd_amd64" extra_path = root / "dist" / extra_name extra_path.write_bytes(b"extra\n") artifacts.append({"type": "Binary", "name": extra_name, "path": f"dist/{extra_name}"}) @@ -197,32 +197,32 @@ def write_ghd_toml(path: Path, signer: str) -> None: signer_workflow = "{signer}" [[packages]] -name = "template-mcp" -description = "Meigma Go MCP server template starter CLI." +name = "template-mcp-codemode" +description = "Meigma CodeMode MCP server template starter CLI." tag_pattern = "v${{version}}" [[packages.assets]] os = "darwin" arch = "amd64" -pattern = "template-mcp_${{version}}_darwin_amd64" +pattern = "template-mcp-codemode_${{version}}_darwin_amd64" [[packages.assets]] os = "darwin" arch = "arm64" -pattern = "template-mcp_${{version}}_darwin_arm64" +pattern = "template-mcp-codemode_${{version}}_darwin_arm64" [[packages.assets]] os = "linux" arch = "amd64" -pattern = "template-mcp_${{version}}_linux_amd64" +pattern = "template-mcp-codemode_${{version}}_linux_amd64" [[packages.assets]] os = "linux" arch = "arm64" -pattern = "template-mcp_${{version}}_linux_arm64" +pattern = "template-mcp-codemode_${{version}}_linux_arm64" [[packages.binaries]] -path = "template-mcp" +path = "template-mcp-codemode" ''', encoding="utf-8", ) diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 5ecb391..68433ba 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -73,7 +73,7 @@ jobs: set -euo pipefail version="0.0.0-dryrun.${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}" - binary_name="template-mcp" + binary_name="template-mcp-codemode" host_os="$(go env GOHOSTOS)" host_arch="$(go env GOHOSTARCH)" bin="dist/release-assets/${binary_name}_${version}_${host_os}_${host_arch}" @@ -92,6 +92,11 @@ jobs: *) echo "unexpected version output: $output" >&2; exit 1 ;; esac + # Exercise the real MCP session, not just --version: the smoke script + # initializes, asserts the exact server identity and tool surface, and runs + # a deterministic CodeMode program in a re-executed worker process. + uv run .github/scripts/mcp_smoke.py -- "$bin" stdio + # Rehearse the container build: build per-arch signed apks on native runners and # assemble the image with apko, WITHOUT pushing, signing, or attesting. melange-build-dry-run: @@ -206,13 +211,19 @@ jobs: run: | set -euo pipefail - apko build apko.yaml template-mcp:dry-run image.tar \ + apko build apko.yaml template-mcp-codemode:dry-run image.tar \ --arch amd64 \ --keyring-append ./melange-amd64.rsa.pub \ --keyring-append ./melange-arm64.rsa.pub docker load < image.tar - docker tag template-mcp:dry-run-amd64 template-mcp:dry-run + docker tag template-mcp-codemode:dry-run-amd64 template-mcp-codemode:dry-run + + docker run --rm template-mcp-codemode:dry-run --version + docker run --rm template-mcp-codemode:dry-run --help - docker run --rm template-mcp:dry-run --version - docker run --rm template-mcp:dry-run --help + # Real MCP session against the assembled image. `docker run -i` keeps stdin + # open for the stdio transport; the run also proves the CodeMode worker can + # re-execute itself as the image's nonroot user with no shell present. + uv run .github/scripts/mcp_smoke.py \ + -- docker run -i --rm template-mcp-codemode:dry-run stdio diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e615fc4..93af72e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ on: permissions: {} env: - IMAGE_NAME: ghcr.io/meigma/template-mcp + IMAGE_NAME: ghcr.io/meigma/template-mcp-codemode jobs: resolve-release: @@ -126,7 +126,7 @@ jobs: set -euo pipefail version="${RELEASE_TAG#v}" - binary_name="template-mcp" + binary_name="template-mcp-codemode" host_os="$(go env GOHOSTOS)" host_arch="$(go env GOHOSTARCH)" bin="dist/release-assets/${binary_name}_${version}_${host_os}_${host_arch}" @@ -145,6 +145,11 @@ jobs: *) echo "unexpected version output: $output" >&2; exit 1 ;; esac + # Exercise the real MCP session, not just --version: the smoke script + # initializes, asserts the exact server identity and tool surface, and runs + # a deterministic CodeMode program in a re-executed worker process. + uv run .github/scripts/mcp_smoke.py -- "$bin" stdio + - name: Upload assets to draft release env: GH_TOKEN: ${{ github.token }} @@ -370,6 +375,12 @@ jobs: docker run --rm "$IMAGE_REF" --version docker run --rm "$IMAGE_REF" --help + # Real MCP session against the published image. `docker run -i` keeps stdin + # open for the stdio transport; the run also proves the CodeMode worker can + # re-execute itself as the image's nonroot user with no shell present. + uv run .github/scripts/mcp_smoke.py \ + -- docker run -i --rm "$IMAGE_REF" stdio + - name: Sign image (keyless, Sigstore/Fulcio via OIDC) env: IMAGE_REF: ${{ steps.publish.outputs.ref }} @@ -433,9 +444,10 @@ jobs: echo echo '```sh' echo "gh release view $RELEASE_TAG --repo $GITHUB_REPOSITORY --json isDraft,assets" - echo "asset=\"template-mcp_${RELEASE_VERSION}_\$(go env GOOS)_\$(go env GOARCH)\"" + echo "asset=\"template-mcp-codemode_${RELEASE_VERSION}_\$(go env GOOS)_\$(go env GOARCH)\"" echo "gh attestation verify \"dist/release-assets/\${asset}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" - echo "ghd download \"$GITHUB_REPOSITORY/template-mcp@${RELEASE_VERSION}\" --output \"\$(mktemp -d)\"" + echo "ghd download \"$GITHUB_REPOSITORY/template-mcp-codemode@${RELEASE_VERSION}\" --output \"\$(mktemp -d)\"" + echo "uv run .github/scripts/mcp_smoke.py -- \"dist/release-assets/\${asset}\" stdio" echo '```' echo echo "Container verification commands:" @@ -444,6 +456,7 @@ jobs: echo "docker login ghcr.io" echo "docker pull \"${IMAGE_NAME}:${RELEASE_TAG}\"" echo "docker run --rm \"${IMAGE_NAME}:${RELEASE_TAG}\" --version" + echo "uv run .github/scripts/mcp_smoke.py -- docker run -i --rm \"${IMAGE_NAME}:${RELEASE_TAG}\" stdio" echo "gh attestation verify \"oci://${IMAGE_NAME}@${IMAGE_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" echo "cosign verify \"${IMAGE_NAME}@${IMAGE_DIGEST}\" --certificate-identity-regexp \"^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@.*\" --certificate-oidc-issuer https://token.actions.githubusercontent.com" echo '```' diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index d44102b..3e6f701 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -50,16 +50,16 @@ jobs: melange keygen melange.rsa melange build melange.yaml --arch amd64 --runner docker \ --signing-key melange.rsa --source-dir . --vars-file melange-vars.yaml - apko build apko.yaml template-mcp:security-scan scan.tar \ + apko build apko.yaml template-mcp-codemode:security-scan scan.tar \ --arch amd64 --keyring-append ./melange.rsa.pub docker load < scan.tar - docker tag template-mcp:security-scan-amd64 template-mcp:security-scan + docker tag template-mcp-codemode:security-scan-amd64 template-mcp-codemode:security-scan - name: Scan local image uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: image - image-ref: template-mcp:security-scan + image-ref: template-mcp-codemode:security-scan scanners: vuln,secret,config vuln-type: os,library severity: HIGH,CRITICAL diff --git a/.golangci.yml b/.golangci.yml index 7e55364..16dd55a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -36,7 +36,7 @@ formatters: # with the given prefixes are grouped after 3rd-party packages. # Default: [] local-prefixes: - - github.com/meigma/template-mcp + - github.com/meigma/template-mcp-codemode golines: # Target maximum line length. diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f3a744e..5a3dc8a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,15 +1,15 @@ version: 2 -project_name: template-mcp +project_name: template-mcp-codemode before: hooks: - go test ./... builds: - - id: template-mcp - main: ./cmd/template-mcp - binary: template-mcp + - id: template-mcp-codemode + main: ./cmd/template-mcp-codemode + binary: template-mcp-codemode env: - CGO_ENABLED=0 goos: @@ -28,9 +28,9 @@ builds: mod_timestamp: '{{ .CommitTimestamp }}' archives: - - id: template-mcp + - id: template-mcp-codemode ids: - - template-mcp + - template-mcp-codemode formats: - binary name_template: >- diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 88f7c73..e18ee07 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.4" + ".": "0.0.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d5decc0..825c32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1 @@ # Changelog - -## [0.1.4](https://github.com/meigma/template-mcp/compare/v0.1.3...v0.1.4) (2026-06-28) - - -### Chores - -* force release 0.1.3 ([#17](https://github.com/meigma/template-mcp/issues/17)) ([ad18779](https://github.com/meigma/template-mcp/commit/ad18779a2afe610417be5481b9e0d89034e95b2f)) -* force release 0.1.4 ([#20](https://github.com/meigma/template-mcp/issues/20)) ([9d1af0c](https://github.com/meigma/template-mcp/commit/9d1af0c758a222b77533924c3f03a25941e6bbe8)) - -## [0.1.3](https://github.com/meigma/template-mcp/compare/v0.1.3...v0.1.3) (2026-06-28) - - -### Chores - -* force release 0.1.3 ([#17](https://github.com/meigma/template-mcp/issues/17)) ([ad18779](https://github.com/meigma/template-mcp/commit/ad18779a2afe610417be5481b9e0d89034e95b2f)) - -## [0.1.3](https://github.com/meigma/template-mcp/compare/v0.1.2...v0.1.3) (2026-06-14) - - -### Features - -* address developer-experience review findings ([#6](https://github.com/meigma/template-mcp/issues/6)) ([d00966c](https://github.com/meigma/template-mcp/commit/d00966cf00ec8973416ac278b42be7f496a5935f)) -* **proxy:** add MCP dev proxy for hot-reloading servers behind a stable client session ([#3](https://github.com/meigma/template-mcp/issues/3)) ([b4f38ba](https://github.com/meigma/template-mcp/commit/b4f38ba1807506b679d258650c5394b571196cb2)) -* **proxy:** add self-building .mcp.json for a zero-setup dev loop ([#5](https://github.com/meigma/template-mcp/issues/5)) ([0eda985](https://github.com/meigma/template-mcp/commit/0eda985f23358e812a60d7dae17a756c33599f3b)) - -## [0.1.2](https://github.com/meigma/template-mcp/compare/v0.1.1...v0.1.2) (2026-06-09) - - -### Features - -* convert repository template into a Go MCP server template ([#1](https://github.com/meigma/template-mcp/issues/1)) ([190ddd8](https://github.com/meigma/template-mcp/commit/190ddd8217e66036daf6cc86413e041072376eaa)) - -## [0.1.1](https://github.com/meigma/template-mcp/compare/template-mcp-v0.1.0...template-mcp-v0.1.1) (2026-05-12) - - -### Features - -* **release:** add container publishing flow ([#1](https://github.com/meigma/template-mcp/issues/1)) ([cb5c8f4](https://github.com/meigma/template-mcp/commit/cb5c8f4a38b5332f77a4adb7e7e0f8dc32114cd3)) -* standardize Go repository template ([7f61141](https://github.com/meigma/template-mcp/commit/7f6114150b9864d090bdee3027f0387a32d8ad75)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47d9b62..7279232 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,64 +1,79 @@ # Contributing -Thank you for your interest in contributing. -This repository is a Go MCP server template, so changes should keep the generated-project path simple and predictable. -For private vulnerability reporting, use [SECURITY.md](SECURITY.md) instead of public channels. +This repository is a Go CodeMode MCP server template. Keep changes focused, preserve the generated-project path, and route private vulnerability reports through [SECURITY.md](SECURITY.md). -## Reporting Bugs +## Report a bug -Report non-security bugs through GitHub issues. -Include the following details when possible: +Use GitHub issues for non-security bugs. Include, when applicable: -- version, commit, or environment details -- steps to reproduce -- expected behavior -- actual behavior -- logs, screenshots, or a minimal reproduction +- version, commit, operating system, and architecture; +- steps to reproduce; +- expected and actual behavior; and +- relevant logs or a minimal reproduction. -If you are reporting a security issue, stop and follow [SECURITY.md](SECURITY.md) instead. +Do not report a vulnerability in a public issue, pull request, or discussion. Follow [SECURITY.md](SECURITY.md) instead. -## Pull Requests +## Pull requests -Contributors should: +1. Keep the change scoped to one problem. +2. Add or update behavior-focused tests when behavior changes. +3. Update documentation when a user-visible contract changes. +4. Use a Conventional Commit subject, such as `feat: add records capability` or `fix: honor canceled handler context`. +5. Run `moon run root:check` before requesting review. -1. Keep changes focused and scoped to a single problem. -2. Add or update tests when behavior changes. -3. Update documentation when user-facing behavior changes. -4. Use Conventional Commit subjects, such as `feat: add config loader` or `fix: handle empty input`. -5. Make sure `moon run root:check` passes before requesting review. +A capability change must preserve the CodeMode boundary: register it through `codemode.Register`, not as another direct MCP tool. The externally listed MCP tools remain `search_api`, `describe_api`, and `execute`. -## Local Setup +## Local setup -The pinned toolchain (Go, Moon, the dev CLIs, Python + uv for the docs) is -provisioned by [mise](https://mise.jdx.dev) from `mise.toml` + `mise.lock`; Moon -runs every task against those tools as `system` binaries on PATH. Install mise, -then provision the toolchain and run the full check: +Install the pinned Go 1.26.6 toolchain and project tools through [mise](https://mise.jdx.dev): ```sh -mise install # provision every pinned tool, honoring mise.lock -moon run root:check # also builds the docs (needs the mise-provided Python + uv) +mise install +moon run root:check ``` -Useful project commands: +Useful commands: ```sh -moon run root:format # check formatting -moon run root:format-fix # apply formatting +moon run root:format +moon run root:format-fix moon run root:lint moon run root:build moon run root:test -moon run docs:serve # preview the docs at http://127.0.0.1:8000 -go run ./cmd/template-mcp --version +moon run docs:serve +go run ./cmd/template-mcp-codemode --version ``` -A few environment notes: +The STDIO server blocks until its client closes input or the process receives a signal. This is expected. macOS does not include `timeout` or `gtimeout` by default; use another time-bounding mechanism or install coreutils when a local script needs one. -- macOS has no `timeout`/`gtimeout` by default; install coreutils or use a - different mechanism when scripting time-bounded runs. -- The `stdio` subcommand is a server: it blocks until the client closes its - input stream or the process is signaled. That is expected, not a hang. +## CodeMode worker entry points -## Release Changes +`codemode.ServeWorkerAndExit()` must remain the first statement of the final binary's `main`, before flags, credentials, service clients, authorizers, handlers, or transports. -Release Please reads Conventional Commit subjects to build changelogs and release PRs. -Keep release-impacting commits clear; routine docs, CI, and maintenance commits should use the appropriate non-release type. +A test package that calls `Builder.Build` must define: + +```go +func TestMain(m *testing.M) { + codemode.ServeWorkerAndExit() + os.Exit(m.Run()) +} +``` + +The worker call must also be the first statement of `TestMain`. Do not add setup before it. Package initializers run before either function, so keep them free of privileged setup and irreversible side effects. + +## Documentation changes + +Use the existing Diátaxis page roles: + +- `getting-started.md` is the runnable tutorial. +- `how-to/add-a-capability.md` is the repository-specific extension procedure. +- `configuration.md` is the CLI and runtime-options reference. +- `security.md` explains deployment and execution boundaries. + +Link to the [canonical CodeMode documentation](https://meigma.github.io/codemode/) instead of duplicating its full public API, Starlark, or MCP tool reference. + +## Release changes + +Release Please uses Conventional Commit subjects to prepare the changelog and release pull request. This repository starts at baseline `0.0.0`, with `0.1.0` as its first pending release. Do not restore release entries inherited from another repository. + +Changes to release configuration must keep the matching dry-run path current. Review binary names, asset patterns, image names, smoke commands, and signer-workflow references together. diff --git a/DELETE_ME.md b/DELETE_ME.md index 146be1f..9b8cff6 100644 --- a/DELETE_ME.md +++ b/DELETE_ME.md @@ -1,224 +1,204 @@ -# Welcome to the Meigma MCP Server Template +# Set up a repository created from the CodeMode template -This repository was generated from `template-mcp`, the standard starter for Meigma [Model Context Protocol](https://modelcontextprotocol.io) servers. -It gives a new MCP server a working baseline on day one: a transport-agnostic server built on the official `modelcontextprotocol/go-sdk`, two ready-to-use transports (STDIO and Streamable HTTP), a single demo tool, Moon task orchestration, pinned CI, dependency automation, repository security defaults, and an enabled release pipeline that has already been exercised by the template application. +This repository was generated from `template-mcp-codemode`. It includes a transport-agnostic CodeMode runtime, STDIO and Streamable HTTP transports, one demo capability, a development proxy, documentation, CI, and release configuration. -Delete this file after you finish the first-repository setup checklist below. -It is only here to orient the initial project owner. +Complete this checklist before feature work, then delete this file. -## What This Template Provides +## Template layout -- A minimal Go module at `github.com/meigma/template-mcp`. -- A transport-agnostic MCP server in `internal/mcpserver` with one demo tool, `random_int`. -- A Cobra/Viper CLI under `cmd/template-mcp` and `internal/cli`, with two transport subcommands: `stdio` and `http`. -- Moon tasks for `format`, `lint`, `build`, `test`, and `check`. -- A hot-reloading dev loop: a checked-in `.mcp.json` wires Claude Code to the dev proxy in `tools/proxy`, which rebuilds the server on save and swaps it behind the live session. -- `golangci-lint` provisioned by mise and wired through Moon. -- CI that delegates to `moon ci --summary minimal` with pinned actions, dependency caches, and minimal token permissions. -- A scheduled container vulnerability scan that uploads SARIF results to GitHub code scanning. -- Dependabot coverage for GitHub Actions, Go modules, and the docs uv project. -- MkDocs Material docs scaffolding under `docs/`, with GitHub Pages as the default publishing target. -- Repository settings for signed commits, squash-only merges, immutable releases, private vulnerability reporting, and protected tags. -- Release workflows for Release Please, GoReleaser binary assets, GHCR container images, checksums, SBOMs, and GitHub artifact attestations. -- A root `ghd.toml` package manifest so released binaries can be installed with `ghd`. +- `cmd/template-mcp-codemode` is the thin executable entry point. `codemode.ServeWorkerAndExit()` is its first statement. +- `internal/cli` constructs the Cobra command tree, resolves configuration, selects trusted subjects, and runs each transport. +- `internal/mcpserver` builds one immutable CodeMode runtime, registers capabilities, and adapts it to the three MCP tools `search_api`, `describe_api`, and `execute`. +- `internal/templateinfo` owns the binary name, client-visible title, and derived environment-variable prefix. +- `tools/proxy` is a nested Go module that rebuilds and swaps the STDIO child during development. -## How It Works +The HTTP command constructs one runtime and MCP server at startup and shares them across all MCP sessions. Keep database pools, clients, and other shared dependencies in `mcpserver.Options.Deps`; do not construct a runtime per session. -The package layout keeps the server independent of any transport: +## Collect the new identity -- `cmd/template-mcp` — thin entrypoint that wires signal handling into the CLI. -- `internal/cli` — builds the Cobra command tree. `root.go` registers the subcommands; `stdio.go` and `http.go` each own one transport. -- `internal/mcpserver` — constructs the MCP server and registers the `random_int` tool. It knows nothing about transports. -- `internal/templateinfo` — the single source of truth for the application name and title, and the derived `TEMPLATE_MCP_*` environment-variable prefix. Renaming the app to your project starts here. (Build metadata — version, commit, date — is separate: GoReleaser injects it via ldflags into `cmd/template-mcp/main.go`.) +Choose each value independently: -Both subcommands call `mcpserver.New(...)` and differ only in how they connect it to a transport, so swapping or deleting a transport never touches the tool or server code. +| Variable | Template value | Used for | +| --- | --- | --- | +| `OWNER` | `meigma` | Go modules, repository URLs, GHCR image, docs, and signer workflow. | +| `REPO` | `template-mcp-codemode` | Repository name, root module suffix, GHCR image, and docs URLs. | +| `BINARY` | `template-mcp-codemode` | `cmd` directory, executable, build output, release assets, and container entry point. | +| `NAME` | `template-mcp-codemode` | `templateinfo.Name`, Cobra command, MCP implementation name, and environment prefix. | +| `TITLE` | `Meigma CodeMode MCP server template` | `templateinfo.Title` and the client-visible MCP implementation title. | -Developing the server with Claude Code needs no setup: the checked-in `.mcp.json` builds the dev proxy (`tools/proxy`) through Moon's cached `proxy:build` task and launches it. -Start `claude` in the repository root, approve the project-scoped `dev` server, and edit the server source — changed tools appear on the next conversation turn with no reconnect. -See `tools/proxy/README.md` for how the proxy works. +Derived values: -Moon is the main entrypoint for local development and CI: +- Root module: `github.com/OWNER/REPO` +- Proxy module: `github.com/OWNER/REPO/tools/proxy` +- Environment prefix: uppercase `NAME` with hyphens replaced by underscores (`template-mcp-codemode` becomes `TEMPLATE_MCP_CODEMODE`) +- Image: `ghcr.io/OWNER/REPO` -```sh -moon run root:check -``` +Do not collapse these values into one global replacement. A repository name, binary name, client-visible title, and environment prefix can differ. + +## Files to regenerate or reset + +Do not blindly rewrite generated or historical files during the identity search: + +- Reset `CHANGELOG.md` to one `# Changelog` heading. Release Please writes the new project's history. +- Regenerate `docs/uv.lock` with `uv lock` after changing `docs/pyproject.toml`. +- Let `go mod tidy` update each `go.sum`. +- Ignore generated output such as `bin/`, `coverage.out`, `dist/`, and `docs/build/`. +- Do not rename this file. Delete it after the checklist is complete. -That aggregate check runs the Go formatter/linter/build/tests plus the docs build. -The GitHub Actions CI workflow runs the same path through: +## Rename the project + +### 1. Rename both Go modules + +The root and development proxy are separate modules: ```sh -moon ci --summary minimal +go mod edit -module github.com/OWNER/REPO +(cd tools/proxy && go mod edit -module github.com/OWNER/REPO/tools/proxy) ``` -The workflow caches Go modules, Go build artifacts, golangci-lint state, and uv's download cache through GitHub Actions. If that is not enough for a larger generated repository, add Moon remote caching later with Depot or another Bazel Remote Execution-compatible backend and repository credentials. +Update imports that refer to the template module. Preserve the `github.com/meigma/codemode` dependency and imports; CodeMode is the runtime library, not a template identity surface. -The `GitHub Pages` workflow builds the MkDocs site on pull requests and deploys the default-branch `docs/build` output to Pages. The repository settings manifest defaults Pages to workflow-based publishing with HTTPS enforcement. +### 2. Rename the binary -The release machinery is intentionally enabled in the template repository so the starter app proves Release Please, GoReleaser binary releases, native-runner container image builds, artifact validation, and attestations before generated projects inherit the setup. -The nominal generated-project path is a server with both a downloadable binary and a container image. If the new project is binary-only, container-only, trim the release files as described below before the first release. +```sh +mv cmd/template-mcp-codemode cmd/BINARY +``` -## First Setup Checklist +Update every build-source and output path, including: -This checklist is the canonical first-setup procedure, written to be followed -top-to-bottom by a person or an AI agent. Collect the inputs below first, then -work through the steps. Two self-checks at the end (a search and a build) confirm -the rename is complete. +- root `moon.yml` +- `.goreleaser.yaml` +- `melange.yaml` +- `apko.yaml` +- `ghd.toml` +- release and security-scan workflows +- `tools/proxy/internal/cli/defaults.go` +- `.mcp.json` if its invocation changes +- README and documentation commands -### Inputs +### 3. Rename application identity -Decide these values once; every step below refers to them. Most projects set -`REPO`, `BINARY`, and `NAME` to the same string, but they are allowed to differ. +Update `Name` and `Title` in `internal/templateinfo/info.go`. `Name` controls the Cobra command, MCP implementation name, and environment prefix. `Title` is reported to MCP clients. -| Variable | This template's value | Used for | -|----------|----------------------|----------| -| `OWNER` | `meigma` | GitHub org/user: module paths, `ghcr.io/OWNER/...`, ghd `signer_workflow`, docs URLs, `apko.yaml` image source annotation, Moon `owner` | -| `REPO` | `template-mcp` | repository name: the root module's last segment, the GHCR image, docs `repo_name`/`repo_url`/`site_url` | -| `BINARY` | `template-mcp` | command/binary name: `cmd/`, build outputs, `.goreleaser.yaml`, `ghd.toml` name/assets/path, `melange.yaml`/`apko.yaml` | -| `NAME` | `template-mcp` | `templateinfo.Name`; **derives** the `TEMPLATE_MCP_*` env prefix | -| `TITLE` | `Meigma MCP server template` | `templateinfo.Title`, reported to MCP clients; also `melange.yaml`/`apko.yaml`/docs descriptions | +Search for every template identity, including human-readable variants: -Derived automatically — do not treat these as separate inputs: +```sh +rg -i "template-mcp-codemode|TEMPLATE_MCP_CODEMODE|Meigma CodeMode MCP server template|meigma" +``` -- Root module = `github.com/OWNER/REPO`; nested module = `github.com/OWNER/REPO/tools/proxy`. -- Env prefix = uppercase, hyphens-to-underscores of `NAME` (`template-mcp` → `TEMPLATE_MCP`); see `EnvPrefix` in `internal/templateinfo/info.go`. -- GHCR image = `ghcr.io/OWNER/REPO`; ghd `signer_workflow` = `OWNER/REPO/.github/workflows/release.yml`. +Map each result to `OWNER`, `REPO`, `BINARY`, `NAME`, or `TITLE`. Update the root and proxy module paths, repository URLs, package names, binary paths, container image, release assets, `ghd.toml` signer workflow, documentation metadata, and environment-variable examples. -### Do not hand-edit (leave alone or regenerate) +Do not replace upstream CodeMode names or links. The fixed adapter default implementation identity `codemode` and its three MCP tool names also belong to the upstream protocol surface, not this repository's brand. -The search in step 5 also matches files you must NOT blindly rewrite: +## Preserve worker wiring -- `CHANGELOG.md` — release history with real commit/PR URLs. Reset it to a single `# Changelog` heading (Release Please regenerates it); do not rewrite the historical links. -- `docs/uv.lock` — regenerate with `cd docs && uv lock` after editing `docs/pyproject.toml`. Never hand-edit. -- `go.sum` — fixed by `go mod tidy`. No manual edits. -- Build/coverage outputs (`bin/`, `coverage.out`, `docs/build/`) — generated; ignore. -- `DELETE_ME.md` (this file) — removed in the final step, so don't rename text inside it. +CodeMode re-executes the final binary for each program worker. This line must remain the first statement of `main`: -### Steps +```go +func main() { + codemode.ServeWorkerAndExit() + // ordinary host setup follows +} +``` -1. Rename the Go modules. There are two: the root module and the nested dev - proxy under `tools/proxy`. +It must precede signal setup, flag parsing, credentials, clients, authorizers, handlers, and transports. Package initialization still runs before `main`, so do not put privileged setup or irreversible side effects in package initializers. - ```sh - go mod edit -module github.com/OWNER/REPO - (cd tools/proxy && go mod edit -module github.com/OWNER/REPO/tools/proxy) - ``` +Every test package that calls `Builder.Build` needs: -2. Rename the binary directory: +```go +func TestMain(m *testing.M) { + codemode.ServeWorkerAndExit() + os.Exit(m.Run()) +} +``` - ```sh - mv cmd/template-mcp cmd/ - ``` +Keep the worker call as the first statement. A test package that never builds a CodeMode server does not need `TestMain`. - The build *source* path `./cmd/template-mcp` is hardcoded in several places and is a hard build-break on rename, not cosmetic. Update every one: +## Replace the demo capability - - the root `moon.yml` `build` task (`go build -o bin/template-mcp ./cmd/template-mcp`), - - `.goreleaser.yaml` `main` (`./cmd/template-mcp`), - - the `melange.yaml` `go/build` pipeline (`packages: ./cmd/template-mcp`, `output: template-mcp`), and - - `defaultBuildCommand` in `tools/proxy/internal/cli/defaults.go`, which the dev proxy's zero-config default uses (or pass explicit `--build` and child arguments in `.mcp.json`). +Add your real capabilities before removing `random.int` so the server remains useful throughout the cutover. For each capability: -3. Choose one transport. +1. Define non-pointer input and output structs with supported fields and JSON tags. +2. Use `int64` for integer inputs; CodeMode does not accept platform-sized `int` input fields. +3. Register a stable capability ID, dotted name, discovery metadata, and typed handler through `codemode.Register`. +4. Pass shared collaborators through `mcpserver.Dependencies` and close over them in the handler. +5. Add behavior-focused tests and update the expected capability catalog. +6. Delete `randomint.go`, its tests, and its registration after the replacement capabilities are registered. - The template ships both the STDIO and Streamable HTTP transports so you can compare them. Most servers keep one: +Do not register each capability as a direct MCP tool. The MCP surface remains exactly `search_api`, `describe_api`, and `execute`. - - **STDIO** for a server the client launches as a local subprocess. - - **Streamable HTTP** for a remote or containerized server. +Keep `codemode.ServeWorkerAndExit` in the final binary and applicable test binaries. Keep the CodeMode module dependency and the `mcpserver.Options.Runtime` construction even after the demo capability is removed. - To keep only one transport, delete the unused subcommand file and remove its single registration line in `internal/cli/root.go`: +## Replace demo identity and authorization - - Keeping STDIO: delete `internal/cli/http.go` and its registration in `root.go`. - - Keeping HTTP: delete `internal/cli/stdio.go` and its registration in `root.go`. +The template's identity and policy wiring is explicit: - The `internal/mcpserver` server and the `random_int` tool do not change when you drop a transport. +- STDIO uses `mcpserver.StaticSubject` because local process ownership is its authentication boundary. +- HTTP uses `mcpserver.ContextSubject`. The MCP receiving middleware copies the SDK-authenticated `req.GetExtra().TokenInfo.UserID` into `authz.WithSubject`; an arbitrary value added only to the outer `net/http` request context is not the adapter's identity channel. +- The demo verifier sets `TokenInfo.UserID` to `shared-token`. Loopback or explicit `--insecure` requests without authentication use `development`. These are development identities, not production principals. +- The CLI passes `authz.AllowAll()` so the demo permits every call. `internal/mcpserver.New` has no hidden authorization fallback. -4. Replace the demo tool. +For production HTTP, replace the shared-token verifier with real authentication that sets a stable, non-secret `auth.TokenInfo.UserID`. Keep the receiving-middleware bridge and `mcpserver.ContextSubject`; the bridge installs that ID as an `authz.Subject` with `authz.WithSubject` on the MCP handler context. Replace `AllowAll` with an authorizer appropriate for the enabled capabilities and their canonical arguments. Do not derive identity from Starlark source, MCP tool arguments, `_meta`, unvalidated headers, or arbitrary outer HTTP context values. - `random_int` in `internal/mcpserver` is a placeholder that exists to prove the end-to-end tool path. Replace it with your own tool (typed input/output structs plus a handler registered via the SDK), or add more tools alongside it; each tool lives in its own file (`randomint.go`) with a matching test file (`randomint_test.go`). The transport subcommands stay the same. +Discovery is not authorization-filtered. Every authenticated subject can search and describe every statically enabled capability. Do not place secrets or tenant-sensitive details in discovery metadata; use static capability disabling when a deployment must hide a capability's existence. -5. Replace template placeholders. Search case-insensitively and include the - human brand variants, not just the slug — a slug-only search misses the - client-visible title: +## Choose a transport - ```sh - rg -i "template-mcp|TEMPLATE_MCP|meigma|MCP server template" - ``` +The template includes both transports: - Map each hit to the right input from the table above (`OWNER`, `REPO`, - `BINARY`, `NAME`, `TITLE`) instead of doing one global replace — these axes can - differ. Skip the files listed under "Do not hand-edit" above. +- Keep STDIO for a server launched as a local subprocess. +- Keep Streamable HTTP for a remote or containerized server. - In particular, update `Name` and `Title` in `internal/templateinfo/info.go`: - `Title` ("Meigma MCP server template") is reported to MCP clients as the - server implementation title, so a stale value ships your project under the - template's brand. `EnvPrefix` (and the `TEMPLATE_MCP_*` variables) derive from - `Name`, so renaming `Name` renames them. +To remove a transport, delete its file in `internal/cli` and its registration in `internal/cli/root.go`. Capability registration remains in `internal/mcpserver`. - Also update Go imports, Moon metadata, README and docs text. For - release-bearing projects, update `.goreleaser.yaml`, - `release-please-config.json`, `ghd.toml`, `melange.yaml`, `apko.yaml`, and - `.github/workflows/release*.yml` as applicable. - Update `docs/mkdocs.yml` (`site_url`, `repo_name`, `repo_url`, `edit_uri`) - with the generated repository's GitHub Pages URL, usually - `https://OWNER.github.io/REPO/`. +If you keep HTTP, preserve the one-runtime-at-startup design. Do not move `mcpserver.New` into the SDK's per-session factory. -6. Refresh generated metadata: +## Configure releases - ```sh - go mod tidy - (cd tools/proxy && go mod tidy) - (cd docs && uv lock) # regenerate the docs lockfile after the pyproject rename - ``` +The template starts at Release Please baseline `0.0.0`; its first pending release is `0.1.0`. A generated project must keep its own changelog and release history. -7. Configure releases for the chosen shape. +For a binary plus container release: - For the nominal binary plus container case: +- Update `.goreleaser.yaml`: project, build ID, main package, binary, archive names, and package paths. +- Update `ghd.toml`: signer workflow, package name, description, asset patterns, and installed path. +- Update `melange.yaml`: package name, description, Go package, and output. +- Update `apko.yaml`: local package, entry point, command, image annotations, and source URL. +- Update the release, dry-run, and security-scan workflows: image name, binary validation paths, smoke commands, and summaries. +- Update `release-please-config.json` and keep `.release-please-manifest.json` at the intended initial baseline. +- Configure the release GitHub App credentials, protected-tag bypass, and package permissions. - - Update `.goreleaser.yaml`: `project_name`, build `id`, `main`, binary name, archive name template, and any linked package paths. - - Update `ghd.toml`: `provenance.signer_workflow`, package name, description, asset patterns, and installed binary path. - - Update `melange.yaml`: `package.name`, `description`, and the `go/build` `packages`/`output`. Update `apko.yaml`: the `@local` package name, image annotations (title/description/source), and the default `cmd` to match the transport you kept (containers usually run `http`). - - Update `.github/workflows/release.yml`: `IMAGE_NAME`, binary validation names, the published image tag, smoke-test commands, summary commands, and verification examples. - - Update `.github/workflows/release-dry-run.yml`: binary validation names, local apko image name, and smoke-test commands. - - Update `.github/workflows/security-scan.yml`: local container image name and scan category. - - Update `.github/repository-settings.toml` only if required status-check names change. +If the project is binary-only, remove the melange/apko jobs, image scan, image configuration, and container required checks. If it is container-only, remove GoReleaser, `ghd.toml`, binary jobs, and binary required checks. Keep the release dry run for every release path that remains. - For binary-only projects: +## Update documentation - - Keep `.goreleaser.yaml`, `ghd.toml`, `Release Please`, `Binary Release Dry Run`, and the binary asset portions of `release.yml`. - - Remove the `melange-build` and `container-image-release` jobs, container verification summary text, and `Melange Build Dry Run` / `Container Image Dry Run`. - - Remove `melange.yaml`, `apko.yaml`, the `image-local` mise task, and `.github/workflows/security-scan.yml` if no container build remains. - - Remove `Container Image Dry Run` from required branch checks. +Rewrite `README.md` and `docs/docs/` for the real capabilities and retained transports. Update `docs/mkdocs.yml` (`site_url`, `repo_name`, `repo_url`, and `edit_uri`) for the generated repository. Review `CONTRIBUTING.md` and `SECURITY.md` and update the license holder if needed. - For container-only projects: +Link to the [canonical CodeMode documentation](https://meigma.github.io/codemode/) for the complete runtime, type, MCP tool, and security contracts rather than copying the upstream reference into the generated project. - - Keep `Release Please`, `Melange Build Dry Run`, `Container Image Dry Run`, `melange-build`, `container-image-release`, `melange.yaml`, and `apko.yaml`. - - Remove `.goreleaser.yaml`, `ghd.toml`, `binary-release-assets`, binary verification summary text, and `Binary Release Dry Run`. - - Change `container-image-release` so it depends only on `resolve-release` and `melange-build`. - - Remove `Binary Release Dry Run` from required branch checks. +## Regenerate and verify - In every release-bearing project, configure the release app credentials, protected-tag bypass, and repository package permissions before the first release. Run the release dry-run workflow after these edits and before merging the first release PR. +Regenerate module and documentation metadata: -8. Verify the rename. First make sure the toolchain is installed (see the - "Install prerequisites" section of the README: install mise, then `mise install`), - then run both gates: +```sh +go mod tidy +(cd tools/proxy && go mod tidy) +(cd docs && uv lock) +``` - ```sh - # Build/lint/test/docs gate — fails on broken module paths, build-source - # paths, or an out-of-date docs lockfile. - moon run root:check +Run the repository gate: - # Completeness gate — should print NOTHING. Any remaining hit is a missed - # rename (or CHANGELOG history you deliberately reset). - rg -i "template-mcp|TEMPLATE_MCP|meigma|MCP server template" - ``` +```sh +moon run root:check +``` -9. Update project-facing docs: +Then repeat the identity search. It should return no template-owned identity except intentional historical context that you reviewed: - - Rewrite `README.md` for the actual server, including its real tools and the transport you kept. - - Rewrite the docs site pages under `docs/docs/` (`index.md`, `getting-started.md`, `add-a-tool.md`, `configuration.md`, `security.md`) for the real server. - - Review `CONTRIBUTING.md` and `SECURITY.md`. - - The template is dual-licensed (`LICENSE-APACHE` / `LICENSE-MIT`). Keep both or swap to your project's license, and update the copyright holder in `LICENSE-MIT`. +```sh +rg -i "template-mcp-codemode|TEMPLATE_MCP_CODEMODE|Meigma CodeMode MCP server template|meigma" +``` -10. Delete this file: +Finally, build the renamed binary and use a real MCP client to call `search_api`, `describe_api`, and `execute` against one replacement capability over the retained transport. Delete this file after those checks pass: - ```sh - rm DELETE_ME.md - ``` +```sh +rm DELETE_ME.md +``` diff --git a/README.md b/README.md index 502d368..6adf8f0 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,227 @@ -# template-mcp +# template-mcp-codemode -`template-mcp` is a Go template for building [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers. -It is built on the official [`modelcontextprotocol/go-sdk`](https://github.com/modelcontextprotocol/go-sdk) and ships with the protocol and security best practices that an MCP server should have on day one. +`template-mcp-codemode` is a Go template for building [Model Context Protocol](https://modelcontextprotocol.io) servers with [CodeMode](https://github.com/meigma/codemode). Instead of registering one MCP tool per operation, you register typed Go capabilities. An agent discovers them and composes several calls in one bounded Starlark program. -The template exposes a single demo tool, `random_int`, and demonstrates serving it over two transports from the same server code: +Every server created from this template exposes exactly three MCP tools: -- **Local** — the STDIO transport (`template-mcp stdio`), which clients spawn as a subprocess. -- **Networked** — the Streamable HTTP transport (`template-mcp http`), suitable for remote and containerized deployments. +- `search_api` finds capabilities by name, summary, and search terms. +- `describe_api` returns the exact input and output shape for one capability. +- `execute` runs a Starlark program and returns the value from its zero-argument `main()` function. -Generated projects keep the transport they need and delete the other (see [Choosing a transport](#choosing-a-transport)). +The included `random.int` capability demonstrates typed input and structured output over both STDIO and Streamable HTTP. -## Local Bootstrap +## Local bootstrap Prerequisites: -- [mise](https://mise.jdx.dev) — provisions every pinned tool from `mise.toml` + - `mise.lock`: Go, Moon, Python + uv (for the MkDocs docs project), the - `golangci-lint` and `mockery` CLIs, and `melange`/`apko`/`cosign` for releases. - Run `mise install` once; there is nothing else to install by hand. -- Docker — only to build and scan the container image locally; not needed to run - the server itself. - -Tool versions live in `mise.toml`; `mise.lock` records a per-platform download URL -and checksum for each (and, for the aqua-backed CLIs, cosign/SLSA/GitHub-attestation -verification). `mise install` runs with `locked = true`, so it **fails closed** if a -tool lacks a pre-resolved, checksummed entry for the current platform — replacing the -former Proto `checksum-url` pins. Moon runs every task against these tools as `system` -binaries on PATH and manages no toolchain itself. To bump a tool, edit its version in -`mise.toml`, run `mise lock --platform linux-x64,linux-arm64,macos-x64,macos-arm64`, -and commit `mise.toml` + `mise.lock`. +- [mise](https://mise.jdx.dev), which provisions the pinned Go 1.26.6 toolchain, Moon, Python and uv, the development CLIs, and the release tools from `mise.toml` and `mise.lock`. The server module pins the official MCP Go SDK v1.7.0. +- Docker, only for local container builds and scans. + +From the repository root: ```sh -# Install mise (https://mise.jdx.dev/installing-mise.html), then from the repo -# root provision every pinned tool (Go, Moon, the dev CLIs, and the release stack): mise install ``` -After creating a new repository from this template, replace the placeholder names before doing feature work: +`mise install` runs with locked tool resolution. To update a tool, edit `mise.toml`, regenerate `mise.lock` for the supported platforms, and commit both files. -```sh -go mod edit -module github.com/meigma/YOUR_REPO -mv cmd/template-mcp cmd/YOUR_BINARY -``` +## Run the server -Then update `template-mcp` references in the Moon tasks, GoReleaser config, `ghd.toml`, README, and package docs. -The full first-setup checklist lives in [DELETE_ME.md](DELETE_ME.md). +Run the local STDIO transport: -## Running the Server +```sh +go run ./cmd/template-mcp-codemode stdio +``` -Run the server over STDIO (the mode a local MCP client launches): +Run Streamable HTTP on its loopback default: ```sh -go run ./cmd/template-mcp stdio +go run ./cmd/template-mcp-codemode http --addr localhost:8080 ``` -Run the server over Streamable HTTP, bound to loopback by default: +Both commands build one immutable CodeMode runtime through `internal/mcpserver`. The HTTP command constructs the runtime and MCP server once at startup and shares them across sessions; it does not rebuild the capability catalog per request or per session. + +For a local MCP client, build the binary and configure its absolute path: ```sh -go run ./cmd/template-mcp http --addr localhost:8080 +go build -o bin/template-mcp-codemode ./cmd/template-mcp-codemode ``` -Both subcommands build the same `internal/mcpserver` server and differ only in how they connect it to a transport. +```json +{ + "mcpServers": { + "template-mcp-codemode": { + "command": "/absolute/path/to/template-mcp-codemode/bin/template-mcp-codemode", + "args": ["stdio"] + } + } +} +``` -## Hot Reload During Development +## Compose capability calls -The repository ships a dev proxy (`tools/proxy`) and a checked-in `.mcp.json` that wires it up, so developing the server with Claude Code needs no setup. -Start `claude` in the repository root, approve the project-scoped `dev` server, and edit the server source: the proxy rebuilds on save and swaps the running server behind the live session — new and changed tools appear on the next conversation turn with no reconnect. -See [tools/proxy/README.md](tools/proxy/README.md) for how it works and its flags. +Ask the client to find and describe a random integer capability, then run this program through `execute`: -## The Demo Tool +```python +def main(): + left = random.int(min=3, max=3) + right = random.int(min=4, max=4) + return { + "left": left["value"], + "right": right["value"], + "total": left["value"] + right["value"], + } +``` -The template registers one tool, `random_int`, in `internal/mcpserver`. -It takes `min` and `max` arguments and returns a uniformly random integer in the inclusive range `[min, max]`. +Each `random.int(...)` call returns a dictionary whose `value` entry is a signed 64-bit integer. The fixed bounds make this example deterministic. The successful structured result is: -The tool is deliberately small but exercises the parts of the protocol you are most likely to use: +```json +{"result":{"left":3,"right":4,"total":7}} +``` -- Typed input and output structs, from which the SDK derives the JSON Schemas automatically. -- Structured output, marshaled from the typed return value by the SDK. -- The tool-error convention: an invalid range (`min > max`) returns a tool-level error result (`IsError`) rather than a JSON-RPC protocol error. +Only `main()`'s final converted value is returned. Intermediate capability results remain inside the worker and do not enter the model's context. -Replace `random_int` with your own tool, or add more tools alongside it. The server and transport code do not change when you do. +## Add capabilities -A tool that needs shared collaborators (a database handle, an HTTP client, a config struct) gets them through the `Dependencies` struct on `mcpserver.Options`: add fields there, and each `registerXxx` function receives them via `Options.Deps`. Because dependencies flow through `Options`, the server stays transport-agnostic. See the [Add a tool](https://meigma.github.io/template-mcp/add-a-tool/) guide for a worked example. +Capabilities live in `internal/mcpserver`. A capability combines: -## Choosing a Transport +- a stable deployment and authorization ID; +- a dotted Starlark name such as `random.int`; +- discovery metadata; +- non-pointer input and output structs; and +- a typed Go handler that receives `context.Context`, the trusted `authz.Subject`, and the input value. -The server in `internal/mcpserver` knows nothing about transports. Each transport is a Cobra subcommand in its own file: +Use direct exported fields and supported scalar input types. In particular, integer inputs use `int64`, not platform-sized `int`. CodeMode accepts JSON struct tags for capability fields and rejects unrelated struct tags. See [Add a capability](docs/docs/how-to/add-a-capability.md) for the repository procedure and the [canonical CodeMode public API reference](https://meigma.github.io/codemode/reference/public-api/) for the complete type contract. -- `internal/cli/stdio.go` — the `stdio` subcommand. -- `internal/cli/http.go` — the `http` subcommand. +## Worker entry points -To keep only one transport, delete the unused file and remove its single registration line in `internal/cli/root.go`. The tool and server code are untouched. +`codemode.ServeWorkerAndExit()` must be the first statement of the final binary's `main` function. Keep it before flag parsing, credential loading, client construction, and all other host setup. CodeMode re-executes the binary for its worker process; late wiring can run privileged host setup in the worker or make the build-time worker probe fail. -## Security & Best Practices +Every test binary that calls `Builder.Build` also needs this first statement: -The template bakes in the practices that an MCP server must have. Preserve them as you build on it. +```go +func TestMain(m *testing.M) { + codemode.ServeWorkerAndExit() + os.Exit(m.Run()) +} +``` -- **stdout is reserved for JSON-RPC.** Over the STDIO transport, stdout carries protocol messages only. Writing anything else to stdout — a stray `fmt.Println`, a logger pointed at `os.Stdout` — silently corrupts the stream and is the most common way a stdio server breaks. The template logs to `os.Stderr` only; keep all logging and diagnostics on stderr. -- **Origin verification and a loopback default for HTTP.** The `http` transport wraps the SDK handler in the standard library's cross-origin protection to defend against DNS-rebinding and CSRF from browsers, and `--addr` defaults to `localhost:8080`. Binding to a non-loopback address exposes the server to the network and is an explicit, security-relevant decision. -- **The HTTP transport fails closed off loopback.** Cross-origin protection stops malicious browsers, not direct clients such as `curl`. So binding a non-loopback address (for example `0.0.0.0`) with no authentication is refused at startup unless you either set `--auth-token` or pass `--insecure` to opt into an unauthenticated, network-exposed server. The container image defaults to `--insecure` so the demo runs out of the box; remove it and supply real authentication before deploying. -- **The bearer-auth seam is demo-only.** The HTTP transport includes a minimal, flag-gated bearer-token check that is off by default and exists to show where authorization belongs. It is not production authorization. A production server needs a real OAuth 2.1 resource server: protected-resource metadata (RFC 9728), audience-restricted tokens (RFC 8707), and PKCE with S256. Validate token signature, expiry, and audience against a trusted authorization server. -- **Authorization is HTTP-only.** Per the MCP specification, authorization applies to HTTP transports only. STDIO servers must not use OAuth; they take any credentials they need from the environment of the process that launched them. +Add one applicable `TestMain` per Go package. Do not put setup before the worker call. -## Common Tasks +## Identity and authorization -Moon is the standard task front door: +The template keeps authentication identity outside program source, tool arguments, and MCP metadata: -```sh -moon run root:format # check formatting (golangci-lint fmt --diff) -moon run root:format-fix # apply formatting -moon run root:lint -moon run root:build -moon run root:test -moon run root:check # format, lint, build, test, docs build, and proxy checks -``` +- STDIO uses `mcpserver.StaticSubject` with the non-secret subject ID `local`. Process ownership is the authentication boundary. +- HTTP uses `mcpserver.ContextSubject`. The receiving MCP middleware reads the SDK-authenticated `req.GetExtra().TokenInfo.UserID`, stores that non-secret identity with `authz.WithSubject`, and then lets the CodeMode adapter resolve it. Setting an arbitrary value only on the outer `net/http` request context is not sufficient. +- The demo verifier sets `TokenInfo.UserID` to `shared-token`; the token value is not used as identity. Allowed loopback and explicitly insecure unauthenticated modes send `development` through the same receiving bridge. -CI runs the same aggregate check: +The CLI passes `authz.AllowAll()` explicitly in `codemode.Options` so the demo is runnable. `AllowAll` is authorization, not authentication, and it permits every capability call for every resolved subject. `internal/mcpserver.New` does not supply a hidden fallback authorizer. Replace this demo policy and the HTTP authentication seam before a real deployment. -```sh -moon ci --summary minimal -``` +Discovery is not filtered by per-invocation authorization. An authenticated subject can search and describe every statically enabled capability; authorization runs for each native capability call during `execute`. Do not put secrets or tenant-sensitive data in capability names, summaries, descriptions, search terms, or field names. Disable a capability at build time if its existence must be hidden. -Preview the documentation site locally with live reload: +## Choose a transport -```sh -moon run docs:serve # serves on http://127.0.0.1:8000 -``` +Transport code is isolated in `internal/cli`: -The CLI entrypoint uses Cobra and Viper in the same shape as other Meigma CLIs: `cmd/template-mcp` stays thin, `internal/cli` owns command construction, and Viper-backed flags such as the HTTP address can also be supplied through `TEMPLATE_MCP_*` environment variables. +- `stdio.go` serves clients that spawn the binary as a local subprocess. +- `http.go` serves networked and containerized clients. -```sh -go run ./cmd/template-mcp --version -go run ./cmd/template-mcp stdio -go run ./cmd/template-mcp http --addr localhost:8080 -go test ./... -``` +To keep one transport, delete the unused file and its registration in `internal/cli/root.go`. The capability registrations in `internal/mcpserver` do not change. + +## Hot reload during development -A local build reports `template-mcp dev (none) built unknown` — GoReleaser -injects the real version, commit, and date at release time. +The checked-in `.mcp.json` starts the development proxy in `tools/proxy`. Start Claude Code in the repository root, approve the project-scoped `dev` server, and edit `cmd` or `internal`; the proxy rebuilds and swaps the child process behind the existing session. -## Logging and Observability +CodeMode capability changes do not change the outer definitions of `search_api`, `describe_api`, or `execute`, so they normally do not emit `notifications/tools/list_changed`. Validate a reload by calling `search_api`, then `describe_api`, then `execute` and checking the capability result. See [the proxy guide](tools/proxy/README.md) for the exact workflow and its fidelity limits. -Both transports log to stderr (never stdout, which the stdio transport reserves -for JSON-RPC). Two persistent flags control logging, and each is also settable -through an environment variable: +## Configuration and logging + +Cobra flags take precedence over `TEMPLATE_MCP_CODEMODE_*` environment variables, which take precedence over defaults. Common commands include: ```sh -go run ./cmd/template-mcp http --log-level debug --log-format json -TEMPLATE_MCP_LOG_LEVEL=debug go run ./cmd/template-mcp stdio +go run ./cmd/template-mcp-codemode --version +go run ./cmd/template-mcp-codemode stdio +go run ./cmd/template-mcp-codemode http --addr localhost:8080 +TEMPLATE_MCP_CODEMODE_LOG_LEVEL=debug go run ./cmd/template-mcp-codemode stdio ``` -- `--log-level` (`TEMPLATE_MCP_LOG_LEVEL`): `debug`, `info` (default), `warn`, or `error`. -- `--log-format` (`TEMPLATE_MCP_LOG_FORMAT`): `text` (default) or `json`. +A local build reports `template-mcp-codemode dev (none) built unknown`. GoReleaser supplies version, commit, and date for releases. + +Both transports log to stderr. STDIO reserves stdout exclusively for JSON-RPC; never write logs or diagnostics there. -The http transport logs a `listening` line on startup and a clean-shutdown pair -on exit. Metrics and tracing are intentionally out of scope for the template; -the `Options.Logger` seam in `internal/mcpserver` is where richer instrumentation -would attach. +CodeMode execution and discovery limits are set programmatically through `mcpserver.Options.Runtime.Limits`. The template does not add limit flags or environment variables. Zero-valued fields receive CodeMode's bounded defaults. See the [configuration reference](docs/docs/configuration.md) for the defaults and option wiring. -## Container Image +## Common tasks -The image is built with [melange](https://github.com/chainguard-dev/melange) — which -compiles the binary into a signed Wolfi apk — and [apko](https://github.com/chainguard-dev/apko), -which assembles that apk plus a minimal Wolfi base into a multi-arch, nonroot OCI -image (uid/gid 65532, ca-certificates, tzdata, no shell). It mirrors the former -`distroless/static-debian12:nonroot` posture. Build it locally and load it into Docker: +Moon is the task front door: ```sh -mise run image-local # builds template-mcp:dev for the host arch -docker run --rm template-mcp:dev --version +moon run root:format # check formatting +moon run root:format-fix # apply formatting +moon run root:lint +moon run root:build +moon run root:test +moon run root:check # formatting, lint, builds, tests, docs, and proxy checks +moon run docs:serve # documentation preview on http://127.0.0.1:8000 ``` -The Wolfi base intentionally floats to the latest (fresh CA bundle/timezones, low CVE -surface); the exact resolved package versions are recorded in the per-build SBOM and -provenance attestation rather than pinned in a lockfile. version/commit/date are -stamped into the binary via melange's `--vars-file`, mirroring GoReleaser. +CI runs the same aggregate check with: -Containers are the networked deployment, so the image defaults to -`http --addr 0.0.0.0:8080 --insecure`, which runs the demo unauthenticated; -`--insecure` is required because the server otherwise refuses to bind a non-loopback -address without authentication. Before deploying, drop `--insecure` and supply real -authorization (see the security expectations above). Override the default at runtime, -for example `docker run --rm template-mcp:dev stdio`. +```sh +moon ci --summary minimal +``` -## CI and Security +## Container image -The default CI workflow keeps permissions minimal, pins external actions, disables checkout credential persistence, and delegates checks to Moon. -It uses GitHub-hosted dependency caches for Go, golangci-lint, and uv download artifacts while leaving Moon remote caching as an optional follow-up for repositories that need a shared task-output cache. -The docs workflow builds the MkDocs site on pull requests and deploys `docs/build` to GitHub Pages from the default branch. -The scheduled security scan workflow builds the local melange/apko image weekly, scans it for high/critical fixed vulnerabilities, and uploads SARIF results to GitHub code scanning. -Dependabot covers GitHub Actions, the root and dev-proxy Go modules, and the docs uv project. +The local image path builds the binary into a signed Wolfi package with [melange](https://github.com/chainguard-dev/melange), then assembles a minimal non-root image with [apko](https://github.com/chainguard-dev/apko): -Repository settings live in `.github/repository-settings.toml`. -They default to immutable releases, private vulnerability reporting, signed commits, squash-only merges, GitHub Pages workflow publishing, and protected tags. +```sh +mise run image-local +docker run --rm template-mcp-codemode:dev --version +``` + +The image runs as uid/gid 65532 and contains CA certificates and timezone data but no shell. Its default command is `http --addr 0.0.0.0:8080 --insecure` so the demonstration starts without credentials. This is intentionally unauthenticated. Remove `--insecure` and install production authentication and authorization before deployment. -## Release Layer +## CI and release configuration -Release automation is enabled for the template application so this repository proves the full binary and container release lifecycle before generated projects inherit it. -Repositories generated from the template should update the release app credentials, package names, asset patterns, container image name, and `ghd.toml` signer workflow before cutting their first release. +The CI workflows use minimal permissions, pinned external actions, disabled checkout credential persistence, and dependency caches. Documentation builds on pull requests and deploys from the default branch. A scheduled workflow builds and scans the container image and uploads SARIF to GitHub code scanning. Dependabot covers GitHub Actions, both Go modules, and the docs project. -The release path is: +This repository starts from a `0.0.0` Release Please baseline. The first pending release is `0.1.0`; no predecessor release history applies to this repository. -- Release Please opens and maintains the release PR. -- Release Please creates a draft GitHub release and tag after merge. -- Release Dry Run rehearses the GoReleaser binary path and the native-runner melange/apko container build path on pull requests. -- GoReleaser builds binaries, checksums, and SBOMs without publishing directly. -- The release workflow uploads assets to the draft release; binary checksum provenance is attested from an isolated reusable workflow (`.github/workflows/attest.yml`). -- The release workflow builds per-arch signed apks with melange on native GitHub-hosted runners, assembles and publishes `ghcr.io/meigma/template-mcp:vX.Y.Z` as a multi-platform apko manifest, signs it with keyless cosign, attaches a GitHub-native SBOM attestation, and attests image provenance from the same isolated `attest.yml` workflow. -- Provenance is generated in `attest.yml` so its signing key is unreachable by the build jobs (SLSA Build L3). Verify with `gh attestation verify --signer-workflow /.github/workflows/attest.yml`; the keyless cosign image signature is still issued by `release.yml`. -- A human inspects the draft release before publication. +The configured release path is: -The root `ghd.toml` matches the default GoReleaser output so generated projects can be installed with `ghd` once the release workflow runs. -After cloning this template, update `provenance.signer_workflow`, package names, asset patterns, binary paths, and image names to match the new repository and binary name. +1. Release Please maintains a release pull request and creates a version tag plus draft GitHub release after merge. +2. The release dry-run workflow rehearses the GoReleaser binary path and the native-runner melange/apko image path on the release pull request. +3. GoReleaser builds binaries, checksums, and SBOMs without publishing directly. The release workflow validates and uploads them to the draft release. +4. Native runners build signed per-architecture Wolfi packages. apko publishes `ghcr.io/meigma/template-mcp-codemode:vX.Y.Z` as a multi-platform image. +5. The isolated reusable `attest.yml` workflow creates GitHub provenance for binary checksums and the image. The release workflow also creates a keyless Cosign image signature and attaches an SBOM attestation. +6. A human inspects the draft before publication. + +Before the first release from a generated project, update the release app credentials, protected-tag bypass, package names, asset patterns, image name, and `ghd.toml` signer workflow. Run the release dry-run workflow before merging that project's first release pull request. ## Documentation -Full documentation is published at : a getting-started tutorial, an add-a-tool how-to, a configuration reference, and the security model. The Go API reference is on [pkg.go.dev](https://pkg.go.dev/github.com/meigma/template-mcp). Preview the site locally with `moon run docs:serve`. +- [Getting started](docs/docs/getting-started.md) +- [Add a capability](docs/docs/how-to/add-a-capability.md) +- [Configuration](docs/docs/configuration.md) +- [Security model](docs/docs/security.md) +- [Canonical CodeMode documentation](https://meigma.github.io/codemode/) +- [Go API](https://pkg.go.dev/github.com/meigma/template-mcp-codemode) ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines, local setup expectations, and pull request workflow. +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and pull request expectations. ## Security -See [SECURITY.md](SECURITY.md) for supported versions and the private vulnerability reporting path. +See [SECURITY.md](SECURITY.md) for supported versions and private vulnerability reporting. Read the [security model](docs/docs/security.md) before exposing the HTTP transport or adding privileged handlers. ## License -Licensed under either of +Licensed under either of: - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) -- MIT license ([LICENSE-MIT](LICENSE-MIT)) - -at your option (`SPDX-License-Identifier: Apache-2.0 OR MIT`). +- MIT License ([LICENSE-MIT](LICENSE-MIT)) -Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. +at your option (`SPDX-License-Identifier: Apache-2.0 OR MIT`). Unless you state otherwise, a contribution intentionally submitted for inclusion is dual-licensed under those terms. diff --git a/SECURITY.md b/SECURITY.md index 2d8b3e0..655d629 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,37 +1,23 @@ # Security Policy -## Supported Versions +## Supported versions -Only the latest released version receives security fixes. Older versions are -not patched; upgrade to the latest release to stay supported. +This repository has no published release yet. Until the initial `0.1.0` release, security fixes apply to the default branch. After releases begin, only the latest released version receives security fixes; generated projects must replace this statement with their own support policy. -| Version | Supported | -|---------|-----------| -| Latest release | Yes | -| Older releases | No | +The `0.0.0` value in release configuration is an automation baseline, not a published or supported release. -A generated project that maintains multiple release lines should replace this -table with its actual support windows. +## Report a vulnerability -## Reporting a Vulnerability +Report vulnerabilities privately through GitHub private vulnerability reporting: open this repository's **Security** tab and choose **Report a vulnerability**. -Report vulnerabilities privately through GitHub's [private vulnerability -reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability): -open the repository's **Security** tab and choose **Report a vulnerability**. +Do not use public GitHub issues, pull requests, discussions, chat channels, or other public forums for vulnerability reports. -Do not use public GitHub issues, pull requests, discussions, chat channels, or -other public forums for vulnerability reports. +Include as much of the following as possible: -When reporting a vulnerability, include as much of the following as possible: +- affected version, commit, or deployment identifier; +- a description of the issue and its security impact; +- steps to reproduce or a minimal proof of concept; +- relevant logs, screenshots, or traces; and +- suggested mitigations, if available. -- affected version, commit, or deployment identifier -- a description of the issue and its security impact -- steps to reproduce or a minimal proof of concept -- any relevant logs, screenshots, or traces -- any suggested mitigations or fixes, if available - -## Response - -We aim to acknowledge a report within a few business days and will keep you -updated as we investigate. Please give us a reasonable opportunity to release a -fix before any public disclosure. +The CodeMode execution boundary and deployment requirements are documented in [docs/docs/security.md](docs/docs/security.md). Non-security bugs belong in GitHub issues as described in [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/apko.yaml b/apko.yaml index c1a33f4..6a1e110 100644 --- a/apko.yaml +++ b/apko.yaml @@ -1,9 +1,10 @@ # Minimal, multi-arch, nonroot runtime image assembled from the locally-built -# template-mcp apk (melange.yaml) plus the Wolfi base packages. Mirrors the former -# gcr.io/distroless/static-debian12:nonroot posture (uid 65532, ca-certs, tzdata, -# no shell). The Wolfi base intentionally floats to the latest (fresh CA -# bundle/timezones, low CVE surface); the exact resolved versions are recorded in -# the per-build SBOM + provenance attestation rather than pinned in a lockfile. +# template-mcp-codemode apk (melange.yaml) plus the Wolfi base packages. Mirrors +# the former gcr.io/distroless/static-debian12:nonroot posture (uid 65532, +# ca-certs, tzdata, no shell). The Wolfi base intentionally floats to the latest +# (fresh CA bundle/timezones, low CVE surface); the exact resolved versions are +# recorded in the per-build SBOM + provenance attestation rather than pinned in a +# lockfile. contents: repositories: - https://packages.wolfi.dev/os @@ -16,7 +17,7 @@ contents: - wolfi-baselayout - ca-certificates-bundle - tzdata - - template-mcp@local + - template-mcp-codemode@local # Containers are the networked deployment, so the image defaults to the http # transport bound to all interfaces. `--insecure` is required because the server @@ -24,7 +25,7 @@ contents: # real authorization before deploying. Override the args (e.g. `docker run ... stdio`) # or the whole command at runtime. entrypoint: - command: /usr/bin/template-mcp + command: /usr/bin/template-mcp-codemode cmd: http --addr 0.0.0.0:8080 --insecure # Create the nonroot user/group (uid/gid 65532) and run as it — Wolfi has no @@ -44,7 +45,7 @@ archs: - arm64 annotations: - org.opencontainers.image.title: template-mcp - org.opencontainers.image.description: Meigma Go MCP server template - org.opencontainers.image.source: https://github.com/meigma/template-mcp - org.opencontainers.image.version: "0.1.4" # x-release-please-version + org.opencontainers.image.title: template-mcp-codemode + org.opencontainers.image.description: Meigma CodeMode MCP server template + org.opencontainers.image.source: https://github.com/meigma/template-mcp-codemode + org.opencontainers.image.version: "0.0.0" # x-release-please-version diff --git a/cmd/template-mcp/main.go b/cmd/template-mcp-codemode/main.go similarity index 88% rename from cmd/template-mcp/main.go rename to cmd/template-mcp-codemode/main.go index a87587a..6af5b43 100644 --- a/cmd/template-mcp/main.go +++ b/cmd/template-mcp-codemode/main.go @@ -7,7 +7,9 @@ import ( "os/signal" "syscall" - "github.com/meigma/template-mcp/internal/cli" + "github.com/meigma/codemode" + + "github.com/meigma/template-mcp-codemode/internal/cli" ) // GoReleaser injects these values with ldflags during releases. When they are @@ -22,6 +24,7 @@ var ( ) func main() { + codemode.ServeWorkerAndExit() os.Exit(run()) } diff --git a/docs/docs/add-a-tool.md b/docs/docs/add-a-tool.md deleted file mode 100644 index 092a326..0000000 --- a/docs/docs/add-a-tool.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Add a tool -description: Replace the demo tool or add your own alongside it. ---- - -# Add a tool - -Tools live in `internal/mcpserver`, one tool per file with a matching test file -(`randomint.go` / `randomint_test.go`). The transport and CLI code never change -when you add, replace, or remove a tool. - -## The pattern - -Each tool is a `registerXxx` function plus typed input/output structs and a -handler. `random_int` is the worked example: - -```go -type randomIntInput struct { - Min int `json:"min" jsonschema:"minimum value, inclusive"` - Max int `json:"max" jsonschema:"maximum value, inclusive"` -} - -type randomIntOutput struct { - Value int `json:"value" jsonschema:"the generated random integer"` -} - -func registerRandomInt(srv *mcp.Server, _ Dependencies) { - mcp.AddTool(srv, &mcp.Tool{ - Name: "random_int", - Description: "Return a random integer in the inclusive range [min, max].", - }, randomInt) -} -``` - -The SDK derives the JSON Schemas from the `json`/`jsonschema` struct tags and -marshals the typed return value into the structured result automatically. - -## Add a new tool - -1. Create `internal/mcpserver/yourtool.go` with input/output structs, a - `registerYourTool(srv *mcp.Server, deps Dependencies)` function, and a - handler. -2. Call it from `New` in `internal/mcpserver/server.go`, next to - `registerRandomInt(srv, options.Deps)`. -3. Add `internal/mcpserver/yourtool_test.go`, and add the new tool name to the - expected tool set asserted in `server_test.go` so a missed registration fails - CI. - -## The tool-error convention - -Return a regular `error` for an input the tool itself rejects (for example, an -out-of-range argument). The SDK turns it into a tool-level error result the model -can read and self-correct from — not a JSON-RPC protocol error: - -```go -if in.Min > in.Max { - return nil, yourOutput{}, fmt.Errorf("min (%d) must be <= max (%d)", in.Min, in.Max) -} -``` - -## Tools that need dependencies - -A real tool often needs shared collaborators — a database handle, an outbound -HTTP client, a config struct. Add them to the `Dependencies` struct in -`server.go`, and the registration function receives them through `Options.Deps`: - -```go -type Dependencies struct { - DB *sql.DB -} - -func registerLookup(srv *mcp.Server, deps Dependencies) { - mcp.AddTool(srv, &mcp.Tool{Name: "lookup", /* ... */}, func( - ctx context.Context, _ *mcp.CallToolRequest, in lookupInput, - ) (*mcp.CallToolResult, lookupOutput, error) { - // use deps.DB here - }) -} -``` - -Construct the dependencies where the transports call `mcpserver.New(...)` -(`internal/cli/stdio.go` and `internal/cli/http.go`) and pass them as -`Options.Deps`. Because dependencies flow through `Options`, the server stays -transport-agnostic — both transports wire them the same way. - -## Remove the demo tool - -Delete `randomint.go` and `randomint_test.go`, remove the `registerRandomInt` -call in `server.go`, and update the expected tool set in `server_test.go`. diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index 9fdfe6e..a58c32b 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -1,61 +1,147 @@ --- title: Configuration -description: CLI flags, environment variables, and transports. +description: CLI flags, environment variables, CodeMode options, limits, and transports. --- # Configuration -The CLI is built with Cobra and Viper. Every flag is also settable through an -environment variable named after the application: the `TEMPLATE_MCP_*` prefix is -derived from the binary name, so renaming the app renames the variables. Flags -take precedence over environment variables, which take precedence over defaults. +The CLI uses Cobra and an instance-scoped Viper configuration. Flags take precedence over environment variables, which take precedence over defaults. `internal/templateinfo.Name` derives the `TEMPLATE_MCP_CODEMODE_*` environment prefix. ## Commands | Command | Purpose | -|---------|---------| -| `template-mcp stdio` | Serve over the STDIO transport (local subprocess). | -| `template-mcp http` | Serve over the Streamable HTTP transport (networked). | -| `template-mcp --version` | Print version, commit, and build date. | +| --- | --- | +| `template-mcp-codemode stdio` | Serve over STDIO for a local client-launched subprocess. | +| `template-mcp-codemode http` | Serve over Streamable HTTP. | +| `template-mcp-codemode --version` | Print version, commit, and build date. | -A local build prints `template-mcp dev (none) built unknown`; GoReleaser injects -the real values at release time. +A local build prints `template-mcp-codemode dev (none) built unknown`. Release builds receive their metadata through linker flags. ## Global flags -These apply to every command. - | Flag | Environment | Default | Meaning | -|------|-------------|---------|---------| -| `--log-level` | `TEMPLATE_MCP_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, or `error`. | -| `--log-format` | `TEMPLATE_MCP_LOG_FORMAT` | `text` | Log format: `text` or `json`. | +| --- | --- | --- | --- | +| `--log-level` | `TEMPLATE_MCP_CODEMODE_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error`. | +| `--log-format` | `TEMPLATE_MCP_CODEMODE_LOG_FORMAT` | `text` | `text` or `json`. | -Logs always go to stderr. On the STDIO transport, stdout is reserved for the -JSON-RPC message stream, so nothing else may write to it. An unrecognized level -or format fails fast at startup. +Invalid values fail at startup. Logs always go to stderr. STDIO reserves stdout for JSON-RPC. -## `http` flags +## HTTP flags | Flag | Environment | Default | Meaning | -|------|-------------|---------|---------| -| `--addr` | `TEMPLATE_MCP_ADDR` | `localhost:8080` | Address to listen on. | -| `--auth-token` | `TEMPLATE_MCP_AUTH_TOKEN` | _(empty)_ | DEMO-ONLY shared bearer token; empty disables auth. | -| `--insecure` | `TEMPLATE_MCP_INSECURE` | `false` | Allow binding a non-loopback address without authentication (UNSAFE). | +| --- | --- | --- | --- | +| `--addr` | `TEMPLATE_MCP_CODEMODE_ADDR` | `localhost:8080` | Listen address. | +| `--auth-token` | `TEMPLATE_MCP_CODEMODE_AUTH_TOKEN` | Empty | Demonstration shared bearer token; empty disables token validation. | +| `--insecure` | `TEMPLATE_MCP_CODEMODE_INSECURE` | `false` | Permit a non-loopback bind without authentication. | + +A non-loopback bind without `--auth-token` fails unless `--insecure` explicitly permits unauthenticated exposure. Cross-origin protection is enabled independently of this bind check. + +The shared token is not a production credential system. It does not validate a signed token, issuer, audience, expiry, or per-client scope. See [Security](security.md). + +## Subject resolution by transport + +| Mode | Resolver | Subject ID | Trust boundary | +| --- | --- | --- | --- | +| STDIO | `mcpserver.StaticSubject` | `local` | Ownership of the launched process. | +| HTTP with valid demo token | `mcpserver.ContextSubject` | `shared-token` | The demo SDK verifier sets `auth.TokenInfo.UserID` after constant-time token validation. The token value is not stored as identity. | +| HTTP on loopback without a token | `mcpserver.ContextSubject` | `development` | Explicit unauthenticated development identity passed through the MCP receiving bridge. | +| HTTP with `--insecure` and no token | `mcpserver.ContextSubject` | `development` | Explicit unauthenticated network identity passed through the MCP receiving bridge. | + +For HTTP, the SDK authentication verifier supplies a stable, non-secret `auth.TokenInfo.UserID`. The `installHTTPSubject` receiving middleware reads `req.GetExtra().TokenInfo.UserID` from each MCP request and stores an `authz.Subject` with `authz.WithSubject` on the MCP handler context. `mcpserver.ContextSubject` resolves that value. Setting a value only on the outer `net/http` request context is not sufficient because the SDK establishes the receiving handler context. MCP tool input, Starlark source, request `_meta`, and unvalidated headers are not trusted identity sources. + +## Template server options + +`internal/mcpserver.New` has this template-owned API: + +```text +New(options Options) (*mcp.Server, error) +``` + +`Options` contains: + +| Field | Contract | +| --- | --- | +| `Version string` | Release version reported in the MCP implementation metadata. | +| `Deps Dependencies` | Shared host collaborators closed over by capability handlers. | +| `Logger *slog.Logger` | Server diagnostics; the CLI supplies a stderr logger. | +| `Resolver codemodemcp.InvocationResolver` | Required trusted-subject resolver selected by the transport. | +| `Runtime codemode.Options` | CodeMode authorizer, static capability filters, and execution/discovery limits. | + +The CLI sets `Runtime.Authorizer` to `authz.AllowAll()` explicitly for the demo. `internal/mcpserver.New` does not replace a missing authorizer. Replace `AllowAll` when not every resolved subject may invoke every enabled capability. + +The HTTP command calls `internal/mcpserver.New` once before serving and reuses the returned server for all sessions. Do not move construction into the per-session server factory. The shared runtime's `MaxConcurrentExecutions` limit then applies across the process, and shared dependencies are not recreated for each session. + +## Runtime limits + +Zero-valued `codemode.Limits` fields receive these bounded defaults during `Builder.Build`: + +| Field | Default | +| --- | ---: | +| `MaxSourceBytes` | 65,536 bytes | +| `MaxExecutionSteps` | 1,000,000 | +| `MaxExecutionTime` | 5 seconds | +| `MaxNativeCalls` | 100 | +| `MaxValueDepth` | 32 | +| `MaxValueBytes` | 1,048,576 bytes | +| `MaxIntermediateValueBytes` | 8,388,608 bytes | +| `MaxSearchQueryBytes` | 256 bytes | +| `MaxSearchResults` | 20 | +| `MaxConcurrentExecutions` | 8 | + +Override only the fields required by the deployment. A zero value does not mean unlimited: + +```go +srv, err := mcpserver.New(mcpserver.Options{ + Version: build.Version, + Logger: logger, + Resolver: codemodemcp.StaticSubject(authz.Subject{ID: "local"}), + Runtime: codemode.Options{ + Authorizer: authz.AllowAll(), + Limits: codemode.Limits{ + MaxExecutionTime: 2 * time.Second, + MaxNativeCalls: 25, + MaxConcurrentExecutions: 4, + }, + }, +}) +``` + +Limits are programmatic options. The template intentionally has no limit flags or `TEMPLATE_MCP_CODEMODE_*` limit variables. For exact accounting and validation rules, see the [CodeMode limits reference](https://meigma.github.io/codemode/reference/public-api/#limits). + +## Upstream MCP adapter options + +The template wrapper eventually calls the CodeMode adapter with the required three-argument signature: + +```go +srv, err := codemodemcp.New( + service, + resolver, + codemodemcp.Options{ + Implementation: &mcp.Implementation{ + Name: templateinfo.Name, + Title: templateinfo.Title, + Version: options.Version, + }, + Logger: logger, + }, +) +``` + +The complete API is: + +```text +mcpserver.New(service Service, resolver InvocationResolver, options Options) (*mcp.Server, error) +``` + +The third argument is required; use `mcpserver.Options{}` to accept upstream defaults. A nil `Options.Implementation` uses implementation name `codemode` and version `2`. `Options.Logger` is optional and a nil value uses the MCP SDK default. The template supplies its own implementation name, title, version, and logger. -Binding a non-loopback address (for example `0.0.0.0`) without authentication is -refused at startup unless you set `--auth-token` or pass `--insecure`. See -[Security](security.md) for the full rationale and the production upgrade path. +See the [canonical `mcpserver` API reference](https://meigma.github.io/codemode/reference/public-api/#mcpserver) for service, resolver, and error contracts. ## Transports -Both subcommands build the same `internal/mcpserver` server and differ only in -how they connect it to a transport: +Both subcommands use the same `internal/mcpserver` constructor: -- **STDIO** (`internal/cli/stdio.go`) — the client launches the process and - speaks JSON-RPC over stdin/stdout. Authorization is out of scope; the process - inherits any credentials from its environment. -- **Streamable HTTP** (`internal/cli/http.go`) — for remote or containerized - clients. Cross-origin protection is enabled and the bind defaults to loopback. +- STDIO exchanges JSON-RPC over stdin/stdout and uses a process-owned static subject. +- Streamable HTTP uses SDK `TokenInfo.UserID`, a receiving-middleware bridge to per-request context subjects, cross-origin protection, a loopback default, and graceful shutdown. -To keep only one transport, delete the unused subcommand file and its single -registration line in `internal/cli/root.go`. +To keep only one transport, delete the unused file in `internal/cli` and its registration in `internal/cli/root.go`. Do not move capability construction into transport code. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 4088cd0..25dcb72 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -1,73 +1,145 @@ --- title: Getting started -description: Clone the template and run the MCP server over both transports. +description: Run the CodeMode server and compose the demo capability. --- # Getting started -This tutorial takes you from a fresh clone to a running MCP server over both -transports. It assumes nothing beyond a terminal. +This tutorial starts the template as a local STDIO server, connects it to an MCP client, and composes two calls to `random.int` in one `execute` request. -## Install prerequisites +## Install the repository toolchain -The toolchain is provisioned by [mise](https://mise.jdx.dev) from `mise.toml` + -`mise.lock` and orchestrated by [Moon](https://moonrepo.dev/moon), which runs -every task against the mise-provided tools as `system` binaries on PATH. Install -mise, then provision the pinned tools from the repository root: +Clone a disposable checkout and provision the pinned Go 1.26.6 toolchain and project tools with [mise](https://mise.jdx.dev). The server module pins the official MCP Go SDK v1.7.0: ```sh -# Install mise: https://mise.jdx.dev/installing-mise.html -# Then, from the repository root, provision Go, Moon, golangci-lint, and more: +git clone https://github.com/meigma/template-mcp-codemode.git +cd template-mcp-codemode mise install ``` -Building the documentation also needs Python and uv; mise provisions both from -the pinned versions, so no extra setup is required. +Moon uses the mise-provided tools as system binaries. Python and uv for the documentation project are included; no separate install is required. -## Run over STDIO - -The STDIO transport is what a local MCP client launches as a subprocess: +## Build the server ```sh -go run ./cmd/template-mcp stdio +go build -o bin/template-mcp-codemode ./cmd/template-mcp-codemode +``` + +The final binary contains both the ordinary server and the CodeMode worker entry point. `codemode.ServeWorkerAndExit()` is the first statement of `main`, so CodeMode can re-execute this same binary for each program run. + +## Connect over STDIO + +Configure an MCP client that accepts the `mcpServers` shape. Replace the path below with the absolute path to your checkout: + +```json +{ + "mcpServers": { + "template-mcp-codemode": { + "command": "/absolute/path/to/template-mcp-codemode/bin/template-mcp-codemode", + "args": ["stdio"] + } + } +} +``` + +Restart or reload the client's MCP servers. STDIO uses the fixed non-secret subject ID `local`; ownership of the launched process is the authentication boundary. The process writes JSON-RPC only to stdout and sends diagnostics to stderr. + +The client lists exactly three MCP tools: + +- `search_api` +- `describe_api` +- `execute` + +`random.int` is a CodeMode capability behind those tools. It is not a fourth MCP tool. + +## Discover the capability + +Ask the client to search for a capability that returns a random integer. The corresponding raw `search_api` input is: + +```json +{"query":"random integer"} ``` -The process speaks newline-delimited JSON-RPC over stdin/stdout and blocks until -the client closes the input stream or the process is signaled. That is expected: -it is a server, not a one-shot command. Diagnostics go to stderr; stdout carries -only protocol messages. +The successful results include the exact dotted name and keyword-only signature: -## Run over Streamable HTTP +```text +random.int(*, min: int, max: int) +``` + +Next, ask the client to describe that exact name. The raw `describe_api` input is: + +```json +{"name":"random.int"} +``` -The HTTP transport suits networked or containerized deployments. It binds -loopback by default: +The description reports required `min` and `max` integer inputs and an output dictionary with a `value` integer field. The Go input fields are `int64`, so values must fit the signed 64-bit range. + +## Compose two calls + +Ask the client to execute this program: + +```python +def main(): + left = random.int(min=3, max=3) + right = random.int(min=4, max=4) + return { + "left": left["value"], + "right": right["value"], + "total": left["value"] + right["value"], + } +``` + +The raw `execute` input has one `source` string property: + +```json +{ + "source": "def main():\n left = random.int(min=3, max=3)\n right = random.int(min=4, max=4)\n return {\"left\": left[\"value\"], \"right\": right[\"value\"], \"total\": left[\"value\"] + right[\"value\"]}" +} +``` + +Each capability call returns a Starlark dictionary, so the program reads `left["value"]` and `right["value"]`. Equal lower and upper bounds make the result deterministic: + +```json +{"result":{"left":3,"right":4,"total":7}} +``` + +CodeMode runs this source in a fresh worker process. Only the final converted value returned by the zero-argument `main()` function appears in the successful MCP result. + +## Try Streamable HTTP + +Start the HTTP transport on loopback: ```sh -go run ./cmd/template-mcp http --addr localhost:8080 +go run ./cmd/template-mcp-codemode http --addr localhost:8080 ``` -You will see a `listening` log line on stderr. Press `Ctrl-C` for a graceful -shutdown. +The server logs its listening address to stderr and shuts down gracefully on `Ctrl-C`. Loopback without a token installs the explicit non-secret development subject ID `development` in trusted request context. -## Call the demo tool +To exercise the demo bearer-token seam: -Both transports serve the same server, which registers one tool, `random_int`. -It takes `min` and `max` and returns a uniformly random integer in the inclusive -range `[min, max]`, or a tool-level error if `min > max`. Point your MCP client -at the server and call `random_int` to see structured output. +```sh +go run ./cmd/template-mcp-codemode http \ + --addr localhost:8080 \ + --auth-token development-only-token +``` -## Run the checks +After constant-time token validation, the demo SDK verifier sets the non-secret `auth.TokenInfo.UserID` to `shared-token`; it never uses the token value as identity. `installHTTPSubject` reads that ID from `req.GetExtra()` in receiving middleware, stores it with `authz.WithSubject` on the MCP handler context, and `mcpserver.ContextSubject` resolves it. This shared token is a demonstration, not production authentication. -Moon is the task front door: +Without a token in an allowed development mode, the same bridge installs `development`. The HTTP command builds one immutable CodeMode runtime and one MCP server before serving, then reuses that instance for every MCP session. An arbitrary value set only on the outer `net/http` request context is not the adapter's identity channel. + +## Run project checks ```sh moon run root:build moon run root:test -moon run root:check # format, lint, build, test, docs build, and the proxy checks +moon run root:check ``` +`root:check` covers formatting, linting, builds, tests, documentation, and the development proxy. + ## Next steps -- [Add a tool](add-a-tool.md) of your own. +- [Add a capability](how-to/add-a-capability.md) and then remove `random.int`. - Review the [configuration](configuration.md) reference. -- Read the [security](security.md) model before exposing the HTTP transport. +- Read the [security](security.md) model before exposing HTTP or adding privileged handlers. +- Consult the [canonical CodeMode documentation](https://meigma.github.io/codemode/) for the full language and API contracts. diff --git a/docs/docs/how-to/add-a-capability.md b/docs/docs/how-to/add-a-capability.md new file mode 100644 index 0000000..92ede01 --- /dev/null +++ b/docs/docs/how-to/add-a-capability.md @@ -0,0 +1,136 @@ +--- +title: Add a capability +description: Register a typed Go capability and remove the random.int demo. +--- + +# Add a capability + +Capabilities live in `internal/mcpserver`. The CLI and transports continue to expose only `search_api`, `describe_api`, and `execute`; adding a capability changes the catalog behind those fixed MCP tools. + +This guide adds `text.uppercase`, verifies how an agent composes it with `random.int`, and then explains how to remove the demo capability. + +## Define the capability + +Create `internal/mcpserver/uppercase.go`: + +```go +package mcpserver + +import ( + "context" + "strings" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" +) + +type uppercaseInput struct { + Value string `json:"value"` +} + +type uppercaseOutput struct { + Value string `json:"value"` +} + +func registerUppercase(builder *codemode.Builder, _ Dependencies) { + codemode.Register(builder, codemode.Capability[uppercaseInput, uppercaseOutput]{ + ID: "text.value.uppercase", + Name: "text.uppercase", + Summary: "Convert text to uppercase.", + Description: "Return the supplied text with Unicode letters mapped to uppercase.", + SearchTerms: []string{"capitalize text", "change letter case"}, + Handler: uppercase, + }) +} + +func uppercase( + _ context.Context, + _ authz.Subject, + in uppercaseInput, +) (uppercaseOutput, error) { + return uppercaseOutput{Value: strings.ToUpper(in.Value)}, nil +} +``` + +The exported struct fields and `json` tags define the callable input and result shape. CodeMode does not use `jsonschema` tags for capability descriptions; `Summary`, `Description`, and `SearchTerms` provide discovery text. + +Use only field types supported by CodeMode. Input structs must be non-pointer structs with direct exported fields. Scalar input fields are `string`, `int64`, `bool`, `float64`, or pointers to those types. Integers are signed 64-bit values: use `int64`, not `int`. JSON tags can rename fields and mark supported pointer fields with `omitempty`; unrelated struct tags are rejected. Output structs support additional recursive shapes. See the [canonical supported-types reference](https://meigma.github.io/codemode/reference/public-api/#supported-input-and-output-types) instead of copying the entire matrix into this repository. + +Set an explicit stable `ID` before policy or deployment filters depend on a capability. `Name` is the dotted Starlark name shown through discovery. `SearchTerms` affect search only; they are not callable aliases and must not contain secrets or tenant-sensitive data. + +## Register it in the runtime + +In `internal/mcpserver/server.go`, call the registration after the builder is created and before `Build`: + +```go +builder := codemode.New(options.Runtime) +registerRandomInt(builder, options.Deps) +registerUppercase(builder, options.Deps) +service, err := builder.Build() +``` + +Keep dependencies flowing through `Options.Deps`. Do not construct them in the registration function, a transport session factory, or a handler call. + +`Builder.Build` validates all registered metadata and type shapes, applies static capability filtering and limits, probes the worker entry point, and returns one immutable runtime. `internal/mcpserver.New` then adapts that runtime through the required resolver. It returns `(*mcp.Server, error)`, so every transport caller must handle construction failure. + +Do not call `mcp.AddTool` for `text.uppercase`. A direct MCP registration would create a second public surface beside CodeMode and would bypass its discovery, authorization, execution, and worker contracts. + +## Keep the worker entry point in tests + +Every test binary that calls `Builder.Build` must serve CodeMode worker mode before test setup. Add one `TestMain` to the applicable package if it does not already have one: + +```go +func TestMain(m *testing.M) { + codemode.ServeWorkerAndExit() + os.Exit(m.Run()) +} +``` + +`codemode.ServeWorkerAndExit()` must be the first statement. The production binary has the same requirement in `main`. + +Test the observable handler contract: discovery metadata and shape where relevant, successful output, meaningful boundary behavior, authorization, and real error cases. Avoid tests that only assert that fields were copied or a registration call exists. + +## Compose the new capability + +After rebuilding or after the development proxy completes a reload: + +1. Call `search_api` with `{"query":"change letter case"}`. +2. Pass the exact returned name `text.uppercase` to `describe_api`. +3. Run this source through `execute`: + +```python +def main(): + draw = random.int(min=7, max=7) + label = text.uppercase(value="draw " + str(draw["value"])) + return { + "draw": draw["value"], + "label": label["value"], + } +``` + +A capability output struct becomes a Starlark dictionary, so this program reads both results through their `"value"` keys. The successful structured MCP result is: + +```json +{"result":{"draw":7,"label":"DRAW 7"}} +``` + +Capability-only edits do not change the outer definitions of `search_api`, `describe_api`, and `execute`. The development proxy therefore does not promise a `notifications/tools/list_changed` notification for this change. Verify the new child by repeating search, description, and execution and checking the returned capability result. + +## Use shared dependencies + +Add database pools, HTTP clients, clocks, or other shared collaborators as typed fields on `Dependencies` in `internal/mcpserver/server.go`. The registration function receives `Dependencies`; close over only the fields its handler needs. + +Construct `Dependencies` once in the CLI startup path and pass the same value through `mcpserver.Options` for either transport. The HTTP server shares the immutable runtime and its collaborators across sessions. Dependencies and handlers must be safe for concurrent calls. + +Handlers run in the privileged parent process, not in the Starlark worker. They must honor context cancellation for I/O, waits, locks, and downstream calls; bound their own resource use; and avoid exposing credentials or trusted diagnostic detail in returned values. CodeMode can kill the worker but cannot forcibly stop a dispatched Go handler or undo its side effects. + +## Remove `random.int` + +Add and verify at least one real capability first. Then: + +1. Delete `internal/mcpserver/randomint.go` and its behavior tests. +2. Remove `registerRandomInt(builder, options.Deps)` from `internal/mcpserver.New`. +3. Update catalog expectations, documentation, and `.github/scripts/mcp_smoke.py` to exercise your replacement capabilities. The shipped smoke script assumes the demo's `min`/`max` arguments and `value` result; changing only its `--capability` flag is not enough when the contract changes. Both `moon run root:smoke` and the release workflows use this script. +4. Use a client to search, describe, and execute a replacement capability over every retained transport. + +Keep the CodeMode builder, `mcpserver.Options.Runtime`, resolver wiring, and worker entry points. Removing the demo does not turn replacement capabilities into direct MCP tools. diff --git a/docs/docs/index.md b/docs/docs/index.md index 7c7ba9b..3394b5e 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,38 +1,24 @@ --- -title: template-mcp +title: template-mcp-codemode slug: / -description: A Go template for building Model Context Protocol servers. +description: A Go template for CodeMode-native Model Context Protocol servers. --- -# template-mcp +# template-mcp-codemode -`template-mcp` is a Go template for building [Model Context Protocol](https://modelcontextprotocol.io) -(MCP) servers on the official -[`modelcontextprotocol/go-sdk`](https://github.com/modelcontextprotocol/go-sdk). -It ships a transport-agnostic server with one demo tool (`random_int`) served -over either the STDIO or Streamable HTTP transport, plus Moon tasks, pinned CI, -dependency automation, secure-by-default settings, and an exercised release -pipeline. +`template-mcp-codemode` is a Go template for building [Model Context Protocol](https://modelcontextprotocol.io) servers with [CodeMode](https://github.com/meigma/codemode). You register typed Go capabilities; an agent uses the fixed `search_api`, `describe_api`, and `execute` MCP tools to discover and compose them in bounded Starlark programs. -## Documentation +The template includes the `random.int` demo capability, STDIO and Streamable HTTP transports, explicit subject and authorization wiring, a hot-reload development proxy, Moon tasks, CI, documentation, and release configuration. -This site follows the [Diátaxis](https://diataxis.fr/) structure: +## Documentation -- **[Getting started](getting-started.md)** — a tutorial: clone the template - and run the server over both transports. -- **[Add a tool](add-a-tool.md)** — a how-to: replace `random_int` or add your - own tool alongside it. -- **[Configuration](configuration.md)** — reference for the CLI flags, - `TEMPLATE_MCP_*` environment variables, and transports. -- **[Security](security.md)** — an explanation of the template's - secure-by-default choices and how to harden a real deployment. +- **[Getting started](getting-started.md)** — clone the repository, run the server, and compose calls to `random.int`. +- **[Add a capability](how-to/add-a-capability.md)** — add a typed Go capability and remove the demo. +- **[Configuration](configuration.md)** — CLI flags, `TEMPLATE_MCP_CODEMODE_*` environment variables, runtime options, and default limits. +- **[Security](security.md)** — trusted identity, authorization, worker isolation, cancellation, and deployment boundaries. -The Go API reference is published on -[pkg.go.dev](https://pkg.go.dev/github.com/meigma/template-mcp). +Use the [canonical CodeMode documentation](https://meigma.github.io/codemode/) for the complete public Go API, fixed MCP tool contracts, supported Starlark surface, and runtime security model. The template-specific Go API is published at [pkg.go.dev](https://pkg.go.dev/github.com/meigma/template-mcp-codemode). -## For generated projects +## Generated projects -A project generated from this template should rewrite this page (and the pages -above) for the real server: its actual tools, the transport it kept, and its -operating and support notes. Update `docs/mkdocs.yml` (`site_url`, `repo_name`, -`repo_url`, `edit_uri`) to point at the generated repository. +After creating a project from this template, follow `DELETE_ME.md`. Rename the root and proxy modules, binary, client-visible implementation identity, environment prefix, repository and image references, and documentation metadata. Preserve the CodeMode dependency and worker entry points, replace the demo with real capabilities, and reset the changelog before the first release. diff --git a/docs/docs/security.md b/docs/docs/security.md index c42b031..0ccd51d 100644 --- a/docs/docs/security.md +++ b/docs/docs/security.md @@ -1,69 +1,104 @@ --- title: Security -description: The template's secure-by-default choices and how to harden a deployment. +description: Identity, authorization, execution, cancellation, and deployment boundaries. --- # Security -The template bakes in the practices an MCP server should have on day one. -This page explains the reasoning so you can preserve the guarantees as you build. +CodeMode restricts the program language and executes each program in a fresh worker process, but the host still controls identity, authorization, capability behavior, and operating-system isolation. -## stdout is reserved for JSON-RPC +## Keep STDIO stdout protocol-only -Over the STDIO transport, stdout carries protocol messages only. A stray -`fmt.Println` or a logger pointed at `os.Stdout` silently corrupts the stream — -the most common way a stdio server breaks. The template logs to stderr only; -keep all logging and diagnostics there. The `--log-format=json` option still -writes to stderr. +STDIO uses stdout for JSON-RPC. A `fmt.Println` call or logger pointed at stdout corrupts the protocol stream. The template sends logs and diagnostics to stderr, including JSON-formatted logs. Keep every code path used by STDIO free of non-protocol stdout writes. -## HTTP defaults to loopback with cross-origin protection +CodeMode workers also use standard input and output for their private protocol. `codemode.ServeWorkerAndExit()` writes no diagnostics and must remain the first statement of `main` and applicable `TestMain` functions. -The `http` transport wraps the SDK handler in the standard library's -cross-origin protection to defend against DNS-rebinding and CSRF from browsers, -and `--addr` defaults to `localhost:8080`. Binding a non-loopback address -exposes the server to the network and is a deliberate, security-relevant choice. +## Establish identity outside model-controlled data -## The HTTP transport fails closed off loopback +The adapter resolves a trusted `authz.Subject` before search, description, or execution: -Cross-origin protection stops malicious browsers, not direct clients such as -`curl`. So binding a non-loopback address (for example `0.0.0.0`) with no -authentication is refused at startup unless you either set `--auth-token` or pass -`--insecure` to opt into an unauthenticated, network-exposed server. The -container image defaults to `--insecure` so the demo runs out of the box; remove -it and supply real authentication before deploying. +- STDIO uses `StaticSubject` with subject ID `local`. This is appropriate only when ownership of the local process is the authentication boundary. +- HTTP uses `ContextSubject`. The SDK authentication verifier places a stable, non-secret identity in `auth.TokenInfo.UserID`. +- The `installHTTPSubject` receiving middleware reads `req.GetExtra().TokenInfo.UserID` from each MCP request, stores an `authz.Subject` with `authz.WithSubject` on the MCP handler context, and then lets `ContextSubject` resolve it. +- A valid demo bearer token produces the fixed user ID `shared-token`. The token value is never used as the user or subject ID. +- Allowed loopback and explicit `--insecure` requests without a token pass the fixed development ID `development` through the same bridge. -## The bearer-auth seam is demo-only +The MCP SDK establishes the receiving handler context, so setting an arbitrary value only on the outer `net/http` request context is not sufficient. Program source, capability arguments, MCP `_meta`, and unvalidated request headers cannot establish or replace identity. -The HTTP transport includes a minimal, flag-gated bearer-token check that is off -by default and exists to show where authorization belongs. It compares a single -shared secret in constant time. It is **not** production authorization. +## Replace demo authentication before deployment -A production server needs a real OAuth 2.1 resource server: +HTTP defaults to `localhost:8080` and enables standard-library cross-origin protection. A non-loopback address without a token is refused unless `--insecure` explicitly permits unauthenticated exposure. -- protected-resource metadata (RFC 9728), -- audience-restricted tokens (RFC 8707), -- PKCE with S256, -- and validation of token signature, expiry, and audience against a trusted - authorization server. +Cross-origin protection mitigates browser-origin attacks; it does not authenticate direct clients. The shared-token seam only performs constant-time comparison with one configured secret. It does not validate a signature, issuer, audience, expiry, revocation state, or client-specific scope. -Per the MCP specification, authorization applies to HTTP transports only. STDIO -servers must not use OAuth; they take any credentials they need from the -environment of the process that launched them. +For production, implement the MCP authorization requirements for an OAuth 2.1 protected resource, including protected-resource metadata, audience-restricted access tokens, PKCE with S256 where applicable, and validation against a trusted authorization server. The real verifier must set `auth.TokenInfo.UserID` to the authenticated caller's stable, non-secret identity. Keep the receiving bridge and `ContextSubject`; do not replace them with an outer HTTP context wrapper. + +STDIO servers do not use HTTP OAuth. They obtain any credentials needed by handlers from the launched process's environment or another local trust channel. + +## Treat `AllowAll` as an explicit demo policy + +The CLI passes `authz.AllowAll()` through `mcpserver.Options.Runtime.Authorizer`. CodeMode has no default authorizer, and the template constructor does not silently create one. + +`AllowAll` permits every validated native call for every resolved subject. It is not authentication. Replace it when authorization depends on subject, stable capability ID, capability name, or canonical arguments. Keep authorization failures coarse at the client boundary and record trusted diagnostic detail only in protected host logs. + +## Discovery is not authorization-filtered + +`search_api` and `describe_api` require a resolved subject, but they do not run per-capability authorization policy. Every authenticated subject can discover every capability that is statically enabled in that runtime. + +Do not put credentials, policy facts, tenant identifiers, or sensitive examples in capability names, summaries, descriptions, search terms, or field names. If a deployment must hide a capability's existence, disable it through `codemode.Options.DisabledCapabilities` when building that deployment. Per-invocation authorization still applies to every native call made by `execute`. + +## Preserve worker entry-point ordering + +CodeMode re-executes the host binary for its build probe and each `execute` worker. Keep this as the first statement of `main`: + +```go +codemode.ServeWorkerAndExit() +``` + +Place flag parsing, credentials, database connections, service clients, authorizers, handlers, and transport construction after it. Test packages that call `Builder.Build` need the same first statement in `TestMain`. + +Go package initialization runs before `main`. Do not initialize credentials, open privileged resources, or perform irreversible work in package initializers. In worker mode, `ServeWorkerAndExit` can call `os.Exit`, so deferred functions do not run. + +## Understand the worker boundary + +Each `execute` call creates a fresh Starlark interpreter in a re-executed child process. Module loading is disabled. The language surface contains standard Starlark built-ins, `sum`, `json`, `math`, and the statically enabled capability namespaces. Native calls are rejected during top-level loading and allowed only from a zero-argument `main()`. + +Only `main()`'s final converted value is returned. Printed text, globals, and interpreter-local intermediate values are discarded. Interpreter state does not cross `execute` calls. + +The worker runs as the same operating-system user as the host binary. Its restricted environment and lack of file, network, environment, or process built-ins reduce reachability, but they are not tenant isolation. CodeMode does not provide an operating-system CPU, heap, filesystem, credential, or network boundary. Add containers, workload isolation, and operating-system resource controls when those boundaries are required. + +## Treat handlers as privileged host code + +Capability handlers and authorizers run in the parent process with the host's privileges, not inside the Starlark worker. CodeMode binds and canonicalizes arguments before authorization and dispatches the handler only after authorization succeeds. + +When a request is canceled or exceeds `MaxExecutionTime`, CodeMode can close, kill, and reap the worker. Cancellation of parent Go code remains cooperative. CodeMode cannot forcibly stop a handler or authorizer goroutine and cannot undo side effects that already occurred. A non-cooperative handler can continue consuming host resources after `execute` returns. + +Handlers and authorizers must: + +- honor the supplied context for I/O, locks, waits, and downstream calls; +- return promptly after cancellation; +- bound their own retries, memory, network, and storage use; +- be safe for concurrent calls against the shared immutable server; +- make non-idempotent effects explicit and independently safe; and +- avoid returning credentials or trusted diagnostic details to the caller. + +## Configure bounded execution programmatically + +CodeMode supplies bounded defaults for source size, bytecode steps, elapsed time, native call count, value depth and size, cumulative intermediate values, search input and results, and concurrent executions. Configure overrides through `mcpserver.Options.Runtime.Limits`; there are no CLI limit flags or environment variables. + +The HTTP transport constructs one runtime and MCP server before serving and shares them across sessions. `MaxConcurrentExecutions` therefore bounds worker spawn attempts and live workers across the process rather than resetting for each session. Each `execute` call receives fresh per-execution budgets. + +These limits do not bound handler-owned resources or impose operating-system CPU and memory quotas. See the [canonical CodeMode security model](https://meigma.github.io/codemode/explanation/security-model/) for exact execution, error-projection, and value-crossing behavior. ## Supply chain and container -- The container builds a static binary into a non-root, digest-pinned - [distroless](https://github.com/GoogleContainerTools/distroless) runtime - image. -- CI keeps token permissions minimal, pins every action by digest, and disables - checkout credential persistence. -- Releases publish checksums and SBOMs and attach GitHub-native attestations to - both the binary checksums and the container manifest. -- A weekly scheduled scan checks the image for vulnerabilities, secrets, and - misconfigurations and uploads results to GitHub code scanning. -- Dependabot updates GitHub Actions, both Go modules, and the docs project. -- Repository settings default to signed commits, squash-only merges, protected - tags, immutable releases, and private vulnerability reporting. - -See [SECURITY.md](https://github.com/meigma/template-mcp/blob/master/SECURITY.md) -for the vulnerability reporting policy. +- The image is assembled from a signed melange-built Wolfi package and runs as non-root uid/gid 65532 with no shell. +- CI uses minimal token permissions, digest-pinned actions, and disabled checkout credential persistence. +- The release configuration produces checksums and SBOMs, isolates provenance signing in a reusable workflow, and configures a keyless Cosign image signature. +- The scheduled image scan uploads SARIF to GitHub code scanning. +- Dependabot covers GitHub Actions, both Go modules, and the docs project. +- Repository settings configure signed commits, squash-only merges, protected tags, immutable releases, and private vulnerability reporting. + +These are configured paths, not evidence that this repository has already published a release. The release baseline is `0.0.0`, with `0.1.0` pending as the first release. + +Report vulnerabilities through the private process in the repository [security policy](https://github.com/meigma/template-mcp-codemode/blob/master/SECURITY.md). diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 6ed6b60..db69271 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,8 +1,8 @@ -site_name: template-mcp -site_description: Meigma Go MCP server template -site_url: https://meigma.github.io/template-mcp/ -repo_name: meigma/template-mcp -repo_url: https://github.com/meigma/template-mcp +site_name: template-mcp-codemode +site_description: Meigma CodeMode MCP server template +site_url: https://meigma.github.io/template-mcp-codemode/ +repo_name: meigma/template-mcp-codemode +repo_url: https://github.com/meigma/template-mcp-codemode edit_uri: edit/master/docs/docs/ docs_dir: docs site_dir: build @@ -36,7 +36,7 @@ theme: nav: - Home: index.md - Getting started: getting-started.md - - Add a tool: add-a-tool.md + - Add a capability: how-to/add-a-capability.md - Configuration: configuration.md - Security: security.md diff --git a/docs/moon.yml b/docs/moon.yml index c0dd12f..746d6b4 100644 --- a/docs/moon.yml +++ b/docs/moon.yml @@ -7,8 +7,8 @@ tags: - 'uv' project: - title: 'template-mcp docs' - description: 'MkDocs documentation site for the Meigma Go MCP server template.' + title: 'template-mcp-codemode docs' + description: 'MkDocs documentation site for the Meigma CodeMode MCP server template.' owner: 'meigma' maintainers: - 'meigma' diff --git a/docs/pyproject.toml b/docs/pyproject.toml index f13797c..ab48e79 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "template-mcp-docs" +name = "template-mcp-codemode-docs" version = "0.0.0" -description = "MkDocs documentation site for the Meigma Go MCP server template." +description = "MkDocs documentation site for the Meigma CodeMode MCP server template." requires-python = ">=3.14" dependencies = [ "mkdocs-material>=9.7.0", diff --git a/docs/uv.lock b/docs/uv.lock index 8a50b3a..50e996d 100644 --- a/docs/uv.lock +++ b/docs/uv.lock @@ -378,7 +378,7 @@ wheels = [ ] [[package]] -name = "template-mcp-docs" +name = "template-mcp-codemode-docs" version = "0.0.0" source = { virtual = "." } dependencies = [ diff --git a/ghd.toml b/ghd.toml index 0e3b4fb..f3c4833 100644 --- a/ghd.toml +++ b/ghd.toml @@ -1,32 +1,32 @@ version = 1 [provenance] -signer_workflow = "meigma/template-mcp/.github/workflows/attest.yml" +signer_workflow = "meigma/template-mcp-codemode/.github/workflows/attest.yml" [[packages]] -name = "template-mcp" -description = "Meigma Go MCP server template starter CLI." +name = "template-mcp-codemode" +description = "Meigma CodeMode MCP server template starter CLI." tag_pattern = "v${version}" [[packages.assets]] os = "darwin" arch = "amd64" -pattern = "template-mcp_${version}_darwin_amd64" +pattern = "template-mcp-codemode_${version}_darwin_amd64" [[packages.assets]] os = "darwin" arch = "arm64" -pattern = "template-mcp_${version}_darwin_arm64" +pattern = "template-mcp-codemode_${version}_darwin_arm64" [[packages.assets]] os = "linux" arch = "amd64" -pattern = "template-mcp_${version}_linux_amd64" +pattern = "template-mcp-codemode_${version}_linux_amd64" [[packages.assets]] os = "linux" arch = "arm64" -pattern = "template-mcp_${version}_linux_arm64" +pattern = "template-mcp-codemode_${version}_linux_arm64" [[packages.binaries]] -path = "template-mcp" +path = "template-mcp-codemode" diff --git a/go.mod b/go.mod index 47f37da..a2f5f63 100644 --- a/go.mod +++ b/go.mod @@ -1,35 +1,35 @@ -module github.com/meigma/template-mcp +module github.com/meigma/template-mcp-codemode -go 1.26.4 +go 1.26.6 require ( - github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/meigma/codemode v0.2.0 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.46.0 // indirect + go.starlark.net v0.0.0-20260708150628-5395d018f003 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.38.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index e0f5ac3..88a7015 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,69 @@ +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= -github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc= +github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/meigma/codemode v0.2.0 h1:N/QYT0PdbLT3/+/seIsE+1A0kccOsmXnknDj450dsFs= +github.com/meigma/codemode v0.2.0/go.mod h1:xZVXK9qH3apoQv3DsAldcQ0TjctDwKDN4n5dmYU0cG0= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/open-policy-agent/opa v1.19.1 h1:aB1nOncChnTbQurjRQVJnjTJxditt8VqszlbaM3GGKU= +github.com/open-policy-agent/opa v1.19.1/go.mod h1:pb6Y6klyf7X7X8uXNDflruA9dQC2gMqWROXI5w/kvv0= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= -github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -48,24 +75,49 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= +github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.starlark.net v0.0.0-20260708150628-5395d018f003 h1:cAxcqHgW8fnmT0cEBU3TzvVYHIFt8IIGDMWUF6rImk4= +go.starlark.net v0.0.0-20260708150628-5395d018f003/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/cli/http.go b/internal/cli/http.go index 3dc7cda..24b637a 100644 --- a/internal/cli/http.go +++ b/internal/cli/http.go @@ -15,7 +15,10 @@ import ( "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/meigma/template-mcp/internal/mcpserver" + "github.com/meigma/codemode/authz" + hostmcp "github.com/meigma/codemode/mcpserver" + + "github.com/meigma/template-mcp-codemode/internal/templateinfo" ) const ( @@ -42,6 +45,14 @@ const ( demoTokenLifetime = time.Hour ) +// httpSharedTokenSubjectID is the demo non-secret identity installed after the +// shared bearer token verifies. It is not the token value. +const httpSharedTokenSubjectID authz.SubjectID = "shared-token" + +// httpDevelopmentSubjectID is the explicit development identity for allowed +// loopback and --insecure unauthenticated HTTP modes. +const httpDevelopmentSubjectID authz.SubjectID = "development" + // httpConfig carries the resolved http subcommand configuration into runHTTP. type httpConfig struct { // build supplies the version reported to MCP clients. @@ -89,13 +100,20 @@ func newHTTPCommand(options Options) *cobra.Command { // --addr defaults to loopback, not 0.0.0.0: binding to all interfaces // exposes the server to the local network and is a deliberate opt-in. - cmd.Flags().String(addrFlag, "localhost:8080", "address to listen on (env TEMPLATE_MCP_ADDR)") + cmd.Flags().String( + addrFlag, + "localhost:8080", + fmt.Sprintf("address to listen on (env %s_ADDR)", templateinfo.EnvPrefix()), + ) // --auth-token is empty by default, which disables auth. See requireBearerToken // for the heavy caveats: this is a DEMO-ONLY seam, not production auth. cmd.Flags().String( authTokenFlag, "", - "DEMO-ONLY shared bearer token; empty disables auth (env TEMPLATE_MCP_AUTH_TOKEN)", + fmt.Sprintf( + "DEMO-ONLY shared bearer token; empty disables auth (env %s_AUTH_TOKEN)", + templateinfo.EnvPrefix(), + ), ) // --insecure is the explicit opt-in to bind a non-loopback address without // authentication. Without it, runHTTP refuses such a configuration so a @@ -103,7 +121,10 @@ func newHTTPCommand(options Options) *cobra.Command { cmd.Flags().Bool( insecureFlag, false, - "allow binding a non-loopback address without authentication (UNSAFE; env TEMPLATE_MCP_INSECURE)", + fmt.Sprintf( + "allow binding a non-loopback address without authentication (UNSAFE; env %s_INSECURE)", + templateinfo.EnvPrefix(), + ), ) return cmd @@ -137,13 +158,18 @@ func serveHTTP(ctx context.Context, ln net.Listener, cfg httpConfig) error { logger = slog.New(slog.DiscardHandler) } - // The factory runs once per session, so each client gets a fresh server with - // no shared state — the safe default. If your tools need state shared across - // sessions (a cache, a DB pool), construct the server once outside this - // closure and return the same *mcp.Server for every request instead. + // One CodeMode runtime and MCP server for the process. Identity is not + // captured here: HTTP uses ContextSubject and per-request trusted context. + mcpServer, err := newTemplateServer(logger, cfg.build.Version, hostmcp.ContextSubject()) + if err != nil { + closeErr := ln.Close() + return errors.Join(err, closeErr) + } + mcpServer.AddReceivingMiddleware(installHTTPSubject(cfg.authToken != "")) + handler := mcp.NewStreamableHTTPHandler( func(*http.Request) *mcp.Server { - return mcpserver.New(mcpserver.Options{Version: cfg.build.Version, Logger: logger}) + return mcpServer }, nil, ) @@ -155,7 +181,8 @@ func serveHTTP(ctx context.Context, ln net.Listener, cfg httpConfig) error { // When a token is configured, gate the server behind the DEMO-ONLY bearer // middleware. The middleware runs outside CrossOriginProtection so that - // unauthenticated requests are rejected as early as possible. + // unauthenticated requests are rejected as early as possible. The shared + // demo identity is installed only after the verifier succeeds. if cfg.authToken != "" { rootHandler = requireBearerToken(cfg.authToken, cfg.addr)(rootHandler) } @@ -191,7 +218,7 @@ func serveHTTP(ctx context.Context, ln net.Listener, cfg httpConfig) error { } }() - err := srv.Serve(ln) + err = srv.Serve(ln) close(serveDone) if err != nil && !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("serve http: %w", err) @@ -217,9 +244,10 @@ func checkBindSecurity(addr, authToken string, insecure bool) error { return fmt.Errorf( "refusing to bind non-loopback address %q without authentication: "+ - "set --auth-token (env TEMPLATE_MCP_AUTH_TOKEN) to require a bearer token, "+ + "set --auth-token (env %s_AUTH_TOKEN) to require a bearer token, "+ "or pass --insecure to expose all tools unauthenticated (UNSAFE)", addr, + templateinfo.EnvPrefix(), ) } @@ -280,9 +308,11 @@ func requireBearerToken(token, addr string) func(http.Handler) http.Handler { // The middleware requires a non-zero expiration and the configured // scopes. A real verifier would read these from the validated token. + // UserID is the demo non-secret identity, never the shared secret. return &auth.TokenInfo{ Scopes: []string{demoAuthScope}, Expiration: time.Now().Add(demoTokenLifetime), + UserID: string(httpSharedTokenSubjectID), }, nil } @@ -299,3 +329,24 @@ func requireBearerToken(token, addr string) func(http.Handler) http.Handler { Scopes: []string{demoAuthScope}, }) } + +// installHTTPSubject copies per-request identity into the MCP handler context. +// TokenInfo is present only after the demo bearer verifier succeeds. Unauthenticated +// loopback and --insecure modes receive the explicit development identity. +// When a token is configured and TokenInfo is missing, the subject is left +// unset so ContextSubject fails closed. +func installHTTPSubject(authenticated bool) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + extra := req.GetExtra() + if extra != nil && extra.TokenInfo != nil { + ctx = authz.WithSubject(ctx, authz.Subject{ID: authz.SubjectID(extra.TokenInfo.UserID)}) + return next(ctx, method, req) + } + if authenticated { + return next(authz.WithSubject(ctx, authz.Subject{}), method, req) + } + return next(authz.WithSubject(ctx, authz.Subject{ID: httpDevelopmentSubjectID}), method, req) + } + } +} diff --git a/internal/cli/http_identity_test.go b/internal/cli/http_identity_test.go new file mode 100644 index 0000000..619385e --- /dev/null +++ b/internal/cli/http_identity_test.go @@ -0,0 +1,76 @@ +package cli + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + hostmcp "github.com/meigma/codemode/mcpserver" + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/template-mcp-codemode/internal/mcpserver" +) + +// alicePolicy allows only the authenticated alice identity, irrespective of program metadata. +type alicePolicy struct{} + +func (alicePolicy) Authorize(_ context.Context, input authz.AuthorizationInput) error { + if input.Subject.ID != "alice" { + return authz.ErrDenied + } + return nil +} + +func TestHTTPAuthorizationUsesEachVerifiedIdentity(t *testing.T) { + t.Parallel() + + server, err := mcpserver.New(mcpserver.Options{ + Version: "test", + Logger: slog.New(slog.DiscardHandler), + Resolver: hostmcp.ContextSubject(), + Runtime: codemode.Options{Authorizer: alicePolicy{}}, + }) + require.NoError(t, err) + server.AddReceivingMiddleware(installHTTPSubject(true)) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil) + verifier := func(_ context.Context, token string, _ *http.Request) (*auth.TokenInfo, error) { + if token != "alice-credential" && token != "bob-credential" { + return nil, auth.ErrInvalidToken + } + id := "bob" + if token == "alice-credential" { + id = "alice" + } + return &auth.TokenInfo{UserID: id, Expiration: time.Now().Add(time.Hour)}, nil + } + httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(handler)) + t.Cleanup(httpServer.Close) + alice := connectHTTPSession(t, httpServer.URL, "alice-credential") + bob := connectHTTPSession(t, httpServer.URL, "bob-credential") + + for _, tc := range []struct { + name string + session *mcp.ClientSession + denied bool + }{ + {name: "bob cannot impersonate alice", session: bob, denied: true}, + {name: "alice remains authorized", session: alice}, + {name: "alice session does not authorize bob", session: bob, denied: true}, + } { + result, callErr := tc.session.CallTool(context.Background(), &mcp.CallToolParams{ + Meta: mcp.Meta{"subject": map[string]any{"id": "alice"}, "subject_id": "alice"}, + Name: "execute", + Arguments: map[string]any{"source": "def main():\n return random.int(min=7, max=7)"}, + }) + require.NoError(t, callErr, tc.name) + assert.Equal(t, tc.denied, result.IsError, tc.name) + } +} diff --git a/internal/cli/http_test.go b/internal/cli/http_test.go index cc756cb..de66361 100644 --- a/internal/cli/http_test.go +++ b/internal/cli/http_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "io" "log/slog" "net" @@ -13,12 +14,20 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/meigma/template-mcp-codemode/internal/templateinfo" ) // serverExitTimeout bounds how long tests wait for a serving function to // return after its shutdown trigger fires. const serverExitTimeout = 5 * time.Second +// httpReadyTimeout bounds how long tests wait for serveHTTP to finish the +// CodeMode worker probe and start accepting requests. +const httpReadyTimeout = 20 * time.Second + func TestIsLoopbackHost(t *testing.T) { t.Parallel() @@ -153,12 +162,7 @@ func TestServeHTTPShutsDownOnContextCancel(t *testing.T) { }) }() - // Prove the server is accepting requests before triggering shutdown. Any - // HTTP response demonstrates liveness; a plain GET without an MCP session - // is not a valid MCP request, so the status itself does not matter here. - resp, err := http.Get("http://" + ln.Addr().String() + "/") - require.NoError(t, err, "request to the running server") - require.NoError(t, resp.Body.Close()) + waitForHTTP(t, "http://"+ln.Addr().String()) cancel() @@ -170,13 +174,13 @@ func TestServeHTTPShutsDownOnContextCancel(t *testing.T) { } } -// TestHTTPCommandReadsAddrFromEnvironment exercises the TEMPLATE_MCP_ADDR -> addr -// binding (the wiring most likely to break silently after the rename step). The -// fail-closed guard refuses the non-loopback address before any socket is bound, -// so the refusal error mentioning that address proves the env value reached the -// command. +// TestHTTPCommandReadsAddrFromEnvironment exercises the +// TEMPLATE_MCP_CODEMODE_ADDR -> addr binding (the wiring most likely to break +// silently after the rename step). The fail-closed guard refuses the +// non-loopback address before any socket is bound, so the refusal error +// mentioning that address proves the env value reached the command. func TestHTTPCommandReadsAddrFromEnvironment(t *testing.T) { - t.Setenv("TEMPLATE_MCP_ADDR", "0.0.0.0:65535") + t.Setenv(templateinfo.EnvPrefix()+"_ADDR", "0.0.0.0:65535") root := NewRootCommand(Options{Viper: viper.New()}) root.SetArgs([]string{httpCommandName}) @@ -189,12 +193,12 @@ func TestHTTPCommandReadsAddrFromEnvironment(t *testing.T) { } // TestEnvBindingResolvesHyphenatedFlag covers the SetEnvKeyReplacer hop that the -// addr test does not: the "auth-token" flag binds to TEMPLATE_MCP_AUTH_TOKEN +// addr test does not: the "auth-token" flag binds to TEMPLATE_MCP_CODEMODE_AUTH_TOKEN // (hyphen -> underscore). A regression dropping the replacer would break this // while the hyphen-free addr key kept working, so it is tested explicitly. It // binds flags directly rather than serving, keeping the test deterministic. func TestEnvBindingResolvesHyphenatedFlag(t *testing.T) { - t.Setenv("TEMPLATE_MCP_AUTH_TOKEN", "from-env") + t.Setenv(templateinfo.EnvPrefix()+"_AUTH_TOKEN", "from-env") vp := viper.New() httpCmd := newHTTPCommand(Options{Viper: vp}) @@ -202,3 +206,168 @@ func TestEnvBindingResolvesHyphenatedFlag(t *testing.T) { assert.Equal(t, "from-env", vp.GetString(authTokenFlag)) } + +func TestServeHTTPExposesCodeModeTools(t *testing.T) { + t.Parallel() + + session, stop := startHTTPSession(t, httpConfig{ + build: BuildInfo{Version: "test"}, + logger: slog.New(slog.DiscardHandler), + }, nil) + defer stop() + + assertCodeModeExecute(t, session) +} + +func TestServeHTTPRejectsMissingBearerThenServes(t *testing.T) { + t.Parallel() + + const token = "s3cret-token" + ln := startHTTPListener(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + serveErr := make(chan error, 1) + go func() { + serveErr <- serveHTTP(ctx, ln, httpConfig{ + build: BuildInfo{Version: "test"}, + addr: ln.Addr().String(), + authToken: token, + logger: slog.New(slog.DiscardHandler), + }) + }() + + endpoint := "http://" + ln.Addr().String() + waitForHTTP(t, endpoint) + + resp, err := http.Get(endpoint + "/") + require.NoError(t, err, "unauthenticated request") + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + session := connectHTTPSession(t, endpoint, token) + assertCodeModeExecute(t, session) + + cancel() + select { + case err := <-serveErr: + require.NoError(t, err, "context cancellation is a clean shutdown") + case <-time.After(serverExitTimeout): + t.Fatal("serveHTTP did not return after context cancellation") + } +} + +func startHTTPListener(t *testing.T) net.Listener { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "listen on an ephemeral port") + return ln +} + +func startHTTPSession(t *testing.T, cfg httpConfig, token *string) (*mcp.ClientSession, context.CancelFunc) { + t.Helper() + + ln := startHTTPListener(t) + cfg.addr = ln.Addr().String() + if cfg.logger == nil { + cfg.logger = slog.New(slog.DiscardHandler) + } + + ctx, cancel := context.WithCancel(context.Background()) + serveErr := make(chan error, 1) + go func() { + serveErr <- serveHTTP(ctx, ln, cfg) + }() + + endpoint := "http://" + ln.Addr().String() + waitForHTTP(t, endpoint) + + authToken := "" + if token != nil { + authToken = *token + } + session := connectHTTPSession(t, endpoint, authToken) + return session, func() { + _ = session.Close() + cancel() + select { + case err := <-serveErr: + require.NoError(t, err, "HTTP server shutdown") + case <-time.After(serverExitTimeout): + t.Fatal("serveHTTP did not return after context cancellation") + } + } +} + +func connectHTTPSession(t *testing.T, endpoint, token string) *mcp.ClientSession { + t.Helper() + + transport := &mcp.StreamableClientTransport{ + Endpoint: endpoint, + DisableStandaloneSSE: true, + } + if token != "" { + transport.HTTPClient = &http.Client{Transport: bearerRoundTripper{token: token}} + } + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil) + session, err := client.Connect(context.Background(), transport, nil) + require.NoError(t, err, "HTTP MCP connect") + t.Cleanup(func() { _ = session.Close() }) + return session +} + +func waitForHTTP(t *testing.T, endpoint string) { + t.Helper() + + client := &http.Client{Timeout: 250 * time.Millisecond} + deadline := time.Now().Add(httpReadyTimeout) + for time.Now().Before(deadline) { + resp, err := client.Get(endpoint + "/") + if err == nil { + _ = resp.Body.Close() + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("HTTP server did not become reachable") +} + +func assertCodeModeExecute(t *testing.T, session *mcp.ClientSession) { + t.Helper() + + tools, err := session.ListTools(context.Background(), nil) + require.NoError(t, err, "tools/list over HTTP") + names := make([]string, 0, len(tools.Tools)) + for _, tool := range tools.Tools { + names = append(names, tool.Name) + } + assert.ElementsMatch(t, []string{"search_api", "describe_api", "execute"}, names) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{"source": "def main():\n return random.int(min=5, max=5)\n"}, + }) + require.NoError(t, err, "execute over HTTP") + require.False(t, result.IsError, "HTTP execute failed, content: %+v", result.Content) + + raw, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + var envelope struct { + Result struct { + Value int64 `json:"value"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &envelope)) + assert.Equal(t, int64(5), envelope.Result.Value) +} + +type bearerRoundTripper struct { + token string +} + +func (transport bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+transport.token) + return http.DefaultTransport.RoundTrip(req) +} diff --git a/internal/cli/main_test.go b/internal/cli/main_test.go new file mode 100644 index 0000000..abd1347 --- /dev/null +++ b/internal/cli/main_test.go @@ -0,0 +1,13 @@ +package cli + +import ( + "os" + "testing" + + "github.com/meigma/codemode" +) + +func TestMain(m *testing.M) { + codemode.ServeWorkerAndExit() + os.Exit(m.Run()) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index a1fd688..d4ffbfa 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,4 +1,4 @@ -// Package cli builds the template-mcp command tree. +// Package cli builds the template-mcp-codemode command tree. // // The root command wires two transport subcommands onto the same // transport-agnostic MCP server from internal/mcpserver: stdio, for clients @@ -12,12 +12,20 @@ package cli import ( "fmt" "io" + "log/slog" "strings" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/meigma/template-mcp/internal/templateinfo" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + hostmcp "github.com/meigma/codemode/mcpserver" + + "github.com/meigma/template-mcp-codemode/internal/mcpserver" + "github.com/meigma/template-mcp-codemode/internal/templateinfo" ) // BuildInfo describes linker-injected build metadata printed by --version. @@ -45,11 +53,11 @@ type Options struct { Build BuildInfo // Viper is the configuration instance used by the command tree. Flags are // bound to environment variables named after [templateinfo.EnvPrefix], - // for example TEMPLATE_MCP_ADDR. + // for example TEMPLATE_MCP_CODEMODE_ADDR. Viper *viper.Viper } -// NewRootCommand creates the template-mcp Cobra command tree. +// NewRootCommand creates the template-mcp-codemode Cobra command tree. // // The root command does no work on its own; it wires the two transport // subcommands (stdio and http) onto the same MCP server. To produce a @@ -94,17 +102,18 @@ func NewRootCommand(options Options) *cobra.Command { root.SetErr(options.Err) // Persistent logging flags apply to every subcommand and bind to - // TEMPLATE_MCP_LOG_LEVEL / TEMPLATE_MCP_LOG_FORMAT via initializeConfig. - // Logs always go to stderr; stdout stays the JSON-RPC channel. + // TEMPLATE_MCP_CODEMODE_LOG_LEVEL / TEMPLATE_MCP_CODEMODE_LOG_FORMAT via + // initializeConfig. Logs always go to stderr; stdout stays the JSON-RPC + // channel. root.PersistentFlags().String( logLevelFlag, defaultLogLevel, - "log level: debug, info, warn, or error (env TEMPLATE_MCP_LOG_LEVEL)", + fmt.Sprintf("log level: debug, info, warn, or error (env %s_LOG_LEVEL)", templateinfo.EnvPrefix()), ) root.PersistentFlags().String( logFormatFlag, defaultLogFormat, - "log format: text or json (env TEMPLATE_MCP_LOG_FORMAT)", + fmt.Sprintf("log format: text or json (env %s_LOG_FORMAT)", templateinfo.EnvPrefix()), ) root.AddCommand(newStdioCommand(options)) @@ -140,3 +149,20 @@ func initializeConfig(cmd *cobra.Command, vp *viper.Viper) error { return nil } + +// newTemplateServer builds the shared CodeMode MCP server used by both +// transports. authz.AllowAll is an explicit demo decision, not a constructor +// default. Runtime limits stay on this composition seam; the CLI does not add +// limit or Rego flags. +func newTemplateServer( + logger *slog.Logger, + version string, + resolver hostmcp.InvocationResolver, +) (*mcp.Server, error) { + return mcpserver.New(mcpserver.Options{ + Version: version, + Logger: logger, + Resolver: resolver, + Runtime: codemode.Options{Authorizer: authz.AllowAll()}, + }) +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index e3bcd13..fbac44d 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -30,7 +30,7 @@ func TestVersionFlagPrintsBuildMetadata(t *testing.T) { err := root.ExecuteContext(context.Background()) require.NoError(t, err) - assert.Equal(t, "template-mcp 0.1.0 (abc1234) built 2026-05-08T10:00:00Z\n", stdout.String()) + assert.Equal(t, "template-mcp-codemode 0.1.0 (abc1234) built 2026-05-08T10:00:00Z\n", stdout.String()) assert.Empty(t, stderr.String(), "version output must not write to stderr") } @@ -46,7 +46,7 @@ func TestVersionFlagDefaultsToDevMetadata(t *testing.T) { root.SetArgs([]string{"--version"}) require.NoError(t, root.ExecuteContext(context.Background())) - assert.Equal(t, "template-mcp dev (none) built unknown\n", stdout.String()) + assert.Equal(t, "template-mcp-codemode dev (none) built unknown\n", stdout.String()) } func TestRootCommandRegistersTransportSubcommands(t *testing.T) { diff --git a/internal/cli/stdio.go b/internal/cli/stdio.go index 3a10e4d..e1c20d8 100644 --- a/internal/cli/stdio.go +++ b/internal/cli/stdio.go @@ -12,12 +12,17 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/meigma/template-mcp/internal/mcpserver" + "github.com/meigma/codemode/authz" + hostmcp "github.com/meigma/codemode/mcpserver" ) // stdioCommandName is the name of the stdio subcommand, also used by its tests. const stdioCommandName = "stdio" +// stdioSubjectID is the process-owned identity for the local stdio transport. +// Possession of the process is the authentication boundary. +const stdioSubjectID authz.SubjectID = "local" + // newStdioCommand builds the "stdio" subcommand, which serves the MCP server // over the stdio transport for local clients that spawn the process. // @@ -60,10 +65,10 @@ func newStdioCommand(options Options) *cobra.Command { // logger receives diagnostics. It must write to stderr, never out: out is the // JSON-RPC channel for this transport. func runStdio(ctx context.Context, logger *slog.Logger, build BuildInfo, in io.Reader, out io.Writer) error { - srv := mcpserver.New(mcpserver.Options{ - Version: build.Version, - Logger: logger, - }) + srv, err := newTemplateServer(logger, build.Version, hostmcp.StaticSubject(authz.Subject{ID: stdioSubjectID})) + if err != nil { + return err + } input := &eofReader{reader: in} transport := &mcp.IOTransport{ @@ -73,7 +78,7 @@ func runStdio(ctx context.Context, logger *slog.Logger, build BuildInfo, in io.R logger.InfoContext(ctx, "serving over stdio") - err := srv.Run(ctx, transport) + err = srv.Run(ctx, transport) // Treat both normal stdio shutdowns as a clean (zero-status) exit; // otherwise every routine disconnect would look like a crash. // - SIGINT/SIGTERM: the signal-derived context is cancelled and diff --git a/internal/cli/stdio_test.go b/internal/cli/stdio_test.go index b579f68..9ac7c89 100644 --- a/internal/cli/stdio_test.go +++ b/internal/cli/stdio_test.go @@ -66,7 +66,15 @@ func TestStdioCommandServesMCP(t *testing.T) { for _, tool := range tools.Tools { names = append(names, tool.Name) } - assert.Contains(t, names, "random_int") + assert.ElementsMatch(t, []string{"search_api", "describe_api", "execute"}, names, + "stdio must expose exactly the CodeMode tool set") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{"source": "def main():\n return random.int(min=5, max=5)\n"}, + }) + require.NoError(t, err, "execute over stdio") + require.False(t, result.IsError, "stdio execute failed, content: %+v", result.Content) require.NoError(t, inW.Close(), "close the command's input stream") diff --git a/internal/mcpserver/main_test.go b/internal/mcpserver/main_test.go new file mode 100644 index 0000000..08d5607 --- /dev/null +++ b/internal/mcpserver/main_test.go @@ -0,0 +1,13 @@ +package mcpserver + +import ( + "os" + "testing" + + "github.com/meigma/codemode" +) + +func TestMain(m *testing.M) { + codemode.ServeWorkerAndExit() + os.Exit(m.Run()) +} diff --git a/internal/mcpserver/randomint.go b/internal/mcpserver/randomint.go index 974cded..95076bf 100644 --- a/internal/mcpserver/randomint.go +++ b/internal/mcpserver/randomint.go @@ -6,38 +6,36 @@ import ( "fmt" "math/big" - "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" ) -// randomIntToolName is the registered name of the random_int tool, surfaced to -// clients via tools/list and tools/call. -const randomIntToolName = "random_int" +// randomIntName is the dotted Starlark name of the demo capability. +const randomIntName = "random.int" -// randomIntInput is the typed input for the random_int tool. The json tags name -// the JSON Schema properties and the jsonschema tags supply their descriptions; -// the SDK derives the tool's inputSchema from this struct automatically. +// randomIntInput is the typed input for random.int. JSON tags name the +// keyword arguments; CodeMode accepts only json tags on int64 fields. type randomIntInput struct { - Min int `json:"min" jsonschema:"minimum value, inclusive"` - Max int `json:"max" jsonschema:"maximum value, inclusive"` + Min int64 `json:"min"` + Max int64 `json:"max"` } -// randomIntOutput is the typed output for the random_int tool. The SDK derives -// the tool's outputSchema from this struct and marshals the value into the -// CallToolResult.StructuredContent field automatically. +// randomIntOutput is the typed output for random.int. type randomIntOutput struct { - Value int `json:"value" jsonschema:"the generated random integer"` + Value int64 `json:"value"` } -// registerRandomInt adds the random_int tool to the server. It accepts the -// server's [Dependencies] to model the wiring real tools use — a handler that -// needs a database or HTTP client would close over deps here — even though -// random_int itself needs none. -func registerRandomInt(srv *mcp.Server, _ Dependencies) { - mcp.AddTool(srv, &mcp.Tool{ - Name: randomIntToolName, - Description: "Return a cryptographically uniform random integer in the " + - "inclusive range [min, max]. Returns a tool error if min > max.", - }, randomInt) +// registerRandomInt adds the random.int capability to the builder. It accepts +// the server's [Dependencies] to model the wiring real capabilities use — a +// handler that needs a database or HTTP client would close over deps here — +// even though random.int itself needs none. +func registerRandomInt(builder *codemode.Builder, _ Dependencies) { + codemode.Register(builder, codemode.Capability[randomIntInput, randomIntOutput]{ + Name: randomIntName, + Summary: "Return a cryptographically uniform random integer in the " + + "inclusive range [min, max].", + Handler: randomInt, + }) } // randomInt generates a uniformly random integer in [in.Min, in.Max]. @@ -47,34 +45,30 @@ func registerRandomInt(srv *mcp.Server, _ Dependencies) { // unpredictable values can substitute math/rand. func randomInt( _ context.Context, - _ *mcp.CallToolRequest, + _ authz.Subject, in randomIntInput, -) (*mcp.CallToolResult, randomIntOutput, error) { +) (randomIntOutput, error) { if in.Min > in.Max { - // Returning a regular error makes the SDK populate - // CallToolResult.IsError, i.e. a tool-level error result the model can - // see and self-correct from, NOT a JSON-RPC protocol error. This is the - // MCP tool-error convention. - return nil, randomIntOutput{}, fmt.Errorf("min (%d) must be <= max (%d)", in.Min, in.Max) + return randomIntOutput{}, fmt.Errorf("min (%d) must be <= max (%d)", in.Min, in.Max) } // span is the size of the half-open interval [0, span) to draw from, i.e. // max - min + 1. It is computed with big.Int throughout: int64 arithmetic // would overflow for client-controlled extreme ranges (for example - // min=math.MinInt, max=math.MaxInt), wrapping to a non-positive value that - // makes crypto/rand.Int panic. - span := new(big.Int).Sub(big.NewInt(int64(in.Max)), big.NewInt(int64(in.Min))) + // min=math.MinInt64, max=math.MaxInt64), wrapping to a non-positive value + // that makes crypto/rand.Int panic. + span := new(big.Int).Sub(big.NewInt(in.Max), big.NewInt(in.Min)) span.Add(span, big.NewInt(1)) n, err := rand.Int(rand.Reader, span) if err != nil { - return nil, randomIntOutput{}, fmt.Errorf("generate random int: %w", err) + return randomIntOutput{}, fmt.Errorf("generate random int: %w", err) } - // Shift the [0, span) draw into the inclusive range [min, max]. The result is - // guaranteed to lie within [min, max], so it fits back into an int and Int64 - // cannot overflow. - value := new(big.Int).Add(n, big.NewInt(int64(in.Min))) + // Shift the [0, span) draw into the inclusive range [min, max]. The result + // is guaranteed to lie within [min, max], so it fits back into an int64 + // and Int64 cannot overflow. + value := new(big.Int).Add(n, big.NewInt(in.Min)) - return nil, randomIntOutput{Value: int(value.Int64())}, nil + return randomIntOutput{Value: value.Int64()}, nil } diff --git a/internal/mcpserver/randomint_test.go b/internal/mcpserver/randomint_test.go index 0803105..c91a886 100644 --- a/internal/mcpserver/randomint_test.go +++ b/internal/mcpserver/randomint_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meigma/codemode/authz" ) func TestRandomInt(t *testing.T) { @@ -23,9 +25,9 @@ func TestRandomInt(t *testing.T) { {name: "spans zero", in: randomIntInput{Min: -3, Max: 3}}, // Extreme ranges must not panic: a naive int64 span computation // (max - min + 1) overflows here and would make crypto/rand.Int panic. - {name: "full int range", in: randomIntInput{Min: math.MinInt, Max: math.MaxInt}}, - {name: "wide range from min", in: randomIntInput{Min: math.MinInt, Max: 0}}, - {name: "wide range to max", in: randomIntInput{Min: 0, Max: math.MaxInt}}, + {name: "full int64 range", in: randomIntInput{Min: math.MinInt64, Max: math.MaxInt64}}, + {name: "wide range from min", in: randomIntInput{Min: math.MinInt64, Max: 0}}, + {name: "wide range to max", in: randomIntInput{Min: 0, Max: math.MaxInt64}}, {name: "min greater than max", in: randomIntInput{Min: 10, Max: 1}, wantErr: true}, } @@ -46,7 +48,7 @@ func TestRandomInt(t *testing.T) { func assertRandomInt(t *testing.T, in randomIntInput, wantErr bool) { t.Helper() - _, out, err := randomInt(context.Background(), nil, in) + out, err := randomInt(context.Background(), authz.Subject{ID: "local"}, in) if wantErr { require.Error(t, err, "randomInt(%+v)", in) return diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 75e27a0..4415f60 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -4,24 +4,29 @@ // the same *mcp.Server is driven by the stdio and http subcommands in // internal/cli. Keeping transport concerns out of this package is the seam that // lets a consumer keep one transport and delete the other without ever touching -// the server or its tools. +// the server or its capabilities. package mcpserver import ( + "fmt" "log/slog" "os" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/meigma/template-mcp/internal/templateinfo" + "github.com/meigma/codemode" + hostmcp "github.com/meigma/codemode/mcpserver" + + "github.com/meigma/template-mcp-codemode/internal/templateinfo" ) -// Dependencies holds the shared collaborators a real server's tools need — for -// example a database handle, an outbound HTTP client, or a config struct. It is -// empty in the template because the demo tool needs nothing; add fields here and -// read them in your tool registrations (see registerRandomInt). Threading -// dependencies through [Options] keeps the server transport-agnostic: the stdio -// and http subcommands construct them and pass them in, the same way for both. +// Dependencies holds the shared collaborators a real server's capabilities +// need — for example a database handle, an outbound HTTP client, or a config +// struct. It is empty in the template because the demo capability needs +// nothing; add fields here and read them in your registrations (see +// registerRandomInt). Threading dependencies through [Options] keeps the +// server transport-agnostic: the stdio and http subcommands construct them +// and pass them in, the same way for both. type Dependencies struct{} // Options configures the template MCP server. @@ -29,8 +34,8 @@ type Options struct { // Version is the release version reported in the server implementation info. Version string - // Deps carries the shared dependencies the server's tools need. The zero - // value is valid; the template's demo tool uses none. + // Deps carries the shared dependencies the server's capabilities need. The + // zero value is valid; the template's demo capability uses none. Deps Dependencies // Logger receives server diagnostics. Nil selects a text handler writing @@ -41,27 +46,49 @@ type Options struct { // there corrupts the protocol. Writing to stderr (the default) is safe for // every transport. Logger *slog.Logger + + // Resolver resolves the trusted invocation subject from host-owned context. + // There is no default. The stdio command supplies a process-owned + // [hostmcp.StaticSubject]; the http command supplies [hostmcp.ContextSubject] + // and installs identity on each request. + Resolver hostmcp.InvocationResolver + + // Runtime configures the CodeMode catalog, authorizer, and execution + // budgets. There is no default authorizer: the CLI supplies + // [github.com/meigma/codemode/authz.AllowAll] for the demo. Zero-valued + // limit fields receive CodeMode defaults at Build. The template CLI does + // not expose limit or Rego flags; change Runtime at this composition seam. + Runtime codemode.Options } -// New constructs the template MCP server and registers its tools. +// New constructs the template MCP server and registers its capabilities. // // New is transport-agnostic; callers choose a transport when they run the -// returned server (see internal/cli). Diagnostics go to [Options.Logger]. -func New(options Options) *mcp.Server { +// returned server (see internal/cli). The official MCP surface is exactly +// search_api, describe_api, and execute. Diagnostics go to [Options.Logger]. +func New(options Options) (*mcp.Server, error) { logger := options.Logger if logger == nil { logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) } - srv := mcp.NewServer(&mcp.Implementation{ - Name: templateinfo.Name, - Title: templateinfo.Title, - Version: options.Version, - }, &mcp.ServerOptions{ + builder := codemode.New(options.Runtime) + registerRandomInt(builder, options.Deps) + service, err := builder.Build() + if err != nil { + return nil, fmt.Errorf("build CodeMode runtime: %w", err) + } + + server, err := hostmcp.New(service, options.Resolver, hostmcp.Options{ + Implementation: &mcp.Implementation{ + Name: templateinfo.Name, + Title: templateinfo.Title, + Version: options.Version, + }, Logger: logger, }) - - registerRandomInt(srv, options.Deps) - - return srv + if err != nil { + return nil, fmt.Errorf("construct MCP server: %w", err) + } + return server, nil } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 3c3c019..61111dd 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -3,6 +3,7 @@ package mcpserver import ( "context" "encoding/json" + "fmt" "log/slog" "testing" @@ -10,88 +11,237 @@ import ( "github.com/stretchr/testify/require" "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/meigma/codemode" + "github.com/meigma/codemode/authz" + hostmcp "github.com/meigma/codemode/mcpserver" ) -// TestServerEndToEnd connects an MCP client to the server over the SDK's -// in-memory transport pair and exercises the full tools/list and tools/call -// path, asserting the random_int tool is discoverable and returns structured -// output within the requested range. +const trustedSubjectID authz.SubjectID = "local" + +type executeEnvelope struct { + Result randomIntOutput `json:"result"` +} + +// localRangePolicy authorizes the local subject only for ranges ending above one. +type localRangePolicy struct{} + +func (localRangePolicy) Authorize(_ context.Context, input authz.AuthorizationInput) error { + maximum, valid := input.Arguments["max"].(int64) + if input.Subject.ID != trustedSubjectID || !valid || maximum <= 1 { + return authz.ErrDenied + } + return nil +} + func TestServerEndToEnd(t *testing.T) { t.Parallel() ctx := context.Background() - session := newClientSession(t) + session := newClientSession(t, Options{}) - // tools/list: the server must expose exactly its expected tool set, so a - // forgotten registration (or an accidental extra tool) fails here. tools, err := session.ListTools(ctx, nil) require.NoError(t, err, "list tools") names := make([]string, 0, len(tools.Tools)) - byName := make(map[string]*mcp.Tool, len(tools.Tools)) for _, tool := range tools.Tools { names = append(names, tool.Name) - byName[tool.Name] = tool } - assert.ElementsMatch(t, []string{"random_int"}, names, - "tools/list must expose exactly the expected tool set") - - randomIntTool := byName["random_int"] - require.NotNil(t, randomIntTool, "tools/list must include random_int") - require.NotNil(t, randomIntTool.InputSchema, "random_int must publish a JSON Schema object") + assert.ElementsMatch(t, []string{"search_api", "describe_api", "execute"}, names, + "tools/list must expose exactly the CodeMode tool set") - // tools/call: the structured Value must fall within the requested range. - const wantMin, wantMax = 3, 7 + searched, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search_api", + Arguments: map[string]any{"query": randomIntName}, + }) + require.NoError(t, err, "search_api") + requireSuccessfulTool(t, searched) + var search codemode.SearchResponse + decodeStructured(t, searched, &search) + require.NotEmpty(t, search.Results, "search_api must find random.int") + assert.Equal(t, randomIntName, search.Results[0].Name) + + described, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "describe_api", + Arguments: map[string]any{"name": randomIntName}, + }) + require.NoError(t, err, "describe_api") + requireSuccessfulTool(t, described) + var description codemode.Description + decodeStructured(t, described, &description) + assert.Equal(t, randomIntName, description.Name) + require.Len(t, description.Input, 2) + assert.Equal(t, "min", description.Input[0].Name) + assert.Equal(t, "int", description.Input[0].Type) + assert.True(t, description.Input[0].Required) + assert.Equal(t, "max", description.Input[1].Name) + assert.Equal(t, "int", description.Input[1].Type) + assert.True(t, description.Input[1].Required) + + const wantMin, wantMax int64 = 3, 7 result, err := session.CallTool(ctx, &mcp.CallToolParams{ - Name: "random_int", - Arguments: map[string]any{"min": wantMin, "max": wantMax}, + Name: "execute", + Arguments: map[string]any{"source": randomIntProgram(wantMin, wantMax)}, }) - require.NoError(t, err, "call tool") - require.False(t, result.IsError, "tool call failed, content: %+v", result.Content) + require.NoError(t, err, "execute") + requireSuccessfulTool(t, result) - // StructuredContent round-trips through JSON; decode it into the typed shape. - var out randomIntOutput - raw, err := json.Marshal(result.StructuredContent) - require.NoError(t, err, "marshal structured content") - require.NoError(t, json.Unmarshal(raw, &out), "unmarshal structured content %q", raw) - assert.GreaterOrEqual(t, out.Value, wantMin) - assert.LessOrEqual(t, out.Value, wantMax) + var out executeEnvelope + decodeStructured(t, result, &out) + assert.GreaterOrEqual(t, out.Result.Value, wantMin) + assert.LessOrEqual(t, out.Result.Value, wantMax) } -// TestServerEndToEndToolError verifies the tool-error convention: an invalid -// range yields a CallToolResult with IsError set, not a protocol error. -func TestServerEndToEndToolError(t *testing.T) { +func TestServerEndToEndEqualBounds(t *testing.T) { t.Parallel() - session := newClientSession(t) + session := newClientSession(t, Options{}) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{"source": randomIntProgram(5, 5)}, + }) + require.NoError(t, err, "execute") + requireSuccessfulTool(t, result) + + var out executeEnvelope + decodeStructured(t, result, &out) + assert.Equal(t, int64(5), out.Result.Value) +} + +func TestServerEndToEndToolError(t *testing.T) { + t.Parallel() + session := newClientSession(t, Options{}) result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ - Name: "random_int", - Arguments: map[string]any{"min": 10, "max": 1}, + Name: "execute", + Arguments: map[string]any{"source": randomIntProgram(10, 1)}, }) require.NoError(t, err, "min > max must be a tool-level error, not a protocol error") - assert.True(t, result.IsError, "call tool result must set IsError for min > max") + requireToolError(t, result, codemode.ErrCapabilityFailure.Error()) } -// newClientSession connects an MCP client to a freshly constructed template -// server over the SDK's in-memory transport pair and returns the client side, -// closing both sessions when the test finishes. -func newClientSession(t *testing.T) *mcp.ClientSession { +func TestServerRejectsMissingSubject(t *testing.T) { + t.Parallel() + + session := newClientSession(t, Options{Resolver: hostmcp.ContextSubject()}) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "execute", + Arguments: map[string]any{"source": randomIntProgram(1, 1)}, + }) + require.NoError(t, err, "missing subject must be a tool-level error") + requireToolError(t, result, codemode.ErrUnauthenticated.Error()) +} + +func TestServerAuthorizesTrustedSubjectAndArguments(t *testing.T) { + t.Parallel() + + session := newClientSession(t, Options{ + Runtime: codemode.Options{Authorizer: localRangePolicy{}}, + }) + + allowed, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Meta: mcp.Meta{ + "subject_id": "subject-attacker", + "subject": map[string]any{"id": "subject-attacker"}, + }, + Name: "execute", + Arguments: map[string]any{"source": randomIntProgram(5, 5)}, + }) + require.NoError(t, err, "execute") + requireSuccessfulTool(t, allowed) + + denied, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Meta: mcp.Meta{ + "subject_id": "subject-attacker", + }, + Name: "execute", + Arguments: map[string]any{"source": randomIntProgram(0, 1)}, + }) + require.NoError(t, err, "denied execute must stay a tool-level error") + requireToolError(t, denied, codemode.ErrPermissionDenied.Error()) +} + +func TestServerRequiresAuthorizer(t *testing.T) { + t.Parallel() + + _, err := New(Options{ + Version: "test", + Logger: slog.New(slog.DiscardHandler), + Resolver: hostmcp.StaticSubject(authz.Subject{ID: trustedSubjectID}), + }) + require.Error(t, err) + assert.ErrorIs(t, err, codemode.ErrInvalidRegistration) +} + +func TestServerRequiresResolver(t *testing.T) { + t.Parallel() + + _, err := New(Options{ + Version: "test", + Logger: slog.New(slog.DiscardHandler), + Runtime: codemode.Options{Authorizer: authz.AllowAll()}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, codemode.ErrInvalidRegistration) +} + +func newClientSession(t *testing.T, options Options) *mcp.ClientSession { t.Helper() - ctx := context.Background() + if options.Logger == nil { + options.Logger = slog.New(slog.DiscardHandler) + } + if options.Version == "" { + options.Version = "test" + } + if options.Resolver == nil { + options.Resolver = hostmcp.StaticSubject(authz.Subject{ID: trustedSubjectID}) + } + if options.Runtime.Authorizer == nil { + options.Runtime.Authorizer = authz.AllowAll() + } + serverTransport, clientTransport := mcp.NewInMemoryTransports() - srv := New(Options{Version: "test", Logger: slog.New(slog.DiscardHandler)}) - serverSession, err := srv.Connect(ctx, serverTransport, nil) + srv, err := New(options) + require.NoError(t, err, "construct server") + serverSession, err := srv.Connect(context.Background(), serverTransport, nil) require.NoError(t, err, "server connect") t.Cleanup(func() { _ = serverSession.Close() }) client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil) - clientSession, err := client.Connect(ctx, clientTransport, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) require.NoError(t, err, "client connect") t.Cleanup(func() { _ = clientSession.Close() }) return clientSession } + +func randomIntProgram(minimum, maximum int64) string { + return fmt.Sprintf("def main():\n return random.int(min=%d, max=%d)\n", minimum, maximum) +} + +func decodeStructured(t *testing.T, result *mcp.CallToolResult, dest any) { + t.Helper() + + raw, err := json.Marshal(result.StructuredContent) + require.NoError(t, err, "marshal structured content") + require.NoError(t, json.Unmarshal(raw, dest), "unmarshal structured content %q", raw) +} + +func requireSuccessfulTool(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + require.NotNil(t, result) + require.False(t, result.IsError, "tool call failed, content: %+v", result.Content) +} + +func requireToolError(t *testing.T, result *mcp.CallToolResult, expected string) { + t.Helper() + require.NotNil(t, result) + require.True(t, result.IsError, "expected a tool-level error") + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "tool error content must be text") + assert.Equal(t, expected, text.Text) +} diff --git a/internal/templateinfo/info.go b/internal/templateinfo/info.go index 639a092..9a89196 100644 --- a/internal/templateinfo/info.go +++ b/internal/templateinfo/info.go @@ -12,14 +12,14 @@ const ( // Name is the application and binary name. It is used as the root command // name, the MCP server implementation name, and the base of the // environment-variable prefix (see [EnvPrefix]). - Name = "template-mcp" + Name = "template-mcp-codemode" // Title is the human-readable server title shown to MCP clients. - Title = "Meigma MCP server template" + Title = "Meigma CodeMode MCP server template" ) // EnvPrefix returns the prefix for the application's environment variables, -// for example TEMPLATE_MCP_ADDR. It is derived from [Name] so a rename keeps -// the command name and the environment variables in sync. +// for example TEMPLATE_MCP_CODEMODE_ADDR. It is derived from [Name] so a +// rename keeps the command name and the environment variables in sync. func EnvPrefix() string { return strings.ToUpper(strings.ReplaceAll(Name, "-", "_")) } diff --git a/internal/templateinfo/info_test.go b/internal/templateinfo/info_test.go index d7f9ef7..40a6d63 100644 --- a/internal/templateinfo/info_test.go +++ b/internal/templateinfo/info_test.go @@ -9,5 +9,5 @@ import ( func TestEnvPrefix(t *testing.T) { t.Parallel() - assert.Equal(t, "TEMPLATE_MCP", EnvPrefix()) + assert.Equal(t, "TEMPLATE_MCP_CODEMODE", EnvPrefix()) } diff --git a/melange.yaml b/melange.yaml index 5e57852..2d0219a 100644 --- a/melange.yaml +++ b/melange.yaml @@ -1,15 +1,15 @@ -# Builds the template-mcp binary into a signed Wolfi apk; apko.yaml assembles it -# into the minimal multi-arch runtime image. version/commit/date are stamped into -# the binary via -ldflags, mirroring GoReleaser and the former Dockerfile. +# Builds the template-mcp-codemode binary into a signed Wolfi apk; apko.yaml +# assembles it into the minimal multi-arch runtime image. version/commit/date are +# stamped into the binary via -ldflags, mirroring GoReleaser and the former Dockerfile. # # package.version is bumped by release-please (extra-files in release-please-config.json). # The build-time version/commit/date are injected via --vars-file (see the release # workflow and the `image-local` mise task), so no date math runs inside the sandbox. package: - name: template-mcp - version: "0.1.4" # x-release-please-version + name: template-mcp-codemode + version: "0.0.0" # x-release-please-version epoch: 0 - description: Meigma Go MCP server template + description: Meigma CodeMode MCP server template environment: contents: @@ -32,8 +32,8 @@ pipeline: # go/build auto-adds -trimpath and installs to /usr/bin/. - uses: go/build with: - packages: ./cmd/template-mcp - output: template-mcp + packages: ./cmd/template-mcp-codemode + output: template-mcp-codemode go-package: go-1.26 modroot: . strip: "-s -w" diff --git a/mise.lock b/mise.lock index 5b0ab50..6003b5b 100644 --- a/mise.lock +++ b/mise.lock @@ -7,21 +7,25 @@ backend = "aqua:astral-sh/uv" [tools."aqua:astral-sh/uv"."platforms.linux-arm64"] checksum = "sha256:658be4b8ec905635f1295468d4d5120d9e1ab1722eec9a104473ce993590babe" url = "https://github.com/astral-sh/uv/releases/download/0.11.0/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/380020571" provenance = "github-attestations" [tools."aqua:astral-sh/uv"."platforms.linux-x64"] checksum = "sha256:bf6b0757c73d1726faa2a819b155d4d864919a95766720215d78fdcd09d42d26" url = "https://github.com/astral-sh/uv/releases/download/0.11.0/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/380020617" provenance = "github-attestations" [tools."aqua:astral-sh/uv"."platforms.macos-arm64"] checksum = "sha256:0c0f32c6a3473c5928aff96c3233715edfc79290e892f255cac93710cde7b91a" url = "https://github.com/astral-sh/uv/releases/download/0.11.0/uv-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/380020558" provenance = "github-attestations" [tools."aqua:astral-sh/uv"."platforms.macos-x64"] checksum = "sha256:31aaec764166af8885cf99321fd6ed24fef80225a6f26ed1ae8ce04111688a7e" url = "https://github.com/astral-sh/uv/releases/download/0.11.0/uv-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/380020610" provenance = "github-attestations" [[tools."aqua:chainguard-dev/apko"]] @@ -31,18 +35,22 @@ backend = "aqua:chainguard-dev/apko" [tools."aqua:chainguard-dev/apko"."platforms.linux-arm64"] checksum = "sha256:d1ba1cb5028a58b080e8ae1c168b67bb363b1a5029069c4ac1394951796db7d6" url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.19/apko_1.2.19_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/chainguard-dev/apko/releases/assets/455907179" [tools."aqua:chainguard-dev/apko"."platforms.linux-x64"] checksum = "sha256:86c7c860ed0b429f9dba0317d4646d382503280f6d8bb7f568a3934515b01b04" url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.19/apko_1.2.19_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/chainguard-dev/apko/releases/assets/455907160" [tools."aqua:chainguard-dev/apko"."platforms.macos-arm64"] checksum = "sha256:1aaf12b7440d772934ca860102675780a9fa7dbbd9769c2b731977932d9d6762" url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.19/apko_1.2.19_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/chainguard-dev/apko/releases/assets/455907167" [tools."aqua:chainguard-dev/apko"."platforms.macos-x64"] checksum = "sha256:98cb42ce222b0e0aa856fdc7ec0bba55b7866810f7067772eb9f03faa1378228" url = "https://github.com/chainguard-dev/apko/releases/download/v1.2.19/apko_1.2.19_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/chainguard-dev/apko/releases/assets/455907168" [[tools."aqua:chainguard-dev/melange"]] version = "0.54.0" @@ -51,18 +59,22 @@ backend = "aqua:chainguard-dev/melange" [tools."aqua:chainguard-dev/melange"."platforms.linux-arm64"] checksum = "sha256:1c1dc145c473adaf86ab52d18b0f748db2dcc299005886d1c00135bea65c9310" url = "https://github.com/chainguard-dev/melange/releases/download/v0.54.0/melange_0.54.0_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/chainguard-dev/melange/releases/assets/449865755" [tools."aqua:chainguard-dev/melange"."platforms.linux-x64"] checksum = "sha256:1c25b9c1bc2f862e32a5f8bac47c9993dcb5a11051b5295e724e95426d3b6bd4" url = "https://github.com/chainguard-dev/melange/releases/download/v0.54.0/melange_0.54.0_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/chainguard-dev/melange/releases/assets/449865756" [tools."aqua:chainguard-dev/melange"."platforms.macos-arm64"] checksum = "sha256:4402934be1032c3a265dcf41dde097b825017664e295b85182695f39f2d92c4e" url = "https://github.com/chainguard-dev/melange/releases/download/v0.54.0/melange_0.54.0_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/chainguard-dev/melange/releases/assets/449865751" [tools."aqua:chainguard-dev/melange"."platforms.macos-x64"] checksum = "sha256:e7b44654803809bf9f79daec70097bbede9a5d33c1d925888ef1e19d5b4cb1a7" url = "https://github.com/chainguard-dev/melange/releases/download/v0.54.0/melange_0.54.0_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/chainguard-dev/melange/releases/assets/449865754" [[tools."aqua:golangci/golangci-lint"]] version = "2.12.2" @@ -71,21 +83,25 @@ backend = "aqua:golangci/golangci-lint" [tools."aqua:golangci/golangci-lint"."platforms.linux-arm64"] checksum = "sha256:44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a" url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413470996" provenance = "github-attestations" [tools."aqua:golangci/golangci-lint"."platforms.linux-x64"] checksum = "sha256:8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553" url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413471054" provenance = "github-attestations" [tools."aqua:golangci/golangci-lint"."platforms.macos-arm64"] checksum = "sha256:a9c54498731b3128f79e090be6110f3e5fffccc617b08142ed244d4126c73f29" url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413470950" provenance = "github-attestations" [tools."aqua:golangci/golangci-lint"."platforms.macos-x64"] checksum = "sha256:f6f06d94b6241521c53d15450c5209b028270bf966f842afb11c030c79f5bc16" url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-darwin-amd64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413470980" provenance = "github-attestations" [[tools."aqua:moonrepo/moon"]] @@ -95,18 +111,21 @@ backend = "aqua:moonrepo/moon" [tools."aqua:moonrepo/moon"."platforms.linux-arm64"] checksum = "sha256:553c2ed2202a0f2376cd21d49cd6436d8ff1cbbce537b2d97681adf7beaca61e" url = "https://github.com/moonrepo/moon/releases/download/v2.3.5/moon_cli-aarch64-unknown-linux-musl.tar.xz" +url_api = "https://api.github.com/repos/moonrepo/moon/releases/assets/458831448" [tools."aqua:moonrepo/moon"."platforms.linux-x64"] checksum = "sha256:5639b8780dc4281776524a48f645b814754f8a5373c3d3958cbf6c2336f6a317" url = "https://github.com/moonrepo/moon/releases/download/v2.3.5/moon_cli-x86_64-unknown-linux-musl.tar.xz" +url_api = "https://api.github.com/repos/moonrepo/moon/releases/assets/458831476" [tools."aqua:moonrepo/moon"."platforms.macos-arm64"] checksum = "sha256:2b981e6827833771a51ac4766e9907d12e111bfa7272ab99b5dce28793401b8e" url = "https://github.com/moonrepo/moon/releases/download/v2.3.5/moon_cli-aarch64-apple-darwin.tar.xz" +url_api = "https://api.github.com/repos/moonrepo/moon/releases/assets/458831440" # Hand-added: `mise lock` resolves but does not persist moon's macos-x64 entry -# (a known mise write quirk); pinned from moon's official v2.3.5 checksum. -# Re-running `mise lock` may drop this — re-add it if so. +# (a known mise write quirk — note the missing url_api below); pinned from moon's +# official v2.3.5 checksum. Re-running `mise lock` may drop this — re-add it if so. [tools."aqua:moonrepo/moon"."platforms.macos-x64"] checksum = "sha256:ffc0bf6e808f2c08f97593adec269c41fc6396e1399f546f13bb2f878efb6ce7" url = "https://github.com/moonrepo/moon/releases/download/v2.3.5/moon_cli-x86_64-apple-darwin.tar.xz" @@ -118,21 +137,25 @@ backend = "aqua:sigstore/cosign" [tools."aqua:sigstore/cosign"."platforms.linux-arm64"] checksum = "sha256:2ec865872e331c32fd12b08dae15332d3f92c0aa029219589684a4903ca85d11" url = "https://github.com/sigstore/cosign/releases/download/v3.1.1/cosign-linux-arm64" +url_api = "https://api.github.com/repos/sigstore/cosign/releases/assets/442898897" provenance = "cosign" [tools."aqua:sigstore/cosign"."platforms.linux-x64"] checksum = "sha256:ae1ecd212663f3693ad9edf8b1a183900c9a52d3155ba6e354237f9a0f6463fc" url = "https://github.com/sigstore/cosign/releases/download/v3.1.1/cosign-linux-amd64" +url_api = "https://api.github.com/repos/sigstore/cosign/releases/assets/442898812" provenance = "cosign" [tools."aqua:sigstore/cosign"."platforms.macos-arm64"] checksum = "sha256:94b42a9e697be95675f6160ab031a9a5f1ec1e646d6f648d7b2f5cd59ececbc5" url = "https://github.com/sigstore/cosign/releases/download/v3.1.1/cosign-darwin-arm64" +url_api = "https://api.github.com/repos/sigstore/cosign/releases/assets/442899033" provenance = "cosign" [tools."aqua:sigstore/cosign"."platforms.macos-x64"] checksum = "sha256:14d2678dfbfde18798151e86fbd91ebdadbb7424b18412a42a155dd8a2df4c7a" url = "https://github.com/sigstore/cosign/releases/download/v3.1.1/cosign-darwin-amd64" +url_api = "https://api.github.com/repos/sigstore/cosign/releases/assets/442899292" provenance = "cosign" [[tools."aqua:vektra/mockery"]] @@ -142,38 +165,42 @@ backend = "aqua:vektra/mockery" [tools."aqua:vektra/mockery"."platforms.linux-arm64"] checksum = "sha256:94dab8c6d8421630da51fe950b4f0f1180e5bef6022a3a5b3fe537acace94741" url = "https://github.com/vektra/mockery/releases/download/v3.7.0/mockery_3.7.0_Linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/368403810" [tools."aqua:vektra/mockery"."platforms.linux-x64"] checksum = "sha256:5e10597d9741d7b8cda699f044b24c9e8b51467dce413e79516d7cd9373c2896" url = "https://github.com/vektra/mockery/releases/download/v3.7.0/mockery_3.7.0_Linux_x86_64.tar.gz" +url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/368403812" [tools."aqua:vektra/mockery"."platforms.macos-arm64"] checksum = "sha256:c0a8ea54d602c071775d1692321d385903ccc80bd2d1ae9f230539a195643d7d" url = "https://github.com/vektra/mockery/releases/download/v3.7.0/mockery_3.7.0_Darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/368403803" [tools."aqua:vektra/mockery"."platforms.macos-x64"] checksum = "sha256:36e7357fd160689a9a8ae57726e099761159845e2749ba31091589853a5a894d" url = "https://github.com/vektra/mockery/releases/download/v3.7.0/mockery_3.7.0_Darwin_x86_64.tar.gz" +url_api = "https://api.github.com/repos/vektra/mockery/releases/assets/368403802" [[tools.go]] -version = "1.26.4" +version = "1.26.6" backend = "core:go" [tools.go."platforms.linux-arm64"] -checksum = "sha256:ef758ae7c6cf9267c9c0ef080b8965f453d89ab2d25d9eb22de4405925238768" -url = "https://dl.google.com/go/go1.26.4.linux-arm64.tar.gz" +checksum = "sha256:d0507e9e9d7fe012aae570108cbd76c15de879e17130ab8cb90d4d7445cb1f2e" +url = "https://dl.google.com/go/go1.26.6.linux-arm64.tar.gz" [tools.go."platforms.linux-x64"] -checksum = "sha256:1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" -url = "https://dl.google.com/go/go1.26.4.linux-amd64.tar.gz" +checksum = "sha256:708effb774be8237570d0add163225abbdfaf4fca28b2611df167beba4feef89" +url = "https://dl.google.com/go/go1.26.6.linux-amd64.tar.gz" [tools.go."platforms.macos-arm64"] -checksum = "sha256:b62ad2b6d7d2464f12a5bcad7ff47f19d08325773b5efd21610e445a05a9bf53" -url = "https://dl.google.com/go/go1.26.4.darwin-arm64.tar.gz" +checksum = "sha256:2dc95ce4675829f2df0e86b28bcef3283635902062a5f0580ca659bf570f3204" +url = "https://dl.google.com/go/go1.26.6.darwin-arm64.tar.gz" [tools.go."platforms.macos-x64"] -checksum = "sha256:05dc9b5f9997744520aaebb3d5deaa7c755371aebbfb7f97c2511a9f3367538d" -url = "https://dl.google.com/go/go1.26.4.darwin-amd64.tar.gz" +checksum = "sha256:08b65a63f244115121ced6c3b55ad38d801a7442acad5c949a17aad84ae6d684" +url = "https://dl.google.com/go/go1.26.6.darwin-amd64.tar.gz" [[tools.python]] version = "3.14.3" diff --git a/mise.toml b/mise.toml index c4c4200..e898f25 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ -# mise: tool + environment management for template-mcp. +# mise: tool + environment management for template-mcp-codemode. # # mise REPLACES Proto (the former .prototools + .moon/proto/*). The committed # mise.lock records per-platform download URLs + checksums; for aqua-backed tools @@ -16,7 +16,7 @@ [tools] # Language runtimes (core backend). -go = "1.26.4" +go = "1.26.6" python = "3.14.3" # Go dev-tool CLIs. Explicit `aqua:` refs force the VERIFYING backend (checksum + @@ -41,7 +41,7 @@ python = "3.14.3" [env] # Pin the Go toolchain: never auto-download a toolchain other than the one above. -# The version here is authoritative and matches go.mod's `go 1.26.4`. +# The version here is authoritative and matches go.mod's `go 1.26.6`. GOTOOLCHAIN = "local" # Local container image. CI builds the release image via the melange/apko jobs in @@ -49,7 +49,7 @@ GOTOOLCHAIN = "local" # Linux, so `--runner docker` makes `image-local` work on macOS too (Docker Desktop # runs the Linux build container). [tasks.image-local] -description = "Build the apko image for the host arch and load it into Docker as template-mcp:dev" +description = "Build the apko image for the host arch and load it into Docker as template-mcp-codemode:dev" run = ''' set -euo pipefail arch="$(go env GOARCH)" @@ -60,10 +60,10 @@ printf 'version: "dev"\ncommit: "%s"\ndate: "%s"\n' \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .melange-vars.local.yaml melange build melange.yaml --arch "$arch" --signing-key melange.rsa --runner docker \ --source-dir . --vars-file .melange-vars.local.yaml -apko build apko.yaml template-mcp:dev image.tar --arch "$arch" --keyring-append ./melange.rsa.pub +apko build apko.yaml template-mcp-codemode:dev image.tar --arch "$arch" --keyring-append ./melange.rsa.pub docker load < image.tar -docker tag "template-mcp:dev-$arch" template-mcp:dev -echo "loaded template-mcp:dev (host arch $arch)" +docker tag "template-mcp-codemode:dev-$arch" template-mcp-codemode:dev +echo "loaded template-mcp-codemode:dev (host arch $arch)" ''' [settings] diff --git a/moon.yml b/moon.yml index 88c07d4..7e218e8 100644 --- a/moon.yml +++ b/moon.yml @@ -3,8 +3,8 @@ layer: 'application' stack: 'backend' project: - title: 'template-mcp' - description: 'Meigma Go MCP server template.' + title: 'template-mcp-codemode' + description: 'Meigma CodeMode MCP server template.' owner: 'meigma' maintainers: - 'meigma' @@ -35,6 +35,7 @@ fileGroups: - 'release-please-config.json' - '.release-please-manifest.json' - '.github/workflows/**/*.yml' + - '.github/scripts/**/*.py' workspace: inheritedTasks: @@ -70,11 +71,11 @@ tasks: mutex: 'golangci-lint' build: - command: 'go build -o bin/template-mcp ./cmd/template-mcp' + command: 'go build -o bin/template-mcp-codemode ./cmd/template-mcp-codemode' inputs: - '@group(goSources)' outputs: - - 'bin/template-mcp' + - 'bin/template-mcp-codemode' test: # Space-separated -coverprofile (not `=coverage.out`): under the `system` @@ -86,12 +87,28 @@ tasks: options: cache: false + # Real MCP session against the freshly built binary: initialize, the exact tool + # surface, capability discovery, and a deterministic program executed in a + # re-executed worker process. This is the same script the release workflows run + # against the released binary and the published container image, so a break in + # the end-to-end path surfaces on every PR instead of at release time. + smoke: + command: 'uv run .github/scripts/mcp_smoke.py -- bin/template-mcp-codemode stdio' + deps: + - 'root:build' + inputs: + - '@group(goSources)' + - '.github/scripts/mcp_smoke.py' + options: + cache: false + check: deps: - 'root:format' - 'root:lint' - 'root:build' - 'root:test' + - 'root:smoke' - 'docs:build' - 'proxy:check' inputs: diff --git a/release-please-config.json b/release-please-config.json index 49619ca..f99e73a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -9,7 +9,8 @@ "bump-patch-for-minor-pre-major": true, "packages": { ".": { - "package-name": "template-mcp", + "package-name": "template-mcp-codemode", + "initial-version": "0.1.0", "changelog-path": "CHANGELOG.md", "extra-files": ["melange.yaml", "apko.yaml"] } diff --git a/tools/proxy/.mockery.yaml b/tools/proxy/.mockery.yaml index 183f75e..f2c3263 100644 --- a/tools/proxy/.mockery.yaml +++ b/tools/proxy/.mockery.yaml @@ -24,7 +24,7 @@ formatter-options: comments: true format-only: true fragment: false - local-prefix: github.com/meigma/template-mcp + local-prefix: github.com/meigma/template-mcp-codemode tab-indent: true tab-width: 8 generate: true @@ -39,6 +39,6 @@ template-schema: '{{.Template}}.schema.json' packages: # The reloader hexagon's ports: Watcher, Builder, Upstream, ChildSession, # Frontend (plus any future boundary interfaces in the package). - github.com/meigma/template-mcp/tools/proxy/internal/reloader: + github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader: config: all: true diff --git a/tools/proxy/README.md b/tools/proxy/README.md index 15703cd..ccfb354 100644 --- a/tools/proxy/README.md +++ b/tools/proxy/README.md @@ -1,25 +1,20 @@ # mcp-devproxy -`mcp-devproxy` is a hot-reloading development proxy for MCP servers. The -client (Claude Code) connects to the proxy once and keeps that session for -the whole dev loop; the proxy watches the source tree, rebuilds the server on -change, swaps the child process, and re-advertises its tools via -`notifications/tools/list_changed` — no reconnect, no lost conversation. +`mcp-devproxy` keeps one client session open while rebuilding and replacing a STDIO MCP server. The client connects to the proxy once; the proxy watches source directories, builds a unique child binary, initializes it, swaps the active child, and forwards calls to the new process. -It exists because developing an MCP server *with* an LLM is otherwise awkward: -the client spawns the server as a stdio subprocess and reads its tool list at -session start, so every code change normally requires killing the -conversation, rebuilding, and reconnecting. +The proxy lives in the nested module `github.com/meigma/template-mcp-codemode/tools/proxy`, so its development dependencies do not enter the server module or release artifacts. -The proxy is dev tooling: it lives in a nested Go module -(`github.com/meigma/template-mcp/tools/proxy`) so its dependencies never leak -into the template's `go.mod`, and it is deliberately excluded from releases. +This template is CodeMode-native. Every healthy child exposes the same three outer MCP tools: + +- `search_api` +- `describe_api` +- `execute` + +Capabilities such as `random.int` live behind those tools. Adding or changing a capability normally leaves the three tool definitions unchanged. ## Quick start -Inside this template there is nothing to set up. The repository's checked-in -`.mcp.json` points Claude Code at a wrapper that builds the proxy through -Moon's cached `proxy:build` task and then execs it: +The checked-in `.mcp.json` points Claude Code at a wrapper that builds the proxy with Moon and then replaces the wrapper process with the proxy: ```json { @@ -35,228 +30,165 @@ Moon's cached `proxy:build` task and then execs it: } ``` -Start `claude` in the repository root, approve the project-scoped `dev` -server on first use, and edit the server source — the proxy rebuilds and -hot-swaps the server on every save. Two details of the wrapper are -load-bearing: +Start Claude Code in the repository root and approve the project-scoped `dev` server. Edits under `cmd` or `internal` trigger rebuilds. -- The `>&2` redirect: stdout is the JSON-RPC channel on this hop, so the - build step's output must go to stderr. -- Building through `proxy:build` rather than a one-time manual build: the - task declares its inputs and outputs, so Moon skips it when nothing - changed (warm starts are near-instant) and the proxy binary can never be - missing or stale. +Two parts of the wrapper are required: -To run the proxy with explicit flags — for another repository layout, or -after renaming the template's binary — the child command after `--` is -re-run for every reload cycle with `{{artifact}}` replaced by that cycle's -freshly built binary. The full CLI shape: +- `>&2` keeps build output away from stdout, which carries JSON-RPC. +- `proxy:build` declares its inputs and outputs, so Moon can skip a warm build without leaving a missing or stale proxy binary. + +The proxy has defaults for this repository. A bare `mcp-devproxy` builds `./cmd/template-mcp-codemode` and runs the artifact with `stdio`. To provide every value explicitly: ```sh mcp-devproxy \ - --build "go build -o {{artifact}} ./cmd/template-mcp" \ + --build "go build -o {{artifact}} ./cmd/template-mcp-codemode" \ --watch cmd --watch internal \ - [--debounce 300ms] [--quiesce 5s] [--terminate 1s] \ + --debounce 300ms \ + --quiesce 5s \ + --terminate 1s \ -- {{artifact}} stdio ``` -**Zero config inside this template:** every flag has a working default for -this repository's layout, so a bare `mcp-devproxy` (empty `args`) builds and -serves `./cmd/template-mcp` over stdio. Each defaulted value is announced on -stderr. The defaults live in one isolated file -(`internal/cli/defaults.go`) so extracting the proxy to a standalone -repository stays clean. +The child command after `--` runs after each successful build. It must contain `{{artifact}}`, because every build uses a new artifact path. ## Flags and environment -Every flag is also settable through an `MCP_DEVPROXY_*` environment variable; -flags take precedence over the environment, which takes precedence over -defaults. +Flags take precedence over `MCP_DEVPROXY_*` environment variables, which take precedence over defaults. | Flag | Environment | Default | Meaning | -|---|---|---|---| -| `--build` | `MCP_DEVPROXY_BUILD` | `go build -o {{artifact}} ./cmd/template-mcp` * | Build command template. Split on whitespace — no shell, so quoting and arguments containing spaces are not supported. Must reference `{{artifact}}`. | -| `--watch` | `MCP_DEVPROXY_WATCH` | `cmd`, `internal` * | Directory to watch recursively for source changes. Repeatable; the environment form is a whitespace-separated list. | -| `--dir` | `MCP_DEVPROXY_DIR` | current directory | Working directory for the build command. | -| `--debounce` | `MCP_DEVPROXY_DEBOUNCE` | `300ms` | How long source-change bursts are coalesced before a rebuild starts. | -| `--quiesce` | `MCP_DEVPROXY_QUIESCE` | `5s` | How long a swap waits for in-flight tool calls on the old child to drain. | -| `--terminate` | `MCP_DEVPROXY_TERMINATE` | `1s` | How long each child shutdown escalation step (stdin close, SIGTERM, SIGKILL) waits. | -| `--verbose` | `MCP_DEVPROXY_VERBOSE` | `false` | Debug logging on stderr, including build output. | - -\* Template-layout zero-config default, applied only when the flag is unset. - -The child command is positional argv after `--` (default: -`{{artifact}} stdio`) and must reference `{{artifact}}` — the rebuilt -binary's path changes every cycle, so a child command that ignores it would -run a stale binary forever. - -## How it works - -The reload lifecycle is `SERVING → BUILDING → STARTING → SWAPPING → SERVING`, -with every failure edge returning to `SERVING` on the old child: - -1. A debounced source change triggers a build into a unique per-cycle - artifact path (never overwriting the running child's binary in place). -2. The new child is spawned, initialized, and health-gated: its tools are - listed under a timeout and every definition validated. The old child keeps - serving the whole time. -3. The proxy quiesces (new calls buffer, bounded and with per-call timeouts), - waits up to the quiesce grace for in-flight calls, swaps the router to the - new child, and closes the old one. -4. The old and new tool sets are diffed by a canonical fingerprint of the - full wire definition; removed tools are unregistered and added or changed - tools re-registered, which emits one coalesced `tools/list_changed`. An - identical tool set emits nothing. -5. Buffered calls drain to the new child only if their tool's definition is - unchanged; otherwise they get the stale-reload error below. - -Cold start serves the client immediately with an empty tool set, then runs -the first build cycle; the first healthy child triggers a normal reconcile -and `list_changed`. A broken first build never blocks the session. - -Failure handling, condensed: +| --- | --- | --- | --- | +| `--build` | `MCP_DEVPROXY_BUILD` | `go build -o {{artifact}} ./cmd/template-mcp-codemode` | Build command template. It is split on whitespace without a shell and must contain `{{artifact}}`. | +| `--watch` | `MCP_DEVPROXY_WATCH` | `cmd`, `internal` | Recursively watched directory. Repeat the flag; the environment form is whitespace-separated. | +| `--dir` | `MCP_DEVPROXY_DIR` | Current directory | Working directory for the build command. | +| `--debounce` | `MCP_DEVPROXY_DEBOUNCE` | `300ms` | Time used to combine a burst of file events into one build. | +| `--quiesce` | `MCP_DEVPROXY_QUIESCE` | `5s` | Maximum wait for calls on the old child to finish before a swap. | +| `--terminate` | `MCP_DEVPROXY_TERMINATE` | `1s` | Wait for each shutdown step: close stdin, send `SIGTERM`, then send `SIGKILL`. | +| `--verbose` | `MCP_DEVPROXY_VERBOSE` | `false` | Enable debug logs, including build output, on stderr. | + +The build command parser does not interpret shell quoting or arguments containing spaces. Use a wrapper executable when a build requires shell behavior. + +The default child argv is `{{artifact}} stdio`. An override that omits `{{artifact}}` is rejected because it would continue to run a stale binary. + +## Reload lifecycle + +The lifecycle is `SERVING → BUILDING → STARTING → SWAPPING → SERVING`. A failure before the swap keeps the last healthy child active. + +1. A debounced source change starts a build at a new artifact path. The running binary is never overwritten in place. +2. The proxy starts the candidate, performs the MCP handshake, lists its tools under a timeout, and validates every listed definition. The current child continues to serve during this health gate. +3. The proxy pauses new dispatches and buffers them within bounded count and time limits. It waits for in-flight calls up to `--quiesce`, switches routing to the candidate, and closes the previous child. +4. The proxy fingerprints and reconciles the outer MCP tool definitions. Removed definitions are unregistered; added or changed definitions are registered. A changed outer list can emit one coalesced `notifications/tools/list_changed`; an identical list emits nothing. +5. Buffered calls are sent to the new child only when the outer definition for that tool is unchanged. A changed or removed outer tool receives a stale-reload tool result instead. + +Cold start serves an empty outer tool list while the first build runs. The first healthy child adds `search_api`, `describe_api`, and `execute`, which is an outer tool-list change and can notify the client. + +## Capability changes and notifications + +A CodeMode capability is catalog data behind the fixed outer tools. Adding `records.lookup`, renaming an input field, changing a summary, or removing `random.int` normally produces the same `tools/list` definitions for `search_api`, `describe_api`, and `execute`. + +The proxy therefore does not promise `notifications/tools/list_changed` for a capability-only edit. This is expected, not a failed reload. After the swap: + +1. Call `search_api` with task vocabulary that should find the changed capability. +2. Call `describe_api` with the exact returned dotted name and check its input and output fields. +3. Call `execute` with a zero-argument `main()` that uses the new shape. +4. Check the returned capability value, not only the absence of an error. + +The existing client session already knows the three outer tools, so it can perform these calls without an outer tool-list refresh. + +The stale-call gate also compares outer tool definitions. It cannot detect that the catalog or handler semantics behind an unchanged `execute` definition changed. A call buffered during a capability-only swap may run on the new child. Do not use the proxy as a transactional deployment boundary, and do not assume its outer-definition fingerprint protects a non-idempotent capability across a catalog edit. + +## Failure behavior | Failure | Behavior | -|---|---| -| Build fails | Keep the old child; log the compile output; stay `SERVING`. | -| New child fails init or health gate | Kill it; keep the old child. | -| First build/child fails (no old child yet) | Serve the empty tool set; retry with backoff. | -| Child crashes while serving | Restart the last good artifact (build-free) with exponential backoff; the session survives. | -| Call arrives mid-swap | Buffered; drained to the new child only if the tool's definition is unchanged. | -| Client exits or the proxy is signaled | Cancel any in-flight cycle, close every child, exit cleanly — no orphans. | - -**Stale-reload errors.** A call issued against a tool the reload changed or -removed — buffered mid-swap, or sent by a session that has not re-listed -since the swap — is answered with a tool *result* (not a protocol error) the -LLM can read and self-correct from: - -> tool "name" changed by dev reload; list refreshes next turn - -The gate opens the moment the session re-lists, which Claude Code does on -`list_changed`. A non-idempotent call issued against old semantics is never -silently executed on new code. - -Tool changes do not propagate mid-turn: reloads land whenever a build -finishes, and the client observes the new tool set at its next turn boundary. - -## v1 fidelity gaps - -The proxy forwards tools only. A child that relies on the following will see -differences from running directly, and every gap is logged loudly on stderr: - -- **Sampling and elicitation** — the child gets an error; the proxy's - upstream client has no handlers for them. -- **Roots** — `roots/list` is rejected with a method-not-found error. -- **Prompts and resources** — they appear *empty* to the client (the - downstream capability envelope advertises them so a later version can - forward them without a reconnect). The health gate logs a prominent warning - when a child actually advertises prompts or resources. -- **Progress** — progress tokens are stripped from forwarded calls; - cancellation still propagates. -- **Instructions** — the child's `instructions` are not forwarded (the - downstream session initializes before the first child exists). - -Child MCP `logging` is forwarded: the client's last `logging/setLevel` is -replayed to each new child, and child log notifications flow back downstream. +| --- | --- | +| Build fails | Keep the current child, log the compiler output, and remain in `SERVING`. | +| Candidate fails initialization or outer tool validation | Terminate the candidate and keep the current child. | +| First build or child fails | Keep the downstream session with an empty outer tool set and retry with backoff. | +| Active child crashes | Restart the last good artifact without rebuilding, using exponential backoff. | +| Call arrives during a swap | Buffer it within the configured count and timeout. | +| Buffered outer tool changed or disappeared | Return a readable stale-reload tool result instead of forwarding it. | +| Old child call outlives the quiesce period | Return an interruption result that says it may have executed; never replay it automatically. | +| Client exits or proxy receives a signal | Cancel the current cycle, close children, and exit. | + +Reloads become visible when a build and swap complete. They are not synchronized with an agent's conversational turn. + +## Forwarding limits + +The proxy forwards tools only. A child that uses other MCP features sees these differences: + +- Sampling and elicitation return errors because the upstream client has no handlers for them. +- `roots/list` returns method not found. +- Prompts and resources appear empty downstream. The proxy logs a warning when the child advertises either. +- Progress tokens are removed from forwarded calls, but request cancellation still propagates. +- Child initialization instructions are not forwarded because the downstream session exists before the first child. + +Child MCP logging is forwarded. The client's latest `logging/setLevel` value is replayed to each replacement child. ## Observability -- All proxy logging goes to **stderr**. stdout is the JSON-RPC protocol - channel on both hops; nothing else may write to it. -- Each child's stderr is passed through to the proxy's stderr — the developer - sees their server's logs as if it ran directly. The child also inherits the - proxy's environment, as a direct run would. -- `--verbose` enables debug logging, including each cycle's build output. - -## Manual acceptance procedure - -The automated suites prove the proxy against a real MCP client and real child -processes, but the load-bearing client behavior — Claude Code re-fetching and -*applying* tool lists on `list_changed` — can only be verified against Claude -Code itself. Run this procedure inside this repository whenever Claude Code's -major version changes, and record the results in the table below. - -### Setup - -1. The checked-in `.mcp.json` already builds and launches the proxy. To watch - the reload cycle, temporarily append a stderr redirect to its wrapper - command: - - ```json - { - "mcpServers": { - "dev": { - "command": "sh", - "args": [ - "-c", - "moon run proxy:build >&2 && exec tools/proxy/bin/mcp-devproxy 2>>/tmp/mcp-devproxy.log" - ] - } - } - } - ``` - -2. Start a tmux session with two panes from the repository root: one running - `claude` (the conversation under test), one for editing source and tailing - `/tmp/mcp-devproxy.log`. - -### Scenarios - -**(a) Added tool.** In the live conversation, confirm an existing tool (for -example `random_int`) is callable through the proxy. In the other pane, add a -new tool to `internal/mcpserver` that returns an unguessable secret string, -and save. Wait for the rebuild in the log, then — next turn — ask Claude to -call the new tool by name. **Pass:** Claude calls it and reports the secret, -with no reconnect. (This re-validates the 2026-06-09 bare-server result -through the full proxy.) - -**(b) Schema-only change to a same-named tool.** Change only the schema of an -existing tool — rename one of `random_int`'s parameters, or add a new -required parameter — and save. Next turn, ask Claude to call that tool. -Record which outcome occurs: - -- Claude applies the refreshed schema and the call succeeds with new-shape - arguments; or -- Claude sends old-shape arguments — before it re-lists, the proxy's stale - gate answers with the friendly stale-reload error; after it re-lists, the - child's own validation rejects the stale arguments. - -This scenario settles the open question of whether Claude Code refreshes the -cached definition of a same-named tool; until it is settled, only the second -outcome's behavior is guaranteed. - -**(c) Cold start, pre-first-turn `list_changed`.** End the Claude Code -session and start a fresh one (which spawns the proxy). Begin a conversation -immediately. **Pass:** the session starts instantly (the tool set may be -empty on the very first turn), and the first build's tools are present and -callable by the first or second turn without a reconnect. - -### Results - -| Date | Claude Code version | (a) added tool | (b) schema-only change | (c) cold start | Notes | -|---|---|---|---|---|---| -| — | — | — | — | — | Not yet run through the proxy. | - -### Empirical notes - -- **2026-06-09, Claude Code 2.1.170 (bare server, pre-proxy):** Claude Code - honors `tools/list_changed` on a live session — it re-fetched the tool list - and called a newly added tool (returning an unguessable secret) the next - turn, with no reconnect. This is the design's load-bearing fact. -- **2026-06-10, integration suite:** each child accepts a - fresh, proxy-identity `initialize` — nothing replays the downstream - client's init params. The handshake, logging-level replay, and health gate - all succeed under the proxy's own identity (`TestIntegrationColdStart` in - `internal/cli/integration_test.go`). - -## Development +All proxy logs go to stderr. Stdout is the downstream JSON-RPC stream. A child's stderr is copied to proxy stderr, and the child inherits the proxy environment. + +Use `--verbose` to include build output and lifecycle details. + +For a temporary reload log while using Claude Code, append a stderr redirect to the wrapper command: + +```json +{ + "mcpServers": { + "dev": { + "command": "sh", + "args": [ + "-c", + "moon run proxy:build >&2 && exec tools/proxy/bin/mcp-devproxy 2>>/tmp/mcp-devproxy.log" + ] + } + } +} +``` + +Remove the redirect after the investigation. + +## Manual CodeMode reload checks + +Run these checks after changing the proxy, its child handshake, or the CodeMode adapter, and when upgrading a client's major version. + +### Added capability + +1. In the live session, call `search_api` for `random integer`, describe `random.int`, and execute it once. +2. Add a capability with a unique search term and a handler that returns an unguessable value. +3. Wait until stderr shows a successful build and swap. +4. Call `search_api` with the unique term, then `describe_api` with the returned exact name. +5. Call `execute` and return the capability's value from `main()`. + +Pass when search and description show the new capability and `execute` returns the unguessable value without reconnecting. Do not require `tools/list_changed`; the outer definitions are unchanged. + +### Input-shape change + +1. Change an existing capability's input field name or required shape without renaming the capability. +2. Wait for a successful swap. +3. Repeat `search_api` and `describe_api`; confirm the new signature and field shape. +4. Run an `execute` program with the new keyword arguments and check its final result. +5. Optionally run the old source and confirm CodeMode rejects its arguments rather than dispatching the handler. + +Pass when description and execution use the new shape. An outer list-change notification is not part of this check. + +### Removed capability + +1. Remove a capability registration and wait for a successful swap. +2. Search for its exact name and task vocabulary. +3. Describe the old exact name. +4. Execute a program that calls the old name. + +Pass when search no longer returns it, description reports `capability not found`, and execution does not dispatch the removed handler. + +### Cold start + +Start a new client session. The initial outer list can be empty while the first build runs. Pass when the first healthy child makes `search_api`, `describe_api`, and `execute` available and they can discover and execute `random.int` without reconnecting. + +## Develop the proxy ```sh -moon run proxy:check # format, lint, build, test -go test -short ./... # skips the slow E2E test +moon run proxy:check +go test -short ./... ``` -The E2E test (`internal/cli/e2e_test.go`) runs a real `go build` and real -child processes; it is guarded by `testing.Short()` and builds offline by -construction (enforced with `GOPROXY=off`). CI runs it un-short via -`proxy:test`. +The short command skips the slow end-to-end test. The end-to-end test performs a real build and starts real child processes with network module lookup disabled; CI runs it through the proxy test task. diff --git a/tools/proxy/cmd/mcp-devproxy/main.go b/tools/proxy/cmd/mcp-devproxy/main.go index fae3927..29723da 100644 --- a/tools/proxy/cmd/mcp-devproxy/main.go +++ b/tools/proxy/cmd/mcp-devproxy/main.go @@ -10,7 +10,7 @@ import ( "os/signal" "syscall" - "github.com/meigma/template-mcp/tools/proxy/internal/cli" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/cli" ) func main() { diff --git a/tools/proxy/go.mod b/tools/proxy/go.mod index 1e77275..7dfe0c5 100644 --- a/tools/proxy/go.mod +++ b/tools/proxy/go.mod @@ -1,6 +1,6 @@ -module github.com/meigma/template-mcp/tools/proxy +module github.com/meigma/template-mcp-codemode/tools/proxy -go 1.26.4 +go 1.26.6 require ( github.com/fsnotify/fsnotify v1.10.1 diff --git a/tools/proxy/internal/build/build.go b/tools/proxy/internal/build/build.go index 5e033d0..dd4cd87 100644 --- a/tools/proxy/internal/build/build.go +++ b/tools/proxy/internal/build/build.go @@ -14,7 +14,7 @@ import ( "sync/atomic" "time" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" ) // artifactToken is the placeholder in the build command template replaced diff --git a/tools/proxy/internal/build/build_test.go b/tools/proxy/internal/build/build_test.go index e9cddfe..61df999 100644 --- a/tools/proxy/internal/build/build_test.go +++ b/tools/proxy/internal/build/build_test.go @@ -10,8 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meigma/template-mcp/tools/proxy/internal/build" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/build" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" ) // promptReturnBound is the ceiling on how long a cancelled Build may take to diff --git a/tools/proxy/internal/cli/assemble.go b/tools/proxy/internal/cli/assemble.go index 9853bbb..8966eaa 100644 --- a/tools/proxy/internal/cli/assemble.go +++ b/tools/proxy/internal/cli/assemble.go @@ -11,11 +11,11 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/meigma/template-mcp/tools/proxy/internal/build" - "github.com/meigma/template-mcp/tools/proxy/internal/downstream" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" - "github.com/meigma/template-mcp/tools/proxy/internal/upstream" - "github.com/meigma/template-mcp/tools/proxy/internal/watch" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/build" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/downstream" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/upstream" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/watch" ) // seams carries test-only adapter overrides; the zero value selects the diff --git a/tools/proxy/internal/cli/defaults.go b/tools/proxy/internal/cli/defaults.go index 40f10ea..14c95b5 100644 --- a/tools/proxy/internal/cli/defaults.go +++ b/tools/proxy/internal/cli/defaults.go @@ -2,7 +2,7 @@ package cli // This file is the zero-config convenience layer for THIS template's layout: // inside the template repository a bare `mcp-devproxy` builds and serves -// ./cmd/template-mcp. It is kept apart from the generic flag handling so +// ./cmd/template-mcp-codemode. It is kept apart from the generic flag handling so // extraction to a standalone repository stays clean — delete this file and // its one call in resolveConfig, and nothing else changes. @@ -12,7 +12,7 @@ import ( ) // defaultBuildCommand builds the template server into the cycle's artifact. -const defaultBuildCommand = "go build -o {{artifact}} ./cmd/template-mcp" +const defaultBuildCommand = "go build -o {{artifact}} ./cmd/template-mcp-codemode" // defaultChildTransport is the template server's stdio transport subcommand. const defaultChildTransport = "stdio" diff --git a/tools/proxy/internal/cli/integration_test.go b/tools/proxy/internal/cli/integration_test.go index 6c98ddb..b85189c 100644 --- a/tools/proxy/internal/cli/integration_test.go +++ b/tools/proxy/internal/cli/integration_test.go @@ -27,8 +27,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" - "github.com/meigma/template-mcp/tools/proxy/internal/upstream" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/upstream" ) // waitTimeout bounds every asynchronous wait in this suite. diff --git a/tools/proxy/internal/cli/root.go b/tools/proxy/internal/cli/root.go index f486420..bf0de53 100644 --- a/tools/proxy/internal/cli/root.go +++ b/tools/proxy/internal/cli/root.go @@ -97,7 +97,7 @@ type launchFunc func(cmd *cobra.Command, cfg config, logger *slog.Logger) error // The child command is positional argv after "--"; everything before it is // flags. Inside this template repository every flag has a working default // (see defaults.go), so a bare invocation builds and serves -// ./cmd/template-mcp. +// ./cmd/template-mcp-codemode. func NewRootCommand(options Options) *cobra.Command { return newRootCommand(options, launchProxy) } @@ -123,12 +123,12 @@ func newRootCommand(options Options, launch launchFunc) *cobra.Command { Long: "Hot-reloading development proxy for MCP servers.\n\n" + "The client connects to the proxy once and keeps that session for the\n" + "whole dev loop. The proxy watches the source tree, rebuilds the server\n" + - "on change, swaps the child process, and re-advertises its tools via\n" + - "tools/list_changed — no reconnect.\n\n" + + "on change, and swaps the child process without reconnecting. It sends\n" + + "tools/list_changed only when the public MCP tool definitions change.\n\n" + "The child command after \"--\" is re-run for every reload cycle with\n" + "{{artifact}} replaced by that cycle's freshly built binary.", Example: " " + appName + " \\\n" + - " --build \"go build -o {{artifact}} ./cmd/template-mcp\" \\\n" + + " --build \"go build -o {{artifact}} ./cmd/template-mcp-codemode\" \\\n" + " --watch cmd --watch internal \\\n" + " -- {{artifact}} stdio", Version: options.Build.Version, diff --git a/tools/proxy/internal/cli/root_test.go b/tools/proxy/internal/cli/root_test.go index 7521758..b13e840 100644 --- a/tools/proxy/internal/cli/root_test.go +++ b/tools/proxy/internal/cli/root_test.go @@ -52,7 +52,7 @@ func discardLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } // anything: construction touches no watch paths and spawns no processes. func validProxyConfig() config { return config{ - buildCommand: "go build -o {{artifact}} ./cmd/template-mcp", + buildCommand: "go build -o {{artifact}} ./cmd/template-mcp-codemode", watchDirs: []string{"."}, childArgv: []string{"{{artifact}}", "stdio"}, } @@ -289,7 +289,7 @@ func TestNewProxyConstruction(t *testing.T) { }, { name: "build command without the artifact token surfaces with the flag name", - mutate: func(cfg *config) { cfg.buildCommand = "go build ./cmd/template-mcp" }, + mutate: func(cfg *config) { cfg.buildCommand = "go build ./cmd/template-mcp-codemode" }, wantErr: "--" + buildFlag + ": the build command must reference " + artifactToken, }, { diff --git a/tools/proxy/internal/downstream/downstream.go b/tools/proxy/internal/downstream/downstream.go index da95b16..4bc3f18 100644 --- a/tools/proxy/internal/downstream/downstream.go +++ b/tools/proxy/internal/downstream/downstream.go @@ -8,7 +8,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" ) // serverName identifies the proxy on the downstream hop when Options.Impl is diff --git a/tools/proxy/internal/downstream/downstream_test.go b/tools/proxy/internal/downstream/downstream_test.go index 0edf5bf..8802bc5 100644 --- a/tools/proxy/internal/downstream/downstream_test.go +++ b/tools/proxy/internal/downstream/downstream_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" ) // waitTimeout bounds every asynchronous wait in this suite. diff --git a/tools/proxy/internal/upstream/child.go b/tools/proxy/internal/upstream/child.go index 97ea6c6..b0340a4 100644 --- a/tools/proxy/internal/upstream/child.go +++ b/tools/proxy/internal/upstream/child.go @@ -8,7 +8,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" ) // childSession is one live child MCP connection, implementing the diff --git a/tools/proxy/internal/upstream/upstream.go b/tools/proxy/internal/upstream/upstream.go index 21e5c10..77e9c4c 100644 --- a/tools/proxy/internal/upstream/upstream.go +++ b/tools/proxy/internal/upstream/upstream.go @@ -14,7 +14,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" ) // artifactToken is the placeholder in the child argv template replaced with diff --git a/tools/proxy/internal/upstream/upstream_test.go b/tools/proxy/internal/upstream/upstream_test.go index 2034917..bd411fa 100644 --- a/tools/proxy/internal/upstream/upstream_test.go +++ b/tools/proxy/internal/upstream/upstream_test.go @@ -21,8 +21,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" - "github.com/meigma/template-mcp/tools/proxy/internal/upstream" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/upstream" ) // waitTimeout bounds every asynchronous wait in this suite. diff --git a/tools/proxy/internal/watch/watch.go b/tools/proxy/internal/watch/watch.go index be8c493..90e6dd9 100644 --- a/tools/proxy/internal/watch/watch.go +++ b/tools/proxy/internal/watch/watch.go @@ -11,7 +11,7 @@ import ( "github.com/fsnotify/fsnotify" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" ) // eventBufferSize is the fsnotify event buffer size; it absorbs editor save diff --git a/tools/proxy/internal/watch/watch_test.go b/tools/proxy/internal/watch/watch_test.go index 097498b..69c3d04 100644 --- a/tools/proxy/internal/watch/watch_test.go +++ b/tools/proxy/internal/watch/watch_test.go @@ -10,8 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meigma/template-mcp/tools/proxy/internal/reloader" - "github.com/meigma/template-mcp/tools/proxy/internal/watch" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/reloader" + "github.com/meigma/template-mcp-codemode/tools/proxy/internal/watch" ) const eventTimeout = 5 * time.Second diff --git a/tools/proxy/moon.yml b/tools/proxy/moon.yml index 17e3da9..f876308 100644 --- a/tools/proxy/moon.yml +++ b/tools/proxy/moon.yml @@ -35,8 +35,8 @@ workspace: # format/lint reuse the root .golangci.yml. No local-prefix override is needed: # goimports matches local-prefixes by string prefix, so the root's -# 'github.com/meigma/template-mcp' already covers this nested module's path -# ('github.com/meigma/template-mcp/tools/proxy'). +# 'github.com/meigma/template-mcp-codemode' already covers this nested module's path +# ('github.com/meigma/template-mcp-codemode/tools/proxy'). tasks: format: command: 'golangci-lint fmt --config ../../.golangci.yml --diff'