diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index faeb9a2..6916c22 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -26,6 +26,13 @@ jobs: with: node-version: "22" + - name: Allow setup-node toolchain under Landlock + shell: bash + run: | + node_executable="$(readlink -f "$(command -v node)")" + node_root="$(dirname "$(dirname "$node_executable")")" + echo "CODING_TOOLS_MCP_EXEC_ALLOW_ROOTS=$node_root" >> "$GITHUB_ENV" + - name: Install runtime and CI tools run: | python -m pip install --upgrade pip diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b6dbb..dc47f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## Unreleased + +### Added + +- Persistent OAuth authorization state in `oauth.sqlite3`: Clients, Grants, + access-token metadata, refresh-token families, signing-key metadata, and + redacted audit events survive restart. Authorization Code + PKCE now returns + rotating refresh tokens; replay of an already-rotated token revokes its family. +- Immutable OAuth Agent-to-Workspace binding. `client_id`, `grant_id`, + `workspace_id`, and `jti` are produced by bearer validation and frozen into + each HTTP MCP Session. cwd, processes, retained output, paths, and project + instructions remain isolated by Session. +- Runtime-initialized upstream MCP Gateway snapshots with stable namespaces, + collision fail-closed behavior, preserved schemas/annotations/ + `structuredContent`, and restart-only administration. +- Dedicated authenticated Admin API and WebUI for revision-aware Settings, + Workspace Catalog, Gateway, OAuth revocation/key lifecycle, Secret Vault, + and Workspace-partitioned chat/Codex session persistence. +- Packaged self-contained Admin WebUI, Desktop package data, privacy-safe Admin + telemetry mode, and Windows-portable npm launcher contract tests. +- `docs/migration-v0.1-to-v0.2.2.md` with upgrade, backup, reauthorization, + and rollback guidance. + +### Changed + +- The stable user configuration directory now owns `server-settings.json`, + `mcp-servers.json`, `oauth.sqlite3`, `oauth-secrets.json`, the server Secret + Vault, and `transcripts.sqlite3`; managed Workspace roots no longer own server + identity state. +- Legacy `tool_profile` is accepted only as migration input, removed on write, + and reported as `legacy_tool_profile_ignored`. It never controls `tools/list`. +- Settings and Gateway writes use optimistic revisions. Gateway changes require + restart and never mutate existing Runtime tool snapshots. + +### Security + +- Ordinary MCP bearer and OAuth credentials never grant Admin authority. +- OAuth, Gateway, Vault, and chat management responses are redacted; raw bearer + tokens, refresh tokens, signing material, client secrets, hashes/digests, and + credential references are not displayed. +- OAuth Store/Vault failures, missing Workspace mappings, disabled Workspaces, + upstream namespace collisions, and invalid Gateway credential references fail + closed. + +### Fixed + +- Refresh-token rotation, replacement-token insertion, access-token metadata, + family timestamps, and both issuance audits now commit in one SQLite + transaction. If access-token persistence or auditing fails, the whole exchange + rolls back, the original refresh token remains usable, and no replacement or + access-token metadata is left behind. + ## 0.2.2 - 2026-07-28 ### Fixed @@ -15,9 +67,9 @@ ### Changed - Raised the default OAuth access token lifetime from one hour to 24 hours - (`CODING_TOOLS_MCP_OAUTH_TOKEN_TTL`, still capped at 604800). The server - does not issue refresh tokens, so a one-hour default forced re-authorization - every hour. + (`CODING_TOOLS_MCP_OAUTH_TOKEN_TTL`, still capped at 604800). The original + upstream 0.2.2 release did not issue refresh tokens; the integrated + Unreleased state above now supports rotating refresh tokens. - Advertised grant and response types now derive from single constants shared by authorization-server metadata and the registration endpoint. diff --git a/README.md b/README.md index e6e80ec..ed1f9d1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **English** | [简体中文](README.zh-CN.md) -> Give any AI chat or agent a safe pair of hands on your codebase. +> Give any MCP-capable AI client a bounded, auditable pair of hands on your codebase. [![PyPI](https://img.shields.io/pypi/v/coding-tools-mcp)](https://pypi.org/project/coding-tools-mcp/) [![npm](https://img.shields.io/npm/v/coding-tools-mcp)](https://www.npmjs.com/package/coding-tools-mcp) @@ -11,46 +11,49 @@ [![release](https://github.com/xyTom/coding-tools-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/xyTom/coding-tools-mcp/actions/workflows/release.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -Coding Tools MCP is a **model-neutral coding runtime** served over the -[Model Context Protocol](https://modelcontextprotocol.io): file reading and -search, structured multi-file patches, command execution, interactive -sessions, and git — one server that any MCP client can drive. Claude Desktop, -Claude Code, Cursor, Cline, or an agent you build yourself all get the same -20 battle-tested tools, confined to one workspace, gated by permission modes. +Coding Tools MCP is a model-neutral coding runtime served over the Model Context +Protocol. It provides bounded file reads and search, atomic multi-file patches, +command execution, interactive process sessions, Git inspection, optional +upstream MCP composition, persistent OAuth, Workspace-bound HTTP sessions, and +an authenticated Admin WebUI. -[![Watch the demo](https://img.youtube.com/vi/N9lQaXt1eqQ/maxresdefault.jpg)](https://youtu.be/N9lQaXt1eqQ?si=LyEwvzzQF6QjUxR0) +The default local catalog contains 20 stable, truthfully annotated tools. Permission +modes change command policy, never `tools/list`. Optional upstream tools are +snapshotted at Runtime initialization and exposed under stable namespaces; their +remote capabilities remain governed by the upstream server, not by this +server's local Workspace boundary. ## Why people use it -- **It turns a chat app into a coding agent.** Claude Desktop — or any MCP - chat client — gets real repo access with the subscription you already have. - No extra product required. -- **Safety is the product, not an afterthought.** One workspace root per - server. Absolute paths, `..` traversal, and symlink escapes are rejected. - Permission modes gate network access, shell expansion, inline scripts, and - destructive commands. On Linux, [Landlock](docs/security-boundary.md) adds - kernel-level filesystem confinement. -- **It is model- and vendor-neutral.** A fixed, truthfully annotated catalog — - no profile switching, no annotation games. Swap models or clients freely; - the runtime and its behavior stay put. -- **It is engineered for context windows.** Results are summarized, paginated, - and capped by design; serialized tool-result bytes dropped 37% - release-over-release on the deterministic dogfood workload with unchanged - task completion. +- **One runtime contract across clients.** Claude Desktop, Claude Code, Codex, + Cursor, Cline, and custom agents use the same MCP schemas and result envelopes. +- **A real safety boundary.** Each MCP Session is immutably bound to one validated + Workspace. Local path tools reject absolute paths outside that root, `..` + traversal, and symlink escapes. Linux Landlock adds kernel-level confinement + when available. +- **Stable tools instead of profiles.** The removed v0.1 `tool_profile` setting is + migration input only. It cannot hide tools, alter annotations, or change the + fixed local catalog. +- **Persistent remote identity.** OAuth Clients, Grants, access-token metadata, + refresh-token families, and signing-key state survive restart and can be + revoked precisely by ID. +- **Operational management without secret disclosure.** `/admin` exposes + revision-aware Settings, Workspace, Gateway, OAuth, Secret Vault, and + conversation/session management through a dedicated Admin credential. +- **Bounded outputs for context windows.** Tool results are summarized and + paginated while `structuredContent` retains the complete machine interface. ## Quickstart -Run it with whichever toolchain you already have (the server is Python ≥ 3.11 -from PyPI; the npm package is a thin launcher that starts it via `uv` or -`pipx`): +The Python package requires Python 3.11 or newer. The npm package is a thin +launcher that starts the pinned Python server through `uvx` or `pipx`. ```bash -uvx coding-tools-mcp --stdio --workspace /path/to/repo # Python toolchain -npx coding-tools-mcp --stdio --workspace /path/to/repo # Node toolchain +uvx coding-tools-mcp --stdio --workspace /path/to/repo +npx coding-tools-mcp --stdio --workspace /path/to/repo ``` -Wire it into Claude Desktop, Claude Code, Cursor, or Cline — the JSON is the -same everywhere (swap `uvx` for `npx` if you prefer Node): +Example MCP client configuration: ```json { @@ -63,157 +66,148 @@ same everywhere (swap `uvx` for `npx` if you prefer Node): } ``` -Then ask your client: *"run the test suite and fix the first failure."* +For Streamable HTTP, omit `--stdio`. The default endpoint is +`http://127.0.0.1:8765/mcp`. The primary protocol is MCP `2025-11-25`, with +explicit `2025-06-18` compatibility. -Prefer HTTP? Drop `--stdio` and the server speaks Streamable HTTP on -`http://127.0.0.1:8765/mcp` (MCP `2025-11-25`, with `2025-06-18` -compatibility). A one-line installer, per-client walkthroughs, and -troubleshooting live in [docs/quickstart.md](docs/quickstart.md) and -[docs/mcp-client-config.md](docs/mcp-client-config.md). +## Integrated v0.2.2 architecture -## Seven things to try +### Sessions and Workspaces -**1. Make Claude Desktop your coding agent.** The config above is all it -takes — the chat window you already pay for can now read, patch, test, and -commit-review a real repository. +Every successful HTTP `initialize` creates an independent Runtime with its own +Workspace binding, cwd, process table, retained output, project instructions, +and upstream-tool snapshot. The binding cannot change during the Session. +Subsequent POST and DELETE requests must match the authorization context used at +initialization. stdio uses the explicitly configured default Workspace and does +not invent an OAuth identity. -**2. Code on your own machine from anywhere.** +### Persistent OAuth -```bash -CODING_TOOLS_MCP_AUTH_MODE=bearer ./scripts/tunnel.sh cloudflared /path/to/repo -``` +OAuth supports Authorization Code + PKCE S256, RFC 7591 dynamic registration, +`authorization_code` and `refresh_token` grants, refresh rotation and family +reuse detection, access-token `jti` revocation, and active/retired/revoked +signing keys. Client secrets are stored only as digests, refresh tokens only as +peppered hashes, and signing material only in the encrypted Secret Vault. -Loopback bind + authenticated HTTPS tunnel (`cloudflared`, `ngrok`, or -Microsoft Dev Tunnel). Point claude.ai on your phone at -`https:///mcp` and drive your home workstation from anywhere. -Bearer tokens and OAuth 2.1 + PKCE (with RFC 7591 dynamic registration) are -built in. → [docs/remote-mcp.md](docs/remote-mcp.md) +OAuth startup is fail-closed: the persistent Store and +`CODING_TOOLS_MCP_SECRETS_KEY`-backed Vault must be available. See +[Remote MCP](docs/remote-mcp.md) and the +[upgrade guide](docs/migration-v0.1-to-v0.2.2.md). -**3. Let an agent loose on untrusted code — inside a disposable sandbox.** +### Gateway -```bash -docker build -t coding-tools-mcp-sandbox:local . -docker run --rm --init -it -p 8765:8765 -v "$PWD:/workspace" coding-tools-mcp-sandbox:local -``` +Upstream MCP configuration is fixed before Runtime initialization. Each Runtime +gets an immutable namespaced snapshot, so `listChanged: false` remains truthful. +Local tool names are reserved; namespace collisions fail closed. Admin Gateway +changes are persisted for restart only—there is no hot reload, start, or stop +control path. -A containerized server with toolchains and caches preconfigured, safe to point -at a sketchy PR and destroy afterwards. → [docs/docker.md](docs/docker.md) +### Admin WebUI -**4. Spin up a cloud sandbox with one MCP call.** The bundled -[Cloudflare Worker control plane](cloudflare/sandbox-control/README.md) exposes -`start_coding_tools_sandbox` as an MCP tool: one call dispatches a GitHub -Actions runner that boots the Docker sandbox and publishes it behind an -authenticated Cloudflare Tunnel. Ephemeral compute, no server of your own. +Configure a dedicated Admin token and open `/admin`. Ordinary MCP bearer and +OAuth access tokens are never promoted to Admin authority. Settings writes use +revision checks; a stale HTTP 409 preserves the browser draft and requires an +explicit conflict resolution. Secret values, token material, hashes, digests, +and Vault references are never displayed. -**5. Drive it from a GUI.** +## The local tool catalog -```bash -python -m pip install "coding-tools-mcp[desktop]" -coding-tools-mcp-desktop -``` +| Group | Tools | +| --- | --- | +| Files and search | `read_file`, `list_dir`, `list_files`, `search_text`, `apply_patch`, `view_image` | +| Execution | `exec_command`, `write_stdin`, `read_output`, `kill_session`, `request_permissions` | +| Git | `git_status`, `git_diff`, `git_log`, `git_show`, `git_blame` | +| Runtime | `server_info`, `check_exec_environment`, `get_default_cwd`, `set_default_cwd` | -Per-workspace profiles, server and tunnel start/stop, credential setup with -clipboard helpers, live health checks. English and 简体中文. +`apply_patch` is the only direct local file-mutation primitive. Root +`AGENTS.md`/`CLAUDE.md` instructions are loaded during initialization. Tool +`content` is concise agent-facing text; `structuredContent` is the stable +machine-readable result. -**6. Keep an interactive session alive.** `exec_command` starts a REPL or -debugger under a real PTY; `write_stdin` feeds it across turns; `read_output` -pages long output; `kill_session` cleans up. Long-running processes are -first-class, with deadline watchdogs and bounded buffers. +## Safety Boundary -**7. Give your own agent production-grade hands.** Building an agent loop with -the Anthropic SDK or anything else? Don't hand-roll file and exec tools — -speak MCP to this server and inherit the whole safety boundary. → -[docs/embedding.md](docs/embedding.md) +| Mode | Intended use | Actual behavior | +| --- | --- | --- | +| `safe` | daily agent work | network-looking commands, shell expansion, inline scripts, and destructive commands require permission | +| `trusted` | normal local development | enables network, expansion, and inline snippets while retaining secret and destructive-command checks | +| `dangerous` | isolated container or VM | disables command permission gates; direct local path tools remain Workspace-bound | -## The tool catalog +Permission modes never hide mutation tools. The advanced +`--dangerously-fake-readonly-annotations` compatibility switch only rewrites +exposure hints in `tools/list`; it does not prevent execution or mutation and is +not a security boundary. -One stable, truthfully annotated set — permission modes change command -*policy*, never which tools the model sees. `apply_patch` is the sole -file-mutation primitive: staged, baseline-checked, atomic across files, with -rollback. +The server is not a complete OS sandbox on every platform. Use the Docker image, +a VM, or another external sandbox for untrusted repositories. An upstream MCP +tool is a remote capability and is controlled by that upstream server's own +security model. -| Group | Tools | -| --- | --- | -| Files & search | `read_file` · `list_dir` · `list_files` · `search_text` · `apply_patch` · `view_image` | -| Execution | `exec_command` · `write_stdin` · `read_output` · `kill_session` · `request_permissions` | -| Git | `git_status` · `git_diff` · `git_log` · `git_show` · `git_blame` | -| Runtime | `server_info` · `check_exec_environment` · `get_default_cwd` · `set_default_cwd` | +## Remote access and management -Root `AGENTS.md`/`CLAUDE.md` files load into the initialize context -automatically. Tool `content` is concise agent-facing text; -`structuredContent` carries the complete machine result. Schemas and result -envelopes: [docs/tools-and-schemas.md](docs/tools-and-schemas.md) · -[docs/runtime-contract-v0.2.md](docs/runtime-contract-v0.2.md). +Keep HTTP bound to loopback and publish it through an authenticated HTTPS tunnel. +The fixed local catalog includes mutation and execution, so never expose +`noauth` publicly. See [Remote MCP](docs/remote-mcp.md). -## Safety Boundary +The Admin WebUI and API are documented separately: -| Mode | Meant for | What it allows | -| --- | --- | --- | -| `safe` (default) | day-to-day agent work | file tools and vetted commands; network-looking commands, shell expansion, inline scripts, and destructive commands all require explicit permission | -| `trusted` | local development | opens network, shell expansion, and inline scripts; keeps secret filtering and destructive-command checks | -| `dangerous` | isolated containers/VMs only | disables `exec_command` permission gates; workspace path boundaries still apply | - -Recursive listing and search exclude `.git`, `node_modules`, build outputs, -virtualenvs, and caches. Commands run with workspace-bound cwd, scrubbed -environment, timeouts, and output caps. Linux hosts with Landlock get -kernel-enforced filesystem confinement; other platforms get an explicit -warning — this is still not a complete OS sandbox, so use the Docker image or -a VM for genuinely untrusted work. Details: -[SECURITY.md](SECURITY.md) · [docs/security-boundary.md](docs/security-boundary.md) · -[docs/permission-modes.md](docs/permission-modes.md) +- [Admin API](docs/admin-api.md) +- [Admin WebUI](docs/admin-webui.md) +- [Chat and Codex session persistence](docs/chat-persistence.md) + +## Desktop client versus Admin WebUI + +The optional Desktop client is a local launcher and tunnel/profile manager: + +```bash +python -m pip install "coding-tools-mcp[desktop]" +coding-tools-mcp-desktop +``` + +Desktop profile and secret storage remain separate from server Settings and the +server Secret Vault. The Admin WebUI manages a running HTTP server through its +dedicated Admin API; it is not the Desktop application and does not start or stop +the Desktop runtime. ## Telemetry -The server sends anonymous usage telemetry (per-tool success/latency counters -and version/platform dimensions — never paths, arguments, commands, or file -contents) to help prioritize fixes. Disable it with -`CODING_TOOLS_MCP_TELEMETRY=off` or `DO_NOT_TRACK=1`; it is automatically off -in CI. `CODING_TOOLS_MCP_TELEMETRY=debug` prints every event to stderr instead -of sending. The full event list and guarantees are in -[docs/telemetry.md](docs/telemetry.md). +Anonymous usage telemetry is enabled by default upstream and contains closed +schemas of counters, enums, durations, and version/platform dimensions—never +paths, Workspace/Agent/Client IDs, commands, arguments, file contents, chat +content, or transcript summaries. Disable it with +`CODING_TOOLS_MCP_TELEMETRY=off` or `DO_NOT_TRACK=1`; CI disables it +automatically. `CODING_TOOLS_MCP_TELEMETRY=debug` prints events to stderr instead +of sending them. See [docs/telemetry.md](docs/telemetry.md). ## Evidence, Dogfood and SWE-bench -Every release ships through a tag-triggered pipeline in which the compliance -suite, real-workload benchmark, and SWE-bench harness run from the same commit -that publishes to PyPI and npm — both via trusted publishing, npm with -provenance. Dogfood efficiency metrics are reproducible (`make dogfood-smoke`) -and checked in under `reports/`. This repository does not claim a -model-generated SWE-bench leaderboard result — see -[docs/swe-bench.md](docs/swe-bench.md) for exactly what is and is not -measured. More: [COMPLIANCE.md](COMPLIANCE.md) · [BENCHMARK.md](BENCHMARK.md) · -[docs/dogfood.md](docs/dogfood.md) +The repository includes reproducible compliance, dogfood, latency, and +SWE-bench harnesses. It does not claim a model-generated SWE-bench leaderboard +result. See [COMPLIANCE.md](COMPLIANCE.md), [BENCHMARK.md](BENCHMARK.md), and +[docs/swe-bench.md](docs/swe-bench.md). ## Documentation -| | | +| Topic | Documents | | --- | --- | -| Getting started | [Quickstart](docs/quickstart.md) · [Client configuration](docs/mcp-client-config.md) · [Troubleshooting](docs/troubleshooting.md) | -| Remote & sandboxed | [Remote MCP](docs/remote-mcp.md) · [Docker sandbox](docs/docker.md) · [Cloud sandbox worker](cloudflare/sandbox-control/README.md) | -| Tools & contract | [Tools and schemas](docs/tools-and-schemas.md) · [Runtime contract](docs/runtime-contract-v0.2.md) · [Permission modes](docs/permission-modes.md) | -| Execution | [Exec recipes](docs/exec-command-recipes.md) · [Exec troubleshooting](docs/troubleshooting-exec.md) | -| Integration | [Embedding](docs/embedding.md) · [npm launcher](npm/coding-tools-mcp/README.md) | -| Security & quality | [Security policy](SECURITY.md) · [Security boundary](docs/security-boundary.md) · [CI and tests](docs/ci-and-tests.md) · [Limitations](docs/limitations.md) · [Competitive analysis](docs/competitive-analysis.md) | +| Getting started | [Quickstart](docs/quickstart.md), [Client configuration](docs/mcp-client-config.md), [Troubleshooting](docs/troubleshooting.md) | +| Runtime contract | [Tools and schemas](docs/tools-and-schemas.md), [Runtime contract](docs/runtime-contract-v0.2.md), [Permission modes](docs/permission-modes.md) | +| Remote and OAuth | [Remote MCP](docs/remote-mcp.md), [Upgrade and rollback](docs/migration-v0.1-to-v0.2.2.md) | +| Administration | [Admin API](docs/admin-api.md), [Admin WebUI](docs/admin-webui.md), [Chat persistence](docs/chat-persistence.md) | +| Security and sandboxing | [Security policy](SECURITY.md), [Security boundary](docs/security-boundary.md), [Docker](docs/docker.md) | +| Packaging and clients | [Desktop README](apps/desktop-client/README.md), [npm launcher](npm/coding-tools-mcp/README.md) | +| Quality | [CI and tests](docs/ci-and-tests.md), [Dogfood](docs/dogfood.md), [SWE-bench](docs/swe-bench.md), [Limitations](docs/limitations.md) | ## Development ```bash python -m pip install -e ".[dev]" -make ci # lint, typecheck, tests, protocol/integration suites, gates +make ci ``` The full gate matrix is in [docs/ci-and-tests.md](docs/ci-and-tests.md). ## License -This project is licensed under the [Apache License 2.0](LICENSE). - -If you use code, documentation, substantial implementation details, or -derivative work from this project, preserve the copyright notice, license -notice, and [NOTICE](NOTICE) file, and clearly attribute the original project. - -Project: Coding Tools MCP -Author: Coding Tools MCP Contributors -Source: https://github.com/xyTom/coding-tools-mcp - -Citation metadata is available in [CITATION.cff](CITATION.cff). +Licensed under the [Apache License 2.0](LICENSE). Preserve the copyright, +license, [NOTICE](NOTICE), and attribution requirements when redistributing +substantial code or documentation. diff --git a/README.zh-CN.md b/README.zh-CN.md index 70c0dfc..91b386b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,49 +2,49 @@ [English](README.md) | **简体中文** -> 让任何 AI 聊天应用或 agent,都能安全地上手你的代码仓库。 +> 为任何支持 MCP 的 AI 客户端提供一双有边界、可审计的“代码之手”。 [![PyPI](https://img.shields.io/pypi/v/coding-tools-mcp)](https://pypi.org/project/coding-tools-mcp/) [![npm](https://img.shields.io/npm/v/coding-tools-mcp)](https://www.npmjs.com/package/coding-tools-mcp) [![Python](https://img.shields.io/pypi/pyversions/coding-tools-mcp)](https://pypi.org/project/coding-tools-mcp/) -[![compliance](https://github.com/xyTom/coding-tools-mcp/actions/workflows/compliance.yml/badge.svg)](https://github.com/xyTom/coding-tools-mcp/actions/workflows/compliance.yml) -[![release](https://github.com/xyTom/coding-tools-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/xyTom/coding-tools-mcp/actions/workflows/release.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -Coding Tools MCP 是一个**模型中立的编程运行时**,通过 -[Model Context Protocol](https://modelcontextprotocol.io) 对外提供服务: -文件读取与搜索、结构化多文件补丁、命令执行、交互式会话、git 操作—— -一个服务器,任何 MCP 客户端都能驱动。Claude Desktop、Claude Code、Cursor、 -Cline,或你自己写的 agent,拿到的都是同一套久经考验的 20 个工具: -限定在单一工作区内,由权限模式层层把关。 - -[![观看演示](https://img.youtube.com/vi/N9lQaXt1eqQ/maxresdefault.jpg)](https://youtu.be/N9lQaXt1eqQ?si=LyEwvzzQF6QjUxR0) - -## 为什么用它 - -- **让聊天应用变成编程 agent。** Claude Desktop——或任何 MCP 聊天客户端—— - 用你已有的订阅直接获得真实的仓库访问能力,无需额外产品。 -- **安全是产品本身,不是附加项。** 每个服务器进程绑定一个工作区根目录; - 绝对路径、`..` 穿越、符号链接逃逸一律拒绝;权限模式对网络访问、shell - 展开、内联脚本和破坏性命令逐项把关;Linux 上还有 - [Landlock](docs/security-boundary.md) 提供内核级文件系统隔离。 -- **模型与厂商中立。** 固定且如实标注的工具目录——没有 profile 切换, - 没有注解把戏。随意更换模型或客户端,运行时行为保持不变。 -- **为上下文窗口精打细算。** 工具结果按设计做摘要、分页与封顶;在确定性 - dogfood 工作负载上,序列化结果字节数相比上一版本下降 37%,任务完成率不变。 +Coding Tools MCP 是一个模型无关的编程运行时,通过 Model Context Protocol +提供受限文件读取与搜索、原子多文件补丁、命令执行、交互式进程、Git 检查、 +可选上游 MCP 组合、持久化 OAuth、Workspace 绑定的 HTTP Session,以及需要 +专用认证的 Admin WebUI。 + +默认本地目录包含 20 个真实标注的工具。权限模式只改变命令策略,不改变 +`tools/list`。可选上游工具在 Runtime 初始化时形成不可变快照,并通过稳定 +namespace 暴露;远端能力由上游服务器自己的安全边界控制,不应误认为受本地 +Workspace 文件边界保护。 + +## 主要特点 + +- **跨客户端统一契约**:Claude Desktop、Claude Code、Codex、Cursor、Cline + 与自建 Agent 使用同一套 MCP schema 和结果 envelope。 +- **不可变 Workspace 绑定**:每个 HTTP MCP Session 在 `initialize` 时绑定一个 + 已校验 Workspace,Session 存续期间不能切换;cwd、进程、输出和项目指令互不共享。 +- **固定工具目录**:旧版 `tool_profile` 仅作为迁移输入被删除,不能隐藏工具、 + 改写目录或控制 annotation。 +- **持久化 OAuth**:Client、Grant、access-token 元数据、refresh-token family + 与 signing-key 状态跨重启保留,并可按稳定 ID 精确撤销。 +- **不泄露 Secret 的管理面**:`/admin` 使用专用 Admin token,支持 Settings、 + Workspace、Gateway、OAuth、Secret Vault 和聊天/session 管理。 +- **适合上下文窗口**:文本结果摘要化、分页化,完整机器结果保存在 + `structuredContent`。 ## 快速开始 -用你手头已有的工具链启动(服务器本体是 PyPI 上的 Python ≥ 3.11 包; -npm 包是一个轻量启动器,会通过 `uv` 或 `pipx` 拉起服务器): +Python 包要求 Python 3.11 或更高版本。npm 包只是一个启动器,通过 `uvx` 或 +`pipx` 启动固定版本的 Python 服务。 ```bash -uvx coding-tools-mcp --stdio --workspace /path/to/repo # Python 工具链 -npx coding-tools-mcp --stdio --workspace /path/to/repo # Node 工具链 +uvx coding-tools-mcp --stdio --workspace /path/to/repo +npx coding-tools-mcp --stdio --workspace /path/to/repo ``` -接入 Claude Desktop、Claude Code、Cursor 或 Cline——各家的 JSON 配置完全相同 -(偏好 Node 的话把 `uvx` 换成 `npx` 即可): +客户端配置示例: ```json { @@ -57,146 +57,126 @@ npx coding-tools-mcp --stdio --workspace /path/to/repo # Node 工具链 } ``` -然后对你的客户端说一句:*"跑一下测试,把第一个失败修了。"* +去掉 `--stdio` 即可使用 Streamable HTTP,默认地址为 +`http://127.0.0.1:8765/mcp`。主协议版本为 MCP `2025-11-25`,并明确兼容 +`2025-06-18`。 -想用 HTTP?去掉 `--stdio`,服务器就在 -`http://127.0.0.1:8765/mcp` 上讲 Streamable HTTP(MCP `2025-11-25`, -兼容 `2025-06-18`)。一行安装脚本、各客户端的完整接入指南和排障见 -[docs/quickstart.md](docs/quickstart.md) 与 -[docs/mcp-client-config.md](docs/mcp-client-config.md)。 +## 集成后的 v0.2.2 架构 -## 七个值得一试的玩法 +### Session 与 Workspace -**1. 让 Claude Desktop 成为你的编程 agent。** -上面那份配置就是全部——你已经在付费的聊天窗口,现在能读代码、打补丁、跑测试、看 diff。 +每次成功的 HTTP `initialize` 都会创建独立 Runtime,拥有独立 Workspace、cwd、 +进程表、保留输出、项目指令和上游工具快照。后续 POST/DELETE 请求必须匹配初始化 +时的认证上下文。stdio 只使用明确的默认 Workspace,不伪造 OAuth Agent。 -**2. 随时随地连回自己的电脑写代码。** +### 持久化 OAuth -```bash -CODING_TOOLS_MCP_AUTH_MODE=bearer ./scripts/tunnel.sh cloudflared /path/to/repo -``` +OAuth 支持 Authorization Code + PKCE S256、RFC 7591 动态注册、 +`authorization_code` 与 `refresh_token` grant、refresh rotation、旧 refresh +重放后的 family 撤销、access-token `jti` 撤销,以及 active/retired/revoked +signing key 生命周期。 -回环地址绑定 + 带认证的 HTTPS 隧道(`cloudflared`、`ngrok` 或 Microsoft Dev -Tunnel)。手机上打开 claude.ai,指向 `https:///mcp`,就能驱动家里的 -工作站。内置 Bearer token 与 OAuth 2.1 + PKCE(含 RFC 7591 动态注册)。 -→ [docs/remote-mcp.md](docs/remote-mcp.md) +Client secret 只保存 digest;refresh token 只保存带 pepper 的 hash;signing +material 只保存在加密 Secret Vault。OAuth Store 或 Vault 不可用时 fail closed, +不会退化到内存实现。详见 [远程 MCP](docs/remote-mcp.md) 和 +[升级/回滚指南](docs/migration-v0.1-to-v0.2.2.md)。 -**3. 在一次性 Docker 沙箱里放心跑可疑代码。** +### Gateway -```bash -docker build -t coding-tools-mcp-sandbox:local . -docker run --rm --init -it -p 8765:8765 -v "$PWD:/workspace" coding-tools-mcp-sandbox:local -``` +Gateway 配置、启用状态和 allowlist 在 Runtime 初始化前固定。每个 Runtime 获得 +不可变的 namespaced 工具快照,因此 `listChanged: false` 是真实语义。本地工具名 +永久保留,namespace 冲突时 fail closed。Admin 保存 Gateway 后只标记 +`restart_required`,不支持热 reload/start/stop。 -容器化的服务器,工具链和缓存都预配好——放心把 agent 指向一个来路不明的 -PR,用完即毁。→ [docs/docker.md](docs/docker.md) +### Admin WebUI -**4. 一个 MCP 调用,起一台云沙箱。**内置的 -[Cloudflare Worker 控制面](cloudflare/sandbox-control/README.md) 把 -`start_coding_tools_sandbox` 暴露为 MCP 工具:一次调用即派发 GitHub Actions -运行器,启动 Docker 沙箱并发布到带认证的 Cloudflare Tunnel 之后。 -临时算力,无需自备服务器。 +配置专用 Admin token 后访问 `/admin`。普通 MCP bearer 和 OAuth access token +不会自动获得管理员权限。Settings 写入使用 revision;遇到 stale HTTP 409 时, +页面保留草稿、刷新 persisted revision 并提示冲突,不会静默覆盖。 -**5. 用图形界面操作。** +OAuth、Gateway 和 Secret Vault 页面不会显示 secret、digest、hash、token material +或内部 reference。Conversation 列表只显示摘要,正文通过明确的分页详情请求按需加载。 -```bash -python -m pip install "coding-tools-mcp[desktop]" -coding-tools-mcp-desktop -``` +## 本地工具目录 -按工作区管理配置、一键启停服务器与隧道、凭证设置带剪贴板助手、实时健康 -检查。支持英文与简体中文。 +| 类别 | 工具 | +| --- | --- | +| 文件与搜索 | `read_file`、`list_dir`、`list_files`、`search_text`、`apply_patch`、`view_image` | +| 执行 | `exec_command`、`write_stdin`、`read_output`、`kill_session`、`request_permissions` | +| Git | `git_status`、`git_diff`、`git_log`、`git_show`、`git_blame` | +| Runtime | `server_info`、`check_exec_environment`、`get_default_cwd`、`set_default_cwd` | -**6. 保持一个活着的交互式会话。**`exec_command` 在真实 PTY 下启动 REPL 或 -调试器;`write_stdin` 跨轮次喂输入;`read_output` 分页读取长输出; -`kill_session` 干净收尾。长时进程是一等公民,配有会话看门狗与有界缓冲。 +`apply_patch` 是唯一的本地直接文件修改原语。根目录的 `AGENTS.md`/`CLAUDE.md` +会在初始化时加载。`content` 是简洁文本,`structuredContent` 是稳定完整的机器结果。 -**7. 给自研 agent 装上生产级的"手"。**用 Anthropic SDK 或任何框架搭 agent -循环?别再手写文件和执行工具——对着这个服务器讲 MCP,整个安全边界直接 -继承。→ [docs/embedding.md](docs/embedding.md) +## 安全边界 -## 工具目录 +| 模式 | 场景 | 实际行为 | +| --- | --- | --- | +| `safe` | 日常 Agent 工作 | 网络命令、shell 展开、内联脚本和破坏性命令需要授权 | +| `trusted` | 正常本地开发 | 开放网络、展开和内联脚本,仍保留 secret 与破坏性命令检查 | +| `dangerous` | 隔离容器/虚拟机 | 关闭命令权限门;本地直接路径工具仍受 Workspace 限制 | -一套稳定且如实标注的目录——权限模式改变的是命令*策略*,而不是模型看到哪些 -工具。`apply_patch` 是唯一的文件修改原语:分阶段、基线校验、跨文件原子提交、 -支持回滚。 +权限模式不会隐藏 mutation tools。“高级危险兼容设置” +`--dangerously-fake-readonly-annotations` 只改写 `tools/list` 的暴露提示, +不会阻止真实执行或修改,也不是安全边界。 -| 分组 | 工具 | -| --- | --- | -| 文件与搜索 | `read_file` · `list_dir` · `list_files` · `search_text` · `apply_patch` · `view_image` | -| 执行 | `exec_command` · `write_stdin` · `read_output` · `kill_session` · `request_permissions` | -| Git | `git_status` · `git_diff` · `git_log` · `git_show` · `git_blame` | -| 运行时 | `server_info` · `check_exec_environment` · `get_default_cwd` · `set_default_cwd` | +在不受 Landlock 保护的平台上,本服务不是完整 OS sandbox。处理不可信仓库时请使用 +Docker、虚拟机或其他外部沙箱。上游 MCP 工具是远端能力,应按远端服务器自身权限评估。 -仓库根部的 `AGENTS.md`/`CLAUDE.md` 会自动载入 initialize 上下文。工具的 -`content` 是给 agent 看的精炼文本,`structuredContent` 则是完整稳定的机器 -结果。Schema 与结果封装: -[docs/tools-and-schemas.md](docs/tools-and-schemas.md) · -[docs/runtime-contract-v0.2.md](docs/runtime-contract-v0.2.md) +## 远程访问与管理 -## 安全边界 +HTTP 建议保持 loopback 绑定,再通过带认证的 HTTPS tunnel 发布。固定本地目录包含 +修改和命令执行能力,绝不能把 `noauth` 公开到网络。详见 +[Remote MCP](docs/remote-mcp.md)。 -| 模式 | 适用场景 | 放行范围 | -| --- | --- | --- | -| `safe`(默认) | 日常 agent 工作 | 文件工具与常规命令;疑似联网命令、shell 展开、内联脚本、破坏性命令均需显式授权 | -| `trusted` | 本地开发 | 放开网络、shell 展开与内联脚本;保留敏感值过滤与破坏性命令检查 | -| `dangerous` | 仅限隔离容器/虚拟机 | 关闭 `exec_command` 权限门;工作区路径边界依然生效 | - -递归列举与搜索默认排除 `.git`、`node_modules`、构建产物、虚拟环境和常见 -缓存。命令在工作区限定的 cwd 下运行,环境经过清洗,带超时与输出上限。 -支持 Landlock 的 Linux 主机获得内核级文件系统隔离;其他平台会收到明确 -警告——这仍不是完整的操作系统级沙箱,真正不可信的工作请用 Docker 镜像或 -虚拟机。详见: -[SECURITY.md](SECURITY.md) · [docs/security-boundary.md](docs/security-boundary.md) · -[docs/permission-modes.md](docs/permission-modes.md) +管理文档: -## 遥测 +- [Admin API](docs/admin-api.md) +- [Admin WebUI](docs/admin-webui.md) +- [聊天与 Codex Session 持久化](docs/chat-persistence.md) + +## Desktop 与 Admin WebUI 的职责区别 -服务器会发送匿名使用遥测(每工具的成功率/延迟计数与版本/平台维度—— -绝不包含路径、参数、命令或文件内容),用于确定修复优先级。设置 -`CODING_TOOLS_MCP_TELEMETRY=off` 或 `DO_NOT_TRACK=1` 可关闭;CI 环境自动 -关闭。`CODING_TOOLS_MCP_TELEMETRY=debug` 会把每个事件打印到 stderr 而不 -发送。完整事件清单与承诺见 [docs/telemetry.md](docs/telemetry.md)。 +Desktop 是本地启动、tunnel 和 profile 管理器: -## 证据、Dogfood 与 SWE-bench +```bash +python -m pip install "coding-tools-mcp[desktop]" +coding-tools-mcp-desktop +``` -每个版本都经由 tag 触发的流水线发布:合规套件、真实工作负载基准和 -SWE-bench 评测与 registry 发布运行在同一个 commit 上——PyPI 与 npm 均走 -trusted publishing,npm 带 provenance。Dogfood 效率指标可复现 -(`make dogfood-smoke`),报告存于 `reports/`。本仓库不宣称任何模型生成的 -SWE-bench 榜单成绩——[docs/swe-bench.md](docs/swe-bench.md) 写明了测了什么、 -没测什么。更多:[COMPLIANCE.md](COMPLIANCE.md) · -[BENCHMARK.md](BENCHMARK.md) · [docs/dogfood.md](docs/dogfood.md) +Desktop 的 profile/secret 存储与服务器 Settings、Secret Vault 相互独立。Admin +WebUI 只通过专用 Admin API 管理已经运行的 HTTP 服务器,不是 Desktop 应用,也不 +负责动态启动或停止 Gateway/Runtime。 + +## 遥测 + +上游默认启用匿名使用遥测,只包含封闭 schema 的计数、枚举、耗时和版本/平台维度; +不包含路径、Workspace/Agent/Client ID、命令、参数、文件内容、聊天正文或 transcript +摘要。可用 `CODING_TOOLS_MCP_TELEMETRY=off` 或 `DO_NOT_TRACK=1` 关闭;CI +自动关闭。`CODING_TOOLS_MCP_TELEMETRY=debug` 只把事件写到 stderr。详见 +[docs/telemetry.md](docs/telemetry.md)。 ## 文档 -| | | +| 主题 | 文档 | | --- | --- | -| 上手 | [快速开始](docs/quickstart.md) · [客户端配置](docs/mcp-client-config.md) · [排障](docs/troubleshooting.md) | -| 远程与沙箱 | [Remote MCP](docs/remote-mcp.md) · [Docker 沙箱](docs/docker.md) · [云沙箱 Worker](cloudflare/sandbox-control/README.md) | -| 工具与契约 | [工具与 Schema](docs/tools-and-schemas.md) · [运行时契约](docs/runtime-contract-v0.2.md) · [权限模式](docs/permission-modes.md) | -| 命令执行 | [Exec 配方](docs/exec-command-recipes.md) · [Exec 排障](docs/troubleshooting-exec.md) | -| 集成 | [嵌入指南](docs/embedding.md) · [npm 启动器](npm/coding-tools-mcp/README.md) | -| 安全与质量 | [安全策略](SECURITY.md) · [安全边界](docs/security-boundary.md) · [CI 与测试](docs/ci-and-tests.md) · [已知限制](docs/limitations.md) · [竞品分析](docs/competitive-analysis.md) | +| 入门 | [快速开始](docs/quickstart.md)、[客户端配置](docs/mcp-client-config.md)、[故障排查](docs/troubleshooting.md) | +| Runtime | [工具与 schema](docs/tools-and-schemas.md)、[Runtime 契约](docs/runtime-contract-v0.2.md)、[权限模式](docs/permission-modes.md) | +| 远程与 OAuth | [Remote MCP](docs/remote-mcp.md)、[升级与回滚](docs/migration-v0.1-to-v0.2.2.md) | +| 管理 | [Admin API](docs/admin-api.md)、[Admin WebUI](docs/admin-webui.md)、[聊天持久化](docs/chat-persistence.md) | +| 安全与沙箱 | [安全策略](SECURITY.md)、[安全边界](docs/security-boundary.md)、[Docker](docs/docker.md) | +| 客户端与打包 | [Desktop README](apps/desktop-client/README.md)、[npm 启动器](npm/coding-tools-mcp/README.md) | +| 质量 | [CI 与测试](docs/ci-and-tests.md)、[Dogfood](docs/dogfood.md)、[SWE-bench](docs/swe-bench.md) | ## 开发 ```bash python -m pip install -e ".[dev]" -make ci # lint、类型检查、测试、协议/集成套件与各项门禁 +make ci ``` -完整门禁矩阵见 [docs/ci-and-tests.md](docs/ci-and-tests.md)。 - ## 许可证 -本项目基于 [Apache License 2.0](LICENSE) 发布。 - -如果你使用了本项目的代码、文档、实质性实现细节或衍生成果,请保留版权 -声明、许可证声明与 [NOTICE](NOTICE) 文件,并清晰注明出处。 - -Project: Coding Tools MCP -Author: Coding Tools MCP Contributors -Source: https://github.com/xyTom/coding-tools-mcp - -引用元数据见 [CITATION.cff](CITATION.cff)。 +本项目使用 [Apache License 2.0](LICENSE)。重新分发大量代码或文档时,请保留版权、 +许可证、[NOTICE](NOTICE) 和来源说明。 diff --git a/coding_tools_mcp/admin.py b/coding_tools_mcp/admin.py new file mode 100644 index 0000000..f35564d --- /dev/null +++ b/coding_tools_mcp/admin.py @@ -0,0 +1,891 @@ +"""Authenticated, restart-aware management services for server configuration.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +import threading +from pathlib import Path +from typing import Any, Callable + +from .codex_sessions import CodexSessionError, CodexSessionScanner, ScanPolicy +from .oauth_store import OAuthAuthorizationStore +from .secret_vault import SecretVault, SecretVaultError +from .settings_definition import ( + SECRET_REFERENCE_FIELDS, + SettingsValidationError, + normalize_startup_settings_with_warnings, + pending_restart_fields, + schema_payload, +) +from .settings_store import ServerSettingsStore, SettingsStoreError, sanitize_settings +from .telemetry import telemetry_mode +from .transcript import TranscriptStore, TranscriptStoreError, WorkspaceScope +from .upstream import UpstreamConfigError, parse_server_config +from .workspace_catalog import WorkspaceCatalog, WorkspaceCatalogError + +ADMIN_API_PREFIX = "/admin/api" +SERVER_SECRET_VAULT_FILENAME = "server-secrets.json" +SENSITIVE_KEY_RE = re.compile( + r"(?:^|[_-])(token|secret|credential|api[_-]?key|password|passwd|authorization)(?:$|[_-])", + re.I, +) + + +class AdminServiceError(ValueError): + status = 400 + code = "admin_error" + + +class AdminConflictError(AdminServiceError): + status = 409 + code = "stale_revision" + + +class AdminUnavailableError(AdminServiceError): + status = 503 + code = "admin_unavailable" + + +class AdminNotFoundError(AdminServiceError): + status = 404 + code = "not_found" + + +def document_revision(value: Any) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _json_copy(value: Any) -> Any: + try: + return json.loads(json.dumps(value, ensure_ascii=False)) + except TypeError as exc: + raise AdminServiceError(f"Value must be JSON serializable: {exc}") from exc + + +def _redact(value: Any, *, key: str = "") -> Any: + if key.endswith("_secret_ref"): + return {"configured": bool(value)} + if isinstance(value, dict): + if "secret_ref" in value: + return {"source": "secret_ref", "configured": bool(value.get("secret_ref"))} + if "env_ref" in value: + return {"source": "env_ref", "configured": bool(value.get("env_ref"))} + return {str(child): _redact(item, key=str(child)) for child, item in value.items()} + if isinstance(value, list): + return [_redact(item, key=key) for item in value] + if SENSITIVE_KEY_RE.search(key): + return "" if value not in (None, "") else value + return value + + +def _redact_oauth_item(item: dict[str, Any]) -> dict[str, Any]: + result = _json_copy(item) + for key in ( + "client_secret_digest", + "secret_ref", + "token_hash", + "refresh_token", + "access_token", + "signing_secret", + ): + result.pop(key, None) + return _redact(result) + + +def _atomic_write_json(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + json.dump(document, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + tmp_path.chmod(0o600) + os.replace(tmp_path, path) + except OSError as exc: + raise AdminUnavailableError(f"Could not atomically save configuration: {exc}") from exc + finally: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + + +def _read_gateway_document(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"servers": {}} + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise AdminUnavailableError(f"Could not read Gateway configuration: {exc}") from exc + except json.JSONDecodeError as exc: + raise AdminServiceError(f"Gateway configuration is not valid JSON: {exc}") from exc + if not isinstance(raw, dict): + raise AdminServiceError("Gateway configuration must be a JSON object.") + servers = raw.get("servers", raw) + if not isinstance(servers, dict): + raise AdminServiceError("Gateway configuration must contain a servers object.") + return {"servers": _json_copy(servers)} + + + + +def gateway_file_revision(path: str | Path) -> str: + return document_revision(_read_gateway_document(Path(path).expanduser())) + +def _secret_refs(value: Any) -> list[str]: + refs: list[str] = [] + if isinstance(value, dict): + ref = value.get("secret_ref") + if isinstance(ref, str) and ref: + refs.append(ref) + for child in value.values(): + refs.extend(_secret_refs(child)) + elif isinstance(value, list): + for child in value: + refs.extend(_secret_refs(child)) + return refs + + +def _validate_gateway_document(document: dict[str, Any], vault: SecretVault) -> dict[str, Any]: + servers = document.get("servers") + if not isinstance(servers, dict): + raise AdminServiceError("Gateway configuration must contain a servers object.") + normalized: dict[str, Any] = {} + for alias, value in servers.items(): + if not isinstance(alias, str) or not isinstance(value, dict): + raise AdminServiceError("Gateway server entries must use string aliases and object values.") + try: + parse_server_config(alias, value) + except UpstreamConfigError as exc: + raise AdminServiceError(str(exc)) from exc + refs = _secret_refs(value) + if refs and not vault.enabled(): + raise AdminUnavailableError( + "Gateway secret_ref requires an enabled server Secret Vault." + ) + for ref in refs: + try: + vault.get_secret(ref) + except SecretVaultError as exc: + raise AdminUnavailableError( + f"Gateway secret_ref {ref!r} cannot be resolved." + ) from exc + env = value.get("env") + if isinstance(env, dict): + for env_name, env_value in env.items(): + if ( + isinstance(env_name, str) + and SENSITIVE_KEY_RE.search(env_name) + and isinstance(env_value, str) + ): + raise AdminServiceError( + f"Sensitive Gateway environment value {env_name!r} must use env_ref or secret_ref." + ) + headers = value.get("headers") + if isinstance(headers, dict): + for header_name, header_value in headers.items(): + if ( + isinstance(header_name, str) + and ( + header_name.lower() in {"authorization", "proxy-authorization"} + or SENSITIVE_KEY_RE.search(header_name) + ) + and isinstance(header_value, str) + and header_value + ): + raise AdminServiceError( + "Sensitive Gateway headers cannot be persisted as plaintext." + ) + normalized[alias] = _json_copy(value) + return {"servers": normalized} + + +class AdminService: + """Pure service layer used by the HTTP handler; it contains no handler state.""" + + def __init__( + self, + *, + settings_store: ServerSettingsStore, + active_settings: dict[str, Any], + fallback_workspace: str | Path, + gateway_path: str | Path, + active_gateway_revision: str, + secret_vault: SecretVault, + oauth_store: OAuthAuthorizationStore | None = None, + active_gateway_status: Callable[[], dict[str, Any]] | None = None, + transcript_store: TranscriptStore | None = None, + session_scanner: CodexSessionScanner | None = None, + ) -> None: + self.settings_store = settings_store + self.active_settings = _json_copy(active_settings) + self.fallback_workspace = Path(fallback_workspace).expanduser().resolve(strict=True) + self.gateway_path = Path(gateway_path).expanduser() + self.active_gateway_revision = active_gateway_revision + self.secret_vault = secret_vault + self.oauth_store = oauth_store + self.active_gateway_status = active_gateway_status + self.transcript_store = transcript_store + self.session_scanner = session_scanner or CodexSessionScanner() + self._settings_lock = threading.Lock() + self._gateway_lock = threading.Lock() + + def status_payload(self) -> dict[str, Any]: + mode = telemetry_mode() + return { + "ok": True, + "admin_api": 1, + "settings": {"available": True}, + "oauth": {"available": self.oauth_store is not None}, + "gateway": {"available": True, "dynamic_reload": False}, + "chat": {"available": self.transcript_store is not None}, + "vault": {"enabled": self.secret_vault.enabled()}, + "telemetry": { + "mode": mode, + "docs": "docs/telemetry.md", + }, + } + + def settings_payload(self) -> dict[str, Any]: + result = self.settings_store.read_result() + persisted = result.settings + pending = set(pending_restart_fields(self.active_settings, persisted)) + pending.update( + field + for field in SECRET_REFERENCE_FIELDS + if self.active_settings.get(field) != persisted.get(field) + ) + schema = schema_payload() + schema["restart_fields"] = sorted( + set(schema.get("restart_fields", ())) | set(SECRET_REFERENCE_FIELDS) + ) + return { + "ok": True, + "active": sanitize_settings(self.active_settings), + "persisted": sanitize_settings(persisted), + "persisted_revision": document_revision(persisted), + "pending_restart": sorted(pending), + "restart_required": bool(pending), + "migration_warnings": list(result.warnings), + "schema": schema, + } + + def validate_settings(self, body: dict[str, Any]) -> dict[str, Any]: + current = self.settings_store.read() + updates = body.get("updates", body) + if not isinstance(updates, dict): + raise AdminServiceError("settings updates must be an object.") + try: + normalized, warnings = normalize_startup_settings_with_warnings( + current, updates, self.fallback_workspace + ) + except SettingsValidationError as exc: + raise AdminServiceError(str(exc)) from exc + pending = set(pending_restart_fields(self.active_settings, normalized)) + pending.update( + field + for field in SECRET_REFERENCE_FIELDS + if self.active_settings.get(field) != normalized.get(field) + ) + return { + "ok": True, + "valid": True, + "normalized": sanitize_settings(normalized), + "pending_restart": sorted(pending), + "restart_required": bool(pending), + "warnings": list(warnings), + } + + def save_settings(self, body: dict[str, Any]) -> dict[str, Any]: + expected = body.get("expected_revision") + updates = body.get("updates") + if not isinstance(expected, str) or not expected: + raise AdminServiceError("expected_revision is required.") + if not isinstance(updates, dict): + raise AdminServiceError("updates must be an object.") + with self._settings_lock: + current = self.settings_store.read() + current_revision = document_revision(current) + if not _constant_equal(expected, current_revision): + raise AdminConflictError( + "Settings changed after this page was loaded; reload before saving." + ) + try: + normalized, warnings = normalize_startup_settings_with_warnings( + current, updates, self.fallback_workspace + ) + write_warnings = self.settings_store.write(normalized) + except (SettingsStoreError, SettingsValidationError) as exc: + raise AdminServiceError(str(exc)) from exc + payload = self.settings_payload() + payload["warnings"] = list(dict.fromkeys((*warnings, *write_warnings))) + return payload + + def gateway_payload(self) -> dict[str, Any]: + document = _read_gateway_document(self.gateway_path) + revision = document_revision(document) + status = self.active_gateway_status() if self.active_gateway_status else None + return { + "ok": True, + "persisted": _redact(document), + "persisted_revision": revision, + "active_revision": self.active_gateway_revision, + "pending_restart": revision != self.active_gateway_revision, + "restart_required": revision != self.active_gateway_revision, + "active_status": _redact(status) if isinstance(status, dict) else None, + "dynamic_reload": False, + } + + def save_gateway(self, body: dict[str, Any]) -> dict[str, Any]: + expected = body.get("expected_revision") + document = body.get("document") + if not isinstance(expected, str) or not expected: + raise AdminServiceError("expected_revision is required.") + if not isinstance(document, dict): + raise AdminServiceError("document must be an object.") + with self._gateway_lock: + current = _read_gateway_document(self.gateway_path) + if not _constant_equal(expected, document_revision(current)): + raise AdminConflictError( + "Gateway configuration changed after this page was loaded; reload before saving." + ) + normalized = _validate_gateway_document(document, self.secret_vault) + _atomic_write_json(self.gateway_path, normalized) + return self.gateway_payload() + + def secrets_payload(self) -> dict[str, Any]: + if not self.secret_vault.enabled(): + raise AdminUnavailableError("Server Secret Vault is not enabled.") + try: + names = self.secret_vault.list_names() + except SecretVaultError as exc: + raise AdminUnavailableError(str(exc)) from exc + return { + "ok": True, + "vault_enabled": self.secret_vault.enabled(), + "secrets": [{"name": name, "configured": True} for name in names], + } + + def set_secret(self, name: str, body: dict[str, Any]) -> dict[str, Any]: + value = body.get("value") + if not isinstance(value, str) or not value: + raise AdminServiceError("Secret value must be a non-empty string.") + try: + existed = name in self.secret_vault.list_names() + self.secret_vault.set_secret(name, value) + except SecretVaultError as exc: + raise AdminUnavailableError(str(exc)) from exc + return { + "ok": True, + "name": name, + "configured": True, + "created": not existed, + "affected_count": 1, + } + + def delete_secret(self, name: str) -> dict[str, Any]: + try: + deleted = self.secret_vault.delete_secret(name) + except SecretVaultError as exc: + raise AdminUnavailableError(str(exc)) from exc + return {"ok": True, "name": name, "affected_count": 1 if deleted else 0} + + def oauth_payload(self, collection: str, query: dict[str, str]) -> dict[str, Any]: + store = self._require_oauth_store() + client_id = query.get("client_id") or None + if collection == "clients": + items = store.list_clients() + elif collection == "grants": + items = store.list_grants(client_id) + elif collection == "tokens": + items = store.list_access_tokens(client_id) + elif collection == "refresh-families": + items = store.list_refresh_token_families(client_id) + elif collection == "signing-keys": + items = store.list_signing_keys() + elif collection == "audit": + try: + limit = int(query.get("limit", "100")) + except ValueError as exc: + raise AdminServiceError("audit limit must be an integer.") from exc + items = store.list_audit_events(limit=limit) + else: + raise AdminNotFoundError("Unknown OAuth collection.") + redacted = [_redact_oauth_item(item) for item in items] + return {"ok": True, "items": redacted, "count": len(redacted)} + + def oauth_action(self, resource: str, identifier: str, action: str) -> dict[str, Any]: + store = self._require_oauth_store() + before_events = { + str(item.get("event_id")) + for item in store.list_audit_events(limit=500) + if item.get("event_id") is not None + } + changed = False + exists = False + if resource == "clients" and action in {"enable", "disable"}: + item = store.get_client(identifier) + exists = item is not None + if item is not None: + desired = action == "enable" + changed = ( + bool(item.get("enabled")) is not desired + and store.set_client_enabled(identifier, desired) + ) + elif resource == "grants" and action == "revoke": + item = store.get_grant(identifier) + exists = item is not None + if item is not None: + changed = ( + item.get("revoked_at") is None + and store.revoke_grant(identifier) + ) + elif resource == "tokens" and action == "revoke": + item = next((row for row in store.list_access_tokens() if row.get("jti") == identifier), None) + exists = item is not None + if item is not None: + changed = ( + item.get("revoked_at") is None + and store.revoke_access_token(identifier) + ) + elif resource == "refresh-families" and action == "revoke": + item = next( + (row for row in store.list_refresh_token_families() if row.get("family_id") == identifier), + None, + ) + exists = item is not None + if item is not None: + changed = ( + item.get("revoked_at") is None + and store.revoke_refresh_family(identifier) + ) + elif resource == "signing-keys" and action in {"activate", "retire", "revoke"}: + item = next((row for row in store.list_signing_keys() if row.get("kid") == identifier), None) + exists = item is not None + if item is not None: + desired_status = {"activate": "active", "retire": "retired", "revoke": "revoked"}[action] + if action == "activate": + applied = store.activate_signing_key(identifier) + elif action == "retire": + applied = store.retire_signing_key(identifier) + else: + applied = store.revoke_signing_key(identifier) + changed = item.get("status") != desired_status and applied + else: + raise AdminNotFoundError("Unknown OAuth management action.") + audit_event_id = None + if changed: + for event in store.list_audit_events(limit=500): + candidate = event.get("event_id") + if candidate is not None and str(candidate) not in before_events: + audit_event_id = str(candidate) + break + return { + "ok": True, + "resource": resource, + "id": identifier, + "action": action, + "found": exists, + "affected_count": 1 if changed else 0, + "audit_event_id": audit_event_id, + } + + def workspaces_payload(self) -> dict[str, Any]: + current = self.settings_store.read() + try: + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + except WorkspaceCatalogError as exc: + raise AdminServiceError(str(exc)) from exc + return { + "ok": True, + **catalog.settings_payload(), + "persisted_revision": document_revision(current), + } + + def workspace_add(self, body: dict[str, Any]) -> dict[str, Any]: + expected = _required_revision(body) + entry = body.get("workspace") + if not isinstance(entry, dict): + raise AdminServiceError("workspace must be an object.") + with self._settings_lock: + current = self._checked_settings(expected) + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + entries = [item.payload() for item in catalog.entries] + new_entry = { + "id": entry.get("id"), + "name": entry.get("name"), + "root": entry.get("root"), + "enabled": entry.get("enabled", True), + "default": entry.get("default", False), + } + entries.append(new_entry) + default_id = str(new_entry["id"]) if new_entry["default"] else catalog.default_id + for item in entries: + item["default"] = item.get("id") == default_id + self._write_workspace_settings(current, entries, default_id) + return self.workspaces_payload() + + def workspace_disable(self, identifier: str, body: dict[str, Any]) -> dict[str, Any]: + expected = _required_revision(body) + with self._settings_lock: + current = self._checked_settings(expected) + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + if identifier == catalog.default_id: + raise AdminServiceError("The default Workspace cannot be disabled.") + entries = [item.payload() for item in catalog.entries] + target = next((item for item in entries if item["id"] == identifier), None) + if target is None: + raise AdminNotFoundError("Workspace is not present in the catalog.") + target["enabled"] = False + self._write_workspace_settings(current, entries, catalog.default_id) + return self.workspaces_payload() + + def workspace_default(self, identifier: str, body: dict[str, Any]) -> dict[str, Any]: + expected = _required_revision(body) + with self._settings_lock: + current = self._checked_settings(expected) + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + entries = [item.payload() for item in catalog.entries] + target = next((item for item in entries if item["id"] == identifier), None) + if target is None: + raise AdminNotFoundError("Workspace is not present in the catalog.") + if not target["enabled"]: + raise AdminServiceError("A disabled Workspace cannot become the default.") + for item in entries: + item["default"] = item["id"] == identifier + self._write_workspace_settings(current, entries, identifier) + return self.workspaces_payload() + + def workspace_check(self, identifier: str) -> dict[str, Any]: + current = self.settings_store.read() + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + entry = next((item for item in catalog.entries if item.id == identifier), None) + if entry is None: + raise AdminNotFoundError("Workspace is not present in the catalog.") + return { + "ok": True, + "workspace": entry.payload(), + "check": { + "exists": entry.root.exists(), + "is_directory": entry.root.is_dir(), + "enabled": entry.enabled, + "is_default": entry.default, + }, + } + + def chat_conversations(self, query: dict[str, str]) -> dict[str, Any]: + store = self._require_transcript_store() + workspace_id = query.get("workspace_id") or None + if workspace_id is not None: + self._workspace_scope(workspace_id) + page = _query_int(query, "page", 1) + page_size = _query_int(query, "page_size", 50) + payload = store.list_conversations( + workspace_id, + page=page, + page_size=page_size, + query=query.get("query") or None, + ) + return {"ok": True, **payload} + + def chat_conversation_detail(self, workspace_id: str, conversation_id: str, query: dict[str, str]) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + payload = store.conversation_detail( + workspace_id, + conversation_id, + message_page=_query_int(query, "message_page", 1), + message_page_size=_query_int(query, "message_page_size", 100), + context_page=_query_int(query, "context_page", 1), + context_page_size=_query_int(query, "context_page_size", 100), + ) + if payload is None: + raise AdminNotFoundError("Conversation is not present in the selected Workspace.") + return {"ok": True, **payload} + + def chat_record_messages(self, workspace_id: str, conversation_id: str, body: dict[str, Any]) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + messages = body.get("messages") + if not isinstance(messages, list): + raise AdminServiceError("messages must be a list.") + return { + "ok": True, + **store.record_messages( + workspace_id, + conversation_id, + messages, + title=body.get("title"), + source=body.get("source") or "admin-api", + ), + } + + def chat_record_context(self, workspace_id: str, conversation_id: str, body: dict[str, Any]) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + entries = body.get("entries") + if not isinstance(entries, list): + raise AdminServiceError("entries must be a list.") + return { + "ok": True, + **store.record_context( + workspace_id, + conversation_id, + entries, + title=body.get("title"), + source=body.get("source") or "admin-api", + ), + } + + def chat_delete(self, resource: str, workspace_id: str, identifier: str) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + if resource == "messages": + result = store.delete_message(workspace_id, identifier) + elif resource == "context": + result = store.delete_context(workspace_id, identifier) + elif resource == "conversations": + result = store.delete_conversation(workspace_id, identifier) + elif resource == "sessions": + result = store.delete_imported_session(workspace_id, identifier) + else: + raise AdminNotFoundError("Unknown chat deletion resource.") + return {"ok": True, **result} + + def chat_clear_workspace(self, workspace_id: str) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + return {"ok": True, **store.clear_workspace(workspace_id)} + + def codex_scan(self, body: dict[str, Any]) -> dict[str, Any]: + workspace_id = body.get("workspace_id") + if not isinstance(workspace_id, str): + raise AdminServiceError("workspace_id is required.") + scope = self._workspace_scope(workspace_id) + roots = body.get("roots") + if roots is not None and (not isinstance(roots, list) or not all(isinstance(item, str) for item in roots)): + raise AdminServiceError("roots must be a list of relative paths.") + try: + policy = _scan_policy(body) + return {"ok": True, **self.session_scanner.scan(scope, roots=roots, policy=policy)} + except CodexSessionError as exc: + raise AdminServiceError(str(exc)) from exc + + def codex_import(self, body: dict[str, Any]) -> dict[str, Any]: + store = self._require_transcript_store() + workspace_id = body.get("workspace_id") + candidate_ids = body.get("candidate_ids") + if not isinstance(workspace_id, str): + raise AdminServiceError("workspace_id is required.") + if not isinstance(candidate_ids, list) or not all(isinstance(item, str) for item in candidate_ids): + raise AdminServiceError("candidate_ids must be a list of strings.") + roots = body.get("roots") + if roots is not None and (not isinstance(roots, list) or not all(isinstance(item, str) for item in roots)): + raise AdminServiceError("roots must be a list of relative paths.") + scope = self._workspace_scope(workspace_id) + try: + return { + "ok": True, + **self.session_scanner.import_candidates( + store, + scope, + candidate_ids=candidate_ids, + roots=roots, + policy=_scan_policy(body), + ), + } + except (CodexSessionError, TranscriptStoreError) as exc: + raise AdminServiceError(str(exc)) from exc + + def codex_sessions(self, query: dict[str, str]) -> dict[str, Any]: + store = self._require_transcript_store() + workspace_id = query.get("workspace_id") or None + if workspace_id is not None: + self._workspace_scope(workspace_id) + return { + "ok": True, + **store.list_imported_sessions( + workspace_id, + page=_query_int(query, "page", 1), + page_size=_query_int(query, "page_size", 50), + ), + } + + def _workspace_scope(self, workspace_id: str) -> WorkspaceScope: + current = self.settings_store.read() + try: + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + entry = catalog.get(workspace_id) + except WorkspaceCatalogError as exc: + raise AdminNotFoundError("Workspace is unknown or disabled.") from exc + return WorkspaceScope.create(entry.id, entry.root) + + def _require_transcript_store(self) -> TranscriptStore: + if self.transcript_store is None: + raise AdminUnavailableError("Chat persistence is not configured.") + return self.transcript_store + + def dispatch( + self, + method: str, + path: str, + body: dict[str, Any], + query: dict[str, str], + ) -> dict[str, Any]: + relative = path.removeprefix(ADMIN_API_PREFIX).strip("/") + parts = [part for part in relative.split("/") if part] + if method == "GET" and parts == ["status"]: + return self.status_payload() + if method == "GET" and parts == ["settings"]: + return self.settings_payload() + if method == "POST" and parts == ["settings", "validate"]: + return self.validate_settings(body) + if method == "PUT" and parts == ["settings"]: + return self.save_settings(body) + if method == "GET" and parts == ["gateway"]: + return self.gateway_payload() + if method == "PUT" and parts == ["gateway"]: + return self.save_gateway(body) + if method == "GET" and parts == ["secrets"]: + return self.secrets_payload() + if len(parts) == 2 and parts[0] == "secrets" and method == "PUT": + return self.set_secret(parts[1], body) + if len(parts) == 2 and parts[0] == "secrets" and method == "DELETE": + return self.delete_secret(parts[1]) + if method == "GET" and parts == ["workspaces"]: + return self.workspaces_payload() + if method == "POST" and parts == ["workspaces"]: + return self.workspace_add(body) + if len(parts) == 3 and parts[0] == "workspaces" and method == "POST": + if parts[2] == "disable": + return self.workspace_disable(parts[1], body) + if parts[2] == "default": + return self.workspace_default(parts[1], body) + if len(parts) == 3 and parts[0] == "workspaces" and parts[2] == "check" and method == "GET": + return self.workspace_check(parts[1]) + if len(parts) == 2 and parts[0] == "oauth" and method == "GET": + return self.oauth_payload(parts[1], query) + if len(parts) == 4 and parts[0] == "oauth" and method == "POST": + return self.oauth_action(parts[1], parts[2], parts[3]) + if method == "GET" and parts == ["chat", "conversations"]: + return self.chat_conversations(query) + if len(parts) == 4 and parts[:2] == ["chat", "conversations"] and method == "GET": + return self.chat_conversation_detail(parts[2], parts[3], query) + if len(parts) == 5 and parts[:2] == ["chat", "conversations"] and method == "POST": + if parts[4] == "messages": + return self.chat_record_messages(parts[2], parts[3], body) + if parts[4] == "context": + return self.chat_record_context(parts[2], parts[3], body) + if len(parts) == 4 and parts[0] == "chat" and parts[1] in {"messages", "context", "sessions"} and method == "DELETE": + return self.chat_delete(parts[1], parts[2], parts[3]) + if len(parts) == 4 and parts[:2] == ["chat", "conversations"] and method == "DELETE": + return self.chat_delete("conversations", parts[2], parts[3]) + if len(parts) == 4 and parts[:2] == ["chat", "workspaces"] and parts[3] == "clear" and method == "POST": + return self.chat_clear_workspace(parts[2]) + if method == "POST" and parts == ["codex", "sessions", "scan"]: + return self.codex_scan(body) + if method == "POST" and parts == ["codex", "sessions", "import"]: + return self.codex_import(body) + if method == "GET" and parts == ["codex", "sessions"]: + return self.codex_sessions(query) + if len(parts) == 4 and parts[:2] == ["codex", "sessions"] and method == "DELETE": + return self.chat_delete("sessions", parts[2], parts[3]) + raise AdminNotFoundError("Unknown Admin API endpoint.") + + def _checked_settings(self, expected_revision: str) -> dict[str, Any]: + current = self.settings_store.read() + if not _constant_equal(expected_revision, document_revision(current)): + raise AdminConflictError( + "Settings changed after this page was loaded; reload before saving." + ) + return current + + def _write_workspace_settings( + self, + current: dict[str, Any], + entries: list[dict[str, Any]], + default_id: str, + ) -> None: + try: + normalized, _warnings = normalize_startup_settings_with_warnings( + current, + { + "workspace_catalog": entries, + "default_workspace_id": default_id, + }, + self.fallback_workspace, + ) + self.settings_store.write(normalized) + except (SettingsStoreError, SettingsValidationError, WorkspaceCatalogError) as exc: + raise AdminServiceError(str(exc)) from exc + + def _require_oauth_store(self) -> OAuthAuthorizationStore: + if self.oauth_store is None: + raise AdminUnavailableError("OAuth persistence is not configured.") + return self.oauth_store + +def _query_int(query: dict[str, str], key: str, default: int) -> int: + raw = query.get(key) + if raw in (None, ""): + return default + assert raw is not None + try: + return int(raw) + except ValueError as exc: + raise AdminServiceError(f"{key} must be an integer.") from exc + + +def _scan_policy(body: dict[str, Any]) -> ScanPolicy: + values: dict[str, int] = {} + for field in ("max_depth", "max_files", "max_file_bytes", "max_total_bytes", "max_messages"): + if field in body: + raw = body[field] + if isinstance(raw, bool): + raise AdminServiceError(f"{field} must be an integer.") + try: + values[field] = int(raw) + except (TypeError, ValueError) as exc: + raise AdminServiceError(f"{field} must be an integer.") from exc + return ScanPolicy(**values).validated() + + +def _required_revision(body: dict[str, Any]) -> str: + value = body.get("expected_revision") + if not isinstance(value, str) or not value: + raise AdminServiceError("expected_revision is required.") + return value + + +def _constant_equal(left: str, right: str) -> bool: + return hashlib.sha256(left.encode("utf-8")).digest() == hashlib.sha256( + right.encode("utf-8") + ).digest() + + +__all__ = [ + "ADMIN_API_PREFIX", + "SERVER_SECRET_VAULT_FILENAME", + "AdminConflictError", + "AdminNotFoundError", + "AdminService", + "AdminServiceError", + "AdminUnavailableError", + "document_revision", + "gateway_file_revision", +] diff --git a/coding_tools_mcp/chat_cli.py b/coding_tools_mcp/chat_cli.py new file mode 100644 index 0000000..000f3d9 --- /dev/null +++ b/coding_tools_mcp/chat_cli.py @@ -0,0 +1,148 @@ +"""Small Workspace-scoped CLI for recording and reading chat persistence.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from .transcript import TranscriptStore, TranscriptStoreError, WorkspaceScope + + +class CliError(ValueError): + pass + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Record Workspace-scoped chat data.") + parser.add_argument("--db-path", required=True) + parser.add_argument("--workspace-id", required=True) + parser.add_argument("--workspace-root", required=True) + sub = parser.add_subparsers(dest="command", required=True) + + record_message = sub.add_parser("record-message") + record_message.add_argument("--conversation-id") + record_message.add_argument("--title") + record_message.add_argument("--message-id") + record_message.add_argument("--role") + record_message.add_argument("--content") + record_message.add_argument("--timestamp") + record_message.add_argument("--source") + record_message.add_argument("--stdin-json", action="store_true") + + record_context = sub.add_parser("record-context") + record_context.add_argument("--conversation-id") + record_context.add_argument("--title") + record_context.add_argument("--context-id") + record_context.add_argument("--kind") + record_context.add_argument("--content") + record_context.add_argument("--timestamp") + record_context.add_argument("--source") + record_context.add_argument("--stdin-json", action="store_true") + + listing = sub.add_parser("list") + listing.add_argument("--page", type=int, default=1) + listing.add_argument("--page-size", type=int, default=50) + listing.add_argument("--query") + + detail = sub.add_parser("detail") + detail.add_argument("--conversation-id", required=True) + detail.add_argument("--page", type=int, default=1) + detail.add_argument("--page-size", type=int, default=100) + return parser + + +def _read_stdin_json() -> dict[str, Any]: + stream = getattr(sys.stdin, "buffer", None) + raw = stream.read() if stream is not None else sys.stdin.read().encode("utf-8", errors="replace") + for encoding in ("utf-8-sig", "gb18030", "utf-16"): + try: + text = raw.decode(encoding) + value = json.loads(text) + if not isinstance(value, dict): + raise CliError("stdin JSON must be an object.") + return value + except UnicodeDecodeError: + continue + except json.JSONDecodeError as exc: + raise CliError(f"stdin is not valid JSON: {exc}") from exc + raise CliError("stdin is not valid UTF-8, UTF-16, or GB18030 text.") + + +def _value(args: argparse.Namespace, payload: dict[str, Any], name: str, default: Any = None) -> Any: + value = getattr(args, name, None) + return value if value not in (None, "") else payload.get(name, default) + + +def run(args: argparse.Namespace) -> dict[str, Any]: + scope = WorkspaceScope.create(args.workspace_id, args.workspace_root) + store = TranscriptStore(Path(args.db_path)) + service = store.scoped(scope.workspace_id, scope.workspace_root) + payload = _read_stdin_json() if getattr(args, "stdin_json", False) else {} + if args.command == "record-message": + conversation_id = _value(args, payload, "conversation_id") + if not conversation_id: + raise CliError("conversation_id is required.") + message = { + "message_id": _value(args, payload, "message_id"), + "role": _value(args, payload, "role", "unknown"), + "content": _value(args, payload, "content", ""), + "timestamp": _value(args, payload, "timestamp"), + "source": _value(args, payload, "source", "chat-cli"), + "metadata": payload.get("metadata", payload.get("metadata_json", {})), + } + return service.record_messages( + str(conversation_id), + [message], + title=_value(args, payload, "title"), + source=_value(args, payload, "source", "chat-cli"), + ) + if args.command == "record-context": + conversation_id = _value(args, payload, "conversation_id") + if not conversation_id: + raise CliError("conversation_id is required.") + entry = { + "context_id": _value(args, payload, "context_id", payload.get("entry_id")), + "kind": _value(args, payload, "kind", "note"), + "content": _value(args, payload, "content", ""), + "timestamp": _value(args, payload, "timestamp"), + "source": _value(args, payload, "source", "chat-cli"), + "metadata": payload.get("metadata", payload.get("metadata_json", {})), + } + return service.record_context( + str(conversation_id), + [entry], + title=_value(args, payload, "title"), + source=_value(args, payload, "source", "chat-cli"), + ) + if args.command == "list": + return service.list_conversations(page=args.page, page_size=args.page_size, query=args.query) + if args.command == "detail": + result = service.conversation_detail( + args.conversation_id, + message_page=args.page, + message_page_size=args.page_size, + context_page=args.page, + context_page_size=args.page_size, + ) + if result is None: + raise CliError("Conversation was not found in this Workspace.") + return result + raise CliError("Unknown command.") + + +def main(argv: list[str] | None = None) -> int: + try: + result = run(build_parser().parse_args(argv)) + text = json.dumps(result, ensure_ascii=False, sort_keys=True) + sys.stdout.write(text.encode("utf-8", errors="replace").decode("utf-8") + "\n") + return 0 + except (CliError, TranscriptStoreError) as exc: + sys.stderr.write(str(exc) + "\n") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/coding_tools_mcp/codex_sessions.py b/coding_tools_mcp/codex_sessions.py new file mode 100644 index 0000000..3c54f0e --- /dev/null +++ b/coding_tools_mcp/codex_sessions.py @@ -0,0 +1,610 @@ +"""Bounded, Workspace-confined discovery and import of Codex session files.""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + +from .transcript import TranscriptStore, WorkspaceScope + + +DEFAULT_MAX_DEPTH = 8 +DEFAULT_MAX_FILES = 500 +DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024 +DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024 +DEFAULT_MAX_MESSAGES = 20_000 +SUPPORTED_SUFFIXES = frozenset({".jsonl", ".json", ".md", ".markdown"}) + + +class CodexSessionError(ValueError): + pass + + +@dataclass(frozen=True) +class ScanPolicy: + max_depth: int = DEFAULT_MAX_DEPTH + max_files: int = DEFAULT_MAX_FILES + max_file_bytes: int = DEFAULT_MAX_FILE_BYTES + max_total_bytes: int = DEFAULT_MAX_TOTAL_BYTES + max_messages: int = DEFAULT_MAX_MESSAGES + + def validated(self) -> "ScanPolicy": + values = { + "max_depth": (self.max_depth, 0, 32), + "max_files": (self.max_files, 1, 10_000), + "max_file_bytes": (self.max_file_bytes, 1024, 100 * 1024 * 1024), + "max_total_bytes": (self.max_total_bytes, 1024, 1024 * 1024 * 1024), + "max_messages": (self.max_messages, 1, 100_000), + } + for name, (value, minimum, maximum) in values.items(): + if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum: + raise CodexSessionError(f"{name} must be between {minimum} and {maximum}.") + return self + + +class CodexSessionScanner: + """Scanner whose cache and candidates are keyed by explicit Workspace identity.""" + + def __init__(self, *, max_cache_entries: int = 1024) -> None: + if not 1 <= max_cache_entries <= 100_000: + raise ValueError("max_cache_entries must be between 1 and 100000.") + self.max_cache_entries = max_cache_entries + self._cache: dict[tuple[str, str, int, int], dict[str, Any]] = {} + + def _cache_put(self, key: tuple[str, str, int, int], parsed: dict[str, Any]) -> None: + workspace_id, relative, _mtime, _size = key + for old_key in list(self._cache): + if old_key[0] == workspace_id and old_key[1] == relative and old_key != key: + self._cache.pop(old_key, None) + while len(self._cache) >= self.max_cache_entries: + self._cache.pop(next(iter(self._cache))) + self._cache[key] = parsed + + def scan( + self, + scope: WorkspaceScope, + *, + roots: list[str] | None = None, + policy: ScanPolicy | None = None, + ) -> dict[str, Any]: + policy = (policy or ScanPolicy()).validated() + resolved_roots = _resolve_roots(scope, roots or ["."]) + candidates: list[dict[str, Any]] = [] + scan_errors: list[dict[str, Any]] = [] + seen_files = 0 + read_bytes = 0 + stop_scan = False + for root in resolved_roots: + if stop_scan: + break + try: + iterator = _iter_candidate_files(scope, root, max_depth=policy.max_depth) + for path in iterator: + if seen_files >= policy.max_files: + scan_errors.append({"code": "file_limit", "message": "Candidate file limit reached."}) + stop_scan = True + break + seen_files += 1 + try: + stat = path.stat() + except OSError: + candidates.append(_file_error_candidate(scope, path, "stat_failed")) + continue + relative = path.relative_to(scope.workspace_root).as_posix() + if stat.st_size > policy.max_file_bytes: + candidates.append( + _file_error_candidate( + scope, + path, + "file_too_large", + file_size=stat.st_size, + file_mtime_ns=stat.st_mtime_ns, + ) + ) + continue + if read_bytes + stat.st_size > policy.max_total_bytes: + scan_errors.append({"code": "total_byte_limit", "message": "Total transcript read limit reached."}) + stop_scan = True + break + cache_key = (scope.workspace_id, relative, stat.st_mtime_ns, stat.st_size) + parsed = self._cache.get(cache_key) + if parsed is None: + parsed = parse_codex_session_file( + scope, + path, + max_file_bytes=policy.max_file_bytes, + max_messages=policy.max_messages, + ) + self._cache_put(cache_key, parsed) + read_bytes += stat.st_size + candidates.append(_preview(parsed)) + except OSError as exc: + scan_errors.append({"code": "root_unreadable", "message": type(exc).__name__}) + candidates.sort(key=lambda item: (item.get("relative_path", ""), item.get("session_id", ""))) + return { + "workspace_id": scope.workspace_id, + "candidates": candidates, + "candidate_count": len(candidates), + "scan_errors": scan_errors, + "scan_error_count": len(scan_errors), + "files_considered": seen_files, + "bytes_read": read_bytes, + "limits": { + "max_depth": policy.max_depth, + "max_files": policy.max_files, + "max_file_bytes": policy.max_file_bytes, + "max_total_bytes": policy.max_total_bytes, + "max_messages": policy.max_messages, + }, + } + + def import_candidates( + self, + store: TranscriptStore, + scope: WorkspaceScope, + *, + candidate_ids: list[str], + roots: list[str] | None = None, + policy: ScanPolicy | None = None, + ) -> dict[str, Any]: + if not isinstance(candidate_ids, list) or not candidate_ids: + raise CodexSessionError("candidate_ids must be a non-empty list.") + wanted = {str(item) for item in candidate_ids} + policy = (policy or ScanPolicy()).validated() + resolved_roots = _resolve_roots(scope, roots or ["."]) + found: dict[str, dict[str, Any]] = {} + files_seen = 0 + total_bytes = 0 + for root in resolved_roots: + for path in _iter_candidate_files(scope, root, max_depth=policy.max_depth): + if files_seen >= policy.max_files or len(found) == len(wanted): + break + files_seen += 1 + try: + stat = path.stat() + except OSError: + continue + if stat.st_size > policy.max_file_bytes or total_bytes + stat.st_size > policy.max_total_bytes: + continue + relative = path.relative_to(scope.workspace_root).as_posix() + candidate_id = _candidate_id(scope.workspace_id, relative) + if candidate_id not in wanted: + continue + cache_key = (scope.workspace_id, relative, stat.st_mtime_ns, stat.st_size) + parsed = self._cache.get(cache_key) + if parsed is None: + parsed = parse_codex_session_file( + scope, + path, + max_file_bytes=policy.max_file_bytes, + max_messages=policy.max_messages, + ) + self._cache_put(cache_key, parsed) + total_bytes += stat.st_size + found[candidate_id] = parsed + inserted = 0 + duplicates = 0 + imported_sessions = 0 + errors: list[dict[str, Any]] = [] + for candidate_id in candidate_ids: + parsed = found.get(candidate_id) + if parsed is None: + errors.append({"candidate_id": candidate_id, "code": "not_found"}) + continue + if parsed.get("fatal_error"): + store.upsert_imported_session(scope.workspace_id, parsed) + errors.append({"candidate_id": candidate_id, "code": str(parsed["fatal_error"])}) + continue + result = store.record_messages( + scope.workspace_id, + str(parsed["conversation_id"]), + list(parsed.get("messages") or []), + title=str(parsed.get("title") or parsed["session_id"]), + source="codex-session-import", + ) + parsed = dict(parsed) + parsed["imported_at"] = __import__("time").time() + store.upsert_imported_session(scope.workspace_id, parsed) + inserted += int(result["inserted_count"]) + duplicates += int(result["duplicate_count"]) + imported_sessions += 1 + return { + "workspace_id": scope.workspace_id, + "requested_count": len(candidate_ids), + "imported_session_count": imported_sessions, + "inserted_count": inserted, + "duplicate_count": duplicates, + "errors": errors, + "error_count": len(errors), + } + + +def parse_codex_session_file( + scope: WorkspaceScope, + path: Path, + *, + max_file_bytes: int = DEFAULT_MAX_FILE_BYTES, + max_messages: int = DEFAULT_MAX_MESSAGES, +) -> dict[str, Any]: + safe_path = _safe_file(scope, path) + relative = safe_path.relative_to(scope.workspace_root).as_posix() + candidate_id = _candidate_id(scope.workspace_id, relative) + try: + stat = safe_path.stat() + except OSError as exc: + return _base_result(scope, relative, candidate_id, fatal_error="stat_failed", parse_errors=[_error("stat_failed", type(exc).__name__)]) + if stat.st_size > max_file_bytes: + return _base_result( + scope, + relative, + candidate_id, + fatal_error="file_too_large", + file_size=stat.st_size, + file_mtime_ns=stat.st_mtime_ns, + parse_errors=[_error("file_too_large", "File exceeds configured size limit.")], + ) + try: + raw = safe_path.read_bytes() + except (OSError, PermissionError) as exc: + return _base_result( + scope, + relative, + candidate_id, + fatal_error="read_failed", + file_size=stat.st_size, + file_mtime_ns=stat.st_mtime_ns, + parse_errors=[_error("read_failed", type(exc).__name__)], + ) + text, encoding, decode_error = _decode_text(raw) + if decode_error: + return _base_result( + scope, + relative, + candidate_id, + fatal_error="invalid_encoding", + file_size=stat.st_size, + file_mtime_ns=stat.st_mtime_ns, + parse_errors=[_error("invalid_encoding", decode_error)], + ) + metadata: dict[str, Any] = {} + messages: list[dict[str, Any]] = [] + parse_errors: list[dict[str, Any]] = [] + suffix = safe_path.suffix.lower() + if suffix == ".jsonl": + _parse_jsonl(text, messages, metadata, parse_errors, max_messages=max_messages) + elif suffix == ".json": + _parse_json(text, messages, metadata, parse_errors, max_messages=max_messages) + else: + _parse_markdown(text, messages, parse_errors, max_messages=max_messages) + source_session_id = _safe_session_id(metadata.get("session_id") or metadata.get("id") or safe_path.stem) + session_id = candidate_id + conversation_id = f"codex-{candidate_id}" + for message in messages: + message["message_id"] = f"{candidate_id}:{message['message_id']}" + title = _clean_text(metadata.get("title") or safe_path.stem, 500) + return { + "workspace_id": scope.workspace_id, + "candidate_id": candidate_id, + "session_id": session_id, + "source_session_id": source_session_id, + "conversation_id": conversation_id, + "relative_path": relative, + "source_kind": "codex", + "title": title, + "summary": f"{len(messages)} parsed messages from {safe_path.name}", + "message_count": len(messages), + "messages": messages, + "parse_errors": parse_errors, + "parse_error_count": len(parse_errors), + "fatal_error": None, + "file_size": stat.st_size, + "file_mtime_ns": stat.st_mtime_ns, + "encoding": encoding, + } + + +def _resolve_roots(scope: WorkspaceScope, roots: list[str]) -> list[Path]: + resolved: list[Path] = [] + for raw in roots: + if not isinstance(raw, str) or not raw.strip(): + raise CodexSessionError("Scan roots must be non-empty relative paths.") + candidate = Path(raw) + if candidate.is_absolute() or ".." in candidate.parts: + raise CodexSessionError("Absolute paths and '..' are not allowed in scan roots.") + try: + path = (scope.workspace_root / candidate).resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise CodexSessionError(f"Scan root cannot be resolved: {exc}") from exc + _ensure_within(scope.workspace_root, path) + if path.is_symlink() or not path.is_dir(): + raise CodexSessionError("Scan root must be a real directory inside the Workspace.") + resolved.append(path) + return list(dict.fromkeys(resolved)) + + +def _iter_candidate_files(scope: WorkspaceScope, root: Path, *, max_depth: int) -> Iterator[Path]: + stack: list[tuple[Path, int]] = [(root, 0)] + while stack: + directory, depth = stack.pop() + try: + entries = sorted(os.scandir(directory), key=lambda entry: entry.name.lower()) + except OSError: + raise + for entry in entries: + entry_path = Path(entry.path) + try: + if entry.is_symlink(): + continue + resolved = entry_path.resolve(strict=True) + _ensure_within(scope.workspace_root, resolved) + if entry.is_dir(follow_symlinks=False): + if depth < max_depth: + stack.append((resolved, depth + 1)) + elif entry.is_file(follow_symlinks=False) and resolved.suffix.lower() in SUPPORTED_SUFFIXES: + yield resolved + except (OSError, RuntimeError, CodexSessionError): + continue + + +def _safe_file(scope: WorkspaceScope, path: Path) -> Path: + if path.is_absolute(): + candidate = path + else: + candidate = scope.workspace_root / path + try: + resolved = candidate.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise CodexSessionError(f"Session file cannot be resolved: {exc}") from exc + _ensure_within(scope.workspace_root, resolved) + if resolved.is_symlink() or not resolved.is_file(): + raise CodexSessionError("Session path must be a real file inside the Workspace.") + return resolved + + +def _ensure_within(root: Path, path: Path) -> None: + try: + path.relative_to(root) + except ValueError as exc: + raise CodexSessionError("Session path escapes the registered Workspace.") from exc + + +def _decode_text(raw: bytes) -> tuple[str, str, str | None]: + attempts: list[tuple[str, str]] = [] + if raw.startswith((b"\xff\xfe", b"\xfe\xff")): + attempts.append(("utf-16", "utf-16")) + if raw.startswith(b"\xef\xbb\xbf"): + attempts.append(("utf-8-sig", "utf-8-sig")) + attempts.extend((("utf-8", "utf-8"), ("gb18030", "gb18030"))) + seen: set[str] = set() + for codec, label in attempts: + if codec in seen: + continue + seen.add(codec) + try: + return raw.decode(codec), label, None + except UnicodeDecodeError: + continue + return "", "unknown", "File is not valid UTF-8, UTF-16, or GB18030 text." + + +def _parse_jsonl( + text: str, + messages: list[dict[str, Any]], + metadata: dict[str, Any], + errors: list[dict[str, Any]], + *, + max_messages: int, +) -> None: + for line_number, line in enumerate(text.splitlines(), start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + errors.append({"line": line_number, "code": "invalid_json", "message": "Line is not complete valid JSON."}) + continue + _consume_record(record, messages, metadata, errors, line_number=line_number, max_messages=max_messages) + + +def _parse_json( + text: str, + messages: list[dict[str, Any]], + metadata: dict[str, Any], + errors: list[dict[str, Any]], + *, + max_messages: int, +) -> None: + try: + document = json.loads(text) + except json.JSONDecodeError: + errors.append({"line": 1, "code": "invalid_json", "message": "Document is not complete valid JSON."}) + return + records = document if isinstance(document, list) else document.get("messages", []) if isinstance(document, dict) else [] + if isinstance(document, dict): + metadata.update({key: document.get(key) for key in ("id", "session_id", "title") if document.get(key) is not None}) + if not isinstance(records, list): + errors.append({"line": 1, "code": "invalid_shape", "message": "JSON messages field must be a list."}) + return + for index, record in enumerate(records, start=1): + _consume_record(record, messages, metadata, errors, line_number=index, max_messages=max_messages) + + +def _parse_markdown(text: str, messages: list[dict[str, Any]], errors: list[dict[str, Any]], *, max_messages: int) -> None: + blocks = [block.strip() for block in text.replace("\r\n", "\n").split("\n\n") if block.strip()] + for index, block in enumerate(blocks, start=1): + if len(messages) >= max_messages: + errors.append({"line": index, "code": "message_limit", "message": "Message limit reached."}) + break + role = "unknown" + lowered = block.lower() + for candidate in ("user", "assistant", "system", "developer", "tool"): + if lowered.startswith(candidate + ":"): + role = candidate + block = block.split(":", 1)[1].lstrip() + break + messages.append({"message_id": f"md-{index}", "role": role, "content": block, "timestamp": None, "source": "codex-markdown"}) + + +def _consume_record( + record: Any, + messages: list[dict[str, Any]], + metadata: dict[str, Any], + errors: list[dict[str, Any]], + *, + line_number: int, + max_messages: int, +) -> None: + if not isinstance(record, dict): + errors.append({"line": line_number, "code": "invalid_record", "message": "Record must be a JSON object."}) + return + record_type = str(record.get("type") or "") + raw_payload = record.get("payload") + payload: dict[str, Any] = raw_payload if isinstance(raw_payload, dict) else {} + if record_type in {"session_meta", "session_metadata"}: + metadata.update({key: payload.get(key) for key in ("id", "session_id", "title", "cwd") if payload.get(key) is not None}) + return + role: str | None = None + content: Any = None + message_id: Any = None + if record_type in {"user_message", "assistant_message", "system_message"}: + role = record_type.removesuffix("_message") + content = record.get("message", record.get("content")) + message_id = record.get("id") + elif record_type in {"response_item", "message"}: + source = payload if payload else record + if source.get("type") == "message" or record_type == "message": + role = str(source.get("role") or "unknown") + content = source.get("content") + message_id = source.get("id") + elif "role" in record and "content" in record: + role = str(record.get("role") or "unknown") + content = record.get("content") + message_id = record.get("id") or record.get("message_id") + if role is None: + return + if len(messages) >= max_messages: + if not any(item.get("code") == "message_limit" for item in errors): + errors.append({"line": line_number, "code": "message_limit", "message": "Message limit reached."}) + return + text = _content_text(content) + if not text: + return + normalized_role = role.lower() + if normalized_role not in {"user", "assistant", "system", "developer", "tool"}: + normalized_role = "unknown" + messages.append( + { + "message_id": _safe_session_id(str(message_id or f"line-{line_number}")), + "role": normalized_role, + "content": text, + "timestamp": record.get("timestamp") or payload.get("timestamp"), + "source": "codex-session", + } + ) + + +def _content_text(value: Any) -> str: + if isinstance(value, str): + return value.encode("utf-8", errors="replace").decode("utf-8", errors="replace") + if isinstance(value, list): + parts: list[str] = [] + for item in value: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") or item.get("content") or item.get("output_text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + if isinstance(value, dict): + text = value.get("text") or value.get("content") + return text if isinstance(text, str) else "" + return "" + + +def _candidate_id(workspace_id: str, relative_path: str) -> str: + digest = hashlib.sha256(f"{workspace_id}\0{relative_path}".encode("utf-8")).hexdigest()[:24] + return f"cand-{digest}" + + +def _safe_session_id(value: str) -> str: + cleaned = "".join(char if char.isalnum() or char in "-._~" else "-" for char in value.strip()) + return (cleaned.strip("-") or hashlib.sha256(value.encode("utf-8")).hexdigest()[:16])[:200] + + +def _clean_text(value: Any, limit: int) -> str: + return str(value or "").encode("utf-8", errors="replace").decode("utf-8", errors="replace")[:limit] + + +def _preview(parsed: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in parsed.items() if key != "messages"} + + +def _base_result( + scope: WorkspaceScope, + relative: str, + candidate_id: str, + *, + fatal_error: str, + parse_errors: list[dict[str, Any]], + file_size: int = 0, + file_mtime_ns: int = 0, +) -> dict[str, Any]: + source_session_id = _safe_session_id(Path(relative).stem) + return { + "workspace_id": scope.workspace_id, + "candidate_id": candidate_id, + "session_id": candidate_id, + "source_session_id": source_session_id, + "conversation_id": f"codex-{candidate_id}", + "relative_path": relative, + "source_kind": "codex", + "title": Path(relative).name, + "summary": "Session could not be parsed.", + "message_count": 0, + "messages": [], + "parse_errors": parse_errors, + "parse_error_count": len(parse_errors), + "fatal_error": fatal_error, + "file_size": file_size, + "file_mtime_ns": file_mtime_ns, + "encoding": None, + } + + +def _file_error_candidate( + scope: WorkspaceScope, + path: Path, + code: str, + *, + file_size: int = 0, + file_mtime_ns: int = 0, +) -> dict[str, Any]: + relative = path.relative_to(scope.workspace_root).as_posix() + return _preview( + _base_result( + scope, + relative, + _candidate_id(scope.workspace_id, relative), + fatal_error=code, + parse_errors=[_error(code, "Session file could not be processed.")], + file_size=file_size, + file_mtime_ns=file_mtime_ns, + ) + ) + + +def _error(code: str, message: str) -> dict[str, Any]: + return {"code": code, "message": message} + + +__all__ = [ + "CodexSessionError", + "CodexSessionScanner", + "ScanPolicy", + "parse_codex_session_file", +] diff --git a/coding_tools_mcp/oauth.py b/coding_tools_mcp/oauth.py index ae4c885..304d532 100644 --- a/coding_tools_mcp/oauth.py +++ b/coding_tools_mcp/oauth.py @@ -7,20 +7,31 @@ import threading import time import urllib.parse -from dataclasses import dataclass, field +import uuid +from dataclasses import dataclass, field, replace from typing import Any import jwt +from .oauth_store import ( + OAuthAuthorizationStore, + OAuthStoreError, + RefreshTokenClientMismatchError, +) +from .secret_vault import SecretVault, SecretVaultError + OAUTH_CODE_TTL_SECONDS = 300 OAUTH_TOKEN_TTL_SECONDS = 24 * 60 * 60 OAUTH_MAX_BODY_BYTES = 8_192 OAUTH_GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" -# Advertised in AS metadata and used to narrow DCR requests. The token endpoint -# implements authorization_code only — adding an entry here requires a matching -# branch in handle_oauth_token, not just a wider check. -OAUTH_GRANT_TYPES_SUPPORTED = (OAUTH_GRANT_TYPE_AUTHORIZATION_CODE,) +OAUTH_GRANT_TYPE_REFRESH_TOKEN = "refresh_token" +# Shared by AS metadata, DCR narrowing, and token-endpoint dispatch. A grant +# type belongs here only after its endpoint branch and focused tests are complete. +OAUTH_GRANT_TYPES_SUPPORTED = ( + OAUTH_GRANT_TYPE_AUTHORIZATION_CODE, + OAUTH_GRANT_TYPE_REFRESH_TOKEN, +) OAUTH_RESPONSE_TYPES_SUPPORTED = ("code",) MAX_REDIRECT_URIS = 10 MAX_REGISTERED_CLIENTS = 1_024 @@ -34,6 +45,7 @@ class OAuthClient: token_endpoint_auth_method: str client_name: str | None = None secret_digest: str | None = None + workspace_id: str | None = None issued_at: int = field(default_factory=lambda: int(time.time())) def accepts_redirect(self, redirect_uri: str) -> bool: @@ -47,6 +59,27 @@ def verifies_secret(self, secret: str) -> bool: return secrets.compare_digest(self.secret_digest, _secret_digest(secret)) +@dataclass(frozen=True) +class OAuthIdentity: + client_id: str + grant_id: str + workspace_id: str + jti: str + + +@dataclass(frozen=True) +class AccessTokenIssue: + token: str + jti: str + client_id: str + grant_id: str + signing_kid: str + scopes: str + issued_at: int + expires_at: int + token_mode: str + + class OAuthClientRegistry: """Thread-safe RFC 7591 client registry for one server process.""" @@ -73,26 +106,7 @@ def add_preregistered( self._clients[client_id] = client def register(self, metadata: dict[str, Any]) -> dict[str, Any]: - redirects = validate_redirect_uris(metadata.get("redirect_uris")) - requested_grant_types = metadata.get("grant_types", list(OAUTH_GRANT_TYPES_SUPPORTED)) - requested_response_types = metadata.get("response_types", list(OAUTH_RESPONSE_TYPES_SUPPORTED)) - if not isinstance(requested_grant_types, list) or not all( - isinstance(item, str) for item in requested_grant_types - ): - raise ValueError("grant_types must be an array of strings") - grant_types = tuple(item for item in OAUTH_GRANT_TYPES_SUPPORTED if item in requested_grant_types) - if not grant_types: - raise ValueError("grant_types must include at least one supported value") - if not isinstance(requested_response_types, list) or not all( - isinstance(item, str) for item in requested_response_types - ): - raise ValueError("response_types must be an array of strings") - response_types = tuple(item for item in OAUTH_RESPONSE_TYPES_SUPPORTED if item in requested_response_types) - if not response_types: - raise ValueError("response_types must include at least one supported value") - method = str(metadata.get("token_endpoint_auth_method") or "none") - if method not in {"none", "client_secret_post", "client_secret_basic"}: - raise ValueError("unsupported token_endpoint_auth_method") + redirects, grant_types, response_types, method, client_name = _validated_registration(metadata) with self._lock: if len(self._clients) >= MAX_REGISTERED_CLIENTS: raise ValueError("dynamic client registration limit reached") @@ -104,24 +118,11 @@ def register(self, metadata: dict[str, Any]) -> dict[str, Any]: client_id=client_id, redirect_uris=redirects, token_endpoint_auth_method=method, - client_name=_optional_text(metadata.get("client_name"), 200), + client_name=client_name, secret_digest=_secret_digest(client_secret) if client_secret is not None else None, ) self._clients[client_id] = client - response: dict[str, Any] = { - "client_id": client.client_id, - "client_id_issued_at": client.issued_at, - "redirect_uris": list(client.redirect_uris), - "grant_types": list(grant_types), - "response_types": list(response_types), - "token_endpoint_auth_method": client.token_endpoint_auth_method, - } - if client.client_name: - response["client_name"] = client.client_name - if client_secret is not None: - response["client_secret"] = client_secret - response["client_secret_expires_at"] = 0 - return response + return _registration_response(client, grant_types, response_types, client_secret) def get(self, client_id: str) -> OAuthClient | None: with self._lock: @@ -140,6 +141,102 @@ def authenticates(self, client_id: str, client_secret: str, auth_method: str) -> ) +class PersistentOAuthClientRegistry(OAuthClientRegistry): + """Store-backed registry preserving the upstream registry interface. + + Store errors propagate so callers can fail closed instead of silently + falling back to an in-memory registry. + """ + + def __init__( + self, + store: OAuthAuthorizationStore, + *, + registration_workspace_id: str | None = "default", + ) -> None: + self.store = store + self.registration_workspace_id = registration_workspace_id + + def add_preregistered( + self, + client_id: str, + redirect_uris: tuple[str, ...], + *, + client_secret: str | None, + workspace_id: str | None = None, + ) -> None: + redirects = validate_redirect_uris(list(redirect_uris)) + method = "client_secret_post" if client_secret is not None else "none" + resolved_workspace_id = workspace_id or self.registration_workspace_id + self.store.upsert_client( + client_id, + display_name=client_id, + scopes="mcp", + redirect_uris=redirects, + client_type="confidential" if client_secret is not None else "public_pkce", + token_endpoint_auth_method=method, + client_secret_digest=( + _secret_digest(client_secret) if client_secret is not None else None + ), + workspace_id=resolved_workspace_id, + ) + + def register(self, metadata: dict[str, Any]) -> dict[str, Any]: + redirects, grant_types, response_types, method, client_name = _validated_registration(metadata) + if len(self.store.list_clients()) >= MAX_REGISTERED_CLIENTS: + raise ValueError("dynamic client registration limit reached") + client_id = secrets.token_urlsafe(24) + while self.store.get_client(client_id) is not None: + client_id = secrets.token_urlsafe(24) + client_secret = secrets.token_urlsafe(32) if method != "none" else None + client = OAuthClient( + client_id=client_id, + redirect_uris=redirects, + token_endpoint_auth_method=method, + client_name=client_name, + secret_digest=_secret_digest(client_secret) if client_secret is not None else None, + ) + self.store.upsert_client( + client.client_id, + display_name=client.client_name or client.client_id, + scopes="mcp", + redirect_uris=client.redirect_uris, + client_type="confidential" if client_secret is not None else "public_pkce", + token_endpoint_auth_method=client.token_endpoint_auth_method, + client_secret_digest=client.secret_digest, + workspace_id=self.registration_workspace_id, + ) + return _registration_response(client, grant_types, response_types, client_secret) + + def get(self, client_id: str) -> OAuthClient | None: + record = self.store.get_client(client_id) + if record is None or not bool(record.get("enabled")) or record.get("revoked_at") is not None: + return None + redirects = record.get("redirect_uris") + if not isinstance(redirects, list) or not all(isinstance(item, str) for item in redirects): + return None + method = record.get("token_endpoint_auth_method") + if method not in {"none", "client_secret_post", "client_secret_basic"}: + return None + digest = record.get("client_secret_digest") + if digest is not None and not isinstance(digest, str): + return None + created_at = record.get("created_at") + return OAuthClient( + client_id=client_id, + redirect_uris=tuple(redirects), + token_endpoint_auth_method=method, + client_name=str(record.get("display_name") or client_id), + secret_digest=digest, + workspace_id=( + str(record["workspace_id"]) + if isinstance(record.get("workspace_id"), str) and record["workspace_id"] + else None + ), + issued_at=int(created_at) if isinstance(created_at, (int, float)) else int(time.time()), + ) + + @dataclass(frozen=True) class OAuthConfig: password: str @@ -147,10 +244,153 @@ class OAuthConfig: token_secret: bytes token_ttl: int = OAUTH_TOKEN_TTL_SECONDS registry: OAuthClientRegistry = field(default_factory=OAuthClientRegistry) + store: OAuthAuthorizationStore | None = None + secret_vault: SecretVault | None = None + refresh_token_ttl: int = 60 * 60 * 24 * 90 + signing_kid: str | None = None + signing_keys: dict[str, bytes] = field(default_factory=dict) pending_codes: dict[str, dict[str, Any]] = field(default_factory=dict) pending_codes_lock: threading.Lock = field(default_factory=threading.Lock) +class OAuthServiceError(RuntimeError): + """Persistent OAuth state cannot safely complete the requested operation.""" + + +def create_authorization_grant( + config: OAuthConfig, + *, + client_id: str, + redirect_uri: str, + scopes: str, +) -> str: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + client = config.registry.get(client_id) + if client is None or not client.accepts_redirect(redirect_uri): + raise OAuthServiceError("OAuth client or redirect URI is not active.") + try: + return config.store.create_grant(client_id, scopes) + except (OAuthStoreError, ValueError) as exc: + raise OAuthServiceError("OAuth authorization store is unavailable.") from exc + + +class OAuthClientAuthenticationError(OAuthServiceError): + pass + + +class OAuthInvalidGrantError(OAuthServiceError): + pass + + +def _active_grant( + config: OAuthConfig, + *, + grant_id: str, + client_id: str, +) -> dict[str, Any]: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + try: + grant = config.store.get_grant(grant_id) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth authorization store is unavailable.") from exc + if ( + grant is None + or grant.get("client_id") != client_id + or not bool(grant.get("enabled")) + or grant.get("revoked_at") is not None + ): + raise OAuthInvalidGrantError("OAuth grant is not active.") + return grant + + +def issue_refresh_token( + config: OAuthConfig, + *, + grant_id: str, + client_id: str, + scopes: str, +) -> str: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + _active_grant(config, grant_id=grant_id, client_id=client_id) + try: + _family_id, token = config.store.issue_refresh_token( + grant_id, + client_id, + scopes, + expires_at=time.time() + config.refresh_token_ttl, + ) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth refresh-token state could not be persisted.") from exc + return token + + +def exchange_refresh_token( + config: OAuthConfig, + *, + refresh_token: str, + client_id: str, + client_secret: str, + auth_method: str, + server_url: str, +) -> dict[str, Any]: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + try: + authenticated = config.registry.authenticates( + client_id, + client_secret, + auth_method, + ) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth client registry is unavailable.") from exc + if not authenticated: + raise OAuthClientAuthenticationError("OAuth client authentication failed.") + try: + binding = config.store.refresh_token_binding(refresh_token) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth refresh-token store is unavailable.") from exc + if binding is None: + raise OAuthInvalidGrantError("Refresh token is invalid, expired, or reused.") + if not secrets.compare_digest(binding.client_id, client_id): + raise OAuthClientAuthenticationError("Refresh token client mismatch.") + + access_issue = _prepare_access_token( + config, + server_url, + client_id=binding.client_id, + grant_id=binding.grant_id, + scope=binding.scopes, + ) + try: + rotated = config.store.rotate_refresh_token_and_record_access_token( + refresh_token, + expected_client_id=client_id, + refresh_expires_at=time.time() + config.refresh_token_ttl, + access_jti=access_issue.jti, + access_signing_kid=access_issue.signing_kid, + access_scopes=access_issue.scopes, + access_issued_at=access_issue.issued_at, + access_expires_at=access_issue.expires_at, + token_mode=access_issue.token_mode, + ) + except RefreshTokenClientMismatchError as exc: + raise OAuthClientAuthenticationError("Refresh token client mismatch.") from exc + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth refresh-token exchange could not be persisted.") from exc + if rotated is None: + raise OAuthInvalidGrantError("Refresh token is invalid, expired, or reused.") + return { + "access_token": access_issue.token, + "token_type": "Bearer", + "expires_in": config.token_ttl, + "scope": rotated.scopes, + "refresh_token": rotated.token, + } + + def validate_redirect_uris(value: Any) -> tuple[str, ...]: if not isinstance(value, list) or not value or len(value) > MAX_REDIRECT_URIS: raise ValueError(f"redirect_uris must contain between 1 and {MAX_REDIRECT_URIS} entries") @@ -174,6 +414,54 @@ def validate_redirect_uris(value: Any) -> tuple[str, ...]: return tuple(redirects) +def _validated_registration( + metadata: dict[str, Any], +) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...], str, str | None]: + redirects = validate_redirect_uris(metadata.get("redirect_uris")) + requested_grant_types = metadata.get("grant_types", list(OAUTH_GRANT_TYPES_SUPPORTED)) + requested_response_types = metadata.get("response_types", list(OAUTH_RESPONSE_TYPES_SUPPORTED)) + if not isinstance(requested_grant_types, list) or not all( + isinstance(item, str) for item in requested_grant_types + ): + raise ValueError("grant_types must be an array of strings") + grant_types = tuple(item for item in OAUTH_GRANT_TYPES_SUPPORTED if item in requested_grant_types) + if not grant_types: + raise ValueError("grant_types must include at least one supported value") + if not isinstance(requested_response_types, list) or not all( + isinstance(item, str) for item in requested_response_types + ): + raise ValueError("response_types must be an array of strings") + response_types = tuple(item for item in OAUTH_RESPONSE_TYPES_SUPPORTED if item in requested_response_types) + if not response_types: + raise ValueError("response_types must include at least one supported value") + method = str(metadata.get("token_endpoint_auth_method") or "none") + if method not in {"none", "client_secret_post", "client_secret_basic"}: + raise ValueError("unsupported token_endpoint_auth_method") + return redirects, grant_types, response_types, method, _optional_text(metadata.get("client_name"), 200) + + +def _registration_response( + client: OAuthClient, + grant_types: tuple[str, ...], + response_types: tuple[str, ...], + client_secret: str | None, +) -> dict[str, Any]: + response: dict[str, Any] = { + "client_id": client.client_id, + "client_id_issued_at": client.issued_at, + "redirect_uris": list(client.redirect_uris), + "grant_types": list(grant_types), + "response_types": list(response_types), + "token_endpoint_auth_method": client.token_endpoint_auth_method, + } + if client.client_name: + response["client_name"] = client.client_name + if client_secret is not None: + response["client_secret"] = client_secret + response["client_secret_expires_at"] = 0 + return response + + def verify_pkce(code_verifier: str, code_challenge: str) -> bool: if not re.fullmatch(r"[A-Za-z0-9\-._~]{43,128}", code_verifier): return False @@ -186,36 +474,265 @@ def valid_pkce_challenge(code_challenge: str) -> bool: return re.fullmatch(r"[A-Za-z0-9_-]{43}", code_challenge) is not None -def create_access_token(config: OAuthConfig, server_url: str, *, client_id: str) -> str: +def signing_key_id(secret: bytes) -> str: + return f"key-{hashlib.sha256(secret).hexdigest()[:16]}" + + +def signing_key_secret_ref(kid: str) -> str: + return f"oauth/signing/{kid}" + + +def initialize_signing_key_ring( + store: OAuthAuthorizationStore, + vault: SecretVault, + initial_secret: bytes, + *, + legacy_secret_ref: str, +) -> tuple[str, bytes, dict[str, bytes]]: + if not vault.enabled(): + raise OAuthServiceError("OAuth Secret Vault is not enabled.") + records = store.list_signing_keys() + initial_kid = signing_key_id(initial_secret) + if not records: + reference = signing_key_secret_ref(initial_kid) + vault.set_secret(reference, initial_secret.hex()) + store.register_signing_key( + initial_kid, + hashlib.sha256(initial_secret).hexdigest(), + secret_ref=reference, + ) + records = store.list_signing_keys() + + keys: dict[str, bytes] = {} + active: list[tuple[str, bytes]] = [] + for record in records: + status = record.get("status") + if status not in {"active", "retired"}: + continue + raw_kid = record.get("kid") + raw_reference = record.get("secret_ref") + if not isinstance(raw_kid, str) or not raw_kid: + raise OAuthServiceError("OAuth signing-key metadata is invalid.") + kid = raw_kid + if not isinstance(raw_reference, str) or not raw_reference: + raise OAuthServiceError(f"OAuth signing key {kid!r} has no Vault reference.") + reference = raw_reference + try: + secret = bytes.fromhex(vault.get_secret(reference)) + except (ValueError, SecretVaultError) as exc: + raise OAuthServiceError( + f"OAuth signing key {kid!r} cannot be loaded from Secret Vault." + ) from exc + fingerprint = hashlib.sha256(secret).hexdigest() + if record.get("fingerprint") != fingerprint or signing_key_id(secret) != kid: + raise OAuthServiceError(f"OAuth signing key {kid!r} metadata does not match its secret.") + if reference == legacy_secret_ref: + migrated_ref = signing_key_secret_ref(kid) + vault.set_secret(migrated_ref, secret.hex()) + store.register_signing_key( + kid, + fingerprint, + secret_ref=migrated_ref, + active=status == "active", + ) + keys[kid] = secret + if status == "active": + active.append((kid, secret)) + if len(active) != 1: + raise OAuthServiceError("OAuth signing-key ring must contain exactly one active key.") + active_kid, active_secret = active[0] + return active_kid, active_secret, keys + + +def rotate_signing_key(config: OAuthConfig) -> OAuthConfig: + if config.store is None or config.secret_vault is None: + raise OAuthServiceError("OAuth signing-key persistence is not configured.") + if not config.secret_vault.enabled(): + raise OAuthServiceError("OAuth Secret Vault is not enabled.") + secret = secrets.token_bytes(32) + kid = signing_key_id(secret) + reference = signing_key_secret_ref(kid) + try: + config.secret_vault.set_secret(reference, secret.hex()) + config.store.register_signing_key( + kid, + hashlib.sha256(secret).hexdigest(), + secret_ref=reference, + ) + except (OAuthStoreError, SecretVaultError, ValueError) as exc: + raise OAuthServiceError("OAuth signing-key rotation failed.") from exc + keys = dict(config.signing_keys) + keys[kid] = secret + return replace( + config, + token_secret=secret, + signing_kid=kid, + signing_keys=keys, + ) + + +def revoke_signing_key(config: OAuthConfig, kid: str) -> bool: + if config.store is None: + raise OAuthServiceError("OAuth signing-key persistence is not configured.") + try: + return config.store.revoke_signing_key(kid) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth signing-key revocation failed.") from exc + + +def oauth_signing_kid(config: OAuthConfig) -> str: + return config.signing_kid or signing_key_id(config.token_secret) + + +def _oauth_signing_key(config: OAuthConfig, kid: str) -> bytes | None: + if kid in config.signing_keys: + return config.signing_keys[kid] + if secrets.compare_digest(kid, oauth_signing_kid(config)): + return config.token_secret + return None + + +def _prepare_access_token( + config: OAuthConfig, + server_url: str, + *, + client_id: str, + grant_id: str, + scope: str = "mcp", + token_mode: str = "standard", +) -> AccessTokenIssue: now = int(time.time()) - return jwt.encode( + expires_at = now + config.token_ttl + jti = str(uuid.uuid4()) + kid = oauth_signing_kid(config) + key = _oauth_signing_key(config, kid) + if key is None: + raise OAuthServiceError("OAuth signing key is unavailable.") + token = jwt.encode( { "iss": server_url, "aud": server_url, - "sub": client_id, + "sub": grant_id, "client_id": client_id, + "grant_id": grant_id, "iat": now, - "exp": now + config.token_ttl, - "scope": "mcp", + "exp": expires_at, + "scope": scope, + "jti": jti, }, - config.token_secret, + key, algorithm="HS256", + headers={"kid": kid}, + ) + return AccessTokenIssue( + token=token, + jti=jti, + client_id=client_id, + grant_id=grant_id, + signing_kid=kid, + scopes=scope, + issued_at=now, + expires_at=expires_at, + token_mode=token_mode, ) -def validate_access_token(token: str, config: OAuthConfig, server_url: str) -> bool: +def create_access_token( + config: OAuthConfig, + server_url: str, + *, + client_id: str, + grant_id: str, + scope: str = "mcp", + token_mode: str = "standard", +) -> str: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + issue = _prepare_access_token( + config, + server_url, + client_id=client_id, + grant_id=grant_id, + scope=scope, + token_mode=token_mode, + ) + try: + config.store.record_access_token( + issue.jti, + issue.grant_id, + issue.client_id, + issue.signing_kid, + issue.scopes, + issued_at=issue.issued_at, + expires_at=issue.expires_at, + token_mode=issue.token_mode, + ) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth access-token state could not be persisted.") from exc + return issue.token + + +def authenticate_access_token( + token: str, + config: OAuthConfig, + server_url: str, +) -> OAuthIdentity | None: + if config.store is None: + return None try: + header = jwt.get_unverified_header(token) + kid = header.get("kid") + if not isinstance(kid, str): + return None + key = _oauth_signing_key(config, kid) + if key is None: + return None claims = jwt.decode( token, - config.token_secret, + key, algorithms=["HS256"], audience=server_url, issuer=server_url, + options={ + "require": [ + "iss", + "aud", + "client_id", + "grant_id", + "iat", + "exp", + "jti", + ] + }, ) except jwt.PyJWTError: - return False + return None client_id = claims.get("client_id") - return isinstance(client_id, str) and config.registry.get(client_id) is not None + grant_id = claims.get("grant_id") + jti = claims.get("jti") + if not isinstance(client_id, str) or not client_id: + return None + if not isinstance(grant_id, str) or not grant_id: + return None + if not isinstance(jti, str) or not jti: + return None + if claims.get("sub") != grant_id: + return None + persisted = config.store.active_access_token_identity(jti) + if persisted is None: + return None + if persisted["client_id"] != client_id or persisted["grant_id"] != grant_id: + return None + return OAuthIdentity( + client_id=client_id, + grant_id=grant_id, + workspace_id=persisted["workspace_id"], + jti=jti, + ) + + +def validate_access_token(token: str, config: OAuthConfig, server_url: str) -> bool: + return authenticate_access_token(token, config, server_url) is not None def _secret_digest(secret: str) -> str: diff --git a/coding_tools_mcp/oauth_store.py b/coding_tools_mcp/oauth_store.py new file mode 100644 index 0000000..7d8b4db --- /dev/null +++ b/coding_tools_mcp/oauth_store.py @@ -0,0 +1,1373 @@ +"""Persistent, metadata-only OAuth authorization state. + +Bearer credentials are deliberately never written to this database. Access +tokens are identified by JWT ``jti`` values and refresh tokens are stored only +as HMAC-SHA256 digests keyed by a server-side pepper. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +import sqlite3 +import time +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +class OAuthStoreError(RuntimeError): + """OAuth persistence cannot safely serve an authorization decision.""" + + +class RefreshTokenClientMismatchError(OAuthStoreError): + """A refresh token is bound to a different authenticated client.""" + + +@dataclass(frozen=True) +class RefreshTokenResult: + family_id: str + token: str + client_id: str + grant_id: str + scopes: str + + +@dataclass(frozen=True) +class RefreshTokenBinding: + family_id: str + client_id: str + grant_id: str + scopes: str + + +class OAuthAuthorizationStore: + """SQLite-backed authorization metadata with fail-closed helpers.""" + + SCHEMA_VERSION = 4 + + def __init__(self, path: str | Path, *, pepper: bytes) -> None: + if not isinstance(pepper, bytes) or not pepper: + raise ValueError("OAuth refresh-token pepper must not be empty.") + self.path = Path(path).expanduser() + self.pepper = bytes(pepper) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._migrate() + + @contextmanager + def _connection(self, operation: str) -> Iterator[sqlite3.Connection]: + conn: sqlite3.Connection | None = None + try: + conn = sqlite3.connect(self.path, timeout=5, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA busy_timeout = 5000") + yield conn + except sqlite3.Error as exc: + raise OAuthStoreError(f"OAuth authorization database {operation} failed: {exc}") from exc + finally: + if conn is not None: + conn.close() + + @contextmanager + def _transaction( + self, + operation: str, + *, + immediate: bool = False, + ) -> Iterator[sqlite3.Connection]: + with self._connection(operation) as conn: + conn.execute("BEGIN IMMEDIATE" if immediate else "BEGIN") + try: + yield conn + except BaseException: + conn.rollback() + raise + else: + conn.commit() + + def _migrate(self) -> None: + with self._connection("migration setup") as conn: + conn.execute("PRAGMA journal_mode = WAL") + + with self._transaction("migration", immediate=True) as conn: + current = int(conn.execute("PRAGMA user_version").fetchone()[0]) + if current > self.SCHEMA_VERSION: + raise OAuthStoreError( + "OAuth authorization database was created by a newer server version." + ) + if current == 0: + for statement in self._schema_v1_statements(): + conn.execute(statement) + conn.execute("PRAGMA user_version = 1") + current = 1 + if current == 1: + columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(oauth_signing_keys)").fetchall() + } + if "secret_ref" not in columns: + conn.execute("ALTER TABLE oauth_signing_keys ADD COLUMN secret_ref TEXT") + conn.execute("PRAGMA user_version = 2") + current = 2 + if current == 2: + client_columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(oauth_clients)").fetchall() + } + if "redirect_uris_json" not in client_columns: + conn.execute("ALTER TABLE oauth_clients ADD COLUMN redirect_uris_json TEXT") + if "token_endpoint_auth_method" not in client_columns: + conn.execute( + "ALTER TABLE oauth_clients ADD COLUMN token_endpoint_auth_method TEXT NOT NULL DEFAULT 'none'" + ) + if "client_secret_digest" not in client_columns: + conn.execute("ALTER TABLE oauth_clients ADD COLUMN client_secret_digest TEXT") + rows = conn.execute( + "SELECT client_id, redirect_uri FROM oauth_clients WHERE redirect_uris_json IS NULL" + ).fetchall() + for row in rows: + conn.execute( + "UPDATE oauth_clients SET redirect_uris_json=? WHERE client_id=?", + (json.dumps([row["redirect_uri"]]), row["client_id"]), + ) + conn.execute("PRAGMA user_version = 3") + current = 3 + if current == 3: + client_columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(oauth_clients)").fetchall() + } + grant_columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(oauth_grants)").fetchall() + } + if "workspace_id" not in client_columns: + conn.execute("ALTER TABLE oauth_clients ADD COLUMN workspace_id TEXT") + if "workspace_id" not in grant_columns: + conn.execute("ALTER TABLE oauth_grants ADD COLUMN workspace_id TEXT") + conn.execute(f"PRAGMA user_version = {self.SCHEMA_VERSION}") + + @classmethod + def _schema_v1_statements(cls) -> tuple[str, ...]: + return ( + """ + CREATE TABLE oauth_clients ( + client_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + client_type TEXT NOT NULL DEFAULT 'public_pkce', + redirect_uri TEXT NOT NULL, + allowed_scopes TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + revoked_at REAL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + first_authorized_at REAL, + last_seen_at REAL + ) + """, + """ + CREATE TABLE oauth_grants ( + grant_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id), + scopes TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + revoked_at REAL, + revoke_reason TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + last_used_at REAL + ) + """, + "CREATE INDEX oauth_grants_client_idx ON oauth_grants(client_id)", + """ + CREATE TABLE oauth_signing_keys ( + kid TEXT PRIMARY KEY, + algorithm TEXT NOT NULL, + fingerprint TEXT NOT NULL, + status TEXT NOT NULL, + created_at REAL NOT NULL, + activated_at REAL, + retired_at REAL, + revoked_at REAL + ) + """, + """ + CREATE TABLE oauth_access_tokens ( + jti TEXT PRIMARY KEY, + grant_id TEXT NOT NULL REFERENCES oauth_grants(grant_id), + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id), + signing_kid TEXT NOT NULL REFERENCES oauth_signing_keys(kid), + scopes TEXT NOT NULL, + token_mode TEXT NOT NULL DEFAULT 'standard', + issued_at REAL NOT NULL, + expires_at REAL NOT NULL, + last_used_at REAL, + revoked_at REAL, + revoke_reason TEXT + ) + """, + "CREATE INDEX oauth_access_tokens_grant_idx ON oauth_access_tokens(grant_id)", + "CREATE INDEX oauth_access_tokens_expires_idx ON oauth_access_tokens(expires_at)", + """ + CREATE TABLE oauth_refresh_token_families ( + family_id TEXT PRIMARY KEY, + grant_id TEXT NOT NULL REFERENCES oauth_grants(grant_id), + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id), + scopes TEXT NOT NULL, + created_at REAL NOT NULL, + expires_at REAL NOT NULL, + last_used_at REAL, + revoked_at REAL, + revoke_reason TEXT + ) + """, + """ + CREATE TABLE oauth_refresh_tokens ( + token_id TEXT PRIMARY KEY, + family_id TEXT NOT NULL REFERENCES oauth_refresh_token_families(family_id), + token_hash TEXT NOT NULL UNIQUE, + issued_at REAL NOT NULL, + expires_at REAL NOT NULL, + used_at REAL, + revoked_at REAL, + replacement_token_id TEXT REFERENCES oauth_refresh_tokens(token_id), + reuse_detected_at REAL + ) + """, + "CREATE INDEX oauth_refresh_tokens_family_idx ON oauth_refresh_tokens(family_id)", + """ + CREATE TABLE oauth_audit_events ( + event_id TEXT PRIMARY KEY, + timestamp REAL NOT NULL, + event_type TEXT NOT NULL, + client_id TEXT, + grant_id TEXT, + token_id TEXT, + key_id TEXT, + actor_kind TEXT NOT NULL, + details_json TEXT NOT NULL + ) + """, + "CREATE INDEX oauth_audit_events_time_idx ON oauth_audit_events(timestamp DESC)", + ) + + @staticmethod + def validate_client_id(client_id: str) -> str: + if not isinstance(client_id, str) or not 1 <= len(client_id) <= 128: + raise ValueError("OAuth client_id must contain 1-128 characters.") + if not all(char.isalnum() or char in "-._~" for char in client_id): + raise ValueError("OAuth client_id contains unsupported characters.") + return client_id + + @staticmethod + def validate_workspace_id(workspace_id: str) -> str: + if not isinstance(workspace_id, str) or not 1 <= len(workspace_id) <= 128: + raise ValueError("Workspace id must contain 1-128 characters.") + if not all(char.isalnum() or char in "._-" for char in workspace_id): + raise ValueError("Workspace id contains unsupported characters.") + return workspace_id + + @staticmethod + def validate_redirect_uri(value: str) -> str: + from urllib.parse import urlsplit, urlunsplit + + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.fragment: + raise ValueError( + "OAuth redirect_uri must be an absolute http(s) URI without a fragment." + ) + if parsed.username or parsed.password: + raise ValueError("OAuth redirect_uri must not contain user credentials.") + return urlunsplit( + ( + parsed.scheme.lower(), + parsed.netloc.lower(), + parsed.path or "/", + parsed.query, + "", + ) + ) + + def upsert_client( + self, + client_id: str, + *, + display_name: str | None = None, + scopes: str, + redirect_uri: str | None = None, + redirect_uris: tuple[str, ...] | list[str] | None = None, + client_type: str = "public_pkce", + token_endpoint_auth_method: str = "none", + client_secret_digest: str | None = None, + workspace_id: str | None = None, + ) -> None: + client_id = self.validate_client_id(client_id) + if workspace_id is not None: + workspace_id = self.validate_workspace_id(workspace_id) + if redirect_uris is not None and redirect_uri is not None: + raise ValueError("Specify redirect_uri or redirect_uris, not both.") + raw_redirects = list(redirect_uris) if redirect_uris is not None else [redirect_uri] + if not 1 <= len(raw_redirects) <= 10 or any(item is None for item in raw_redirects): + raise ValueError("OAuth clients require between 1 and 10 redirect URIs.") + normalized_redirects = tuple( + self.validate_redirect_uri(str(item)) for item in raw_redirects + ) + if len(set(normalized_redirects)) != len(normalized_redirects): + raise ValueError("OAuth redirect URIs must be unique.") + if token_endpoint_auth_method not in { + "none", + "client_secret_post", + "client_secret_basic", + }: + raise ValueError("Unsupported token_endpoint_auth_method.") + if token_endpoint_auth_method == "none" and client_secret_digest is not None: + raise ValueError("Public OAuth clients must not have a client-secret digest.") + if token_endpoint_auth_method != "none": + if ( + not isinstance(client_secret_digest, str) + or len(client_secret_digest) != 64 + or any( + char not in "0123456789abcdef" + for char in client_secret_digest.lower() + ) + ): + raise ValueError( + "Confidential OAuth clients require a SHA-256 secret digest." + ) + client_secret_digest = client_secret_digest.lower() + redirects_json = json.dumps(list(normalized_redirects), separators=(",", ":")) + now = time.time() + with self._transaction("client upsert", immediate=True) as conn: + existing = conn.execute( + """ + SELECT redirect_uri, redirect_uris_json, enabled, workspace_id + FROM oauth_clients WHERE client_id = ? + """, + (client_id,), + ).fetchone() + existing_redirects = None + if existing is not None: + raw_existing = existing["redirect_uris_json"] + existing_redirects = tuple( + json.loads(raw_existing) + if raw_existing + else [existing["redirect_uri"]] + ) + if ( + existing_redirects is not None + and existing_redirects != normalized_redirects + ): + raise ValueError( + "OAuth redirect URIs do not exactly match the registered client URIs." + ) + if existing is None: + conn.execute( + """ + INSERT INTO oauth_clients( + client_id, display_name, client_type, redirect_uri, + allowed_scopes, created_at, updated_at, first_authorized_at, + redirect_uris_json, token_endpoint_auth_method, + client_secret_digest, workspace_id + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + client_id, + display_name or client_id, + client_type, + normalized_redirects[0], + scopes, + now, + now, + now, + redirects_json, + token_endpoint_auth_method, + client_secret_digest, + workspace_id, + ), + ) + self._audit( + conn, + "client_authorized", + client_id=client_id, + actor_kind="user", + details={ + "redirect_uris": list(normalized_redirects), + "workspace_id": workspace_id, + }, + ) + return + if not bool(existing["enabled"]): + raise OAuthStoreError("OAuth client is disabled.") + conn.execute( + """ + UPDATE oauth_clients + SET display_name=?, client_type=?, allowed_scopes=?, updated_at=?, + redirect_uris_json=?, token_endpoint_auth_method=?, + client_secret_digest=?, workspace_id=COALESCE(?, workspace_id) + WHERE client_id=? + """, + ( + display_name or client_id, + client_type, + scopes, + now, + redirects_json, + token_endpoint_auth_method, + client_secret_digest, + workspace_id, + client_id, + ), + ) + + def get_client(self, client_id: str) -> dict[str, Any] | None: + with self._connection("client query") as conn: + row = conn.execute( + "SELECT * FROM oauth_clients WHERE client_id=?", + (client_id,), + ).fetchone() + return self._client_payload(row) if row is not None else None + + def set_client_workspace(self, client_id: str, workspace_id: str) -> bool: + client_id = self.validate_client_id(client_id) + workspace_id = self.validate_workspace_id(workspace_id) + with self._transaction("client workspace binding", immediate=True) as conn: + row = conn.execute( + "SELECT enabled, revoked_at, workspace_id FROM oauth_clients WHERE client_id=?", + (client_id,), + ).fetchone() + if row is None: + return False + if not bool(row["enabled"]) or row["revoked_at"] is not None: + raise OAuthStoreError("OAuth client is not active.") + if row["workspace_id"] == workspace_id: + return True + now = time.time() + conn.execute( + "UPDATE oauth_clients SET workspace_id=?, updated_at=? WHERE client_id=?", + (workspace_id, now, client_id), + ) + self._audit( + conn, + "client_workspace_bound", + client_id=client_id, + actor_kind="admin", + details={"workspace_id": workspace_id}, + ) + return True + + def set_client_enabled( + self, + client_id: str, + enabled: bool, + *, + reason: str = "administrator", + ) -> bool: + with self._transaction("client state update", immediate=True) as conn: + row = conn.execute( + "SELECT enabled, revoked_at FROM oauth_clients WHERE client_id=?", + (client_id,), + ).fetchone() + if row is None: + return False + if bool(row["enabled"]) is enabled: + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_clients + SET enabled=?, revoked_at=?, updated_at=? + WHERE client_id=? + """, + (1 if enabled else 0, None if enabled else now, now, client_id), + ) + if not enabled: + conn.execute( + """ + UPDATE oauth_grants + SET enabled=0, revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE client_id=? + """, + (now, reason, client_id), + ) + conn.execute( + """ + UPDATE oauth_access_tokens + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE client_id=? + """, + (now, reason, client_id), + ) + conn.execute( + """ + UPDATE oauth_refresh_token_families + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE client_id=? + """, + (now, reason, client_id), + ) + self._audit( + conn, + "client_enabled" if enabled else "client_disabled", + client_id=client_id, + actor_kind="admin", + details={"reason": reason}, + ) + return True + + def create_grant(self, client_id: str, scopes: str) -> str: + now = time.time() + grant_id = str(uuid.uuid4()) + with self._transaction("grant creation", immediate=True) as conn: + client = conn.execute( + "SELECT enabled, revoked_at, workspace_id FROM oauth_clients WHERE client_id=?", + (client_id,), + ).fetchone() + if ( + client is None + or not bool(client["enabled"]) + or client["revoked_at"] is not None + ): + raise OAuthStoreError("OAuth client is not active.") + workspace_id = client["workspace_id"] + if not isinstance(workspace_id, str) or not workspace_id: + raise OAuthStoreError("OAuth client has no authorized Workspace binding.") + conn.execute( + """ + INSERT INTO oauth_grants( + grant_id, client_id, scopes, workspace_id, created_at, updated_at + ) VALUES(?,?,?,?,?,?) + """, + (grant_id, client_id, scopes, workspace_id, now, now), + ) + self._audit( + conn, + "grant_created", + client_id=client_id, + grant_id=grant_id, + actor_kind="user", + details={"scopes": scopes, "workspace_id": workspace_id}, + ) + return grant_id + + def get_grant(self, grant_id: str) -> dict[str, Any] | None: + with self._connection("grant query") as conn: + row = conn.execute( + "SELECT * FROM oauth_grants WHERE grant_id=?", + (grant_id,), + ).fetchone() + return dict(row) if row is not None else None + + def revoke_grant(self, grant_id: str, *, reason: str = "administrator") -> bool: + with self._transaction("grant revocation", immediate=True) as conn: + row = conn.execute( + "SELECT client_id, revoked_at FROM oauth_grants WHERE grant_id=?", + (grant_id,), + ).fetchone() + if row is None: + return False + if row["revoked_at"] is not None: + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_grants + SET enabled=0, revoked_at=?, revoke_reason=?, updated_at=? + WHERE grant_id=? + """, + (now, reason, now, grant_id), + ) + conn.execute( + """ + UPDATE oauth_access_tokens + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE grant_id=? + """, + (now, reason, grant_id), + ) + conn.execute( + """ + UPDATE oauth_refresh_token_families + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE grant_id=? + """, + (now, reason, grant_id), + ) + self._audit( + conn, + "grant_revoked", + client_id=row["client_id"], + grant_id=grant_id, + actor_kind="admin", + details={"reason": reason}, + ) + return True + + def register_signing_key( + self, + kid: str, + fingerprint: str, + *, + secret_ref: str, + algorithm: str = "HS256", + active: bool = True, + ) -> None: + if not kid or not fingerprint or not secret_ref: + raise ValueError("Signing keys require kid, fingerprint, and secret_ref metadata.") + now = time.time() + with self._transaction("signing-key registration", immediate=True) as conn: + existing = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + if existing is not None and existing["status"] == "revoked": + raise OAuthStoreError("A revoked signing key cannot be registered again.") + if active: + conn.execute( + """ + UPDATE oauth_signing_keys + SET status='retired', retired_at=COALESCE(retired_at,?) + WHERE status='active' AND kid<>? + """, + (now, kid), + ) + status = "active" if active else "retired" + conn.execute( + """ + INSERT INTO oauth_signing_keys( + kid, algorithm, fingerprint, status, created_at, + activated_at, retired_at, secret_ref + ) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(kid) DO UPDATE SET + algorithm=excluded.algorithm, + fingerprint=excluded.fingerprint, + status=excluded.status, + activated_at=excluded.activated_at, + retired_at=excluded.retired_at, + secret_ref=excluded.secret_ref + """, + ( + kid, + algorithm, + fingerprint, + status, + now, + now if active else None, + None if active else now, + secret_ref, + ), + ) + self._audit( + conn, + "signing_key_registered", + key_id=kid, + actor_kind="admin", + details={"status": status}, + ) + + def activate_signing_key(self, kid: str) -> bool: + with self._transaction("signing-key activation", immediate=True) as conn: + row = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + if row is None or row["status"] == "revoked": + return False + if row["status"] == "active": + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_signing_keys + SET status='retired', retired_at=COALESCE(retired_at,?) + WHERE status='active' AND kid<>? + """, + (now, kid), + ) + conn.execute( + """ + UPDATE oauth_signing_keys + SET status='active', activated_at=?, retired_at=NULL + WHERE kid=? + """, + (now, kid), + ) + self._audit( + conn, + "signing_key_activated", + key_id=kid, + actor_kind="admin", + details={}, + ) + return True + + def retire_signing_key(self, kid: str) -> bool: + with self._transaction("signing-key retirement", immediate=True) as conn: + row = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + if row is None or row["status"] == "revoked": + return False + if row["status"] == "retired": + return True + now = time.time() + conn.execute( + "UPDATE oauth_signing_keys SET status='retired', retired_at=? WHERE kid=?", + (now, kid), + ) + self._audit( + conn, + "signing_key_retired", + key_id=kid, + actor_kind="admin", + details={}, + ) + return True + + def revoke_signing_key(self, kid: str) -> bool: + with self._transaction("signing-key revocation", immediate=True) as conn: + row = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + if row is None: + return False + if row["status"] == "revoked": + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_signing_keys + SET status='revoked', revoked_at=? + WHERE kid=? + """, + (now, kid), + ) + conn.execute( + """ + UPDATE oauth_access_tokens + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,'signing_key_revoked') + WHERE signing_kid=? + """, + (now, kid), + ) + self._audit( + conn, + "signing_key_revoked", + key_id=kid, + actor_kind="admin", + details={"severity": "high"}, + ) + return True + + def signing_key_is_usable(self, kid: str) -> bool: + with self._connection("signing-key query") as conn: + row = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + return row is not None and row["status"] in {"active", "retired"} + + def record_access_token( + self, + jti: str, + grant_id: str, + client_id: str, + signing_kid: str, + scopes: str, + *, + issued_at: float, + expires_at: float, + token_mode: str = "standard", + ) -> None: + if not jti: + raise ValueError("Access-token jti must not be empty.") + with self._transaction("access-token recording", immediate=True) as conn: + self._record_access_token_in_transaction( + conn, + jti, + grant_id, + client_id, + signing_kid, + scopes, + issued_at=issued_at, + expires_at=expires_at, + token_mode=token_mode, + ) + + def _record_access_token_in_transaction( + self, + conn: sqlite3.Connection, + jti: str, + grant_id: str, + client_id: str, + signing_kid: str, + scopes: str, + *, + issued_at: float, + expires_at: float, + token_mode: str, + ) -> None: + conn.execute( + """ + INSERT INTO oauth_access_tokens( + jti, grant_id, client_id, signing_kid, scopes, + token_mode, issued_at, expires_at + ) VALUES(?,?,?,?,?,?,?,?) + """, + ( + jti, + grant_id, + client_id, + signing_kid, + scopes, + token_mode, + issued_at, + expires_at, + ), + ) + self._audit( + conn, + "access_token_issued", + client_id=client_id, + grant_id=grant_id, + token_id=jti, + key_id=signing_kid, + actor_kind="server", + details={"mode": token_mode}, + ) + + def active_access_token_identity( + self, + jti: str, + *, + now: float | None = None, + ) -> dict[str, str] | None: + checked_at = time.time() if now is None else now + with self._transaction("access-token query") as conn: + row = conn.execute( + """ + SELECT t.expires_at, t.revoked_at, t.client_id, t.grant_id, + c.enabled AS client_enabled, c.revoked_at AS client_revoked, + g.enabled AS grant_enabled, g.revoked_at AS grant_revoked, + g.workspace_id AS workspace_id, k.status AS key_status + FROM oauth_access_tokens t + JOIN oauth_clients c ON c.client_id=t.client_id + JOIN oauth_grants g ON g.grant_id=t.grant_id + JOIN oauth_signing_keys k ON k.kid=t.signing_kid + WHERE t.jti=? + """, + (jti,), + ).fetchone() + if row is None: + return None + workspace_id = row["workspace_id"] + active = ( + row["expires_at"] > checked_at + and row["revoked_at"] is None + and bool(row["client_enabled"]) + and row["client_revoked"] is None + and bool(row["grant_enabled"]) + and row["grant_revoked"] is None + and row["key_status"] in {"active", "retired"} + and isinstance(workspace_id, str) + and bool(workspace_id) + ) + if not active: + return None + conn.execute( + """ + UPDATE oauth_access_tokens + SET last_used_at=? + WHERE jti=? AND (last_used_at IS NULL OR last_used_at < ?) + """, + (checked_at, jti, checked_at - 60), + ) + return { + "client_id": str(row["client_id"]), + "grant_id": str(row["grant_id"]), + "workspace_id": workspace_id, + "jti": jti, + } + + def access_token_is_active(self, jti: str, *, now: float | None = None) -> bool: + return self.active_access_token_identity(jti, now=now) is not None + + def revoke_access_token(self, jti: str, *, reason: str = "administrator") -> bool: + with self._transaction("access-token revocation", immediate=True) as conn: + row = conn.execute( + """ + SELECT client_id, grant_id, revoked_at + FROM oauth_access_tokens WHERE jti=? + """, + (jti,), + ).fetchone() + if row is None: + return False + if row["revoked_at"] is not None: + return True + conn.execute( + """ + UPDATE oauth_access_tokens + SET revoked_at=?, revoke_reason=? WHERE jti=? + """, + (time.time(), reason, jti), + ) + self._audit( + conn, + "access_token_revoked", + client_id=row["client_id"], + grant_id=row["grant_id"], + token_id=jti, + actor_kind="admin", + details={"reason": reason}, + ) + return True + + def issue_refresh_token( + self, + grant_id: str, + client_id: str, + scopes: str, + *, + expires_at: float, + ) -> tuple[str, str]: + family_id = str(uuid.uuid4()) + token_id = str(uuid.uuid4()) + token = secrets.token_urlsafe(48) + now = time.time() + if expires_at <= now: + raise ValueError("Refresh-token expiry must be in the future.") + with self._transaction("refresh-token issuance", immediate=True) as conn: + state = conn.execute( + """ + SELECT g.enabled AS grant_enabled, g.revoked_at AS grant_revoked, + c.enabled AS client_enabled, c.revoked_at AS client_revoked + FROM oauth_grants g + JOIN oauth_clients c ON c.client_id=g.client_id + WHERE g.grant_id=? AND g.client_id=? + """, + (grant_id, client_id), + ).fetchone() + if ( + state is None + or not bool(state["grant_enabled"]) + or state["grant_revoked"] is not None + or not bool(state["client_enabled"]) + or state["client_revoked"] is not None + ): + raise OAuthStoreError("OAuth client or grant is not active.") + conn.execute( + """ + INSERT INTO oauth_refresh_token_families( + family_id, grant_id, client_id, scopes, created_at, expires_at + ) VALUES(?,?,?,?,?,?) + """, + (family_id, grant_id, client_id, scopes, now, expires_at), + ) + conn.execute( + """ + INSERT INTO oauth_refresh_tokens( + token_id, family_id, token_hash, issued_at, expires_at + ) VALUES(?,?,?,?,?) + """, + (token_id, family_id, self._refresh_hash(token), now, expires_at), + ) + self._audit( + conn, + "refresh_token_issued", + client_id=client_id, + grant_id=grant_id, + token_id=family_id, + actor_kind="server", + details={}, + ) + return family_id, token + + def refresh_token_binding(self, token: str) -> RefreshTokenBinding | None: + digest = self._refresh_hash(token) + with self._connection("refresh-token binding") as conn: + row = self._refresh_token_row(conn, digest) + if row is None: + return None + return RefreshTokenBinding( + family_id=str(row["family_id"]), + client_id=str(row["client_id"]), + grant_id=str(row["grant_id"]), + scopes=str(row["scopes"]), + ) + + def rotate_refresh_token( + self, + token: str, + *, + expires_at: float, + ) -> RefreshTokenResult | None: + now = time.time() + digest = self._refresh_hash(token) + with self._transaction("refresh-token rotation", immediate=True) as conn: + row = self._refresh_token_row(conn, digest) + return self._rotate_refresh_token_in_transaction( + conn, + row, + expires_at=expires_at, + now=now, + ) + + def rotate_refresh_token_and_record_access_token( + self, + token: str, + *, + expected_client_id: str, + refresh_expires_at: float, + access_jti: str, + access_signing_kid: str, + access_scopes: str, + access_issued_at: float, + access_expires_at: float, + token_mode: str = "standard", + ) -> RefreshTokenResult | None: + if not expected_client_id: + raise ValueError("Expected OAuth client id must not be empty.") + if not access_jti: + raise ValueError("Access-token jti must not be empty.") + now = time.time() + digest = self._refresh_hash(token) + with self._transaction("refresh-token exchange", immediate=True) as conn: + row = self._refresh_token_row(conn, digest) + if row is None: + return None + stored_client_id = str(row["client_id"]) + if not secrets.compare_digest(stored_client_id, expected_client_id): + raise RefreshTokenClientMismatchError( + "Refresh token is bound to a different OAuth client." + ) + stored_scopes = str(row["scopes"]) + if stored_scopes != access_scopes: + raise OAuthStoreError( + "Access-token scopes do not match the refresh-token family." + ) + rotated = self._rotate_refresh_token_in_transaction( + conn, + row, + expires_at=refresh_expires_at, + now=now, + ) + if rotated is None: + return None + self._record_access_token_in_transaction( + conn, + access_jti, + rotated.grant_id, + rotated.client_id, + access_signing_kid, + rotated.scopes, + issued_at=access_issued_at, + expires_at=access_expires_at, + token_mode=token_mode, + ) + return rotated + + @staticmethod + def _refresh_token_row( + conn: sqlite3.Connection, + digest: str, + ) -> sqlite3.Row | None: + return conn.execute( + """ + SELECT t.token_id, t.family_id, t.used_at, + t.revoked_at AS token_revoked, t.replacement_token_id, + t.expires_at AS token_expires_at, + f.grant_id, f.client_id, f.scopes, + f.expires_at AS family_expires_at, + f.revoked_at AS family_revoked, + g.enabled AS grant_enabled, g.revoked_at AS grant_revoked, + c.enabled AS client_enabled, c.revoked_at AS client_revoked + FROM oauth_refresh_tokens t + JOIN oauth_refresh_token_families f ON f.family_id=t.family_id + JOIN oauth_grants g ON g.grant_id=f.grant_id + JOIN oauth_clients c ON c.client_id=f.client_id + WHERE t.token_hash=? + """, + (digest,), + ).fetchone() + + def _rotate_refresh_token_in_transaction( + self, + conn: sqlite3.Connection, + row: sqlite3.Row | None, + *, + expires_at: float, + now: float, + ) -> RefreshTokenResult | None: + if row is None: + return None + already_used = ( + row["used_at"] is not None + or row["token_revoked"] is not None + or row["replacement_token_id"] is not None + ) + if already_used: + if row["replacement_token_id"] is not None and row["family_revoked"] is None: + conn.execute( + """ + UPDATE oauth_refresh_token_families + SET revoked_at=?, revoke_reason='refresh_token_reuse' + WHERE family_id=? + """, + (now, row["family_id"]), + ) + conn.execute( + """ + UPDATE oauth_refresh_tokens + SET revoked_at=COALESCE(revoked_at,?), + reuse_detected_at=CASE WHEN token_id=? THEN ? ELSE reuse_detected_at END + WHERE family_id=? + """, + (now, row["token_id"], now, row["family_id"]), + ) + self._audit( + conn, + "refresh_token_reuse", + client_id=row["client_id"], + grant_id=row["grant_id"], + token_id=row["family_id"], + actor_kind="server", + details={"severity": "high"}, + ) + return None + valid = ( + row["token_expires_at"] > now + and row["family_expires_at"] > now + and row["family_revoked"] is None + and bool(row["grant_enabled"]) + and row["grant_revoked"] is None + and bool(row["client_enabled"]) + and row["client_revoked"] is None + ) + if not valid: + return None + new_token = secrets.token_urlsafe(48) + new_id = str(uuid.uuid4()) + new_expiry = min(expires_at, float(row["family_expires_at"])) + if new_expiry <= now: + return None + conn.execute( + """ + INSERT INTO oauth_refresh_tokens( + token_id, family_id, token_hash, issued_at, expires_at + ) VALUES(?,?,?,?,?) + """, + ( + new_id, + row["family_id"], + self._refresh_hash(new_token), + now, + new_expiry, + ), + ) + conn.execute( + """ + UPDATE oauth_refresh_tokens + SET used_at=?, revoked_at=?, replacement_token_id=? + WHERE token_id=? + """, + (now, now, new_id, row["token_id"]), + ) + conn.execute( + "UPDATE oauth_refresh_token_families SET last_used_at=? WHERE family_id=?", + (now, row["family_id"]), + ) + self._audit( + conn, + "refresh_token_rotated", + client_id=row["client_id"], + grant_id=row["grant_id"], + token_id=row["family_id"], + actor_kind="server", + details={}, + ) + return RefreshTokenResult( + str(row["family_id"]), + new_token, + str(row["client_id"]), + str(row["grant_id"]), + str(row["scopes"]), + ) + + def refresh_family_is_revoked(self, family_id: str) -> bool: + with self._connection("refresh-family query") as conn: + row = conn.execute( + "SELECT revoked_at FROM oauth_refresh_token_families WHERE family_id=?", + (family_id,), + ).fetchone() + return row is not None and row["revoked_at"] is not None + + def revoke_refresh_family( + self, + family_id: str, + *, + reason: str = "administrator", + ) -> bool: + with self._transaction("refresh-family revocation", immediate=True) as conn: + row = conn.execute( + """ + SELECT client_id, grant_id, revoked_at + FROM oauth_refresh_token_families WHERE family_id=? + """, + (family_id,), + ).fetchone() + if row is None: + return False + if row["revoked_at"] is not None: + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_refresh_token_families + SET revoked_at=?, revoke_reason=? WHERE family_id=? + """, + (now, reason, family_id), + ) + conn.execute( + """ + UPDATE oauth_refresh_tokens + SET revoked_at=COALESCE(revoked_at,?) WHERE family_id=? + """, + (now, family_id), + ) + self._audit( + conn, + "refresh_family_revoked", + client_id=row["client_id"], + grant_id=row["grant_id"], + token_id=family_id, + actor_kind="admin", + details={"reason": reason}, + ) + return True + + def list_clients(self) -> list[dict[str, Any]]: + with self._connection("client listing") as conn: + rows = conn.execute( + "SELECT * FROM oauth_clients ORDER BY created_at DESC, client_id" + ).fetchall() + return [self._client_payload(row) for row in rows] + + @staticmethod + def _client_payload(row: sqlite3.Row) -> dict[str, Any]: + item = dict(row) + raw_redirects = item.pop("redirect_uris_json", None) + item["redirect_uris"] = ( + json.loads(raw_redirects) if raw_redirects else [item["redirect_uri"]] + ) + return item + + def list_grants(self, client_id: str | None = None) -> list[dict[str, Any]]: + query = "SELECT * FROM oauth_grants" + args: tuple[Any, ...] = () + if client_id: + query += " WHERE client_id=?" + args = (client_id,) + query += " ORDER BY created_at DESC, grant_id" + with self._connection("grant listing") as conn: + rows = conn.execute(query, args).fetchall() + return [dict(row) for row in rows] + + def list_access_tokens(self, client_id: str | None = None) -> list[dict[str, Any]]: + query = "SELECT * FROM oauth_access_tokens" + args: tuple[Any, ...] = () + if client_id: + query += " WHERE client_id=?" + args = (client_id,) + query += " ORDER BY issued_at DESC, jti" + with self._connection("access-token listing") as conn: + rows = conn.execute(query, args).fetchall() + return [dict(row) for row in rows] + + def list_refresh_token_families( + self, + client_id: str | None = None, + ) -> list[dict[str, Any]]: + query = "SELECT * FROM oauth_refresh_token_families" + args: tuple[Any, ...] = () + if client_id: + query += " WHERE client_id=?" + args = (client_id,) + query += " ORDER BY created_at DESC, family_id" + with self._connection("refresh-family listing") as conn: + rows = conn.execute(query, args).fetchall() + return [dict(row) for row in rows] + + def list_signing_keys(self) -> list[dict[str, Any]]: + with self._connection("signing-key listing") as conn: + rows = conn.execute( + "SELECT * FROM oauth_signing_keys ORDER BY created_at DESC, kid" + ).fetchall() + return [dict(row) for row in rows] + + def list_audit_events(self, *, limit: int = 100) -> list[dict[str, Any]]: + bounded_limit = max(1, min(int(limit), 500)) + with self._connection("audit listing") as conn: + rows = conn.execute( + """ + SELECT * FROM oauth_audit_events + ORDER BY timestamp DESC, event_id DESC LIMIT ? + """, + (bounded_limit,), + ).fetchall() + result: list[dict[str, Any]] = [] + for row in rows: + item = dict(row) + item["details"] = json.loads(item.pop("details_json")) + result.append(item) + return result + + def _refresh_hash(self, token: str) -> str: + if not isinstance(token, str) or not token: + raise ValueError("Refresh token must be a non-empty string.") + return hmac.new(self.pepper, token.encode("utf-8"), hashlib.sha256).hexdigest() + + @staticmethod + def _audit( + conn: sqlite3.Connection, + event_type: str, + *, + client_id: str | None = None, + grant_id: str | None = None, + token_id: str | None = None, + key_id: str | None = None, + actor_kind: str, + details: dict[str, Any], + ) -> None: + conn.execute( + """ + INSERT INTO oauth_audit_events( + event_id, timestamp, event_type, client_id, grant_id, + token_id, key_id, actor_kind, details_json + ) VALUES(?,?,?,?,?,?,?,?,?) + """, + ( + str(uuid.uuid4()), + time.time(), + event_type, + client_id, + grant_id, + token_id, + key_id, + actor_kind, + json.dumps(details, sort_keys=True, ensure_ascii=True), + ), + ) diff --git a/coding_tools_mcp/processes.py b/coding_tools_mcp/processes.py index 7de337c..82049d8 100644 --- a/coding_tools_mcp/processes.py +++ b/coding_tools_mcp/processes.py @@ -16,6 +16,26 @@ HARD_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) +def _terminate_windows_process_tree(process: subprocess.Popen[bytes]) -> bool: + try: + completed = subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + if completed.returncode != 0: + return False + try: + process.wait(timeout=2) + except (OSError, subprocess.SubprocessError): + pass + return True + + def terminate_process_group( process: subprocess.Popen[bytes], signum: signal.Signals, @@ -23,15 +43,18 @@ def terminate_process_group( force: bool = False, ) -> None: if not hasattr(os, "killpg"): - if os.name == "nt" and not force: - event = getattr(signal, "CTRL_BREAK_EVENT", None) - if event is not None: - try: - process.send_signal(event) - process.wait(timeout=1) - return - except Exception: - pass + if os.name == "nt": + if not force: + event = getattr(signal, "CTRL_BREAK_EVENT", None) + if event is not None: + try: + process.send_signal(event) + process.wait(timeout=1) + return + except Exception: + pass + if _terminate_windows_process_tree(process): + return try: if force: process.kill() @@ -87,10 +110,11 @@ def spawn_process( details={"platform": os.name, "retry_hint": "Run the command without tty=true."}, ) try: - import pty - - master_fd, slave_fd = pty.openpty() - except (ImportError, OSError) as exc: + openpty = getattr(os, "openpty", None) + if openpty is None: + raise OSError("POSIX pseudo-terminal support is unavailable.") + master_fd, slave_fd = openpty() + except OSError as exc: raise ToolFailure( "TTY_UNSUPPORTED", "A POSIX pseudo-terminal could not be created.", diff --git a/coding_tools_mcp/secret_vault.py b/coding_tools_mcp/secret_vault.py new file mode 100644 index 0000000..0fa43cf --- /dev/null +++ b/coding_tools_mcp/secret_vault.py @@ -0,0 +1,237 @@ +"""Small encrypted secret store used by server-side persistence modules.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +VAULT_VERSION = 1 +RECORD_VERSION = 1 +KDF_NAME = "pbkdf2-sha256" +CIPHER_NAME = "hmac-sha256-stream+hmac-sha256" + + +class SecretVaultError(ValueError): + pass + + +class SecretVault: + def __init__(self, path: str | Path | None, master_key: str | None) -> None: + self.path = Path(path).expanduser() if path else None + self.master_key = master_key or None + + def enabled(self) -> bool: + return self.path is not None and self.master_key is not None + + def status_payload(self) -> dict[str, Any]: + return { + "enabled": self.enabled(), + "path": str(self.path) if self.path else None, + "secret_count": len(self.list_names()), + } + + def list_names(self) -> list[str]: + raw = self._read_raw(require_key=False) + secrets = raw["secrets"] + return sorted(secrets) + + def set_secret(self, name: str, value: str) -> None: + self._require_enabled() + validate_secret_name(name) + if not isinstance(value, str): + raise SecretVaultError("Secret value must be a string.") + assert self.master_key is not None + raw = self._read_raw(require_key=True) + raw["secrets"][name] = encrypt_value(value, self.master_key) + self._write_raw(raw) + + def get_secret(self, name: str) -> str: + self._require_enabled() + validate_secret_name(name) + assert self.master_key is not None + raw = self._read_raw(require_key=True) + record = raw["secrets"].get(name) + if not isinstance(record, dict): + raise SecretVaultError(f"Secret {name!r} is not set.") + return decrypt_value(record, self.master_key) + + def delete_secret(self, name: str) -> bool: + self._require_enabled() + validate_secret_name(name) + raw = self._read_raw(require_key=True) + existed = name in raw["secrets"] + if existed: + raw["secrets"].pop(name) + self._write_raw(raw) + return existed + + def _require_enabled(self) -> None: + if self.path is None: + raise SecretVaultError("Secret vault path is not configured.") + if self.master_key is None: + raise SecretVaultError( + "CODING_TOOLS_MCP_SECRETS_KEY is required to read or write secret values." + ) + + def _read_raw(self, *, require_key: bool) -> dict[str, Any]: + if require_key: + self._require_enabled() + if self.path is None or not self.path.exists(): + return {"version": VAULT_VERSION, "secrets": {}} + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except OSError as exc: + raise SecretVaultError(f"Could not read secret vault: {exc}") from exc + except json.JSONDecodeError as exc: + raise SecretVaultError(f"Secret vault is not valid JSON: {exc}") from exc + if not isinstance(raw, dict): + raise SecretVaultError("Secret vault must be a JSON object.") + if raw.get("version") != VAULT_VERSION: + raise SecretVaultError("Secret vault was written by an unsupported version.") + secrets = raw.get("secrets") + if not isinstance(secrets, dict): + raise SecretVaultError("Secret vault secrets field must be an object.") + for name, record in secrets.items(): + validate_secret_name(name) + if not isinstance(record, dict): + raise SecretVaultError("Secret vault contains an invalid record.") + return {"version": VAULT_VERSION, "secrets": dict(secrets)} + + def _write_raw(self, raw: dict[str, Any]) -> None: + if self.path is None: + raise SecretVaultError("Secret vault path is not configured.") + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", + suffix=".tmp", + dir=self.path.parent, + ) + tmp_path = Path(tmp_name) + payload = {"version": VAULT_VERSION, "secrets": raw.get("secrets", {})} + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + tmp_path.chmod(0o600) + os.replace(tmp_path, self.path) + _fsync_directory(self.path.parent) + except OSError as exc: + raise SecretVaultError(f"Could not atomically save secret vault: {exc}") from exc + finally: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + + +def validate_secret_name(name: str) -> None: + if not isinstance(name, str) or not name or len(name) > 128: + raise SecretVaultError("Secret name must be a non-empty string up to 128 characters.") + allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-/") + if any(char not in allowed for char in name): + raise SecretVaultError("Secret name contains unsupported characters.") + + +def encrypt_value(value: str, master_key: str) -> dict[str, str | int]: + salt = os.urandom(16) + nonce = os.urandom(16) + enc_key, mac_key = derive_keys(master_key, salt) + plaintext = value.encode("utf-8") + ciphertext = xor_bytes(plaintext, key_stream(enc_key, nonce, len(plaintext))) + tag = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest() + return { + "version": RECORD_VERSION, + "kdf": KDF_NAME, + "cipher": CIPHER_NAME, + "salt": b64e(salt), + "nonce": b64e(nonce), + "ciphertext": b64e(ciphertext), + "tag": b64e(tag), + } + + +def decrypt_value(record: dict[str, Any], master_key: str) -> str: + if ( + record.get("version") != RECORD_VERSION + or record.get("kdf") != KDF_NAME + or record.get("cipher") != CIPHER_NAME + ): + raise SecretVaultError("Secret record uses an unsupported format.") + try: + salt = b64d(str(record["salt"])) + nonce = b64d(str(record["nonce"])) + ciphertext = b64d(str(record["ciphertext"])) + tag = b64d(str(record["tag"])) + except (KeyError, ValueError) as exc: + raise SecretVaultError("Secret record is corrupt.") from exc + if len(salt) != 16 or len(nonce) != 16 or len(tag) != hashlib.sha256().digest_size: + raise SecretVaultError("Secret record is corrupt.") + enc_key, mac_key = derive_keys(master_key, salt) + expected = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest() + if not hmac.compare_digest(tag, expected): + raise SecretVaultError("Secret vault key is incorrect or the record was modified.") + plaintext = xor_bytes(ciphertext, key_stream(enc_key, nonce, len(ciphertext))) + try: + return plaintext.decode("utf-8") + except UnicodeDecodeError as exc: + raise SecretVaultError("Secret record plaintext is not valid UTF-8.") from exc + + +def derive_keys(master_key: str, salt: bytes) -> tuple[bytes, bytes]: + key_material = hashlib.pbkdf2_hmac( + "sha256", + master_key.encode("utf-8"), + salt, + 200_000, + dklen=64, + ) + return key_material[:32], key_material[32:] + + +def key_stream(key: bytes, nonce: bytes, length: int) -> bytes: + output = bytearray() + counter = 0 + while len(output) < length: + output.extend( + hmac.new(key, nonce + counter.to_bytes(8, "big"), hashlib.sha256).digest() + ) + counter += 1 + return bytes(output[:length]) + + +def xor_bytes(left: bytes, right: bytes) -> bytes: + return bytes(a ^ b for a, b in zip(left, right, strict=True)) + + +def b64e(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii") + + +def b64d(value: str) -> bytes: + try: + return base64.b64decode(value.encode("ascii"), altchars=b"-_", validate=True) + except (ValueError, UnicodeEncodeError) as exc: + raise ValueError("invalid base64") from exc + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + try: + descriptor = os.open(path, os.O_RDONLY) + except OSError: + return + try: + os.fsync(descriptor) + finally: + os.close(descriptor) diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 24a3bb4..9950393 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import copy import base64 import ctypes import hashlib @@ -32,23 +33,54 @@ from typing import Any, cast from . import __version__ +from .admin import ( + ADMIN_API_PREFIX, + SERVER_SECRET_VAULT_FILENAME, + AdminService, + AdminServiceError, + AdminUnavailableError, + gateway_file_revision, +) from .envutils import ENV_PREFIX, truthy_env +from .codex_sessions import CodexSessionScanner from .errors import JsonRpcError, ToolFailure from .landlock_exec import libc_syscall from .oauth import ( OAUTH_CODE_TTL_SECONDS, OAUTH_GRANT_TYPE_AUTHORIZATION_CODE, + OAUTH_GRANT_TYPE_REFRESH_TOKEN, OAUTH_GRANT_TYPES_SUPPORTED, OAUTH_MAX_BODY_BYTES, OAUTH_RESPONSE_TYPES_SUPPORTED, MAX_PENDING_CODES, OAUTH_TOKEN_TTL_SECONDS, + OAuthClientAuthenticationError, OAuthConfig, + OAuthIdentity, + OAuthInvalidGrantError, + OAuthServiceError, + PersistentOAuthClientRegistry, + authenticate_access_token, create_access_token, + create_authorization_grant, + exchange_refresh_token, + initialize_signing_key_ring, + issue_refresh_token, valid_pkce_challenge, - validate_access_token, verify_pkce, ) +from .oauth_store import OAuthAuthorizationStore, OAuthStoreError +from .secret_vault import SecretVault, SecretVaultError +from .settings_definition import ( + SettingsValidationError, + normalize_allowed_origins, + normalize_oauth_client_workspace_bindings, +) +from .settings_store import ( + ServerSettingsStore, + SettingsStoreError, + default_settings_dir, +) from .patching import ( AtomicPatchCommitter, FileBaseline, @@ -79,8 +111,22 @@ from .telemetry import SessionTelemetry from .textutils import DEFAULT_MAX_LINES, TextTruncation, truncate_text_head from .tool_results import make_tool_result +from .transcript import TranscriptStore from .transport_http import HTTPSessionManager from .transport_stdio import serve_stdio +from .upstream import ( + UpstreamConfigError, + UpstreamConfigSnapshot, + UpstreamManager, + load_upstream_config_snapshot, +) +from .workspace_binding import ( + WorkspaceBinding, + WorkspaceBindingError, + WorkspaceBindingResolver, +) +from .webui import admin_console_html +from .workspace_catalog import WorkspaceCatalog, WorkspaceCatalogError SERVER_NAME = "coding-tools-mcp" @@ -323,6 +369,20 @@ class RuntimePolicy: fake_readonly_annotations: bool = False +@dataclass(frozen=True) +class AuthorizationContext: + method: str + oauth_identity: OAuthIdentity | None = None + + def authorization_key(self, workspace_id: str) -> tuple[str, str | None, str | None, str]: + return ( + self.method, + self.oauth_identity.client_id if self.oauth_identity is not None else None, + self.oauth_identity.grant_id if self.oauth_identity is not None else None, + workspace_id, + ) + + OAUTH_TOKEN_AUTH_METHODS = ("client_secret_basic", "client_secret_post", "none") @@ -700,35 +760,25 @@ def json_response_payload(payload: Any) -> bytes: return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") -@functools.lru_cache(maxsize=8) -def _configured_allowed_origins(raw: str) -> frozenset[str]: - return frozenset(item.strip().rstrip("/") for item in raw.split(",") if item.strip()) +_ACTIVE_ALLOWED_ORIGINS: frozenset[str] = frozenset() + + +def configure_allowed_origins(value: Any) -> frozenset[str]: + global _ACTIVE_ALLOWED_ORIGINS + normalized = frozenset(normalize_allowed_origins(value)) + _ACTIVE_ALLOWED_ORIGINS = normalized + return normalized def is_allowed_origin(origin: str) -> bool: - # Authentication does not replace browser Origin validation. + # Authentication does not replace browser Origin validation. The same + # canonical validator is used for startup and Admin settings writes. try: - parsed = urllib.parse.urlparse(origin) - except ValueError: - return False - if ( - parsed.scheme not in {"http", "https"} - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.path not in {"", "/"} - or parsed.params - or parsed.query - or parsed.fragment - ): + normalized_values = normalize_allowed_origins([origin]) + parsed = urllib.parse.urlsplit(normalized_values[0]) + except (IndexError, SettingsValidationError, ValueError): return False - try: - _ = parsed.port - except ValueError: - return False - normalized = origin.rstrip("/") - configured = _configured_allowed_origins(os.environ.get(f"{ENV_PREFIX}_ALLOWED_ORIGINS", "")) - return parsed.hostname in {"localhost", "127.0.0.1", "::1"} or normalized in configured + return parsed.hostname in {"localhost", "127.0.0.1", "::1"} or normalized_values[0] in _ACTIVE_ALLOWED_ORIGINS def is_loopback_bind_host(host: str) -> bool: @@ -1213,17 +1263,28 @@ def __init__( auth_token: str | None = None, oauth_config: OAuthConfig | None = None, project_context: ProjectContext | None = None, + workspace_binding: WorkspaceBinding | None = None, + authorization_context: AuthorizationContext | None = None, + upstream_manager: UpstreamManager | None = None, fake_readonly_annotations: bool = False, transport: str = "stdio", ) -> None: self.workspace = Workspace(workspace) + if workspace_binding is not None and workspace_binding.root != self.workspace.root: + raise ToolFailure( + "INVALID_ARGUMENT", + "Workspace binding root does not match Runtime workspace.", + category="validation", + ) + self.workspace_binding = workspace_binding or WorkspaceBinding( + "default", + self.workspace.root, + transport, + ) + self.authorization_context = authorization_context or AuthorizationContext( + self.workspace_binding.authorization_method + ) self.enable_view_image = enable_view_image - self._exposed_tool_names = [ - name - for name, spec in TOOL_REGISTRY.items() - if spec.gated_by is None or getattr(self, spec.gated_by) - ] - self._exposed_tool_name_set = frozenset(self._exposed_tool_names) if permission_mode not in PERMISSION_MODE_CHOICES: raise ToolFailure( "INVALID_ARGUMENT", @@ -1256,6 +1317,32 @@ def __init__( self.allow_network = allow_network or self.capabilities.network self.auth_token = auth_token or None self.oauth_config = oauth_config + self.upstream_manager = upstream_manager or UpstreamManager.empty( + PROTOCOL_VERSION, + reserved_names=TOOL_REGISTRY, + ) + local_tool_names = [ + name + for name, spec in TOOL_REGISTRY.items() + if spec.gated_by is None or getattr(self, spec.gated_by) + ] + upstream_definitions = self.upstream_manager.tool_definitions() + self._upstream_tool_definitions = { + str(definition["name"]): definition for definition in upstream_definitions + } + upstream_tool_names = self.upstream_manager.tool_names() + collisions = sorted(set(TOOL_REGISTRY) & set(upstream_tool_names)) + if collisions: + self.upstream_manager.close() + raise ToolFailure( + "UPSTREAM_TOOL_COLLISION", + f"Upstream Gateway collided with reserved local tools: {', '.join(collisions)}", + category="configuration", + ) + self._local_tool_name_set = frozenset(local_tool_names) + self._upstream_tool_name_set = frozenset(upstream_tool_names) + self._exposed_tool_names = [*local_tool_names, *upstream_tool_names] + self._exposed_tool_name_set = frozenset(self._exposed_tool_names) self.server_instance_id = secrets.token_urlsafe(12) self._set_runtime_dir(runtime_dir_for_workspace(self.workspace.root, self.server_instance_id)) self.fallback_runtime_dir = fallback_runtime_dir_for_workspace(self.workspace.root, self.server_instance_id) @@ -1283,6 +1370,9 @@ def __init__( self.telemetry = SessionTelemetry(permission_mode=self.permission_mode, transport=transport) self._tool_handlers = {name: getattr(self, name) for name in TOOL_REGISTRY} + def session_authorization_key(self) -> tuple[str, str | None, str | None, str]: + return self.authorization_context.authorization_key(self.workspace_binding.workspace_id) + def _set_runtime_dir(self, runtime_dir: Path) -> None: self.runtime_dir = runtime_dir self.home_dir = self.runtime_dir / "home" @@ -1302,6 +1392,7 @@ def close(self) -> None: if session.process.poll() is None: terminate_process_group(session.process, signal.SIGTERM) session.drain_readers() + self.upstream_manager.close() shutil.rmtree(self.runtime_dir, ignore_errors=True) self.telemetry.finish() @@ -1383,16 +1474,28 @@ def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any } def list_tools(self) -> dict[str, Any]: - return { - "tools": [ - tool_definition(name, fake_readonly=self.fake_readonly_annotations) - for name in self.exposed_tool_names() - ] - } + local_definitions = [ + tool_definition(name, fake_readonly=self.fake_readonly_annotations) + for name in self._exposed_tool_names + if name in self._local_tool_name_set + ] + upstream_definitions = [ + copy.deepcopy(self._upstream_tool_definitions[name]) + for name in self._exposed_tool_names + if name in self._upstream_tool_name_set + ] + return {"tools": [*local_definitions, *upstream_definitions]} def exposed_tool_names(self) -> list[str]: return list(self._exposed_tool_names) + def real_tool_annotations(self, name: str) -> dict[str, Any]: + if name in self._local_tool_name_set: + return tool_annotations(name, fake_readonly=False) + definition = self._upstream_tool_definitions.get(name) + annotations = definition.get("annotations") if isinstance(definition, dict) else None + return copy.deepcopy(annotations) if isinstance(annotations, dict) else {} + def auth_enabled(self) -> bool: return self.auth_token is not None or self.oauth_config is not None @@ -1459,6 +1562,7 @@ def server_info_payload(self) -> dict[str, Any]: }, "tools": tools, "tool_count": len(tools), + "upstream": self.upstream_manager.status_payload(), } def call_tool( @@ -1470,7 +1574,14 @@ def call_tool( ) -> dict[str, Any]: started_at = time.time() args = arguments or {} - handler = self._tool_handlers.get(name) if name in self._exposed_tool_name_set else None + if name in self._upstream_tool_name_set: + result = self.upstream_manager.call_tool(name, args) + structured = result.get("structuredContent") + payload = copy.deepcopy(structured) if isinstance(structured, dict) else {} + payload.setdefault("ok", not bool(result.get("isError"))) + self.emit_tool_trace(name, args, payload, started_at) + return result + handler = self._tool_handlers.get(name) if name in self._local_tool_name_set else None if handler is None: raise JsonRpcError(-32602, f"Unknown tool: {name}", {"reason": "unknown_tool"}) spec = TOOL_REGISTRY[name] @@ -4013,7 +4124,10 @@ def open_landlock_ruleset(workspace: Path, read_roots: list[str], *, write_roots def add_landlock_path(ruleset_fd: int, path: Path, allowed_access: int, *, required: bool = True) -> None: try: - fd = os.open(path, getattr(os, "O_PATH", os.O_RDONLY) | os.O_CLOEXEC) + fd = os.open( + path, + getattr(os, "O_PATH", os.O_RDONLY) | getattr(os, "O_CLOEXEC", 0), + ) except OSError as exc: if required: raise ToolFailure( @@ -4649,7 +4763,7 @@ def server_card_payload(runtime: Runtime, *, oauth_base_url: str | None = None) names = runtime.exposed_tool_names() # Always the real annotations, never the tools/list override: this card is # what an operator fetches to find out what the endpoint actually does. - annotations = {name: tool_annotations(name, fake_readonly=False) for name in names} + annotations = {name: runtime.real_tool_annotations(name) for name in names} read_only = [name for name in names if annotations[name].get("readOnlyHint") is True] mutating = [name for name in names if annotations[name].get("readOnlyHint") is not True] payload = { @@ -4707,22 +4821,196 @@ def send_rpc_error( head_only=head_only, ) + def _admin_service(self) -> AdminService | None: + service = getattr(self.server, "admin_service", None) # type: ignore[attr-defined] + return service if isinstance(service, AdminService) else None + + def _is_admin_authorized(self) -> bool: + configured = getattr(self.server, "admin_token", None) # type: ignore[attr-defined] + if not isinstance(configured, str) or not configured: + return False + explicit = self.headers.get("X-Admin-Token", "").strip() + bearer = self.headers.get("Authorization", "").strip() + candidates = [explicit] + if bearer.startswith("Bearer "): + candidates.append(bearer.removeprefix("Bearer ").strip()) + return any(value and secrets.compare_digest(value, configured) for value in candidates) + + def _read_admin_json(self) -> dict[str, Any] | None: + if self.command in {"GET", "HEAD", "DELETE"}: + return {} + if self.headers.get_content_type().lower() != "application/json": + self.send_json({"error": {"code": "invalid_content_type", "message": "Content-Type must be application/json"}}, status=415) + return None + raw_length = self.headers.get("Content-Length") + if raw_length is None: + self.send_json({"error": {"code": "invalid_request", "message": "Content-Length is required"}}, status=411) + return None + try: + length = int(raw_length) + except ValueError: + self.send_json({"error": {"code": "invalid_request", "message": "Content-Length must be an integer"}}, status=400) + return None + if length < 0 or length > MAX_HTTP_REQUEST_BYTES: + self.send_json({"error": {"code": "invalid_request", "message": "Admin request body size is invalid"}}, status=413) + return None + try: + value = json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + self.send_json({"error": {"code": "invalid_json", "message": "Body must be valid JSON"}}, status=400) + return None + if not isinstance(value, dict): + self.send_json({"error": {"code": "invalid_request", "message": "Body must be a JSON object"}}, status=400) + return None + return value + + def handle_admin_request(self, method: str, *, head_only: bool = False) -> None: + service = self._admin_service() + if service is None: + self.send_json({"error": "Unknown endpoint"}, status=404, head_only=head_only) + return + origin = self.headers.get("Origin") + if origin and not is_allowed_origin(origin): + self.send_json({"error": {"code": "origin_denied", "message": "Origin denied"}}, status=403, head_only=head_only) + return + if not self._is_admin_authorized(): + self.send_json( + {"error": {"code": "admin_auth_required", "message": "Admin authentication is required"}}, + status=401, + extra_headers={"WWW-Authenticate": 'Bearer realm="coding-tools-mcp-admin"'}, + head_only=head_only, + ) + return + body = self._read_admin_json() + if body is None: + return + parsed = urllib.parse.urlsplit(self.path) + query = {key: values[-1] for key, values in urllib.parse.parse_qs(parsed.query).items() if values} + try: + payload = service.dispatch(method, posixpath.normpath(parsed.path), body, query) + except AdminUnavailableError as exc: + self.send_json( + { + "error": { + "code": exc.code, + "message": "An Admin backing service is unavailable.", + } + }, + status=exc.status, + head_only=head_only, + ) + return + except AdminServiceError as exc: + self.send_json( + {"error": {"code": exc.code, "message": str(exc)}}, + status=exc.status, + head_only=head_only, + ) + return + except (OAuthStoreError, SecretVaultError, SettingsStoreError): + self.send_json( + { + "error": { + "code": "admin_unavailable", + "message": "An Admin backing service is unavailable.", + } + }, + status=503, + head_only=head_only, + ) + return + except Exception: # noqa: BLE001 - Admin responses must remain redacted. + self.send_json( + { + "error": { + "code": "admin_internal_error", + "message": "The Admin request could not be completed.", + } + }, + status=500, + head_only=head_only, + ) + return + self.send_json(payload, head_only=head_only) + def do_GET(self) -> None: + normalized = posixpath.normpath(self.path.split("?", 1)[0]) + if normalized == "/admin": + if self._admin_service() is None: + self.send_json({"error": "Unknown endpoint"}, status=404) + return + origin = self.headers.get("Origin") + if origin and not is_allowed_origin(origin): + self.send_json( + {"error": {"code": "origin_denied", "message": "Origin denied"}}, + status=403, + ) + return + if not self._is_admin_authorized(): + self.send_json( + {"error": {"code": "admin_auth_required", "message": "Admin authentication is required"}}, + status=401, + extra_headers={"WWW-Authenticate": 'Bearer realm="coding-tools-mcp-admin"'}, + ) + return + body = admin_console_html().encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_cors_headers() + self.end_headers() + self.wfile.write(body) + return + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("GET") + return self.handle_metadata_request(head_only=False) def do_HEAD(self) -> None: + normalized = posixpath.normpath(self.path.split("?", 1)[0]) + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("GET", head_only=True) + return self.handle_metadata_request(head_only=True) + def do_PUT(self) -> None: + normalized = posixpath.normpath(self.path.split("?", 1)[0]) + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("PUT") + return + self.send_json({"error": "Unknown endpoint"}, status=404) + def do_DELETE(self) -> None: request_path = self.path.split("?", 1)[0] - if posixpath.normpath(request_path) != MCP_ENDPOINT_PATH: + normalized = posixpath.normpath(request_path) + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("DELETE") + return + if normalized != MCP_ENDPOINT_PATH: self.send_json({"error": "Unknown endpoint"}, status=404) return if not self.is_authorized(): self.send_unauthorized() return session_id = self.headers.get("Mcp-Session-Id") - if not session_id or not self.server.sessions.delete(session_id): # type: ignore[attr-defined] + runtime = self.server.sessions.get(session_id) if session_id else None # type: ignore[attr-defined] + if runtime is None: + self.send_rpc_error(-32001, "Unknown MCP session", status=404) + return + authorization_context = getattr(self, "_authorization_context", None) + if ( + not isinstance(authorization_context, AuthorizationContext) + or runtime.session_authorization_key() + != authorization_context.authorization_key(runtime.workspace_binding.workspace_id) + ): + self.send_rpc_error( + -32000, + "Authorization context does not match the initialized MCP session", + status=403, + ) + return + if not self.server.sessions.delete(session_id): # type: ignore[attr-defined] self.send_rpc_error(-32001, "Unknown MCP session", status=404) return self.send_response(200) @@ -4732,7 +5020,9 @@ def do_DELETE(self) -> None: def do_OPTIONS(self) -> None: request_path = self.path.split("?", 1)[0] - if posixpath.normpath(request_path) not in { + normalized = posixpath.normpath(request_path) + if not normalized.startswith(ADMIN_API_PREFIX) and normalized not in { + "/admin", MCP_ENDPOINT_PATH, "/.well-known/mcp.json", "/.well-known/mcp/server-card.json", @@ -4749,7 +5039,7 @@ def do_OPTIONS(self) -> None: self.send_json({"error": "Origin denied"}, status=403) return self.send_response(204) - self.send_header("Allow", "GET, HEAD, POST, DELETE, OPTIONS") + self.send_header("Allow", "GET, HEAD, POST, PUT, DELETE, OPTIONS") self.send_cors_headers() self.end_headers() @@ -4789,6 +5079,9 @@ def handle_metadata_request(self, *, head_only: bool) -> None: def do_POST(self) -> None: request_path = self.path.split("?", 1)[0] normalized = posixpath.normpath(request_path) + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("POST") + return if normalized == "/oauth/authorize": self.handle_oauth_authorize_post() return @@ -4868,9 +5161,18 @@ def do_POST(self) -> None: -32600, "initialize must not include Mcp-Session-Id", request_id=request.get("id") ) return + authorization_context = getattr(self, "_authorization_context", None) + if not isinstance(authorization_context, AuthorizationContext): + self.send_rpc_error( + -32000, + "Request authorization context is unavailable", + status=503, + request_id=request.get("id"), + ) + return try: - self._runtime = self.server.sessions.create() # type: ignore[attr-defined] - except RuntimeError as exc: + self._runtime = self.server.sessions.create(authorization_context) # type: ignore[attr-defined] + except (RuntimeError, WorkspaceBindingError) as exc: self.send_rpc_error(-32000, str(exc), status=503, request_id=request.get("id")) return self._send_session_header = True @@ -4882,6 +5184,19 @@ def do_POST(self) -> None: -32001, "Unknown MCP session", status=404, request_id=response_id(request) ) return + authorization_context = getattr(self, "_authorization_context", None) + if ( + not isinstance(authorization_context, AuthorizationContext) + or runtime.session_authorization_key() + != authorization_context.authorization_key(runtime.workspace_binding.workspace_id) + ): + self.send_rpc_error( + -32000, + "Authorization context does not match the initialized MCP session", + status=403, + request_id=request.get("id"), + ) + return self._runtime = runtime self._send_session_header = True if protocol_version != runtime.protocol_version: @@ -4917,15 +5232,31 @@ def handle_rpc(self, request: dict[str, Any]) -> dict[str, Any] | None: return jsonrpc_error(response_id(request), -32603, str(exc)) def is_authorized(self) -> bool: + self._authorization_context = None if not self.runtime.auth_enabled(): + self._authorization_context = AuthorizationContext("noauth") return True header = self.headers.get("Authorization", "").strip() if self.runtime.auth_token is not None: if secrets.compare_digest(header, f"Bearer {self.runtime.auth_token}"): + self._authorization_context = AuthorizationContext("bearer") return True if self.runtime.oauth_config is not None and header.startswith("Bearer "): token = header[len("Bearer "):] - if validate_access_token(token, self.runtime.oauth_config, self.oauth_base_url()): + try: + identity = authenticate_access_token( + token, + self.runtime.oauth_config, + self.oauth_base_url(), + ) + except OAuthStoreError: + self.log_error("OAuth bearer validation unavailable; request denied") + return False + if identity is not None: + self._authorization_context = AuthorizationContext( + "oauth", + oauth_identity=identity, + ) return True return False @@ -5075,10 +5406,16 @@ def handle_oauth_authorize_get(self) -> None: if _p("response_type") != "code": self._send_html("

Error

response_type must be 'code'

", status=400) return - if cfg.registry.get(client_id) is None: + try: + client = cfg.registry.get(client_id) + redirect_allowed = cfg.registry.accepts_redirect(client_id, redirect_uri) + except OAuthStoreError: + self._send_html("

Error

OAuth persistence is unavailable

", status=503) + return + if client is None: self._send_html("

Error

Unknown client_id

", status=400) return - if not cfg.registry.accepts_redirect(client_id, redirect_uri): + if not redirect_allowed: self._send_html("

Error

redirect_uri is not registered for this client

", status=400) return if code_challenge_method != "S256" or not valid_pkce_challenge(code_challenge): @@ -5121,7 +5458,13 @@ def fail(error: str, status: int = 400) -> None: error=error, ), status=status) - if cfg.registry.get(client_id) is None or not cfg.registry.accepts_redirect(client_id, redirect_uri): + try: + client = cfg.registry.get(client_id) + redirect_allowed = cfg.registry.accepts_redirect(client_id, redirect_uri) + except OAuthStoreError: + fail("OAuth persistence is unavailable", status=503) + return + if client is None or not redirect_allowed: fail("Invalid client or redirect URI") return if code_challenge_method != "S256" or not valid_pkce_challenge(code_challenge): @@ -5133,6 +5476,16 @@ def fail(error: str, status: int = 400) -> None: if not secrets.compare_digest(password, cfg.password): fail("Invalid password", status=401) return + try: + grant_id = create_authorization_grant( + cfg, + client_id=client_id, + redirect_uri=redirect_uri, + scopes="mcp", + ) + except OAuthServiceError: + fail("OAuth authorization store is unavailable", status=503) + return code = secrets.token_urlsafe(32) now = time.time() @@ -5147,6 +5500,7 @@ def fail(error: str, status: int = 400) -> None: "client_id": client_id, "redirect_uri": redirect_uri, "state": state, + "grant_id": grant_id, "expires_at": now + OAUTH_CODE_TTL_SECONDS, "server_url": self.oauth_base_url(), "resource": resource.rstrip("/"), @@ -5167,9 +5521,12 @@ def handle_oauth_token(self) -> None: self.send_json({"error": "unsupported_grant_type"}, status=400) return - def _err(error: str, description: str) -> None: + def _err(error: str, description: str, *, status: int = 400) -> None: self.log_message("OAuth token error: %s - %s", error, description) - self.send_json({"error": error, "error_description": description}, status=400) + self.send_json( + {"error": error, "error_description": description}, + status=status, + ) body = self._read_oauth_body() if body is None: @@ -5203,13 +5560,53 @@ def _err(error: str, description: str) -> None: except Exception: # noqa: BLE001 pass + if grant_type == OAUTH_GRANT_TYPE_REFRESH_TOKEN: + refresh_token = _p("refresh_token") + if not refresh_token: + _err("invalid_grant", "refresh_token is required") + return + try: + response = exchange_refresh_token( + cfg, + refresh_token=refresh_token, + client_id=client_id, + client_secret=client_secret, + auth_method=presented_auth_method, + server_url=self.oauth_base_url(), + ) + except OAuthClientAuthenticationError: + _err("invalid_client", "Invalid client authentication") + return + except OAuthInvalidGrantError: + _err("invalid_grant", "Invalid, expired, or reused refresh token") + return + except OAuthServiceError: + _err( + "server_error", + "Refresh-token persistence is unavailable", + status=503, + ) + return + self.send_json(response) + return if grant_type != OAUTH_GRANT_TYPE_AUTHORIZATION_CODE: - _err("unsupported_grant_type", "Only authorization_code is supported") + supported = ", ".join(OAUTH_GRANT_TYPES_SUPPORTED) + _err("unsupported_grant_type", f"Supported grant types: {supported}") + return + try: + client = cfg.registry.get(client_id) + authenticated = cfg.registry.authenticates( + client_id, + client_secret, + presented_auth_method, + ) + except OAuthStoreError: + _err("server_error", "OAuth client registry is unavailable", status=503) return - if cfg.registry.get(client_id) is None: + if client is None: _err("invalid_client", "Unknown client_id") return - if not cfg.registry.authenticates(client_id, client_secret, presented_auth_method): + if not authenticated: _err("invalid_client", "Invalid client_secret") return if not code: @@ -5241,9 +5638,40 @@ def _err(error: str, description: str) -> None: _err("invalid_grant", "PKCE verification failed") return + grant_id = code_data.get("grant_id") + if not isinstance(grant_id, str) or not grant_id: + _err("server_error", "Authorization grant is unavailable") + return server_url = resource - access_token = create_access_token(cfg, server_url, client_id=client_id) - self.send_json({"access_token": access_token, "token_type": "Bearer", "expires_in": cfg.token_ttl}) + try: + access_token = create_access_token( + cfg, + server_url, + client_id=client_id, + grant_id=grant_id, + ) + refresh_token = issue_refresh_token( + cfg, + grant_id=grant_id, + client_id=client_id, + scopes="mcp", + ) + except OAuthServiceError: + _err( + "server_error", + "OAuth token state could not be persisted", + status=503, + ) + return + self.send_json( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": cfg.token_ttl, + "scope": "mcp", + "refresh_token": refresh_token, + } + ) def handle_oauth_register(self) -> None: cfg = self.runtime.oauth_config @@ -5266,6 +5694,15 @@ def handle_oauth_register(self) -> None: return try: registered = cfg.registry.register(metadata) + except OAuthStoreError: + self.send_json( + { + "error": "server_error", + "error_description": "OAuth persistence is unavailable", + }, + status=503, + ) + return except ValueError as exc: self.send_json({"error": "invalid_client_metadata", "error_description": str(exc)}, status=400) return @@ -5276,10 +5713,10 @@ def send_cors_headers(self) -> None: if origin and is_allowed_origin(origin): self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Vary", "Origin") - self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS") self.send_header( "Access-Control-Allow-Headers", - "Accept, Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id", + "Accept, Authorization, X-Admin-Token, Content-Type, MCP-Protocol-Version, Mcp-Session-Id", ) def send_json( @@ -5314,10 +5751,15 @@ def __init__( handler: type[MCPHandler], control_runtime: Runtime, runtime_factory: Any, + *, + admin_service: AdminService | None = None, + admin_token: str | None = None, ) -> None: super().__init__(address, handler) self.control_runtime = control_runtime self.sessions = HTTPSessionManager(runtime_factory) + self.admin_service = admin_service + self.admin_token = admin_token or None def server_close(self) -> None: self.sessions.close() @@ -5333,21 +5775,36 @@ def build_runtime( oauth_config: OAuthConfig | None = None, emit_warning: bool = True, project_context: ProjectContext | None = None, + workspace_binding: WorkspaceBinding | None = None, + authorization_context: AuthorizationContext | None = None, + upstream_manager: UpstreamManager | None = None, transport: str = "stdio", ) -> Runtime: - workspace = Path(args.workspace or os.environ.get(f"{ENV_PREFIX}_WORKSPACE") or os.getcwd()) - runtime = Runtime( - workspace, - enable_view_image=args.enable_view_image, - permission_mode=runtime_policy.permission_mode, - shell_env_policy=runtime_policy.shell_env_policy, - allow_network=runtime_policy.allow_network, - auth_token=auth_token, - oauth_config=oauth_config, - project_context=project_context, - fake_readonly_annotations=runtime_policy.fake_readonly_annotations, - transport=transport, + workspace = ( + workspace_binding.root + if workspace_binding is not None + else Path(args.workspace or os.environ.get(f"{ENV_PREFIX}_WORKSPACE") or os.getcwd()) ) + try: + runtime = Runtime( + workspace, + enable_view_image=args.enable_view_image, + permission_mode=runtime_policy.permission_mode, + shell_env_policy=runtime_policy.shell_env_policy, + allow_network=runtime_policy.allow_network, + auth_token=auth_token, + oauth_config=oauth_config, + project_context=project_context, + workspace_binding=workspace_binding, + authorization_context=authorization_context, + upstream_manager=upstream_manager, + fake_readonly_annotations=runtime_policy.fake_readonly_annotations, + transport=transport, + ) + except BaseException: + if upstream_manager is not None: + upstream_manager.close() + raise if emit_warning and runtime.capabilities.skip_all_permissions: print( "WARNING: permission_mode=dangerous disables MCP safety gates. Use only inside an isolated container or VM.", @@ -5364,6 +5821,352 @@ def build_runtime( AUTH_MODE_CHOICES = ("bearer", "noauth", "oauth") +OAUTH_DB_FILENAME = "oauth.sqlite3" +OAUTH_SECRET_VAULT_FILENAME = "oauth-secrets.json" +OAUTH_PASSWORD_SECRET = "oauth/authorization-password" +OAUTH_TOKEN_SECRET = "oauth/token-secret" +OAUTH_REFRESH_PEPPER_SECRET = "oauth/refresh-pepper" + + +def _vault_secret( + vault: SecretVault, + name: str, + *, + generated_value: Callable[[], str], +) -> tuple[str, bool]: + if name in vault.list_names(): + return vault.get_secret(name), False + value = generated_value() + vault.set_secret(name, value) + return value, True + + +def _hex_secret( + vault: SecretVault, + name: str, + *, + configured_hex: str | None, + byte_length: int, +) -> bytes: + if configured_hex: + try: + value = bytes.fromhex(configured_hex) + except ValueError as exc: + raise ValueError(f"{name} must be hex-encoded bytes.") from exc + if len(value) < byte_length: + raise ValueError(f"{name} must contain at least {byte_length} bytes.") + normalized = value.hex() + if name not in vault.list_names() or vault.get_secret(name) != normalized: + vault.set_secret(name, normalized) + return value + stored, _created = _vault_secret( + vault, + name, + generated_value=lambda: secrets.token_bytes(byte_length).hex(), + ) + try: + value = bytes.fromhex(stored) + except ValueError as exc: + raise ValueError(f"Secret vault entry {name!r} is not valid hex.") from exc + if len(value) < byte_length: + raise ValueError(f"Secret vault entry {name!r} is too short.") + return value + + +def build_persistent_oauth_config( + config_dir: Path, + *, + master_key: str | None, + password: str | None, + server_url: str | None, + token_ttl: int, + token_secret_hex: str | None = None, + refresh_pepper_hex: str | None = None, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uris: tuple[str, ...] = (), + registration_workspace_id: str | None = "default", + client_workspace_id: str | None = None, +) -> tuple[OAuthConfig, bool]: + vault = SecretVault(config_dir / OAUTH_SECRET_VAULT_FILENAME, master_key) + if not vault.enabled(): + raise ValueError( + f"{ENV_PREFIX}_SECRETS_KEY is required when OAuth persistence is enabled." + ) + resolved_password = password + password_created = False + if not resolved_password: + resolved_password, password_created = _vault_secret( + vault, + OAUTH_PASSWORD_SECRET, + generated_value=lambda: secrets.token_urlsafe(32), + ) + token_secret = _hex_secret( + vault, + OAUTH_TOKEN_SECRET, + configured_hex=token_secret_hex, + byte_length=32, + ) + refresh_pepper = _hex_secret( + vault, + OAUTH_REFRESH_PEPPER_SECRET, + configured_hex=refresh_pepper_hex, + byte_length=32, + ) + store = OAuthAuthorizationStore(config_dir / OAUTH_DB_FILENAME, pepper=refresh_pepper) + signing_kid, active_secret, signing_keys = initialize_signing_key_ring( + store, + vault, + token_secret, + legacy_secret_ref=OAUTH_TOKEN_SECRET, + ) + registry = PersistentOAuthClientRegistry( + store, + registration_workspace_id=registration_workspace_id, + ) + if client_id: + registry.add_preregistered( + client_id, + redirect_uris or ("http://127.0.0.1/callback",), + client_secret=client_secret, + workspace_id=client_workspace_id, + ) + return ( + OAuthConfig( + password=resolved_password, + server_url=server_url, + token_secret=active_secret, + token_ttl=token_ttl, + registry=registry, + store=store, + secret_vault=vault, + signing_kid=signing_kid, + signing_keys=signing_keys, + ), + password_created, + ) + + +SERVER_SETTINGS_FILENAME = "server-settings.json" +UPSTREAM_CONFIG_FILENAME = "mcp-servers.json" +TRANSCRIPT_DB_FILENAME = "transcripts.sqlite3" + + +def load_workspace_startup( + args: argparse.Namespace, +) -> tuple[Path, dict[str, Any], WorkspaceCatalog]: + config_dir = default_settings_dir() + settings = ServerSettingsStore(config_dir / SERVER_SETTINGS_FILENAME).read() + fallback_root = Path( + args.workspace + or os.environ.get(f"{ENV_PREFIX}_WORKSPACE") + or os.getcwd() + ) + catalog = WorkspaceCatalog.from_settings(settings, fallback_root) + return config_dir, settings, catalog + + +def upstream_config_path(args: argparse.Namespace, config_dir: Path) -> Path: + explicit = ( + getattr(args, "upstream_config", None) + or os.environ.get(f"{ENV_PREFIX}_UPSTREAM_CONFIG") + or None + ) + return Path(str(explicit)).expanduser() if explicit else config_dir / UPSTREAM_CONFIG_FILENAME + + +def load_upstream_startup( + args: argparse.Namespace, + config_dir: Path, +) -> UpstreamConfigSnapshot: + path = upstream_config_path(args, config_dir) + explicit = bool( + getattr(args, "upstream_config", None) + or os.environ.get(f"{ENV_PREFIX}_UPSTREAM_CONFIG") + ) + if not path.exists() and not explicit: + return UpstreamConfigSnapshot.empty() + return load_upstream_config_snapshot(path) + + +def upstream_secret_resolver( + snapshot: UpstreamConfigSnapshot, + vault: SecretVault, +) -> Callable[[str], str] | None: + refs: set[str] = set() + for config in snapshot.configs: + for value in config.env.values(): + if isinstance(value, dict): + secret_ref = value.get("secret_ref") + if isinstance(secret_ref, str) and secret_ref: + refs.add(secret_ref) + if not refs: + return vault.get_secret if vault.enabled() else None + if not vault.enabled(): + raise SecretVaultError( + "Gateway secret_ref requires CODING_TOOLS_MCP_SECRETS_KEY and the server Secret Vault." + ) + for ref in sorted(refs): + vault.get_secret(ref) + return vault.get_secret + + +def load_upstream_startup_with_revision( + args: argparse.Namespace, + config_dir: Path, +) -> tuple[UpstreamConfigSnapshot, str]: + path = upstream_config_path(args, config_dir) + before = gateway_file_revision(path) + snapshot = load_upstream_startup(args, config_dir) + after = gateway_file_revision(path) + if before != after: + raise UpstreamConfigError( + "Gateway configuration changed while the startup snapshot was being created." + ) + return snapshot, before + + +def build_upstream_manager( + snapshot: UpstreamConfigSnapshot, + *, + secret_resolver: Callable[[str], str] | None = None, +) -> UpstreamManager: + return UpstreamManager.from_snapshot( + snapshot, + protocol_version=PROTOCOL_VERSION, + secret_resolver=secret_resolver, + reserved_names=TOOL_REGISTRY, + ) + + +def apply_oauth_workspace_bindings( + config: OAuthConfig, + catalog: WorkspaceCatalog, + bindings: dict[str, str], +) -> None: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + normalized = normalize_oauth_client_workspace_bindings(bindings, catalog) + for client_id, workspace_id in normalized.items(): + if config.store.get_client(client_id) is None: + raise OAuthServiceError( + f"OAuth Workspace binding references unknown client_id {client_id!r}." + ) + if not config.store.set_client_workspace(client_id, workspace_id): + raise OAuthServiceError( + f"OAuth Workspace binding could not be applied to client_id {client_id!r}." + ) + + enabled = catalog.enabled_entries() + if len(enabled) == 1: + default_id = catalog.default_id + for client in config.store.list_clients(): + if not client.get("workspace_id"): + if not config.store.set_client_workspace(str(client["client_id"]), default_id): + raise OAuthServiceError( + "OAuth client could not be migrated to the sole enabled Workspace." + ) + + +def active_settings_payload( + startup_settings: dict[str, Any], + workspace_catalog: WorkspaceCatalog, + args: argparse.Namespace, + runtime_policy: RuntimePolicy, + allowed_origins: frozenset[str], +) -> dict[str, Any]: + active = dict(startup_settings) + active.update(workspace_catalog.settings_payload()) + active.update( + { + "workspace": str(workspace_catalog.default().root), + "host": str(args.host), + "port": int(args.port), + "permission_mode": runtime_policy.permission_mode, + "shell_env_inherit": runtime_policy.shell_env_policy.inherit, + "allowed_origins": sorted(allowed_origins), + } + ) + return active + + +def resolve_admin_token( + args: argparse.Namespace, + startup_settings: dict[str, Any], + vault: SecretVault, +) -> str | None: + direct = ( + getattr(args, "admin_token", None) + or os.environ.get(f"{ENV_PREFIX}_ADMIN_TOKEN") + or None + ) + if direct: + return str(direct) + secret_ref = startup_settings.get("admin_token_secret_ref") + if not secret_ref: + return None + if not isinstance(secret_ref, str): + raise SecretVaultError("admin_token_secret_ref must be a string.") + return vault.get_secret(secret_ref) + + +class BoundRuntimeFactory: + def __init__( + self, + args: argparse.Namespace, + runtime_policy: RuntimePolicy, + resolver: WorkspaceBindingResolver, + *, + auth_token: str | None, + oauth_config: OAuthConfig | None, + upstream_snapshot: UpstreamConfigSnapshot | None = None, + upstream_secret_resolver: Callable[[str], str] | None = None, + ) -> None: + self.args = args + self.runtime_policy = runtime_policy + self.resolver = resolver + self.auth_token = auth_token + self.oauth_config = oauth_config + self.upstream_snapshot = upstream_snapshot or UpstreamConfigSnapshot.empty() + self.upstream_secret_resolver = upstream_secret_resolver + self._project_contexts: dict[tuple[str, str], ProjectContext] = {} + self._lock = threading.Lock() + + def project_context(self, binding: WorkspaceBinding) -> ProjectContext: + key = (binding.workspace_id, str(binding.root)) + with self._lock: + cached = self._project_contexts.get(key) + if cached is not None: + return cached + loaded = load_project_context(binding.root) + with self._lock: + return self._project_contexts.setdefault(key, loaded) + + def __call__(self, context: AuthorizationContext) -> Runtime: + binding = self.resolver.resolve_http(context.method, context.oauth_identity) + try: + upstream_manager = build_upstream_manager( + self.upstream_snapshot, + secret_resolver=self.upstream_secret_resolver, + ) + except UpstreamConfigError as exc: + raise RuntimeError("Upstream Gateway initialization failed.") from exc + try: + return build_runtime( + self.args, + self.runtime_policy, + auth_token=self.auth_token, + oauth_config=self.oauth_config, + emit_warning=False, + project_context=self.project_context(binding), + workspace_binding=binding, + authorization_context=context, + upstream_manager=upstream_manager, + transport="http", + ) + except BaseException: + upstream_manager.close() + raise def run_http(args: argparse.Namespace) -> int: @@ -5375,9 +6178,37 @@ def run_http(args: argparse.Namespace) -> int: auth_token = args.auth_token or os.environ.get(f"{ENV_PREFIX}_AUTH_TOKEN") or None try: runtime_policy = runtime_policy_from_args(args) - except ValueError as exc: - print(f"ERROR: {exc}", file=sys.stderr) + config_dir, startup_settings, workspace_catalog = load_workspace_startup(args) + workspace_bindings = normalize_oauth_client_workspace_bindings( + startup_settings.get("oauth_client_workspace_bindings"), + workspace_catalog, + ) + upstream_snapshot, active_gateway_revision = load_upstream_startup_with_revision( + args, + config_dir, + ) + allowed_origin_source = startup_settings.get("allowed_origins") + if allowed_origin_source is None: + allowed_origin_source = os.environ.get(f"{ENV_PREFIX}_ALLOWED_ORIGINS", "") + allowed_origins = configure_allowed_origins(allowed_origin_source) + except ( + SettingsStoreError, + UpstreamConfigError, + WorkspaceCatalogError, + ValueError, + ) as exc: + print(f"ERROR: Startup configuration is unavailable: {exc}", file=sys.stderr) return 2 + server_vault = SecretVault( + config_dir / SERVER_SECRET_VAULT_FILENAME, + os.environ.get(f"{ENV_PREFIX}_SECRETS_KEY"), + ) + try: + gateway_secret_resolver = upstream_secret_resolver(upstream_snapshot, server_vault) + except SecretVaultError as exc: + print(f"ERROR: Gateway credentials are unavailable: {exc}", file=sys.stderr) + return 2 + workspace_resolver = WorkspaceBindingResolver(workspace_catalog) oauth_config: OAuthConfig | None = None oauth_mode = ( @@ -5388,55 +6219,76 @@ def run_http(args: argparse.Namespace) -> int: if oauth_mode: client_id = os.environ.get(f"{ENV_PREFIX}_OAUTH_CLIENT_ID") or None client_secret = os.environ.get(f"{ENV_PREFIX}_OAUTH_CLIENT_SECRET") or None - env_password = os.environ.get(f"{ENV_PREFIX}_OAUTH_PASSWORD") - password = env_password or secrets.token_urlsafe(32) + env_password = os.environ.get(f"{ENV_PREFIX}_OAUTH_PASSWORD") or None + client_workspace_id = ( + os.environ.get(f"{ENV_PREFIX}_OAUTH_WORKSPACE_ID") + or (workspace_bindings.get(client_id) if client_id else None) + ) + if client_id and client_workspace_id: + workspace_bindings[client_id] = client_workspace_id + registration_workspace_id = ( + workspace_catalog.default_id + if len(workspace_catalog.enabled_entries()) == 1 + else None + ) server_url = (os.environ.get(f"{ENV_PREFIX}_SERVER_URL") or "").rstrip("/") or None - if not env_password: - print(f"OAuth authorize password: {password}", file=sys.stderr) - raw_secret = os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_SECRET") or "" - if raw_secret: - try: - token_secret = bytes.fromhex(raw_secret) - except ValueError: - print( - f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_SECRET must be hex-encoded bytes.", - file=sys.stderr, - ) - return 2 - if len(token_secret) < 32: - print( - f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_SECRET must contain at least 32 bytes.", - file=sys.stderr, - ) - return 2 - else: - token_secret = secrets.token_bytes(32) try: - token_ttl = int(os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_TTL") or OAUTH_TOKEN_TTL_SECONDS) + token_ttl = int( + os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_TTL") + or OAUTH_TOKEN_TTL_SECONDS + ) except ValueError: print(f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_TTL must be an integer.", file=sys.stderr) return 2 if not 60 <= token_ttl <= 604_800: - print(f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_TTL must be between 60 and 604800 seconds.", file=sys.stderr) + print( + f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_TTL must be between 60 and 604800 seconds.", + file=sys.stderr, + ) return 2 - oauth_config = OAuthConfig( - password=password, - server_url=server_url, - token_secret=token_secret, - token_ttl=token_ttl, + raw_redirects = ( + os.environ.get(f"{ENV_PREFIX}_OAUTH_REDIRECT_URIS") + or "http://127.0.0.1/callback" ) - if client_id: - raw_redirects = os.environ.get(f"{ENV_PREFIX}_OAUTH_REDIRECT_URIS") or "http://127.0.0.1/callback" - redirect_uris = tuple(item.strip() for item in raw_redirects.split(",") if item.strip()) - try: - oauth_config.registry.add_preregistered( - client_id, - redirect_uris, - client_secret=client_secret, - ) - except ValueError as exc: - print(f"ERROR: invalid OAuth redirect URI configuration: {exc}", file=sys.stderr) - return 2 + redirect_uris = tuple( + item.strip() for item in raw_redirects.split(",") if item.strip() + ) + try: + oauth_config, password_created = build_persistent_oauth_config( + config_dir, + master_key=os.environ.get(f"{ENV_PREFIX}_SECRETS_KEY"), + password=env_password, + server_url=server_url, + token_ttl=token_ttl, + token_secret_hex=( + os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_SECRET") or None + ), + refresh_pepper_hex=( + os.environ.get(f"{ENV_PREFIX}_OAUTH_REFRESH_TOKEN_PEPPER") + or None + ), + client_id=client_id, + client_secret=client_secret, + redirect_uris=redirect_uris, + registration_workspace_id=registration_workspace_id, + client_workspace_id=client_workspace_id, + ) + apply_oauth_workspace_bindings( + oauth_config, + workspace_catalog, + workspace_bindings, + ) + except ( + OSError, + OAuthServiceError, + OAuthStoreError, + SecretVaultError, + ValueError, + ) as exc: + print(f"ERROR: OAuth persistence is unavailable: {exc}", file=sys.stderr) + return 2 + if password_created: + print(f"OAuth authorize password: {oauth_config.password}", file=sys.stderr) if auth_token: print( "Auth: dual credentials enabled — both static bearer token and OAuth 2.1 access tokens will be accepted.", @@ -5473,20 +6325,85 @@ def run_http(args: argparse.Namespace) -> int: ) return 2 - runtime = build_runtime(args, runtime_policy, auth_token=auth_token, oauth_config=oauth_config, transport="http") - - def runtime_factory() -> Runtime: - return build_runtime( - args, - runtime_policy, - auth_token=auth_token, - oauth_config=oauth_config, - emit_warning=False, - project_context=runtime.project_context, - transport="http", + default_workspace = workspace_catalog.default() + control_binding = WorkspaceBinding( + default_workspace.id, + default_workspace.root, + "control", + ) + try: + control_upstream = build_upstream_manager( + upstream_snapshot, + secret_resolver=gateway_secret_resolver, ) + except UpstreamConfigError as exc: + print(f"ERROR: Upstream Gateway configuration is unavailable: {exc}", file=sys.stderr) + return 2 + runtime = build_runtime( + args, + runtime_policy, + auth_token=auth_token, + oauth_config=oauth_config, + project_context=load_project_context(control_binding.root), + workspace_binding=control_binding, + authorization_context=AuthorizationContext("control"), + upstream_manager=control_upstream, + transport="http", + ) + runtime_factory = BoundRuntimeFactory( + args, + runtime_policy, + workspace_resolver, + auth_token=auth_token, + oauth_config=oauth_config, + upstream_snapshot=upstream_snapshot, + upstream_secret_resolver=gateway_secret_resolver, + ) + + try: + admin_token = resolve_admin_token(args, startup_settings, server_vault) + except SecretVaultError as exc: + runtime.close() + print(f"ERROR: Admin authentication is unavailable: {exc}", file=sys.stderr) + return 2 + admin_service: AdminService | None = None + if admin_token: + gateway_path = upstream_config_path(args, config_dir) + try: + admin_active_settings = active_settings_payload( + startup_settings, + workspace_catalog, + args, + runtime_policy, + allowed_origins, + ) + if oauth_config is not None and oauth_config.server_url is not None: + admin_active_settings["oauth_server_url"] = oauth_config.server_url + admin_service = AdminService( + settings_store=ServerSettingsStore(config_dir / SERVER_SETTINGS_FILENAME), + active_settings=admin_active_settings, + fallback_workspace=workspace_catalog.default().root, + gateway_path=gateway_path, + active_gateway_revision=active_gateway_revision, + secret_vault=server_vault, + oauth_store=oauth_config.store if oauth_config is not None else None, + active_gateway_status=runtime.upstream_manager.status_payload, + transcript_store=TranscriptStore(config_dir / TRANSCRIPT_DB_FILENAME), + session_scanner=CodexSessionScanner(), + ) + except (AdminServiceError, OSError, SecretVaultError, SettingsStoreError) as exc: + runtime.close() + print(f"ERROR: Admin service is unavailable: {exc}", file=sys.stderr) + return 2 - server = RuntimeHTTPServer((args.host, args.port), MCPHandler, runtime, runtime_factory) + server = RuntimeHTTPServer( + (args.host, args.port), + MCPHandler, + runtime, + runtime_factory, + admin_service=admin_service, + admin_token=admin_token, + ) if oauth_config: url_label = oauth_config.server_url or "dynamic request URL" suffix = " + bearer" if runtime.auth_token else "" @@ -5509,16 +6426,50 @@ def runtime_factory() -> Runtime: def run_stdio(args: argparse.Namespace) -> int: try: runtime_policy = runtime_policy_from_args(args) - except ValueError as exc: - print(f"ERROR: {exc}", file=sys.stderr) + config_dir, _settings, workspace_catalog = load_workspace_startup(args) + binding = WorkspaceBindingResolver(workspace_catalog).resolve_stdio() + upstream_snapshot = load_upstream_startup(args, config_dir) + server_vault = SecretVault( + config_dir / SERVER_SECRET_VAULT_FILENAME, + os.environ.get(f"{ENV_PREFIX}_SECRETS_KEY"), + ) + gateway_secret_resolver = upstream_secret_resolver(upstream_snapshot, server_vault) + upstream_manager = build_upstream_manager( + upstream_snapshot, + secret_resolver=gateway_secret_resolver, + ) + except ( + SettingsStoreError, + UpstreamConfigError, + WorkspaceBindingError, + WorkspaceCatalogError, + ValueError, + ) as exc: + print(f"ERROR: Startup configuration is unavailable: {exc}", file=sys.stderr) return 2 - runtime = build_runtime(args, runtime_policy) + runtime = build_runtime( + args, + runtime_policy, + project_context=load_project_context(binding.root), + workspace_binding=binding, + authorization_context=AuthorizationContext("stdio"), + upstream_manager=upstream_manager, + transport="stdio", + ) return serve_stdio(runtime) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Serve workspace-confined coding tools over MCP.") parser.add_argument("--workspace", help="workspace root; defaults to CODING_TOOLS_MCP_WORKSPACE or cwd") + parser.add_argument( + "--upstream-config", + default=None, + help=( + "JSON config for upstream MCP Gateway servers; defaults to " + f"{ENV_PREFIX}_UPSTREAM_CONFIG or the stable config directory/{UPSTREAM_CONFIG_FILENAME}" + ), + ) parser.add_argument( "--host", default=os.environ.get(f"{ENV_PREFIX}_HOST") or "127.0.0.1", @@ -5536,6 +6487,14 @@ def build_parser() -> argparse.ArgumentParser: default=None, help=f"require Authorization: Bearer on /mcp; defaults to {ENV_PREFIX}_AUTH_TOKEN", ) + parser.add_argument( + "--admin-token", + default=None, + help=( + "enable the authenticated Admin API with a dedicated token; defaults to " + f"{ENV_PREFIX}_ADMIN_TOKEN" + ), + ) parser.add_argument( "--oauth-mode", action="store_true", diff --git a/coding_tools_mcp/settings_definition.py b/coding_tools_mcp/settings_definition.py new file mode 100644 index 0000000..b7f983b --- /dev/null +++ b/coding_tools_mcp/settings_definition.py @@ -0,0 +1,331 @@ +"""Canonical validation and migration rules for persisted startup settings.""" + +from __future__ import annotations + +import ipaddress +import re +import urllib.parse +from pathlib import Path +from typing import Any + +from .workspace_catalog import WorkspaceCatalog, WorkspaceCatalogError + + +PERMISSION_MODE_CHOICES = ("safe", "trusted", "dangerous") +SHELL_ENV_INHERIT_CHOICES = ("core", "all", "none") +LEGACY_TOOL_PROFILE_WARNING = "legacy_tool_profile_ignored" +SECRET_REFERENCE_FIELDS = frozenset( + { + "auth_token_secret_ref", + "admin_token_secret_ref", + "oauth_authorization_password_secret_ref", + "oauth_active_key_secret_ref", + "oauth_refresh_token_pepper_secret_ref", + } +) +RESTART_FIELDS = frozenset( + { + "host", + "port", + "workspace_catalog", + "default_workspace_id", + "oauth_server_url", + "oauth_compatibility_mode", + "oauth_client_workspace_bindings", + "permission_mode", + "shell_env_inherit", + "allowed_origins", + } +) + + +class SettingsValidationError(ValueError): + def __init__( + self, + field_errors: dict[str, str] | None = None, + form_errors: list[str] | None = None, + ) -> None: + self.field_errors = field_errors or {} + self.form_errors = form_errors or [] + message = ( + next(iter(self.field_errors.values()), None) + or "; ".join(self.form_errors) + or "Invalid settings." + ) + super().__init__(message) + + +def migrate_persisted_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], tuple[str, ...]]: + """Return settings with obsolete fields removed and stable warning codes. + + Legacy ``tool_profile`` values are accepted as migration input only. Known + and unknown values have the same outcome: the value is ignored, the fixed + tool catalog remains authoritative, and the field is omitted on the next + successful settings write. + """ + + migrated = dict(settings) + warnings: list[str] = [] + if "tool_profile" in migrated: + migrated.pop("tool_profile", None) + warnings.append(LEGACY_TOOL_PROFILE_WARNING) + return migrated, tuple(warnings) + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _items(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, str): + return [item for item in re.split(r"[\s,]+", value) if item] + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] + + +def normalize_allowed_origins(value: Any) -> list[str]: + normalized: list[str] = [] + for item in _items(value): + raw = _text(item).rstrip("/") + try: + parsed = urllib.parse.urlsplit(raw) + except ValueError: + parsed = None + if ( + not raw + or raw in {"*", "null"} + or parsed is None + or not parsed.scheme + or not parsed.netloc + or parsed.path + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + or parsed.hostname is None + ): + raise SettingsValidationError( + {"allowed_origins": f"Unsupported allowed origin: {raw or 'empty value'}"} + ) + host = parsed.hostname.lower() + if ":" in host and not host.startswith("["): + host = f"[{host}]" + try: + port = parsed.port + except ValueError as exc: + raise SettingsValidationError( + {"allowed_origins": f"Allowed origin has an invalid port: {raw}"} + ) from exc + origin = urllib.parse.urlunsplit( + (parsed.scheme.lower(), f"{host}:{port}" if port is not None else host, "", "", "") + ) + if origin not in normalized: + normalized.append(origin) + return normalized + + +def _normalize_host(value: Any) -> str: + host = _text(value) + if not host: + raise SettingsValidationError({"host": "Host is required."}) + try: + ipaddress.ip_address(host) + return host + except ValueError: + pass + if len(host) > 253 or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]*", host) is None: + raise SettingsValidationError({"host": "Host must be a valid IP address or hostname."}) + return host.lower() + + +def _normalize_url(value: Any) -> str: + url = _text(value) + if not url: + return "" + try: + parsed = urllib.parse.urlsplit(url) + except ValueError as exc: + raise SettingsValidationError({"oauth_server_url": "OAuth server URL is invalid."}) from exc + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password: + raise SettingsValidationError( + {"oauth_server_url": "OAuth server URL must use http:// or https:// without user information."} + ) + return urllib.parse.urlunsplit( + (parsed.scheme.lower(), parsed.netloc.lower(), parsed.path.rstrip("/"), "", "") + ) + + +def _normalize_choice(value: Any, field: str, choices: tuple[str, ...]) -> str: + normalized = _text(value).lower() + if normalized not in choices: + raise SettingsValidationError( + {field: f"Unsupported value; expected one of: {', '.join(choices)}."} + ) + return normalized + + +def normalize_oauth_client_workspace_bindings( + value: Any, + catalog: WorkspaceCatalog, +) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict): + raise SettingsValidationError( + {"oauth_client_workspace_bindings": "OAuth client Workspace bindings must be an object."} + ) + normalized: dict[str, str] = {} + for raw_client_id, raw_workspace_id in value.items(): + if ( + not isinstance(raw_client_id, str) + or not 1 <= len(raw_client_id) <= 128 + or not all(char.isalnum() or char in "-._~" for char in raw_client_id) + ): + raise SettingsValidationError( + {"oauth_client_workspace_bindings": "OAuth client binding contains an invalid client_id."} + ) + if not isinstance(raw_workspace_id, str) or not raw_workspace_id: + raise SettingsValidationError( + {"oauth_client_workspace_bindings": "OAuth client binding requires a Workspace id."} + ) + try: + workspace = catalog.get(raw_workspace_id) + except WorkspaceCatalogError as exc: + raise SettingsValidationError( + { + "oauth_client_workspace_bindings": ( + f"OAuth client {raw_client_id!r} references an unknown or disabled Workspace." + ) + } + ) from exc + normalized[raw_client_id] = workspace.id + return dict(sorted(normalized.items())) + + +def _canonicalize_catalog(settings: dict[str, Any], fallback_workspace: str | Path) -> None: + try: + catalog = WorkspaceCatalog.from_settings(settings, fallback_workspace) + except WorkspaceCatalogError as exc: + raise SettingsValidationError({"workspace_catalog": str(exc)}) from exc + settings.update(catalog.settings_payload()) + settings["workspace"] = str(catalog.default().root) + + +def normalize_startup_settings_with_warnings( + current: dict[str, Any], + updates: dict[str, Any], + fallback_workspace: str | Path, +) -> tuple[dict[str, Any], tuple[str, ...]]: + settings, current_warnings = migrate_persisted_settings(current) + clean_updates, update_warnings = migrate_persisted_settings(updates) + accepted = { + "host", + "port", + "workspace", + "workspace_catalog", + "default_workspace_id", + "oauth_server_url", + "oauth_compatibility_mode", + "oauth_client_workspace_bindings", + "permission_mode", + "shell_env_inherit", + "allowed_origins", + *SECRET_REFERENCE_FIELDS, + } + for key, value in clean_updates.items(): + if key not in accepted: + continue + if key == "allowed_origins": + if value is None: + settings.pop(key, None) + else: + settings[key] = normalize_allowed_origins(value) + elif key in { + "workspace_catalog", + "oauth_compatibility_mode", + "oauth_client_workspace_bindings", + }: + settings[key] = value + elif value is None or value == "": + settings.pop(key, None) + else: + settings[key] = value + + if "host" in settings: + settings["host"] = _normalize_host(settings["host"]) + if "port" in settings: + try: + port = int(settings["port"]) + except (TypeError, ValueError) as exc: + raise SettingsValidationError({"port": "Port must be an integer from 1 to 65535."}) from exc + if not 1 <= port <= 65535: + raise SettingsValidationError({"port": "Port must be an integer from 1 to 65535."}) + settings["port"] = port + if "oauth_server_url" in settings: + settings["oauth_server_url"] = _normalize_url(settings["oauth_server_url"]) + for key, choices in ( + ("permission_mode", PERMISSION_MODE_CHOICES), + ("shell_env_inherit", SHELL_ENV_INHERIT_CHOICES), + ): + if key in settings: + settings[key] = _normalize_choice(settings[key], key, choices) + if "oauth_compatibility_mode" in settings and not isinstance( + settings["oauth_compatibility_mode"], bool + ): + raise SettingsValidationError( + {"oauth_compatibility_mode": "OAuth compatibility mode must be a boolean."} + ) + if ( + "workspace_catalog" in settings + or "default_workspace_id" in settings + or "workspace" in clean_updates + ): + _canonicalize_catalog(settings, fallback_workspace) + if "oauth_client_workspace_bindings" in settings: + try: + catalog = WorkspaceCatalog.from_settings(settings, fallback_workspace) + except WorkspaceCatalogError as exc: + raise SettingsValidationError({"workspace_catalog": str(exc)}) from exc + settings["oauth_client_workspace_bindings"] = normalize_oauth_client_workspace_bindings( + settings["oauth_client_workspace_bindings"], + catalog, + ) + + warnings = tuple(dict.fromkeys((*current_warnings, *update_warnings))) + return settings, warnings + + +def normalize_startup_settings( + current: dict[str, Any], + updates: dict[str, Any], + fallback_workspace: str | Path, +) -> dict[str, Any]: + settings, _warnings = normalize_startup_settings_with_warnings( + current, + updates, + fallback_workspace, + ) + return settings + + +def pending_restart_fields( + active: dict[str, Any], + persisted: dict[str, Any], +) -> list[str]: + return sorted( + field + for field in RESTART_FIELDS + if active.get(field) != persisted.get(field) + ) + + +def schema_payload() -> dict[str, Any]: + return { + "permission_mode": list(PERMISSION_MODE_CHOICES), + "shell_env_inherit": list(SHELL_ENV_INHERIT_CHOICES), + "restart_fields": sorted(RESTART_FIELDS), + "migration_warnings": [LEGACY_TOOL_PROFILE_WARNING], + } diff --git a/coding_tools_mcp/settings_store.py b/coding_tools_mcp/settings_store.py new file mode 100644 index 0000000..a0302fd --- /dev/null +++ b/coding_tools_mcp/settings_store.py @@ -0,0 +1,144 @@ +"""Versioned server settings kept independently of a managed workspace.""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .envutils import ENV_PREFIX +from .settings_definition import migrate_persisted_settings + + +class SettingsStoreError(RuntimeError): + pass + + +SECRET_SETTING_KEYS = frozenset( + { + "auth_token", + "admin_token", + "oauth_password", + "oauth_token_secret", + "oauth_refresh_token_pepper", + } +) +SETTINGS_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class SettingsReadResult: + settings: dict[str, Any] + warnings: tuple[str, ...] + migrated: bool + + +def default_settings_dir(app_name: str = "coding-tools-mcp") -> Path: + configured = os.environ.get(f"{ENV_PREFIX}_CONFIG_DIR") + if configured: + return Path(configured).expanduser() + if os.name == "nt": + base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") + else: + base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + return base / app_name + + +def sanitize_settings(settings: dict[str, Any]) -> dict[str, Any]: + result = dict(settings) + for key in SECRET_SETTING_KEYS: + if result.get(key): + result[f"{key}_configured"] = True + result.pop(key, None) + for key in list(result): + if key.endswith("_secret_ref"): + result[key] = {"configured": bool(result[key])} + return result + + +class ServerSettingsStore: + def __init__(self, path: str | Path) -> None: + self.path = Path(path).expanduser() + + def read(self) -> dict[str, Any]: + return self.read_result().settings + + def read_result(self) -> SettingsReadResult: + if not self.path.exists(): + return SettingsReadResult({}, (), False) + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except OSError as exc: + raise SettingsStoreError(f"Could not read server settings: {exc}") from exc + except json.JSONDecodeError as exc: + raise SettingsStoreError( + f"Server settings JSON is corrupt; refusing to overwrite {self.path}: {exc}" + ) from exc + if not isinstance(raw, dict): + raise SettingsStoreError("Server settings must be a JSON object.") + + version = raw.get("schema_version", 0) + if isinstance(version, bool) or not isinstance(version, int): + raise SettingsStoreError("Server settings schema_version must be an integer.") + if version not in (0, SETTINGS_SCHEMA_VERSION): + raise SettingsStoreError("Server settings were written by an unsupported schema version.") + + migrated, warnings = migrate_persisted_settings(raw) + changed = migrated != raw or version == 0 + migrated["schema_version"] = SETTINGS_SCHEMA_VERSION + return SettingsReadResult(migrated, warnings, changed) + + def write(self, settings: dict[str, Any]) -> tuple[str, ...]: + if not isinstance(settings, dict): + raise SettingsStoreError("Server settings must be a JSON object.") + payload, warnings = migrate_persisted_settings(settings) + plaintext_keys = sorted( + key for key in SECRET_SETTING_KEYS if payload.get(key) not in (None, "") + ) + if plaintext_keys: + raise SettingsStoreError( + "Secret values must be stored in SecretVault and referenced from settings: " + + ", ".join(plaintext_keys) + ) + payload["schema_version"] = SETTINGS_SCHEMA_VERSION + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", + suffix=".tmp", + dir=self.path.parent, + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + tmp_path.chmod(0o600) + os.replace(tmp_path, self.path) + _fsync_directory(self.path.parent) + except OSError as exc: + raise SettingsStoreError(f"Could not atomically save server settings: {exc}") from exc + finally: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + return warnings + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + try: + descriptor = os.open(path, os.O_RDONLY) + except OSError: + return + try: + os.fsync(descriptor) + finally: + os.close(descriptor) diff --git a/coding_tools_mcp/transcript.py b/coding_tools_mcp/transcript.py new file mode 100644 index 0000000..1f33daa --- /dev/null +++ b/coding_tools_mcp/transcript.py @@ -0,0 +1,673 @@ +"""Workspace-partitioned chat, context, and imported-session persistence.""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + + +class TranscriptStoreError(RuntimeError): + pass + + +MAX_PAGE_SIZE = 200 +MAX_CONTENT_CHARS = 2_000_000 +SCHEMA_VERSION = 1 + + +def _now() -> float: + return time.time() + + +def _require_id(value: Any, field: str) -> str: + if not isinstance(value, str) or not value or len(value) > 256: + raise TranscriptStoreError(f"{field} must be a non-empty string up to 256 characters.") + if value in {".", ".."} or any(char in value for char in "\x00/\\"): + raise TranscriptStoreError(f"{field} contains unsupported characters.") + return value + + +def _text(value: Any, *, limit: int = MAX_CONTENT_CHARS) -> str: + if value is None: + return "" + result = str(value).replace("\x00", "\ufffd") + result = result.encode("utf-8", errors="replace").decode("utf-8", errors="replace") + if len(result) > limit: + raise TranscriptStoreError(f"Text exceeds the {limit}-character limit.") + return result + + +def _json(value: Any) -> str: + try: + return json.dumps(value if value is not None else {}, ensure_ascii=False, sort_keys=True) + except TypeError as exc: + raise TranscriptStoreError(f"Metadata must be JSON serializable: {exc}") from exc + + +def _page(page: int, page_size: int) -> tuple[int, int]: + try: + normalized_page = max(1, int(page)) + normalized_size = max(1, min(int(page_size), MAX_PAGE_SIZE)) + except (TypeError, ValueError) as exc: + raise TranscriptStoreError("page and page_size must be integers.") from exc + return normalized_page, normalized_size + + +def _summary(text: str, limit: int = 240) -> str: + flattened = " ".join(text.split()) + return flattened if len(flattened) <= limit else flattened[: limit - 1] + "\u2026" + + +@dataclass(frozen=True) +class WorkspaceScope: + workspace_id: str + workspace_root: Path + + @classmethod + def create(cls, workspace_id: str, workspace_root: str | Path) -> "WorkspaceScope": + identifier = _require_id(workspace_id, "workspace_id") + try: + root = Path(workspace_root).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise TranscriptStoreError(f"Workspace root cannot be resolved: {exc}") from exc + if not root.is_dir(): + raise TranscriptStoreError("Workspace root must be an existing directory.") + return cls(identifier, root) + + +class TranscriptStore: + """SQLite store where every key is partitioned by explicit Workspace identity.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path).expanduser() + self.path.parent.mkdir(parents=True, exist_ok=True) + self._write_lock = threading.RLock() + self._migrate() + + @contextmanager + def _connection(self, *, write: bool = False) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(self.path, timeout=5.0) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=5000") + if write: + conn.execute("BEGIN IMMEDIATE") + yield conn + if write: + conn.commit() + except BaseException: + if write: + conn.rollback() + raise + finally: + conn.close() + + def _migrate(self) -> None: + with self._write_lock, self._connection(write=True) as conn: + version = int(conn.execute("PRAGMA user_version").fetchone()[0]) + if version > SCHEMA_VERSION: + raise TranscriptStoreError("Transcript database was written by a newer version.") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_conversations( + workspace_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + title TEXT, + source TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY(workspace_id, conversation_id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_messages( + workspace_id TEXT NOT NULL, + message_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + timestamp TEXT, + content TEXT NOT NULL, + source TEXT, + metadata_json TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY(workspace_id, message_id), + FOREIGN KEY(workspace_id, conversation_id) + REFERENCES chat_conversations(workspace_id, conversation_id) + ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_context_entries( + workspace_id TEXT NOT NULL, + context_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + kind TEXT NOT NULL, + timestamp TEXT, + content TEXT NOT NULL, + source TEXT, + metadata_json TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY(workspace_id, context_id), + FOREIGN KEY(workspace_id, conversation_id) + REFERENCES chat_conversations(workspace_id, conversation_id) + ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS imported_sessions( + workspace_id TEXT NOT NULL, + session_id TEXT NOT NULL, + conversation_id TEXT, + relative_path TEXT NOT NULL, + source_kind TEXT NOT NULL, + title TEXT, + summary TEXT, + message_count INTEGER NOT NULL DEFAULT 0, + parse_error_count INTEGER NOT NULL DEFAULT 0, + parse_errors_json TEXT NOT NULL DEFAULT '[]', + file_size INTEGER NOT NULL DEFAULT 0, + file_mtime_ns INTEGER NOT NULL DEFAULT 0, + imported_at REAL, + updated_at REAL NOT NULL, + PRIMARY KEY(workspace_id, session_id) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_conversations_updated ON chat_conversations(workspace_id, updated_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_messages_conversation ON chat_messages(workspace_id, conversation_id, created_at, message_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_context_conversation ON chat_context_entries(workspace_id, conversation_id, created_at, context_id)" + ) + conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + + def scoped(self, workspace_id: str, workspace_root: str | Path) -> "WorkspaceTranscriptService": + return WorkspaceTranscriptService(self, WorkspaceScope.create(workspace_id, workspace_root)) + + def record_messages( + self, + workspace_id: str, + conversation_id: str, + messages: list[dict[str, Any]], + *, + title: str | None = None, + source: str | None = None, + ) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + conversation_id = _require_id(conversation_id, "conversation_id") + if not isinstance(messages, list) or len(messages) > 10_000: + raise TranscriptStoreError("messages must be a list with at most 10000 items.") + inserted = 0 + duplicates = 0 + now = _now() + with self._write_lock, self._connection(write=True) as conn: + conn.execute( + """ + INSERT INTO chat_conversations(workspace_id, conversation_id, title, source, created_at, updated_at) + VALUES(?,?,?,?,?,?) + ON CONFLICT(workspace_id, conversation_id) DO UPDATE SET + title=COALESCE(excluded.title, chat_conversations.title), + source=COALESCE(excluded.source, chat_conversations.source), + updated_at=excluded.updated_at + """, + (workspace_id, conversation_id, _text(title, limit=500) or None, _text(source, limit=200) or None, now, now), + ) + for index, message in enumerate(messages): + if not isinstance(message, dict): + raise TranscriptStoreError("Each message must be an object.") + message_id = _require_id( + message.get("message_id") or message.get("id") or f"msg-{uuid.uuid4().hex}", + "message_id", + ) + role = _text(message.get("role") or "unknown", limit=64).lower() + if role not in {"user", "assistant", "system", "tool", "developer", "unknown"}: + role = "unknown" + content = _text(message.get("content")) + timestamp = _text(message.get("timestamp"), limit=128) or None + item_source = _text(message.get("source") or source, limit=200) or None + metadata = message.get("metadata", message.get("metadata_json", {})) + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except json.JSONDecodeError: + metadata = {"raw": _text(metadata, limit=20_000)} + cursor = conn.execute( + """ + INSERT OR IGNORE INTO chat_messages( + workspace_id, message_id, conversation_id, role, timestamp, + content, source, metadata_json, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?) + """, + (workspace_id, message_id, conversation_id, role, timestamp, content, item_source, _json(metadata), now + index / 1_000_000, now), + ) + if cursor.rowcount: + inserted += 1 + else: + duplicates += 1 + conn.execute( + "UPDATE chat_conversations SET updated_at=? WHERE workspace_id=? AND conversation_id=?", + (now, workspace_id, conversation_id), + ) + return { + "workspace_id": workspace_id, + "conversation_id": conversation_id, + "inserted_count": inserted, + "duplicate_count": duplicates, + } + + def record_context( + self, + workspace_id: str, + conversation_id: str, + entries: list[dict[str, Any]], + *, + title: str | None = None, + source: str | None = None, + ) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + conversation_id = _require_id(conversation_id, "conversation_id") + if not isinstance(entries, list) or len(entries) > 10_000: + raise TranscriptStoreError("entries must be a list with at most 10000 items.") + inserted = 0 + duplicates = 0 + now = _now() + with self._write_lock, self._connection(write=True) as conn: + conn.execute( + """ + INSERT INTO chat_conversations(workspace_id, conversation_id, title, source, created_at, updated_at) + VALUES(?,?,?,?,?,?) + ON CONFLICT(workspace_id, conversation_id) DO UPDATE SET + title=COALESCE(excluded.title, chat_conversations.title), + source=COALESCE(excluded.source, chat_conversations.source), + updated_at=excluded.updated_at + """, + (workspace_id, conversation_id, _text(title, limit=500) or None, _text(source, limit=200) or None, now, now), + ) + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise TranscriptStoreError("Each context entry must be an object.") + context_id = _require_id( + entry.get("context_id") or entry.get("entry_id") or entry.get("id") or f"ctx-{uuid.uuid4().hex}", + "context_id", + ) + kind = _text(entry.get("kind") or "note", limit=64).lower() + content = _text(entry.get("content")) + timestamp = _text(entry.get("timestamp"), limit=128) or None + item_source = _text(entry.get("source") or source, limit=200) or None + metadata = entry.get("metadata", entry.get("metadata_json", {})) + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except json.JSONDecodeError: + metadata = {"raw": _text(metadata, limit=20_000)} + cursor = conn.execute( + """ + INSERT OR IGNORE INTO chat_context_entries( + workspace_id, context_id, conversation_id, kind, timestamp, + content, source, metadata_json, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?) + """, + (workspace_id, context_id, conversation_id, kind, timestamp, content, item_source, _json(metadata), now + index / 1_000_000, now), + ) + if cursor.rowcount: + inserted += 1 + else: + duplicates += 1 + conn.execute( + "UPDATE chat_conversations SET updated_at=? WHERE workspace_id=? AND conversation_id=?", + (now, workspace_id, conversation_id), + ) + return { + "workspace_id": workspace_id, + "conversation_id": conversation_id, + "inserted_count": inserted, + "duplicate_count": duplicates, + } + + def list_conversations( + self, + workspace_id: str | None, + *, + page: int = 1, + page_size: int = 50, + query: str | None = None, + ) -> dict[str, Any]: + if workspace_id is not None: + workspace_id = _require_id(workspace_id, "workspace_id") + page, page_size = _page(page, page_size) + clauses: list[str] = [] + args: list[Any] = [] + if workspace_id is not None: + clauses.append("c.workspace_id=?") + args.append(workspace_id) + if query: + clauses.append("(c.conversation_id LIKE ? OR c.title LIKE ? OR c.source LIKE ?)") + pattern = f"%{_text(query, limit=200)}%" + args.extend((pattern, pattern, pattern)) + where = " WHERE " + " AND ".join(clauses) if clauses else "" + with self._connection() as conn: + total = int(conn.execute(f"SELECT COUNT(*) FROM chat_conversations c{where}", args).fetchone()[0]) + rows = conn.execute( + f""" + SELECT c.workspace_id, c.conversation_id, c.title, c.source, + c.created_at, c.updated_at, + COUNT(DISTINCT m.message_id) AS message_count, + COUNT(DISTINCT x.context_id) AS context_count, + MAX(CASE WHEN m.role='user' THEN substr(m.content,1,240) END) AS preview + FROM chat_conversations c + LEFT JOIN chat_messages m ON m.workspace_id=c.workspace_id AND m.conversation_id=c.conversation_id + LEFT JOIN chat_context_entries x ON x.workspace_id=c.workspace_id AND x.conversation_id=c.conversation_id + {where} + GROUP BY c.workspace_id, c.conversation_id + ORDER BY c.updated_at DESC, c.workspace_id, c.conversation_id + LIMIT ? OFFSET ? + """, + (*args, page_size, (page - 1) * page_size), + ).fetchall() + items = [dict(row) for row in rows] + for item in items: + item["preview"] = _summary(item.get("preview") or "") + return {"items": items, "count": len(items), "total": total, "page": page, "page_size": page_size} + + def conversation_detail( + self, + workspace_id: str, + conversation_id: str, + *, + message_page: int = 1, + message_page_size: int = 100, + context_page: int = 1, + context_page_size: int = 100, + ) -> dict[str, Any] | None: + workspace_id = _require_id(workspace_id, "workspace_id") + conversation_id = _require_id(conversation_id, "conversation_id") + message_page, message_page_size = _page(message_page, message_page_size) + context_page, context_page_size = _page(context_page, context_page_size) + with self._connection() as conn: + row = conn.execute( + "SELECT * FROM chat_conversations WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone() + if row is None: + return None + messages_total = int(conn.execute( + "SELECT COUNT(*) FROM chat_messages WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + contexts_total = int(conn.execute( + "SELECT COUNT(*) FROM chat_context_entries WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + messages = conn.execute( + """ + SELECT workspace_id, message_id, conversation_id, role, timestamp, + content, source, metadata_json, created_at, updated_at + FROM chat_messages + WHERE workspace_id=? AND conversation_id=? + ORDER BY created_at, message_id LIMIT ? OFFSET ? + """, + (workspace_id, conversation_id, message_page_size, (message_page - 1) * message_page_size), + ).fetchall() + contexts = conn.execute( + """ + SELECT workspace_id, context_id, conversation_id, kind, timestamp, + content, source, metadata_json, created_at, updated_at + FROM chat_context_entries + WHERE workspace_id=? AND conversation_id=? + ORDER BY created_at, context_id LIMIT ? OFFSET ? + """, + (workspace_id, conversation_id, context_page_size, (context_page - 1) * context_page_size), + ).fetchall() + message_items = [_decode_metadata(dict(item)) for item in messages] + context_items = [_decode_metadata(dict(item)) for item in contexts] + return { + "conversation": dict(row), + "messages": message_items, + "messages_total": messages_total, + "message_page": message_page, + "message_page_size": message_page_size, + "contexts": context_items, + "contexts_total": contexts_total, + "context_page": context_page, + "context_page_size": context_page_size, + } + + def delete_message(self, workspace_id: str, message_id: str) -> dict[str, Any]: + return self._delete_by_id("chat_messages", "message_id", workspace_id, message_id) + + def delete_context(self, workspace_id: str, context_id: str) -> dict[str, Any]: + return self._delete_by_id("chat_context_entries", "context_id", workspace_id, context_id) + + def delete_conversation(self, workspace_id: str, conversation_id: str) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + conversation_id = _require_id(conversation_id, "conversation_id") + with self._write_lock, self._connection(write=True) as conn: + message_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_messages WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + context_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_context_entries WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + cursor = conn.execute( + "DELETE FROM chat_conversations WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ) + return { + "workspace_id": workspace_id, + "conversation_id": conversation_id, + "affected_count": int(cursor.rowcount), + "deleted_message_count": message_count if cursor.rowcount else 0, + "deleted_context_count": context_count if cursor.rowcount else 0, + } + + def clear_workspace(self, workspace_id: str) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + with self._write_lock, self._connection(write=True) as conn: + conversations = int(conn.execute("SELECT COUNT(*) FROM chat_conversations WHERE workspace_id=?", (workspace_id,)).fetchone()[0]) + messages = int(conn.execute("SELECT COUNT(*) FROM chat_messages WHERE workspace_id=?", (workspace_id,)).fetchone()[0]) + contexts = int(conn.execute("SELECT COUNT(*) FROM chat_context_entries WHERE workspace_id=?", (workspace_id,)).fetchone()[0]) + sessions = int(conn.execute("SELECT COUNT(*) FROM imported_sessions WHERE workspace_id=?", (workspace_id,)).fetchone()[0]) + conn.execute("DELETE FROM chat_conversations WHERE workspace_id=?", (workspace_id,)) + conn.execute("DELETE FROM imported_sessions WHERE workspace_id=?", (workspace_id,)) + return { + "workspace_id": workspace_id, + "affected_count": conversations + messages + contexts + sessions, + "deleted_conversation_count": conversations, + "deleted_message_count": messages, + "deleted_context_count": contexts, + "deleted_session_count": sessions, + } + + def upsert_imported_session(self, workspace_id: str, item: dict[str, Any]) -> None: + workspace_id = _require_id(workspace_id, "workspace_id") + session_id = _require_id(item.get("session_id"), "session_id") + errors = item.get("parse_errors") or [] + with self._write_lock, self._connection(write=True) as conn: + conn.execute( + """ + INSERT INTO imported_sessions( + workspace_id, session_id, conversation_id, relative_path, source_kind, + title, summary, message_count, parse_error_count, parse_errors_json, + file_size, file_mtime_ns, imported_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(workspace_id, session_id) DO UPDATE SET + conversation_id=excluded.conversation_id, + relative_path=excluded.relative_path, + source_kind=excluded.source_kind, + title=excluded.title, + summary=excluded.summary, + message_count=excluded.message_count, + parse_error_count=excluded.parse_error_count, + parse_errors_json=excluded.parse_errors_json, + file_size=excluded.file_size, + file_mtime_ns=excluded.file_mtime_ns, + imported_at=COALESCE(excluded.imported_at, imported_sessions.imported_at), + updated_at=excluded.updated_at + """, + ( + workspace_id, + session_id, + item.get("conversation_id"), + _text(item.get("relative_path"), limit=2000), + _text(item.get("source_kind") or "codex", limit=100), + _text(item.get("title"), limit=500) or None, + _text(item.get("summary"), limit=1000) or None, + int(item.get("message_count") or 0), + len(errors), + _json(errors), + int(item.get("file_size") or 0), + int(item.get("file_mtime_ns") or 0), + item.get("imported_at"), + _now(), + ), + ) + + def list_imported_sessions( + self, + workspace_id: str | None, + *, + page: int = 1, + page_size: int = 50, + ) -> dict[str, Any]: + if workspace_id is not None: + workspace_id = _require_id(workspace_id, "workspace_id") + page, page_size = _page(page, page_size) + where = " WHERE workspace_id=?" if workspace_id else "" + args: tuple[Any, ...] = (workspace_id,) if workspace_id else () + with self._connection() as conn: + total = int(conn.execute(f"SELECT COUNT(*) FROM imported_sessions{where}", args).fetchone()[0]) + rows = conn.execute( + f"SELECT * FROM imported_sessions{where} ORDER BY updated_at DESC, workspace_id, session_id LIMIT ? OFFSET ?", + (*args, page_size, (page - 1) * page_size), + ).fetchall() + items = [] + for row in rows: + item = dict(row) + item["parse_errors"] = json.loads(item.pop("parse_errors_json")) + items.append(item) + return {"items": items, "count": len(items), "total": total, "page": page, "page_size": page_size} + + def delete_imported_session(self, workspace_id: str, session_id: str) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + session_id = _require_id(session_id, "session_id") + with self._write_lock, self._connection(write=True) as conn: + session = conn.execute( + "SELECT conversation_id FROM imported_sessions WHERE workspace_id=? AND session_id=?", + (workspace_id, session_id), + ).fetchone() + if session is None: + return { + "workspace_id": workspace_id, + "session_id": session_id, + "affected_count": 0, + "deleted_session_count": 0, + "deleted_conversation_count": 0, + "deleted_message_count": 0, + "deleted_context_count": 0, + } + conversation_id = session["conversation_id"] + message_count = 0 + context_count = 0 + conversation_count = 0 + if isinstance(conversation_id, str) and conversation_id: + message_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_messages WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + context_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_context_entries WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + conversation_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_conversations WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + conn.execute( + "DELETE FROM chat_conversations WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ) + cursor = conn.execute( + "DELETE FROM imported_sessions WHERE workspace_id=? AND session_id=?", + (workspace_id, session_id), + ) + session_count = int(cursor.rowcount) + return { + "workspace_id": workspace_id, + "session_id": session_id, + "affected_count": session_count + conversation_count + message_count + context_count, + "deleted_session_count": session_count, + "deleted_conversation_count": conversation_count, + "deleted_message_count": message_count, + "deleted_context_count": context_count, + } + + def _delete_by_id(self, table: str, field: str, workspace_id: str, identifier: str) -> dict[str, Any]: + if table not in {"chat_messages", "chat_context_entries", "imported_sessions"}: + raise TranscriptStoreError("Unsupported delete target.") + workspace_id = _require_id(workspace_id, "workspace_id") + identifier = _require_id(identifier, field) + with self._write_lock, self._connection(write=True) as conn: + cursor = conn.execute( + f"DELETE FROM {table} WHERE workspace_id=? AND {field}=?", + (workspace_id, identifier), + ) + return {"workspace_id": workspace_id, field: identifier, "affected_count": int(cursor.rowcount)} + + +class WorkspaceTranscriptService: + """Non-admin facade fixed to one immutable Workspace scope.""" + + def __init__(self, store: TranscriptStore, scope: WorkspaceScope) -> None: + self.store = store + self.scope = scope + + def record_messages(self, conversation_id: str, messages: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]: + return self.store.record_messages(self.scope.workspace_id, conversation_id, messages, **kwargs) + + def record_context(self, conversation_id: str, entries: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]: + return self.store.record_context(self.scope.workspace_id, conversation_id, entries, **kwargs) + + def list_conversations(self, **kwargs: Any) -> dict[str, Any]: + return self.store.list_conversations(self.scope.workspace_id, **kwargs) + + def conversation_detail(self, conversation_id: str, **kwargs: Any) -> dict[str, Any] | None: + return self.store.conversation_detail(self.scope.workspace_id, conversation_id, **kwargs) + + +def _decode_metadata(item: dict[str, Any]) -> dict[str, Any]: + raw = item.pop("metadata_json", "{}") + try: + item["metadata"] = json.loads(raw) + except json.JSONDecodeError: + item["metadata"] = {"parse_error": True} + return item + + +__all__ = [ + "MAX_PAGE_SIZE", + "TranscriptStore", + "TranscriptStoreError", + "WorkspaceScope", + "WorkspaceTranscriptService", +] diff --git a/coding_tools_mcp/transport_http.py b/coding_tools_mcp/transport_http.py index 698d082..191e5e3 100644 --- a/coding_tools_mcp/transport_http.py +++ b/coding_tools_mcp/transport_http.py @@ -26,14 +26,14 @@ class HTTPSessionRecord: class HTTPSessionManager: """Own independent Runtime instances for Streamable HTTP sessions.""" - def __init__(self, factory: Callable[[], Any]) -> None: + def __init__(self, factory: Callable[[Any], Any]) -> None: self._factory = factory self._sessions: dict[str, HTTPSessionRecord] = {} self._lock = threading.Lock() self._creating = 0 self._closed = False - def create(self) -> Any: + def create(self, context: Any) -> Any: self.prune() with self._lock: if self._closed: @@ -44,7 +44,7 @@ def create(self) -> Any: runtime: Any | None = None installed = False try: - runtime = self._factory() + runtime = self._factory(context) record = HTTPSessionRecord(runtime=runtime, last_seen=time.time()) with self._lock: if self._closed: diff --git a/coding_tools_mcp/upstream.py b/coding_tools_mcp/upstream.py new file mode 100644 index 0000000..580ed1f --- /dev/null +++ b/coding_tools_mcp/upstream.py @@ -0,0 +1,1181 @@ +from __future__ import annotations + +import atexit +import copy +import json +import os +import queue +import re +import signal +import socket +import subprocess +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from collections import deque +from collections.abc import Callable, Collection, Iterable +from dataclasses import dataclass, field +from http.client import RemoteDisconnected +from pathlib import Path +from typing import Any + + +DEFAULT_PROTOCOL_VERSION = "2025-11-25" +DEFAULT_TIMEOUT_MS = 30_000 +MAX_RESPONSE_BYTES = 1_048_576 +MAX_TOOL_NAME_CHARS = 512 +ALIAS_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") +FORBIDDEN_STDIO_COMMANDS = { + "cmd", + "cmd.exe", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", + "bash", + "bash.exe", + "sh", + "sh.exe", + "zsh", + "zsh.exe", + "fish", + "fish.exe", +} +SHELL_FRAGMENT_RE = re.compile(r"(\|\||&&|[|<>;`]|\$\(|\$\{)") +UPSTREAM_BASE_ENV_NAMES = frozenset( + { + "PATH", + "PATHEXT", + "COMSPEC", + "SYSTEMROOT", + "WINDIR", + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "TEMP", + "TMP", + "LANG", + "LC_ALL", + "TERM", + } +) + + +class UpstreamConfigError(ValueError): + """Gateway configuration cannot safely form a fixed tool snapshot.""" + + +class UpstreamError(Exception): + def __init__( + self, + code: str, + message: str, + *, + category: str = "runtime", + retryable: bool = False, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.category = category + self.retryable = retryable + self.details = details or {} + + +@dataclass(frozen=True) +class UpstreamServerConfig: + alias: str + transport: str + enabled: bool = True + url: str | None = None + command: str | None = None + args: tuple[str, ...] = () + env: dict[str, Any] = field(default_factory=dict) + headers: dict[str, str] = field(default_factory=dict) + authorization_env: str | None = None + include_tools: tuple[str, ...] = () + exclude_tools: tuple[str, ...] = () + timeout_ms: int = DEFAULT_TIMEOUT_MS + + +@dataclass(frozen=True) +class UpstreamConfigSnapshot: + """Configuration, enable state, and allowlists fixed before Runtime creation.""" + + configs: tuple[UpstreamServerConfig, ...] = () + source: str | None = None + + @classmethod + def empty(cls) -> "UpstreamConfigSnapshot": + return cls() + + +@dataclass(frozen=True) +class UpstreamTool: + public_name: str + remote_name: str + definition: dict[str, Any] + + +@dataclass +class UpstreamStatus: + alias: str + transport: str + enabled: bool + initialized: bool = False + tool_count: int = 0 + error: dict[str, Any] | None = None + target: str | None = None + + def payload(self) -> dict[str, Any]: + result: dict[str, Any] = { + "alias": self.alias, + "transport": self.transport, + "enabled": self.enabled, + "initialized": self.initialized, + "tool_count": self.tool_count, + } + if self.target is not None: + result["target"] = self.target + if self.error is not None: + result["error"] = copy.deepcopy(self.error) + return result + + +class BaseUpstreamClient: + def __init__( + self, + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: Callable[[str], str] | None = None, + ) -> None: + self.config = config + self.protocol_version = protocol_version + self.secret_resolver = secret_resolver + self._next_id = 1 + self._id_lock = threading.Lock() + + def initialize(self) -> None: + self.request( + "initialize", + { + "protocolVersion": self.protocol_version, + "capabilities": {}, + "clientInfo": {"name": "coding-tools-mcp-upstream", "version": "0"}, + }, + ) + self.notify("notifications/initialized", {}) + + def list_tools(self) -> list[dict[str, Any]]: + response = self.request("tools/list", {}) + tools = response.get("tools") if isinstance(response, dict) else None + if not isinstance(tools, list): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/list response did not include a tools list.", + category="protocol", + ) + if not all(isinstance(tool, dict) for tool in tools): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/list contained a non-object tool definition.", + category="protocol", + ) + return [copy.deepcopy(tool) for tool in tools] + + def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + response = self.request("tools/call", {"name": name, "arguments": arguments}) + return normalize_tool_result(response) + + def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + raise NotImplementedError + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + raise NotImplementedError + + def close(self) -> None: + return None + + def _next_request_id(self) -> int: + with self._id_lock: + request_id = self._next_id + self._next_id += 1 + return request_id + + +def _rpc_result(response: Any, request_id: int, method: str) -> dict[str, Any]: + if not isinstance(response, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response was not a JSON object.", + category="protocol", + details={"method": method}, + ) + if response.get("jsonrpc") != "2.0": + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response did not use JSON-RPC 2.0.", + category="protocol", + details={"method": method}, + ) + if response.get("id") != request_id: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response id did not match the request id.", + category="protocol", + details={"method": method}, + ) + has_result = "result" in response + has_error = "error" in response + if has_result == has_error: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response must contain exactly one of result or error.", + category="protocol", + details={"method": method}, + ) + if has_error: + error = response.get("error") + if not isinstance(error, dict) or not isinstance(error.get("message"), str): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream JSON-RPC error envelope was invalid.", + category="protocol", + details={"method": method}, + ) + raise UpstreamError( + "UPSTREAM_RPC_ERROR", + error["message"], + category="upstream", + details={"method": method, "rpc_error": copy.deepcopy(error)}, + ) + result = response.get("result") + if not isinstance(result, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response result was not an object.", + category="protocol", + details={"method": method}, + ) + return result + + +class HttpUpstreamClient(BaseUpstreamClient): + def __init__( + self, + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: Callable[[str], str] | None = None, + ) -> None: + super().__init__(config, protocol_version, secret_resolver=secret_resolver) + if not config.url: + raise UpstreamConfigError( + f"Upstream {config.alias!r} requires url for streamable_http transport." + ) + self.url = config.url + self.session_id: str | None = None + + def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + request_id = self._next_request_id() + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + } + if params is not None: + payload["params"] = params + response = self._send(payload, expect_response=True) + return _rpc_result(response, request_id, method) + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + self._send(payload, expect_response=False) + + def _send(self, payload: dict[str, Any], *, expect_response: bool) -> dict[str, Any] | None: + data = json.dumps(payload, separators=(",", ":")).encode("utf-8") + headers = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + **self.config.headers, + } + if self.session_id: + headers["Mcp-Session-Id"] = self.session_id + token = os.environ.get(self.config.authorization_env) if self.config.authorization_env else None + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(self.url, data=data, headers=headers, method="POST") + timeout_s = max(self.config.timeout_ms, 1) / 1000 + try: + with urllib.request.urlopen(request, timeout=timeout_s) as response: + session_id = response.headers.get("Mcp-Session-Id") + if session_id: + self.session_id = session_id + if not expect_response or response.status in {202, 204}: + return None + raw = _read_bounded_response(response) + expected_id = payload.get("id") + return decode_http_rpc_response( + raw, + response.headers.get("Content-Type", ""), + expected_id=expected_id if isinstance(expected_id, int) else None, + ) + except urllib.error.HTTPError as exc: + raw = exc.read(MAX_RESPONSE_BYTES + 1) + if len(raw) <= MAX_RESPONSE_BYTES and raw: + try: + return decode_http_rpc_response( + raw, + exc.headers.get("Content-Type", ""), + expected_id=payload.get("id") if isinstance(payload.get("id"), int) else None, + ) + except (UpstreamError, UnicodeDecodeError, json.JSONDecodeError): + pass + raise UpstreamError( + "UPSTREAM_HTTP_ERROR", + f"Upstream MCP server returned HTTP {exc.code}.", + category="upstream", + retryable=500 <= exc.code < 600, + details={"status": exc.code}, + ) from exc + except (TimeoutError, socket.timeout) as exc: + raise UpstreamError( + "UPSTREAM_TIMEOUT", + "Timed out waiting for upstream MCP server.", + retryable=True, + ) from exc + except urllib.error.URLError as exc: + if isinstance(exc.reason, (TimeoutError, socket.timeout)): + raise UpstreamError( + "UPSTREAM_TIMEOUT", + "Timed out waiting for upstream MCP server.", + retryable=True, + ) from exc + raise UpstreamError( + "UPSTREAM_CONNECTION_FAILED", + "Could not connect to upstream MCP server.", + retryable=True, + ) from exc + except (RemoteDisconnected, ConnectionError, BrokenPipeError, ConnectionResetError) as exc: + raise UpstreamError( + "UPSTREAM_DISCONNECTED", + "Upstream MCP server disconnected.", + retryable=True, + ) from exc + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream returned invalid JSON.", + category="protocol", + ) from exc + + +class StdioUpstreamClient(BaseUpstreamClient): + def __init__( + self, + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: Callable[[str], str] | None = None, + ) -> None: + super().__init__(config, protocol_version, secret_resolver=secret_resolver) + if not config.command: + raise UpstreamConfigError( + f"Upstream {config.alias!r} requires command for stdio transport." + ) + self._lock = threading.Lock() + self._responses: queue.Queue[dict[str, Any] | UpstreamError] = queue.Queue() + self._stderr_lines: deque[str] = deque(maxlen=500) + self._stderr_lock = threading.Lock() + env = base_upstream_environment() + env.update(resolve_env_config(config.env, secret_resolver=self.secret_resolver)) + creationflags = 0 + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + self.process = subprocess.Popen( + [config.command, *config.args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="strict", + bufsize=1, + env=env, + creationflags=creationflags, + ) + self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True) + self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) + self._stdout_thread.start() + self._stderr_thread.start() + atexit.register(self.close) + + def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + request_id = self._next_request_id() + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + } + if params is not None: + payload["params"] = params + with self._lock: + self._write(payload) + deadline = time.monotonic() + max(self.config.timeout_ms, 1) / 1000 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise UpstreamError( + "UPSTREAM_TIMEOUT", + "Timed out waiting for upstream MCP server.", + retryable=True, + ) + if self.process.poll() is not None and self._responses.empty(): + raise self._process_exited_error() + try: + response = self._responses.get(timeout=min(remaining, 0.1)) + except queue.Empty: + continue + if isinstance(response, UpstreamError): + raise response + return _rpc_result(response, request_id, method) + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + with self._lock: + self._write(payload) + + def close(self) -> None: + process = getattr(self, "process", None) + if process is None or process.poll() is not None: + return + try: + if os.name == "nt": + process.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined] + else: + process.terminate() + process.wait(timeout=2) + except Exception: # noqa: BLE001 + try: + process.kill() + except Exception: # noqa: BLE001 + pass + + def _write(self, payload: dict[str, Any]) -> None: + if self.process.poll() is not None: + raise self._process_exited_error() + if self.process.stdin is None: + raise UpstreamError( + "UPSTREAM_DISCONNECTED", + "Upstream MCP stdin is closed.", + retryable=True, + ) + try: + self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + self.process.stdin.flush() + except OSError as exc: + raise UpstreamError( + "UPSTREAM_DISCONNECTED", + "Upstream MCP stdio process disconnected.", + retryable=True, + ) from exc + + def _read_stdout(self) -> None: + if self.process.stdout is None: + return + try: + for raw_line in self.process.stdout: + line = raw_line.strip() + if not line: + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + self._responses.put( + UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream stdio returned invalid JSON.", + category="protocol", + ) + ) + continue + if not isinstance(parsed, dict): + self._responses.put( + UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream stdio response was not a JSON object.", + category="protocol", + ) + ) + continue + if "id" not in parsed and isinstance(parsed.get("method"), str): + continue + self._responses.put(parsed) + except UnicodeError: + self._responses.put( + UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream stdio returned non-UTF-8 output.", + category="protocol", + ) + ) + + def _read_stderr(self) -> None: + if self.process.stderr is None: + return + for line in self.process.stderr: + item = line.rstrip("\r\n")[:500] + with self._stderr_lock: + self._stderr_lines.append(item) + + def _process_exited_error(self) -> UpstreamError: + with self._stderr_lock: + stderr_tail = list(self._stderr_lines)[-5:] + return UpstreamError( + "UPSTREAM_PROCESS_EXITED", + "Upstream MCP stdio process exited.", + retryable=True, + details={"returncode": self.process.returncode, "stderr_tail": stderr_tail}, + ) + + +class UpstreamManager: + """Per-Runtime upstream clients with an immutable discovered tool snapshot.""" + + def __init__( + self, + configs: Iterable[UpstreamServerConfig], + *, + protocol_version: str = DEFAULT_PROTOCOL_VERSION, + secret_resolver: Callable[[str], str] | None = None, + reserved_names: Collection[str] = (), + ) -> None: + self.protocol_version = protocol_version + self.secret_resolver = secret_resolver + self.configs = tuple(configs) + self.clients: dict[str, BaseUpstreamClient] = {} + self.statuses: dict[str, UpstreamStatus] = {} + self._tools: dict[str, UpstreamTool] = {} + self._tool_order: tuple[str, ...] = () + self._closed = False + try: + self._initialize_configs(frozenset(reserved_names)) + except BaseException: + self.close() + raise + + @classmethod + def empty( + cls, + protocol_version: str = DEFAULT_PROTOCOL_VERSION, + *, + reserved_names: Collection[str] = (), + ) -> "UpstreamManager": + return cls((), protocol_version=protocol_version, reserved_names=reserved_names) + + @classmethod + def from_snapshot( + cls, + snapshot: UpstreamConfigSnapshot, + *, + protocol_version: str = DEFAULT_PROTOCOL_VERSION, + secret_resolver: Callable[[str], str] | None = None, + reserved_names: Collection[str] = (), + ) -> "UpstreamManager": + return cls( + snapshot.configs, + protocol_version=protocol_version, + secret_resolver=secret_resolver, + reserved_names=reserved_names, + ) + + def tool_definitions(self) -> list[dict[str, Any]]: + return [copy.deepcopy(self._tools[name].definition) for name in self._tool_order] + + def tool_names(self) -> list[str]: + return list(self._tool_order) + + def has_tool(self, name: str) -> bool: + return name in self._tools + + def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + tool = self._tools.get(name) + if tool is None: + return upstream_error_result( + "UPSTREAM_TOOL_NOT_FOUND", + f"Unknown upstream tool: {name}", + category="validation", + ) + if self._closed: + return upstream_error_result( + "UPSTREAM_DISCONNECTED", + "Upstream Gateway is closed.", + retryable=True, + alias=name.partition("__")[0], + tool_name=name, + ) + alias, _separator, _remote = name.partition("__") + client = self.clients.get(alias) + if client is None: + return upstream_error_result( + "UPSTREAM_NOT_AVAILABLE", + f"Upstream {alias!r} is not available.", + retryable=True, + alias=alias, + tool_name=name, + ) + try: + return client.call_tool(tool.remote_name, arguments or {}) + except UpstreamError as exc: + return upstream_error_result( + exc.code, + exc.message, + category=exc.category, + retryable=exc.retryable, + details=exc.details, + alias=alias, + tool_name=name, + ) + except OSError: + return upstream_error_result( + "UPSTREAM_DISCONNECTED", + "Upstream MCP server disconnected.", + retryable=True, + alias=alias, + tool_name=name, + ) + + def status_payload(self) -> dict[str, Any]: + statuses = [self.statuses[alias].payload() for alias in sorted(self.statuses)] + return { + "enabled": any(status.enabled for status in self.statuses.values()), + "server_count": len(self.statuses), + "initialized_count": sum(1 for status in self.statuses.values() if status.initialized), + "tool_count": len(self._tool_order), + "snapshot_immutable": True, + "remote_capability_boundary": "upstream_server", + "servers": statuses, + } + + def close(self) -> None: + if self._closed: + return + self._closed = True + for client in list(self.clients.values()): + client.close() + self.clients.clear() + + def _initialize_configs(self, reserved_names: frozenset[str]) -> None: + seen_public_names = set(reserved_names) + ordered_names: list[str] = [] + for config in self.configs: + status = UpstreamStatus( + alias=config.alias, + transport=config.transport, + enabled=config.enabled, + target=safe_target(config), + ) + self.statuses[config.alias] = status + if not config.enabled: + continue + client: BaseUpstreamClient | None = None + try: + client = build_client( + config, + self.protocol_version, + secret_resolver=self.secret_resolver, + ) + client.initialize() + raw_tools = filter_tools(client.list_tools(), config) + registered: list[UpstreamTool] = [] + for raw_tool in raw_tools: + remote_name = raw_tool.get("name") + if not isinstance(remote_name, str) or not remote_name: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool definition had no valid name.", + category="protocol", + ) + public_name = namespaced_tool_name(config.alias, remote_name) + if public_name in seen_public_names: + raise UpstreamConfigError( + f"Upstream tool namespace collision: {public_name!r}." + ) + seen_public_names.add(public_name) + registered.append( + UpstreamTool( + public_name=public_name, + remote_name=remote_name, + definition=namespaced_tool_definition(public_name, raw_tool), + ) + ) + self.clients[config.alias] = client + for tool in registered: + self._tools[tool.public_name] = tool + ordered_names.append(tool.public_name) + status.initialized = True + status.tool_count = len(registered) + except UpstreamConfigError: + if client is not None: + client.close() + raise + except (OSError, UpstreamError) as exc: + if client is not None: + client.close() + status.error = error_payload(exc) + self._tool_order = tuple(ordered_names) + + +def load_upstream_config_snapshot(path: str | Path) -> UpstreamConfigSnapshot: + config_path = Path(path).expanduser() + try: + text = config_path.read_text(encoding="utf-8") + except OSError as exc: + raise UpstreamConfigError(f"Could not read upstream config {str(config_path)!r}: {exc}") from exc + try: + raw = json.loads(text, object_pairs_hook=_reject_duplicate_keys) + except json.JSONDecodeError as exc: + raise UpstreamConfigError( + f"Upstream config {str(config_path)!r} is not valid JSON: {exc}" + ) from exc + if not isinstance(raw, dict): + raise UpstreamConfigError("Upstream config must be a JSON object.") + servers = raw.get("servers", raw) + if not isinstance(servers, dict): + raise UpstreamConfigError("Upstream config must contain a servers object.") + configs = tuple(parse_server_config(alias, value) for alias, value in servers.items()) + return UpstreamConfigSnapshot(configs=configs, source=str(config_path.resolve(strict=False))) + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise UpstreamConfigError(f"Duplicate JSON key in upstream config: {key!r}.") + result[key] = value + return result + + +def parse_server_config(alias: str, value: Any) -> UpstreamServerConfig: + if not isinstance(alias, str) or not ALIAS_RE.fullmatch(alias) or "__" in alias: + raise UpstreamConfigError( + f"Invalid upstream alias {alias!r}. Use 1-64 letters, digits, underscores, or hyphens; '__' is reserved." + ) + if not isinstance(value, dict): + raise UpstreamConfigError(f"Upstream {alias!r} config must be an object.") + transport = str(value.get("transport") or "streamable_http") + if transport == "http": + transport = "streamable_http" + if transport not in {"streamable_http", "stdio"}: + raise UpstreamConfigError( + f"Upstream {alias!r} transport must be streamable_http or stdio." + ) + enabled = value.get("enabled", True) + if not isinstance(enabled, bool): + raise UpstreamConfigError(f"Upstream {alias!r} enabled must be a boolean.") + try: + timeout_ms = int(value.get("timeout_ms") or DEFAULT_TIMEOUT_MS) + except (TypeError, ValueError) as exc: + raise UpstreamConfigError(f"Upstream {alias!r} timeout_ms must be positive.") from exc + if timeout_ms <= 0: + raise UpstreamConfigError(f"Upstream {alias!r} timeout_ms must be positive.") + command = _optional_str(value.get("command")) + args = _string_tuple(value.get("args"), field_name="args", alias=alias) + if transport == "stdio": + validate_stdio_launch(alias, command, args) + elif not _optional_str(value.get("url")): + raise UpstreamConfigError( + f"Upstream {alias!r} requires url for streamable_http transport." + ) + include_tools = _string_tuple( + value.get("include_tools"), field_name="include_tools", alias=alias + ) + exclude_tools = _string_tuple( + value.get("exclude_tools"), field_name="exclude_tools", alias=alias + ) + overlap = sorted(set(include_tools) & set(exclude_tools)) + if overlap: + raise UpstreamConfigError( + f"Upstream {alias!r} cannot include and exclude the same tools: {', '.join(overlap)}." + ) + return UpstreamServerConfig( + alias=alias, + transport=transport, + enabled=enabled, + url=_optional_str(value.get("url")), + command=command, + args=args, + env=_env_dict(value.get("env"), field_name="env", alias=alias), + headers=_string_dict(value.get("headers"), field_name="headers", alias=alias), + authorization_env=_optional_str(value.get("authorization_env")), + include_tools=include_tools, + exclude_tools=exclude_tools, + timeout_ms=timeout_ms, + ) + + +def build_client( + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: Callable[[str], str] | None = None, +) -> BaseUpstreamClient: + if config.transport == "stdio": + return StdioUpstreamClient(config, protocol_version, secret_resolver=secret_resolver) + return HttpUpstreamClient(config, protocol_version, secret_resolver=secret_resolver) + + +def filter_tools( + tools: list[dict[str, Any]], config: UpstreamServerConfig +) -> list[dict[str, Any]]: + included = set(config.include_tools) + excluded = set(config.exclude_tools) + result: list[dict[str, Any]] = [] + for tool in tools: + name = tool.get("name") + if not isinstance(name, str): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool definition had a non-string name.", + category="protocol", + ) + if included and name not in included: + continue + if name in excluded: + continue + result.append(tool) + return result + + +def namespaced_tool_name(alias: str, remote_name: str) -> str: + if not remote_name or any(ord(char) < 32 for char in remote_name): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool name was empty or contained control characters.", + category="protocol", + ) + public_name = f"{alias}__{remote_name}" + if len(public_name) > MAX_TOOL_NAME_CHARS: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Namespaced upstream tool name exceeded the supported length.", + category="protocol", + ) + return public_name + + +def namespaced_tool_definition( + public_name: str, tool: dict[str, Any] +) -> dict[str, Any]: + input_schema = tool.get("inputSchema") + if not isinstance(input_schema, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool definition did not contain an object inputSchema.", + category="protocol", + ) + annotations = tool.get("annotations") + if annotations is not None and not isinstance(annotations, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool annotations were not an object.", + category="protocol", + ) + output_schema = tool.get("outputSchema") + if output_schema is not None and not isinstance(output_schema, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool outputSchema was not an object.", + category="protocol", + ) + definition = copy.deepcopy(tool) + definition["name"] = public_name + return definition + + +def normalize_tool_result(result: dict[str, Any]) -> dict[str, Any]: + if not isinstance(result, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/call result was not an object.", + category="protocol", + ) + normalized = copy.deepcopy(result) + content = normalized.get("content") + if content is None: + normalized["content"] = [] + elif not isinstance(content, list) or not all(isinstance(item, dict) for item in content): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/call content was not an array of content objects.", + category="protocol", + ) + is_error = normalized.get("isError") + if is_error is None: + normalized["isError"] = False + elif not isinstance(is_error, bool): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/call isError was not a boolean.", + category="protocol", + ) + return normalized + + +def upstream_error_result( + code: str, + message: str, + *, + category: str = "runtime", + retryable: bool = False, + details: dict[str, Any] | None = None, + alias: str | None = None, + tool_name: str | None = None, +) -> dict[str, Any]: + error: dict[str, Any] = { + "code": code, + "message": message, + "category": category, + "retryable": retryable, + "details": copy.deepcopy(details or {}), + } + payload: dict[str, Any] = {"ok": False, "error": error} + if alias is not None: + payload["upstream_alias"] = alias + if tool_name is not None: + payload["tool_name"] = tool_name + return { + "content": [{"type": "text", "text": message}], + "structuredContent": payload, + "isError": True, + } + + +def _read_bounded_response(response: Any) -> bytes: + raw = response.read(MAX_RESPONSE_BYTES + 1) + if len(raw) > MAX_RESPONSE_BYTES: + raise UpstreamError( + "UPSTREAM_RESPONSE_TOO_LARGE", + "Upstream response exceeded the maximum supported size.", + category="protocol", + ) + return raw + + +def decode_http_rpc_response( + raw: bytes, + content_type: str, + *, + expected_id: int | None = None, +) -> dict[str, Any]: + text = raw.decode("utf-8") + if "text/event-stream" not in content_type.lower(): + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream HTTP response JSON was not an object.", + category="protocol", + ) + events: list[str] = [] + current: list[str] = [] + for line in text.splitlines(): + if not line: + if current: + events.append("\n".join(current)) + current = [] + continue + if line.startswith("data:"): + current.append(line.removeprefix("data:").lstrip()) + if current: + events.append("\n".join(current)) + if not events: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream SSE response did not include data events.", + category="protocol", + ) + candidates: list[dict[str, Any]] = [] + for event in events: + parsed = json.loads(event) + if not isinstance(parsed, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream SSE data was not a JSON object.", + category="protocol", + ) + candidates.append(parsed) + if expected_id is not None: + for candidate in candidates: + if candidate.get("id") == expected_id: + return candidate + return candidates[0] + + +def safe_target(config: UpstreamServerConfig) -> str | None: + if config.transport == "stdio": + command = config.command or "" + args = " ".join(config.args[:3]) + suffix = " ..." if len(config.args) > 3 else "" + return f"{command} {args}{suffix}".strip() + if not config.url: + return None + parsed = urllib.parse.urlsplit(config.url) + redacted = parsed._replace(query="", fragment="") + return urllib.parse.urlunsplit(redacted) + + +def validate_stdio_launch(alias: str, command: str | None, args: tuple[str, ...]) -> None: + if not command or not command.strip(): + raise UpstreamConfigError(f"Upstream {alias!r} requires command for stdio transport.") + command_text = command.strip() + if "\n" in command_text or "\r" in command_text: + raise UpstreamConfigError( + f"Upstream {alias!r} command must be a single executable path or name." + ) + first_word = command_text.split()[0].strip('"\'').lower() + leaf = command_text.strip('"\'').replace("\\", "/").rsplit("/", 1)[-1].lower() + if first_word in FORBIDDEN_STDIO_COMMANDS or leaf in FORBIDDEN_STDIO_COMMANDS: + raise UpstreamConfigError( + f"Upstream {alias!r} command cannot be a shell interpreter." + ) + if SHELL_FRAGMENT_RE.search(command_text): + raise UpstreamConfigError( + f"Upstream {alias!r} command cannot contain shell control syntax." + ) + for index, arg in enumerate(args): + if "\n" in arg or "\r" in arg or SHELL_FRAGMENT_RE.search(arg): + raise UpstreamConfigError( + f"Upstream {alias!r} args[{index}] cannot contain shell control syntax." + ) + + +def base_upstream_environment() -> dict[str, str]: + return { + name: value + for name, value in os.environ.items() + if name.upper() in UPSTREAM_BASE_ENV_NAMES + } + + +def resolve_env_config( + env_config: dict[str, Any], + *, + secret_resolver: Callable[[str], str] | None = None, +) -> dict[str, str]: + resolved: dict[str, str] = {} + for name, value in env_config.items(): + if isinstance(value, str): + resolved[name] = value + continue + if not isinstance(value, dict): + raise UpstreamConfigError( + f"Environment value for {name!r} must be a string or reference object." + ) + env_ref = value.get("env_ref") + secret_ref = value.get("secret_ref") + if isinstance(env_ref, str) and env_ref: + resolved[name] = os.environ.get(env_ref, "") + continue + if isinstance(secret_ref, str) and secret_ref: + if secret_resolver is None: + raise UpstreamConfigError("secret_ref requires a configured secret resolver.") + resolved[name] = secret_resolver(secret_ref) + continue + raise UpstreamConfigError( + f"Environment reference for {name!r} must contain env_ref or secret_ref." + ) + return resolved + + +def error_payload(exc: BaseException) -> dict[str, Any]: + if isinstance(exc, UpstreamError): + payload: dict[str, Any] = { + "code": exc.code, + "message": exc.message, + "category": exc.category, + "retryable": exc.retryable, + } + if exc.details: + payload["details"] = copy.deepcopy(exc.details) + return payload + if isinstance(exc, UpstreamConfigError): + return { + "code": "UPSTREAM_CONFIG_INVALID", + "message": str(exc), + "category": "configuration", + "retryable": False, + } + return { + "code": "UPSTREAM_INITIALIZATION_FAILED", + "message": str(exc), + "category": "runtime", + "retryable": True, + } + + +def _optional_str(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise UpstreamConfigError("Expected string value.") + return value + + +def _string_tuple(value: Any, *, field_name: str, alias: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must be a list of strings." + ) + if len(set(value)) != len(value): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must not contain duplicates." + ) + return tuple(value) + + +def _string_dict(value: Any, *, field_name: str, alias: str) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict) or not all( + isinstance(key, str) and isinstance(item, str) for key, item in value.items() + ): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must be an object with string keys and values." + ) + return dict(value) + + +def _env_dict(value: Any, *, field_name: str, alias: str) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, dict): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must be an object." + ) + result: dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must use string keys." + ) + if isinstance(item, str): + result[key] = item + continue + if isinstance(item, dict) and len(item) == 1: + ref_key, ref_value = next(iter(item.items())) + if ref_key in {"env_ref", "secret_ref"} and isinstance(ref_value, str) and ref_value: + result[key] = {ref_key: ref_value} + continue + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name}.{key} must be a string, env_ref, or secret_ref." + ) + return result diff --git a/coding_tools_mcp/webui.py b/coding_tools_mcp/webui.py new file mode 100644 index 0000000..e1df81a --- /dev/null +++ b/coding_tools_mcp/webui.py @@ -0,0 +1,37 @@ +"""Packaged Admin WebUI entry points. + +``webui/src/**`` is the only editable frontend source. The packaged HTML is +created by ``npm --prefix webui run build`` and is intentionally self-contained +so the authenticated ``/admin`` route does not need a second static-file router. +""" + +from __future__ import annotations + +from pathlib import Path + +WEBUI_DIST = Path(__file__).with_name("webui_dist") +ADMIN_HTML = WEBUI_DIST / "admin.html" + + +def admin_console_html() -> str: + try: + return ADMIN_HTML.read_text(encoding="utf-8") + except OSError: + return """ + + + + + MCP Admin Console + + +
+

MCP Admin Console

+

The generated WebUI artifact is missing.

+

Run npm --prefix webui run build from the repository root.

+
+ +""" + + +__all__ = ["ADMIN_HTML", "WEBUI_DIST", "admin_console_html"] diff --git a/coding_tools_mcp/webui_dist/admin.html b/coding_tools_mcp/webui_dist/admin.html new file mode 100644 index 0000000..89c134a --- /dev/null +++ b/coding_tools_mcp/webui_dist/admin.html @@ -0,0 +1,1147 @@ + + + + + + + Coding Tools MCP Admin + + + + +
+
+

coding-tools-mcp

+

Admin Console

+
+
+
+ + +

仅保存在当前页面内存中;不会写入 URL、浏览器持久化存储、日志或服务器设置。

+
+ + +
+
+ +
+ + +
+
输入专用 Admin token 后连接。
+ +
+

Runtime

概览

+
+

Admin API

未连接

普通 MCP bearer 不具备管理员权限。

+

Gateway

未连接

工具快照在 Runtime 初始化时冻结,不支持热 reload。

+

Telemetry

未报告

+

Vault

未连接

页面从不读取或显示 Secret 值。

+
+
+ + + + + + + + + + + + +
+
+ + +
+

确认操作

+

+
+
+
+ + + + + + + + diff --git a/coding_tools_mcp/workspace_binding.py b/coding_tools_mcp/workspace_binding.py new file mode 100644 index 0000000..7d932f9 --- /dev/null +++ b/coding_tools_mcp/workspace_binding.py @@ -0,0 +1,85 @@ +"""Immutable Workspace selection for stdio and Streamable HTTP runtimes.""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from pathlib import Path + +from .oauth import OAuthIdentity +from .workspace_catalog import WorkspaceCatalog, WorkspaceCatalogError + + +class WorkspaceBindingError(RuntimeError): + """A request identity cannot be safely bound to an enabled Workspace.""" + + +@dataclass(frozen=True) +class WorkspaceBinding: + workspace_id: str + root: Path + authorization_method: str + client_id: str | None = None + grant_id: str | None = None + + def authorization_key(self) -> tuple[str, str | None, str | None, str]: + return ( + self.authorization_method, + self.client_id, + self.grant_id, + self.workspace_id, + ) + + +class WorkspaceBindingResolver: + """Resolve a startup Catalog snapshot into immutable per-Runtime bindings. + + Replacing the Catalog affects only future resolutions. Existing Runtime + instances retain their frozen binding and Workspace adapter until closed. + """ + + def __init__(self, catalog: WorkspaceCatalog) -> None: + self._catalog = catalog + self._lock = threading.Lock() + + def catalog(self) -> WorkspaceCatalog: + with self._lock: + return self._catalog + + def update_catalog(self, catalog: WorkspaceCatalog) -> None: + with self._lock: + self._catalog = catalog + + def resolve_stdio(self) -> WorkspaceBinding: + catalog = self.catalog() + entry = catalog.default() + return WorkspaceBinding(entry.id, entry.root, "stdio") + + def resolve_http( + self, + authorization_method: str, + identity: OAuthIdentity | None, + ) -> WorkspaceBinding: + catalog = self.catalog() + if authorization_method == "oauth": + if identity is None: + raise WorkspaceBindingError("OAuth identity is required for Workspace binding.") + try: + entry = catalog.get(identity.workspace_id) + except WorkspaceCatalogError as exc: + raise WorkspaceBindingError( + "OAuth identity has no enabled Workspace mapping." + ) from exc + return WorkspaceBinding( + entry.id, + entry.root, + "oauth", + client_id=identity.client_id, + grant_id=identity.grant_id, + ) + if identity is not None: + raise WorkspaceBindingError( + "OAuth identity cannot be combined with a non-OAuth authorization method." + ) + entry = catalog.default() + return WorkspaceBinding(entry.id, entry.root, authorization_method) diff --git a/coding_tools_mcp/workspace_catalog.py b/coding_tools_mcp/workspace_catalog.py new file mode 100644 index 0000000..f9c3f5e --- /dev/null +++ b/coding_tools_mcp/workspace_catalog.py @@ -0,0 +1,206 @@ +"""Validated catalog of independently selectable workspace roots.""" + +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + + +WORKSPACE_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") + + +class WorkspaceCatalogError(ValueError): + pass + + +@dataclass(frozen=True) +class WorkspaceEntry: + id: str + name: str + root: Path + enabled: bool = True + default: bool = False + + def payload(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "root": str(self.root), + "enabled": self.enabled, + "default": self.default, + } + + +class WorkspaceCatalog: + def __init__(self, entries: list[WorkspaceEntry], default_id: str) -> None: + if not entries: + raise WorkspaceCatalogError("Workspace catalog must contain at least one workspace.") + if not isinstance(default_id, str) or not default_id: + raise WorkspaceCatalogError("Workspace catalog requires a default workspace id.") + + normalized = [ + WorkspaceEntry( + id=_validated_id(entry.id), + name=_validated_name(entry.name), + root=_validated_root(entry.root), + enabled=bool(entry.enabled), + default=bool(entry.default), + ) + for entry in entries + ] + default_flags = [entry.id for entry in normalized if entry.default] + if len(default_flags) > 1: + raise WorkspaceCatalogError("Workspace catalog must contain only one default workspace.") + if default_flags and default_flags[0] != default_id: + raise WorkspaceCatalogError("Default workspace flag conflicts with default_workspace_id.") + + by_id = {entry.id: entry for entry in normalized} + if len(by_id) != len(normalized): + raise WorkspaceCatalogError("Workspace IDs must be unique.") + if default_id not in by_id: + raise WorkspaceCatalogError("Default workspace id is not present in the catalog.") + if not by_id[default_id].enabled: + raise WorkspaceCatalogError("The default workspace must be enabled.") + + canonical = [replace(entry, default=entry.id == default_id) for entry in normalized] + self._validate_roots(canonical) + self.entries = tuple(canonical) + self.default_id = default_id + self._by_id = {entry.id: entry for entry in canonical} + + @classmethod + def single(cls, root: str | Path) -> "WorkspaceCatalog": + resolved = _validated_root(root) + identifier = "ws-" + hashlib.sha256(_root_identity(resolved).encode("utf-8")).hexdigest()[:16] + return cls( + [WorkspaceEntry(identifier, resolved.name or str(resolved), resolved, True, True)], + identifier, + ) + + @classmethod + def from_settings(cls, settings: dict[str, Any], fallback_root: str | Path) -> "WorkspaceCatalog": + raw = settings.get("workspace_catalog") + explicit_default = settings.get("default_workspace_id") + if not isinstance(raw, list) or not raw: + return cls.single(settings.get("workspace") or fallback_root) + + entries: list[WorkspaceEntry] = [] + flagged_defaults: list[str] = [] + for item in raw: + if not isinstance(item, dict): + raise WorkspaceCatalogError("Each workspace catalog entry must be an object.") + entry = WorkspaceEntry( + id=_validated_id(item.get("id")), + name=_validated_name(item.get("name")), + root=_validated_root(item.get("root")), + enabled=_validated_bool(item.get("enabled", True), "enabled"), + default=_validated_bool(item.get("default", False), "default"), + ) + entries.append(entry) + if entry.default: + flagged_defaults.append(entry.id) + + if len(flagged_defaults) > 1: + raise WorkspaceCatalogError("Workspace catalog must contain only one default workspace.") + if explicit_default is not None and not isinstance(explicit_default, str): + raise WorkspaceCatalogError("default_workspace_id must be a string.") + if isinstance(explicit_default, str) and explicit_default and flagged_defaults: + if explicit_default != flagged_defaults[0]: + raise WorkspaceCatalogError("Default workspace flag conflicts with default_workspace_id.") + chosen = ( + explicit_default + if isinstance(explicit_default, str) and explicit_default + else flagged_defaults[0] + if flagged_defaults + else entries[0].id + ) + return cls(entries, chosen) + + def default(self) -> WorkspaceEntry: + return self._by_id[self.default_id] + + def get(self, identifier: str) -> WorkspaceEntry: + entry = self._by_id.get(identifier) + if entry is None or not entry.enabled: + raise WorkspaceCatalogError("Workspace is unknown or disabled.") + return entry + + def enabled_entries(self) -> tuple[WorkspaceEntry, ...]: + return tuple(entry for entry in self.entries if entry.enabled) + + def payload(self) -> dict[str, Any]: + return { + "default_workspace_id": self.default_id, + "workspaces": [entry.payload() for entry in self.enabled_entries()], + } + + def settings_payload(self) -> dict[str, Any]: + return { + "workspace_catalog": [entry.payload() for entry in self.entries], + "default_workspace_id": self.default_id, + } + + @staticmethod + def _validate_roots(entries: list[WorkspaceEntry]) -> None: + roots: list[Path] = [] + identities: set[str] = set() + for entry in entries: + root = entry.root + identity = _root_identity(root) + if identity in identities: + raise WorkspaceCatalogError("Workspace roots must be unique.") + for other in roots: + if _relative_to(root, other) or _relative_to(other, root): + raise WorkspaceCatalogError("Nested workspace roots are not allowed.") + roots.append(root) + identities.add(identity) + + +def _validated_id(raw: Any) -> str: + if not isinstance(raw, str) or WORKSPACE_ID_PATTERN.fullmatch(raw) is None: + raise WorkspaceCatalogError( + "Workspace id must contain 1-128 ASCII letters, digits, dots, underscores, or hyphens." + ) + return raw + + +def _validated_name(raw: Any) -> str: + if not isinstance(raw, str) or not raw.strip() or len(raw.strip()) > 200: + raise WorkspaceCatalogError("Workspace name must contain 1-200 characters.") + return raw.strip() + + +def _validated_bool(raw: Any, field: str) -> bool: + if not isinstance(raw, bool): + raise WorkspaceCatalogError(f"Workspace {field} must be a boolean.") + return raw + + +def _validated_root(raw: Any) -> Path: + if not isinstance(raw, (str, Path)) or not str(raw).strip(): + raise WorkspaceCatalogError("Workspace root is required.") + try: + root = Path(raw).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise WorkspaceCatalogError(f"Workspace root cannot be resolved: {exc}") from exc + if not root.is_dir(): + raise WorkspaceCatalogError("Workspace root must be an existing directory.") + if root == Path(root.anchor): + raise WorkspaceCatalogError("Filesystem roots cannot be managed as workspaces.") + return root + + +def _root_identity(root: Path) -> str: + return os.path.normcase(str(root)) + + +def _relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False diff --git a/docs/admin-api.md b/docs/admin-api.md new file mode 100644 index 0000000..f6bc5dc --- /dev/null +++ b/docs/admin-api.md @@ -0,0 +1,108 @@ +# Admin API + +Phase 08 exposes a backend-only management API under `/admin/api`. The API is disabled unless a dedicated Admin token is configured with `--admin-token`, `CODING_TOOLS_MCP_ADMIN_TOKEN`, or `admin_token_secret_ref` in the server Secret Vault. + +Ordinary MCP bearer credentials and OAuth access tokens are never promoted to Admin authority. Management clients authenticate with either: + +```http +Authorization: Bearer +``` + +or: + +```http +X-Admin-Token: +``` + +All responses use `Cache-Control: no-store`, apply the same validated allowed-origin policy as the MCP endpoint, and redact secret material. + +## Endpoints + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/admin/api/status` | Backend capability status. | +| GET | `/admin/api/settings` | Active, persisted, and pending-restart settings. | +| POST | `/admin/api/settings/validate` | Validate settings without writing. | +| PUT | `/admin/api/settings` | Save settings with `expected_revision`. | +| GET | `/admin/api/gateway` | Active status plus redacted persisted Gateway config. | +| PUT | `/admin/api/gateway` | Persist Gateway config with `expected_revision`; restart only. | +| GET | `/admin/api/secrets` | List configured Secret Vault names without values. | +| PUT | `/admin/api/secrets/{name}` | Set one Vault value; the value is never returned. | +| DELETE | `/admin/api/secrets/{name}` | Delete one Vault value idempotently. | +| GET | `/admin/api/workspaces` | List the persisted validated Workspace Catalog. | +| POST | `/admin/api/workspaces` | Add a Workspace with `expected_revision`. | +| POST | `/admin/api/workspaces/{id}/disable` | Disable a non-default Workspace. | +| POST | `/admin/api/workspaces/{id}/default` | Select an enabled default Workspace. | +| GET | `/admin/api/workspaces/{id}/check` | Check only a catalog Workspace ID; arbitrary paths are not accepted. | +| GET | `/admin/api/oauth/{collection}` | List redacted Clients, Grants, Tokens, Refresh Families, Signing Keys, or Audit Events. | +| POST | `/admin/api/oauth/{resource}/{id}/{action}` | Perform an exact-ID idempotent OAuth action. | +| GET | `/admin/api/chat/conversations` | Paginated conversation summaries; optional registered `workspace_id`. | +| GET | `/admin/api/chat/conversations/{workspace_id}/{conversation_id}` | Explicit paginated message/context detail. | +| POST | `/admin/api/chat/conversations/{workspace_id}/{conversation_id}/messages` | Record messages in one registered Workspace. | +| POST | `/admin/api/chat/conversations/{workspace_id}/{conversation_id}/context` | Record durable context in one registered Workspace. | +| DELETE | `/admin/api/chat/messages/{workspace_id}/{message_id}` | Stable-ID idempotent message deletion. | +| DELETE | `/admin/api/chat/context/{workspace_id}/{context_id}` | Stable-ID idempotent context deletion. | +| DELETE | `/admin/api/chat/conversations/{workspace_id}/{conversation_id}` | Delete one Workspace-scoped conversation. | +| POST | `/admin/api/chat/workspaces/{workspace_id}/clear` | Clear chat/session persistence for one registered Workspace. | +| POST | `/admin/api/codex/sessions/scan` | Bounded scan of relative roots inside a registered Workspace. | +| POST | `/admin/api/codex/sessions/import` | Import explicitly selected candidate IDs. | +| GET | `/admin/api/codex/sessions` | Paginated imported-session summaries. | +| DELETE | `/admin/api/codex/sessions/{workspace_id}/{session_id}` | Stable-ID idempotent imported-session deletion. | + +OAuth collections are `clients`, `grants`, `tokens`, `refresh-families`, `signing-keys`, and `audit`. Supported actions are Client `enable`/`disable`, Grant/Token/Refresh Family `revoke`, and Signing Key `activate`/`retire`/`revoke`. + +## Telemetry status + +`GET /admin/api/status` reports the effective upstream telemetry mode and the documentation entry only: + +```json +{ + "telemetry": { + "mode": "on", + "docs": "docs/telemetry.md" + } +} +``` + +`mode` is `on`, `off`, or `debug` according to the same environment controls used by the runtime. The status response does not add paths, Workspace/Agent/Client IDs, commands, arguments, file contents, or telemetry event data. Phase 11 does not change the upstream v0.2.2 default policy; see `docs/telemetry.md` for the complete privacy schema and opt-out controls. + +## Revision writes + +Settings and Gateway writes require the revision returned by the corresponding GET response: + +```json +{ + "expected_revision": "", + "updates": { + "port": 9000 + } +} +``` + +A stale revision returns HTTP 409 with `stale_revision`. The server does not merge an old page over newer persisted state. + +## Restart semantics + +Settings responses distinguish: + +- `active`: the immutable startup snapshot currently in use. +- `persisted`: the latest saved configuration. +- `pending_restart`: fields whose persisted values differ from active values. + +Gateway writes only update `mcp-servers.json`; they never start, stop, or reload an upstream server. Existing Runtime and Session tool snapshots remain unchanged and `restart_required` becomes true. + +## Secret boundary + +Responses never include client-secret digests, bearer or refresh token plaintext, signing-key secret references, Vault values, or upstream credentials. Gateway `secret_ref` entries are accepted only when the server Secret Vault is enabled and the reference resolves. Startup and Admin validation fail closed otherwise. + +## Chat and session persistence + +Conversation lists are summary-only and paginated. Message and context content is returned only by the explicit conversation-detail endpoint. Every request that addresses stored content includes a registered Workspace ID; unknown or disabled Workspaces are rejected. + +Codex scan roots are relative to the selected Workspace. The API rejects absolute paths, `..`, and escapes through symlinks or reparse points. Scan requests may set bounded `max_depth`, `max_files`, `max_file_bytes`, `max_total_bytes`, and `max_messages` values. Malformed or partially written JSONL records appear as item errors while other candidates remain available. + +All delete and clear routes require the dedicated Admin credential, are idempotent, and return actual affected counts. Ordinary MCP bearer and OAuth credentials do not authorize these routes. + +## Admin WebUI + +The generated administration interface is served at `/admin`. Its source/build, authentication, stale-revision, redaction, accessibility, and conversation-detail boundaries are documented in `docs/admin-webui.md`. diff --git a/docs/admin-webui.md b/docs/admin-webui.md new file mode 100644 index 0000000..c727071 --- /dev/null +++ b/docs/admin-webui.md @@ -0,0 +1,49 @@ +# Admin WebUI + +The Admin WebUI is served at `/admin` and consumes only the authenticated Phase 08/09 endpoints under `/admin/api`. + +## Source and build boundary + +- `webui/src/**` is the only editable frontend source. +- `npm --prefix webui run build` removes and recreates `coding_tools_mcp/webui_dist/**`. +- The generated `admin.html` is self-contained and is never edited directly. +- `coding_tools_mcp.webui.admin_console_html()` reads the generated artifact. + +## Authentication + +The page uses a dedicated Admin token only. The token is kept in page memory and sent in the `Authorization` header. It is not written to a URL, browser persistent storage, logs, Settings, or Gateway configuration. Ordinary MCP bearer and OAuth access tokens do not grant Admin authority. + +## Settings and Gateway + +Settings writes include the last `persisted_revision`. A stale HTTP 409 keeps the current form draft, fetches the latest persisted state/revision, and shows a conflict instead of overwriting newer configuration. + +Gateway changes are persisted for restart only. The WebUI has no upstream start, stop, reload, or Runtime mutation action. Credential-bearing Gateway configurations are shown only as redacted summaries; the browser editor accepts only credential-free replacement documents. + +## Permission and compatibility wording + +The WebUI has no tool-profile state or controls. Permission modes do not change the fixed tool catalog. Safe mode limits runtime capabilities but does not hide mutation tools. + +The fake-readonly annotation override appears only in an advanced danger section. The warning states that it does not hide tools, block mutation, change handlers, or create a security boundary. + +## Secret and OAuth rendering + +OAuth, Gateway, Settings, and Vault responses pass through a defensive redaction layer before rendering. Client secrets, token material, digests, hashes, signing secrets, credential references, and Vault values are not displayed. Secret Vault values are accepted only through password inputs and are immediately cleared after requests. + +## Conversation rendering + +Conversation lists call the summary endpoint. Full messages and context are loaded only after an explicit selection through the paginated detail endpoint. Transcript, message, context, Workspace labels, OAuth metadata, and server errors are treated as untrusted text and rendered with DOM node creation and `textContent`; the source contains no `innerHTML` path. + +## Accessibility + +- Keyboard-accessible navigation and buttons. +- Explicit labels for every form control. +- Alert regions for errors and stale-revision conflicts. +- Focus restoration after destructive confirmation dialogs. +- 44-pixel minimum controls and responsive layouts for narrow screens. +- Destructive confirmations include the exact Workspace/object ID and expected impact before execution; actual affected counts are displayed afterward. + +## Telemetry status + +The status page displays the effective `on`, `off`, or `debug` mode returned by the Admin backend. The backend supplies only the mode and the `docs/telemetry.md` documentation entry; it does not expose telemetry events or add paths, identifiers, commands, arguments, or file content to the response. + +The WebUI continues to show the documented controls `CODING_TOOLS_MCP_TELEMETRY=off`, `DO_NOT_TRACK=1`, and automatic CI suppression. Phase 11 preserves the upstream v0.2.2 default and does not add a browser-side telemetry switch. diff --git a/docs/chat-persistence.md b/docs/chat-persistence.md new file mode 100644 index 0000000..5de4f05 --- /dev/null +++ b/docs/chat-persistence.md @@ -0,0 +1,9 @@ +# Chat and Codex Session Persistence + +Phase 09 stores chat messages, durable context, and imported Codex session metadata in `transcripts.sqlite3` under the stable server configuration directory. + +Every primary key and query is partitioned by `workspace_id`. Normal callers receive a `WorkspaceTranscriptService` fixed to one validated Workspace root. Admin aggregation and destructive operations remain behind the dedicated Admin authentication boundary. + +Session discovery scans only caller-selected relative roots below a registered Workspace. It does not follow symlinks and enforces configurable depth, file count, per-file bytes, total bytes, and message count ceilings. UTF-8, UTF-16 BOM, GB18030, CRLF, locked files, malformed lines, and partial final JSONL records have focused tests. + +List endpoints are summary-only and paginated. Full message/context content is returned only by explicit paginated detail requests. Stable-ID message, context, conversation, and imported-session deletion requires the dedicated Admin credential, is idempotent, and reports actual affected counts. Stored chat content, paths, commands, model output, and summaries are not sent to telemetry. diff --git a/docs/integration-contract-v0.2.2.md b/docs/integration-contract-v0.2.2.md new file mode 100644 index 0000000..1abbbad --- /dev/null +++ b/docs/integration-contract-v0.2.2.md @@ -0,0 +1,381 @@ +# v0.2.2 Extension Integration Contract + +Status: normative integration decision record for +`integration/upstream-v0.2.2`. + +This document fixes the compatibility decisions that later integration phases +must implement. It extends the upstream `0.2.2` runtime contract without +changing upstream product behavior in Phase 02. + +## Protocol and version + +- The MCP protocol target remains `2025-11-25` with explicit compatibility for + `2025-06-18`. +- Negotiation continues to accept only the versions listed by + `coding_tools_mcp.protocol.SUPPORTED_PROTOCOL_VERSIONS`; dates are not + compared lexicographically. +- The package version remains `0.2.2` during integration. A fork release version + is a release-phase decision. + +## Fixed tool catalog + +- `coding_tools_mcp.server.TOOL_REGISTRY` remains the single source for local + tools and the permanently reserved local names. +- Every client sees the same deterministic local catalog for a given + installation. Explicit installation capability gates such as optional + `view_image` support may remove their own tool, but persisted profiles must + not filter the list. Phase 07 may append a namespaced upstream snapshot fixed + at Runtime initialization; it never replaces or renames local tools. +- The legacy values `full`, `read-only`, and `compat-readonly-all` are accepted + only as migration inputs. They do not control `tools/list` and are omitted on + the next successful settings write. +- Unknown legacy `tool_profile` values are ignored with the same migration + warning rather than being reinterpreted as a security policy. + +`--dangerously-fake-readonly-annotations` is an annotation compatibility +override, not a security feature. It does not hide tools, change schemas, block +handlers, or prevent mutation. The upstream requirements remain: dangerous +permission mode is mandatory, and HTTP use requires authentication. UI copy +must never describe this switch as safe or genuinely read-only. + +## OAuth protocol and persistence + +- `coding_tools_mcp.oauth.OAUTH_GRANT_TYPES_SUPPORTED` is the single source for + authorization-server metadata and dynamic-client-registration narrowing. +- `coding_tools_mcp.oauth.OAUTH_RESPONSE_TYPES_SUPPORTED` is the corresponding + response-type source. +- Phase 05 advertises `authorization_code` and `refresh_token` from the shared + grant-type constant after both token-endpoint branches and rotation/reuse + tests are complete. Response type `code` remains the only supported response. +- Phase 04 introduces a persistent, transactional OAuth Store in the stable + user configuration directory. It must persist clients, grants, access-token + metadata, refresh-token families and hashes, signing-key metadata, and audit + events. Authorization codes remain short-lived and process-local. +- Store migrations are forward-only, idempotent, and transactional. Reopening + the database must preserve data; a failed schema migration must not leave a + half-migrated database. Bearer and refresh token plaintext must never be + stored. +- Phase 05 adapts the upstream registry and handlers to that store. Store + unavailability fails closed; it must not silently fall back to a permissive + process-local registry. +- Refresh exchange commits replacement-token creation, old-token consumption, + family last-used state, access-token metadata, and issuance audits in one + `BEGIN IMMEDIATE` transaction. Any failure rolls back all of those writes and + leaves the original refresh token retryable. No OAuth schema migration is + required for this atomicity fix. + +## Agent to Workspace binding + +- The binding point is HTTP initialization/runtime creation, after the + authenticated OAuth identity (`client_id` and, when available, `grant_id`) is + known and before project context or tools are exposed. +- The mapping resolves to one Workspace ID and constructs the session Runtime + with that Workspace adapter. The binding is immutable for the lifetime of the + MCP HTTP session. +- Ordinary MCP tools do not switch Workspace roots. Administrative mapping + changes apply only to new sessions. Existing sessions retain their frozen + Runtime and root until closed; new sessions targeting a missing or disabled + Workspace fail closed rather than falling back to another root. +- stdio keeps one explicit default Workspace because it has no OAuth Agent + identity. + +## Upstream MCP Gateway + +- Gateway configuration, server enable state, and include/exclude allowlists are + parsed before a Runtime is created. The default configuration file is + `mcp-servers.json` in the stable server configuration directory; an explicit + `--upstream-config` or `CODING_TOOLS_MCP_UPSTREAM_CONFIG` path is also + supported. +- Each Runtime owns independent upstream clients. It discovers tools during + Runtime initialization and freezes the resulting definitions and routing map + for that Runtime's lifetime. No start, stop, reload, profile, or notification + path mutates `tools/list`, so `listChanged: false` remains truthful. +- Public names use the stable form `{alias}__{remote_name}`. `__` is reserved in + aliases, nested remote names are preserved, every local `TOOL_REGISTRY` name + remains reserved, and any public-name collision fails Runtime creation. +- Apart from replacing `name` with its public namespace, upstream tool + definitions retain their original title, description, `inputSchema`, + `outputSchema`, and real annotations. The local fake-readonly compatibility + override never rewrites upstream annotations. +- `structuredContent`, `content`, and `isError` from a valid upstream + `tools/call` result are preserved. Missing `content` is normalized to an empty + array; structured data is never serialized into model text merely to fill the + content field. +- Timeout, disconnect, oversized response, invalid JSON-RPC envelope, invalid + schema/result shape, HTTP failure, and upstream RPC errors use stable + structured Gateway errors. +- Gateway tools are remote capabilities. Local Workspace path confinement and + local permission gates describe local tools only; they are not claimed as a + security boundary for a remote server. The remote server controls its own + data and side effects. A stdio upstream receives only a minimal process + environment; additional variables require explicit Gateway configuration + through literal values or `env_ref`. The library-level `secret_ref` form is + accepted only when composition supplies a secret resolver; Phase 07 startup + does not connect one and therefore fails closed for such entries. +- Calling a Gateway tool cannot alter the Runtime's OAuth identity, Workspace + binding, cwd, local process sessions, retained output, or project context. + Different MCP Sessions do not share upstream client/session state. +- Legacy `tool_profile` values remain settings-migration inputs only. Gateway + configuration, discovery, visibility, routing, and annotations contain no + `tool_profile` control path. + +## Authenticated Admin API + +- The Admin API is enabled only when a dedicated Admin token is configured. + Ordinary MCP bearer credentials and OAuth access tokens do not imply Admin + authority. The same HTTP Authorization header may carry the dedicated token, + but it is compared only with the Admin credential; `X-Admin-Token` is also + accepted for explicit management clients. +- HTTP handlers authenticate, parse JSON, and dispatch to the Admin service. + They contain no SQL and do not implement independent Settings, OAuth, + Workspace, Gateway, Secret Vault, or CORS validation rules. +- Settings responses separate active startup values, persisted values, and the + exact restart-required field list. Settings and Gateway writes require the + revision read by the caller; stale revisions return a conflict instead of + overwriting newer configuration. +- All responses are redacted. Client-secret digests, bearer or refresh token + material, signing-key secret references, Vault values, and upstream + credentials are not returned. +- OAuth management addresses Clients, Grants, Access Token JTIs, Refresh + Families, and Signing Key KIDs by exact ID. Disable/revoke operations are + idempotent and return an affected count plus the audit event ID when a state + transition occurred. +- Workspace add/disable/default/check operations reuse the validated Workspace + Catalog and never accept an arbitrary path for a check request. +- Gateway writes validate and persist configuration only. They set + `restart_required` and do not start, stop, reload, or mutate any existing + Runtime or Session snapshot. +- Gateway `secret_ref` values resolve only through the server Secret Vault. + Missing Vault configuration, an incorrect key, or an unknown reference fails + closed during startup and Admin validation. +- Allowed origins use `normalize_allowed_origins` for startup, Admin validation, + persistence, and HTTP request checks. + +## Chat, transcript, and Codex session persistence + +- Chat conversations, messages, durable context entries, and imported Codex + sessions are keyed by explicit Workspace ID. Conversation, message, context, + session, query, cache, and deletion keys never fall back to a global row ID. +- Ordinary callers use a `WorkspaceTranscriptService` fixed to the immutable + Workspace binding already established for their MCP Session. Cross-Workspace + listing, import, clear, and deletion remain dedicated Admin operations. +- Codex session roots are relative paths inside a registered Workspace. Absolute + paths, `..`, symlink/reparse-point escapes, disabled/unknown Workspaces, and + files outside the selected root fail closed. +- Session scanning enforces depth, file-count, per-file byte, total-byte, and + message-count limits. Invalid encoding, locked files, truncated JSONL, and + malformed individual records are represented as per-file or per-line errors + without failing the entire page. +- List APIs return summaries and bounded pagination. Full message/context body is + returned only by an explicit Workspace-and-conversation detail request. +- Message, context, conversation, and imported-session deletion uses stable IDs, + is idempotent, and returns actual affected counts. +- Chat text, transcript paths, commands, model responses, and summaries are not + added to telemetry or ordinary logs. + +## Admin WebUI + +- `webui/src/**` is the sole editable frontend source. Packaged files under `coding_tools_mcp/webui_dist/**` are recreated only by the formal build. +- The page consumes the dedicated-Admin Phase 08/09 API. It does not bypass authentication, settings/Gateway revisions, Workspace IDs, or conversation pagination. +- The Admin token is kept in page memory only and is never placed in URLs or browser persistent storage. +- No legacy tool-profile UI/state/serialization exists. Safe mode does not claim to hide mutation tools; fake-readonly annotations are presented only as a dangerous non-security compatibility override. +- Stale settings writes preserve the user draft, refresh the persisted revision, and present a conflict instead of silently overwriting. Gateway writes remain restart-only. +- OAuth/Gateway/Vault credential material and references are not displayed. Conversation lists use summaries and full text is fetched only through paginated detail calls. +- Untrusted server/transcript content is rendered through node creation and `textContent`, not `innerHTML`. Destructive actions require an ID-specific confirmation and restore focus. + +## Telemetry and secret-store boundaries + +- Integration preserves the upstream v0.2.2 telemetry default: anonymous + telemetry is enabled unless disabled by the existing environment controls, + `DO_NOT_TRACK`, or CI behavior. Changing that default is a separate product + decision. +- Telemetry must not gain Workspace IDs, Agent IDs, paths, commands, file + contents, OAuth identifiers, or secret material during integration. +- Server Admin settings use the server Secret Vault introduced in Phase 03. + Desktop profiles keep their existing desktop-local storage. The two stores + are not unified during this integration, and neither side may silently read + the other's secrets. + +## Machine-readable decision table + +The following block is consumed by the Phase 02 contract tests. + + +```json +{ + "schema_version": 1, + "protocol": { + "target": "2025-11-25", + "compatible": [ + "2025-06-18" + ] + }, + "version": { + "integration": "0.2.2" + }, + "tool_catalog": { + "strategy": "fixed", + "source": "coding_tools_mcp.server.TOOL_REGISTRY", + "legacy_tool_profile_controls_catalog": false, + "optional_installation_gates": [ + "view_image" + ], + "fake_readonly": { + "security_boundary": false, + "changes_catalog": false, + "changes_handlers": false, + "requires_permission_mode": "dangerous", + "http_requires_authentication": true + } + }, + "legacy_tool_profile_migration": { + "warning_code": "legacy_tool_profile_ignored", + "persist_on_next_write": false, + "unknown_value": "ignore_with_warning", + "cases": [ + { + "input": { + "tool_profile": "full" + }, + "output": { + "tool_profile": null, + "catalog": "fixed", + "warning": "legacy_tool_profile_ignored" + } + }, + { + "input": { + "tool_profile": "read-only" + }, + "output": { + "tool_profile": null, + "catalog": "fixed", + "warning": "legacy_tool_profile_ignored" + } + }, + { + "input": { + "tool_profile": "compat-readonly-all" + }, + "output": { + "tool_profile": null, + "catalog": "fixed", + "warning": "legacy_tool_profile_ignored" + } + } + ] + }, + "oauth": { + "grant_types_source": "coding_tools_mcp.oauth.OAUTH_GRANT_TYPES_SUPPORTED", + "response_types_source": "coding_tools_mcp.oauth.OAUTH_RESPONSE_TYPES_SUPPORTED", + "advertised_grant_types": [ + "authorization_code", + "refresh_token" + ], + "advertised_response_types": [ + "code" + ], + "authorization_codes": "ephemeral", + "persistent_store_phase": 4, + "http_integration_phase": 5, + "migration": "idempotent_transactional" + }, + "workspace_binding": { + "phase": 6, + "point": "http_initialize_runtime_factory", + "identity_fields": [ + "client_id", + "grant_id", + "workspace_id" + ], + "immutable_per_session": true, + "ordinary_tool_switching": false, + "invalid_mapping": "fail_closed", + "disabled_workspace_new_session": "fail_closed", + "disabled_workspace_existing_session": "retain_frozen_binding_until_close", + "stdio_binding": "default_workspace" + }, + "gateway": { + "phase": 7, + "namespace": "{alias}__{remote_name}", + "local_names_reserved": true, + "collision": "fail_closed", + "snapshot_point": "runtime_initialize", + "immutable_per_runtime": true, + "list_changed": false, + "config_before_initialize": true, + "schema": "preserve_except_public_name", + "annotations": "preserve_real", + "structured_content": "preserve", + "content": "normalize_boundary_without_json_assumption", + "remote_workspace_boundary_claim": false, + "session_identity_mutation": false, + "tool_profile_controls": false + }, + "admin_api": { + "phase": 8, + "authentication": "dedicated_admin_token", + "ordinary_mcp_bearer_is_admin": false, + "handler_sql": false, + "responses_redacted": true, + "settings_views": [ + "active", + "persisted", + "pending_restart" + ], + "stale_update": "revision_conflict", + "gateway_change": "persist_and_restart_only", + "gateway_dynamic_reload": false, + "gateway_secret_ref": "server_secret_vault_fail_closed", + "oauth_actions": "exact_id_idempotent_affected_count_audit_event", + "workspace_validation": "workspace_catalog", + "allowed_origins_source": "coding_tools_mcp.settings_definition.normalize_allowed_origins" + }, + "chat_persistence": { + "phase": 9, + "workspace_keyed": true, + "ordinary_scope": "immutable_workspace_service", + "global_operations_authentication": "dedicated_admin_token", + "scan_roots": "registered_workspace_relative_only", + "scan_limits": [ + "depth", + "files", + "file_bytes", + "total_bytes", + "messages" + ], + "malformed_record": "item_error_continue", + "list_default": "summary_paginated", + "full_content": "explicit_detail_only", + "delete": "stable_id_workspace_keyed_idempotent_affected_count", + "telemetry_content": false + }, + "telemetry": { + "default_policy": "upstream_v0.2.2", + "change_during_integration": false + }, + "secret_stores": { + "server_admin": "server_secret_vault", + "desktop": "desktop_profile_storage", + "shared": false + }, + "webui": { + "phase": 10, + "source_root": "webui/src", + "dist": "build_generated_only", + "authentication": "dedicated_admin_token", + "admin_token_storage": "page_memory_only", + "tool_profile_controls": false, + "safe_mode_hides_mutation_tools": false, + "fake_readonly_security_boundary": false, + "settings_stale_update": "preserve_draft_refresh_revision_conflict", + "gateway_change": "persist_and_restart_only", + "gateway_dynamic_reload": false, + "secret_material_displayed": false, + "conversation_list": "summary_only", + "conversation_detail": "explicit_paginated", + "untrusted_rendering": "dom_text_content" + } +} +``` + diff --git a/docs/mcp-client-config.md b/docs/mcp-client-config.md index 2dac9af..48575cd 100644 --- a/docs/mcp-client-config.md +++ b/docs/mcp-client-config.md @@ -1,9 +1,12 @@ # MCP Client Configuration Use MCP protocol version `2025-11-25`. Version `2025-06-18` remains supported -for existing clients. +for existing clients. Tool profiles are not supported; permission modes never +change the fixed local catalog. -## Codex +## stdio clients + +### Codex ```toml [mcp_servers.coding_tools] @@ -11,7 +14,7 @@ command = "uvx" args = ["coding-tools-mcp", "--stdio", "--workspace", "/path/to/repo"] ``` -## Claude Code +### Claude Code, Cursor, Cline, and compatible JSON clients ```json { @@ -24,48 +27,65 @@ args = ["coding-tools-mcp", "--stdio", "--workspace", "/path/to/repo"] } ``` -## Cursor - -```json -{ - "mcpServers": { - "coding-tools": { - "command": "uvx", - "args": ["coding-tools-mcp", "--stdio", "--workspace", "/path/to/repo"] - } - } -} -``` +stdio uses the explicit/default Workspace for the process and does not invent an +OAuth Agent identity. -## Continue, Cursor, Cline, And Generic HTTP Clients +## Local Streamable HTTP -Configure a Streamable HTTP MCP server at: +Configure the MCP endpoint: ```text http://127.0.0.1:8765/mcp ``` -The server is designed for local loopback use. Do not bind it to a public interface without external authentication and sandboxing. +Every client first calls `initialize` without `Mcp-Session-Id`. Later calls send +the returned Session ID and negotiated protocol version. Each Session owns an +independent Workspace binding, cwd, process table, retained output, project +context, and optional Gateway snapshot. + +Loopback HTTP may use static bearer, OAuth, or explicit local no-auth. Never bind +no-auth to a public interface. -## Remote MCP +## Remote static bearer -For remote MCP clients, keep the server on loopback and expose it through an -HTTPS tunnel with authentication. The fixed tool set includes mutation and -command execution: +Keep the server on loopback and expose it through an HTTPS tunnel: ```bash CODING_TOOLS_MCP_AUTH_MODE=bearer \ -scripts/tunnel.sh cloudflared /path/to/repo + scripts/tunnel.sh cloudflared /path/to/repo ``` -Configure the remote MCP client with: +Configure the remote client with: ```text URL: https:///mcp +Authorization: Bearer ``` -Static bearer-token auth is available for clients that support custom -`Authorization` headers. OAuth-aware MCP clients can use `--oauth-mode`, which -publishes protected-resource and authorization-server discovery plus RFC 7591 -dynamic registration and a PKCE authorization flow. Clients that support -neither require an external authenticated proxy. See [Remote MCP](remote-mcp.md). +The bearer identifies MCP access only. It is not an Admin credential. + +## Remote OAuth + +OAuth-aware MCP clients can discover protected-resource and authorization-server +metadata, register through RFC 7591, and complete Authorization Code + PKCE. +The server advertises `authorization_code` and `refresh_token` grants and rotates +refresh tokens after use. Dynamic Client records and Grants persist across +restart; authorization codes remain short-lived and process-local. + +With one enabled Workspace, new/legacy Clients bind to the sole default. With +multiple enabled Workspaces, configure an explicit Client mapping before +creating a Grant. See [Remote MCP](remote-mcp.md). + +## Dedicated Admin access + +The Admin WebUI at `/admin` and API at `/admin/api` require a separate Admin +token. Do not place that token in an MCP client configuration, URL, or persistent +browser storage. Ordinary MCP bearer and OAuth credentials receive `401` from +Admin endpoints. + +## Upstream Gateway tools + +Optional upstream tools appear with stable names such as +`github__search_issues`. The list is frozen when the Runtime initializes. Client +code must not expect `tools/list` change notifications or attempt to hot-reload +the Gateway. Remote tool permissions are controlled by the upstream server. diff --git a/docs/migration-v0.1-to-v0.2.2.md b/docs/migration-v0.1-to-v0.2.2.md new file mode 100644 index 0000000..def6703 --- /dev/null +++ b/docs/migration-v0.1-to-v0.2.2.md @@ -0,0 +1,202 @@ +# Upgrade and rollback: v0.1 / upstream v0.2.2 to the integrated v0.2.2 runtime + +This guide describes the implemented migration boundary. It intentionally does +not restore `docs/profile-v0.1.md` as a current contract. Historical profile +behavior belongs in migration history only; the normative runtime contract is +[Runtime Contract v0.2](runtime-contract-v0.2.md). + +## Before upgrading + +Stop every server process and make a filesystem-level backup of the complete +stable configuration directory. The directory is selected by +`CODING_TOOLS_MCP_CONFIG_DIR`, or defaults to: + +- Windows: `%APPDATA%\coding-tools-mcp` +- POSIX: `$XDG_CONFIG_HOME/coding-tools-mcp` or `~/.config/coding-tools-mcp` + +Back up these files together when present: + +- `server-settings.json` +- `mcp-servers.json` +- `oauth.sqlite3` plus any SQLite `-wal`/`-shm` files after a clean shutdown +- `oauth-secrets.json` +- `server-secrets.json` +- `transcripts.sqlite3` plus any SQLite sidecars after a clean shutdown + +Also preserve the independent `CODING_TOOLS_MCP_SECRETS_KEY`. Do not store the +master key in the same backup archive as the encrypted Vault unless the archive +has its own access controls. + +Record the public OAuth origin (`CODING_TOOLS_MCP_SERVER_URL`) and current active +signing-key ID/fingerprint, but never copy secret material into tickets, logs, or +migration notes. + +## Stable configuration directory + +The integrated runtime reads server identity state from the stable user +configuration directory, not from a managed Workspace. `--workspace` and +`CODING_TOOLS_MCP_WORKSPACE` provide the fallback/default Workspace root; they do +not relocate OAuth or Settings files. + +Older fork deployments may have stored settings under a Workspace-specific +`.coding-tools-mcp` directory. There is no silent recursive search for such +files. Review and migrate those values explicitly into the stable directory so +an unrelated Workspace cannot redefine server identity. Do not copy plaintext +secret fields into `server-settings.json`; put secret values into the Secret +Vault and persist only references. + +## Settings schema migration + +`server-settings.json` is versioned and written atomically. Corrupt JSON or a +future unsupported schema version fails closed and is not overwritten. + +### Legacy `tool_profile` + +`tool_profile` is accepted only as migration input. On read/write it is removed +and emits the stable warning: + +```text +legacy_tool_profile_ignored +``` + +Known and unknown old values have the same result. There is no `--tool-profile`, +`CODING_TOOLS_MCP_TOOL_PROFILE`, WebUI selector, or runtime filtering path. +Permission modes do not change the fixed tool catalog. + +### Legacy single Workspace + +A legacy `workspace` value remains a fallback when no `workspace_catalog` is +present. Migrate explicitly through the Admin Workspace page/API to a catalog +containing stable `id`, `name`, `root`, `enabled`, and one default entry. New +HTTP Sessions freeze one catalog Workspace at initialization; ordinary tools +cannot switch roots. + +Workspace roots must exist, be directories, be unique, not be filesystem roots, +and not be nested inside one another. Unknown or disabled entries fail closed. + +## OAuth migration + +The integrated OAuth implementation requires persistent state and the encrypted +Vault: + +```text +oauth.sqlite3 +oauth-secrets.json +CODING_TOOLS_MCP_SECRETS_KEY +``` + +The Store persists Clients, Grants, access-token `jti` metadata, refresh-token +families, signing-key metadata, and audit events. It does not persist plaintext +bearer tokens, refresh tokens, client secrets, or signing material. + +### Reauthorization expectation + +Tokens issued by older stateless implementations may lack required `kid`, +`grant_id`, `workspace_id`, or `jti` state and therefore are not accepted by the +new fail-closed validator. Plan a controlled reauthorization after upgrade. +Persistent dynamic Client registration and refresh rotation then avoid routine +reauthorization across later restarts, provided the public origin, Store, Vault, +Client/Grant state, and signing-key ring remain intact. + +### Workspace mapping + +- If exactly one Workspace is enabled, old unbound Clients are assigned to that + sole default. +- If multiple Workspaces are enabled, configure + `oauth_client_workspace_bindings` or + `CODING_TOOLS_MCP_OAUTH_WORKSPACE_ID` for a pre-registered Client. +- A Grant copies the Client Workspace at authorization time; changing the Client + mapping does not rewrite existing Grants. +- Missing or disabled mappings reject Grant/Session creation. + +### Refresh and signing keys + +Authorization Code exchange returns a refresh token. Each refresh rotates the +opaque token; reuse of an old token revokes the family. Refresh rotation, +replacement, access-token `jti` metadata, family timestamps, and issuance audits +commit in one +SQLite transaction. A persistence/audit failure rolls the whole exchange back, +so the original refresh token remains retryable and no partial replacement is +left behind. Signing-key rotation creates a new active key and retires the +previous key so unexpired access tokens remain verifiable. Emergency key revoke +invalidates tokens associated with that key. + +Do not delete retired key material until every token that depends on it has +expired or been revoked. Admin pages expose IDs, status, fingerprints, and +impact counts only—not secret material. + +## Gateway migration + +Place Gateway configuration in the stable `mcp-servers.json` or provide +`--upstream-config` / `CODING_TOOLS_MCP_UPSTREAM_CONFIG`. Configuration, +enabled state, and allowlists are read before Runtime initialization. Each +Runtime receives an immutable namespaced snapshot. + +Saving Gateway configuration only persists a new revision and sets +`restart_required`; it does not reload established Sessions. A local/upstream +namespace collision fails closed. `secret_ref` requires the server Secret Vault +and a valid master key. + +## Admin WebUI and token separation + +The Admin WebUI at `/admin` requires a dedicated Admin token. An MCP static +bearer or OAuth access token is never promoted to Admin. The browser token is +kept in page memory and sent only in the request header. + +Settings/Gateway writes use an expected revision. A stale `409` must be resolved +explicitly; the WebUI preserves the draft and refreshes the persisted revision. + +## Desktop separation + +The optional Desktop client keeps its profiles and secrets under its own storage +root. Do not copy Desktop secret/profile files into server Settings or connect +them to the server Vault. The Desktop app launches/manages local profiles; +`/admin` manages a running HTTP server. + +## Rollback + +Do not start an older binary against a configuration directory after the newer +binary has migrated SQLite or settings schemas. Older code may not understand +new columns, key-ring state, Workspace bindings, or secret references. + +A safe rollback is a full snapshot rollback: + +1. Stop all new and old server processes. +2. Preserve the failed-upgrade directory for diagnosis. +3. Restore `server-settings.json`, both Vault files, `oauth.sqlite3`, + `mcp-servers.json`, and `transcripts.sqlite3` from the same pre-upgrade + snapshot. +4. Restore the matching `CODING_TOOLS_MCP_SECRETS_KEY` and public OAuth origin. +5. Start the older binary only against that restored directory. +6. Expect Agents authorized after the snapshot to reauthorize. + +Never restore only `oauth.sqlite3` without the matching Vault/key ring, or only a +Vault without the matching Store. Never generate a new master key to “repair” an +existing encrypted Vault. + +## Validation checklist + +After upgrade: + +- `server_info` reports the expected protocol, Workspace, and fixed local tool + count; +- two HTTP Sessions do not share cwd/process/output state; +- OAuth discovery advertises `authorization_code` and `refresh_token`; +- a refresh succeeds once and replay of the old refresh token fails; +- a disabled Client/Grant/access-token `jti` fails on the next request; +- existing Sessions keep their frozen Workspace, while new Sessions reject a + disabled Workspace; +- Gateway changes report `restart_required` and do not alter established + `tools/list`; +- `/admin` rejects ordinary MCP/OAuth bearer credentials; +- Admin responses and logs contain no token or Vault material; +- `npm --prefix webui test`, `docs-required`, and `schema-drift` pass. + +## Refresh exchange recovery + +Refresh exchange is atomic and does not require a schema migration. If any +replacement-token write, access-token metadata write, family update, or issuance +audit fails, the transaction rolls back and the same original refresh token may +be retried after the persistence problem is corrected. A successfully committed +exchange still consumes the old token, and replay continues to revoke the token +family. diff --git a/docs/remote-mcp.md b/docs/remote-mcp.md index bd4b596..4fb221c 100644 --- a/docs/remote-mcp.md +++ b/docs/remote-mcp.md @@ -1,10 +1,10 @@ # Remote MCP -`coding-tools-mcp` exposes Streamable HTTP at `/mcp`. Keep it bound to loopback -and publish it through an HTTPS tunnel. The fixed tool set includes -`apply_patch` and `exec_command`; there is no reduced read-only catalog, so every -public deployment must use bearer auth, OAuth, or an external authenticated -proxy. +`coding-tools-mcp` exposes Streamable HTTP at `/mcp`. Keep the listener on +loopback and publish it through an authenticated HTTPS tunnel. The fixed local +catalog contains `apply_patch` and `exec_command`; there is no reduced read-only +catalog, so a public endpoint must use static bearer auth, OAuth, or an external +authenticated proxy. ## One-command bearer tunnel @@ -14,36 +14,35 @@ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts ``` The script generates a bearer token, starts the server on `127.0.0.1`, and -prints the HTTPS tunnel URL and header: - -```text -URL: https:///mcp -Header: Authorization: Bearer -``` - -From a checkout, the equivalent commands are: +prints the HTTPS MCP URL and `Authorization` header. From a checkout: ```bash export CODING_TOOLS_MCP_AUTH_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" CODING_TOOLS_MCP_AUTH_MODE=bearer scripts/tunnel.sh cloudflared /path/to/repo ``` -The scripts also support `ngrok` and `devtunnel`. +The tunnel scripts also support `ngrok` and `devtunnel`. -## OAuth 2.1 + dynamic registration +## Persistent OAuth 2.1 -For clients that cannot set a static `Authorization` header but support MCP -OAuth discovery: +Start OAuth mode with a stable Secret Vault master key: ```bash -CODING_TOOLS_MCP_AUTH_MODE=oauth \ -scripts/tunnel.sh cloudflared /path/to/repo +export CODING_TOOLS_MCP_SECRETS_KEY='' +export CODING_TOOLS_MCP_SERVER_URL='https://mcp.example.com' +coding-tools-mcp --oauth-mode --workspace /path/to/repo ``` -The server implements Authorization Code + PKCE S256 and RFC 7591 dynamic -client registration. A client discovers and registers itself; operators do not -need to invent a client ID or copy a client secret into the MCP host. The script -prints the password that the operator enters on the authorization page. +The server implements: + +- Authorization Code + PKCE S256; +- RFC 7591 dynamic client registration; +- exact redirect URI matching; +- `authorization_code` and `refresh_token` grants; +- access-token `jti` tracking and exact revocation; +- refresh-token rotation and family revocation after reuse; +- active, retired, and revoked signing-key states; +- persistent Client/Grant/token/key metadata across restart. Discovery and OAuth endpoints: @@ -54,115 +53,118 @@ Discovery and OAuth endpoints: - `POST /oauth/authorize` - `POST /oauth/token` -Registration rules: +Authorization codes remain process-local, single-use, and expire after five +minutes. Dynamic Client records persist. Registration requests are narrowed to +the supported grant types (`authorization_code`, `refresh_token`) and response +type (`code`) instead of being widened silently. -- `redirect_uris` are required, unique, and matched exactly. -- HTTPS redirects are accepted. HTTP is accepted only for `localhost`, - `127.0.0.1`, or `::1` loopback callbacks. -- Supported token authentication methods are `none`, `client_secret_post`, and - `client_secret_basic`. A client must use the method it registered. -- Client secrets are stored as digests. Public clients rely on mandatory PKCE. -- Registrations and authorization codes are process-local. A restart requires - dynamic clients to register again. +### Persistent files -Authorization codes are single-use and expire after five minutes. Access tokens -default to 24 hours and are bound to the registered client and exact MCP -resource URL. +The stable configuration directory is selected by +`CODING_TOOLS_MCP_CONFIG_DIR`, or defaults to: -## OAuth configuration +- Windows: `%APPDATA%\coding-tools-mcp` +- POSIX: `$XDG_CONFIG_HOME/coding-tools-mcp` or `~/.config/coding-tools-mcp` -```bash -# Generated and printed when omitted: -CODING_TOOLS_MCP_OAUTH_PASSWORD= - -# Optional stable public origin, without /mcp: -CODING_TOOLS_MCP_SERVER_URL=https://mcp.example.com - -# Optional stable HS256 key; hex-encoded bytes: -CODING_TOOLS_MCP_OAUTH_TOKEN_SECRET= - -# Optional token lifetime in seconds; default 86400: -CODING_TOOLS_MCP_OAUTH_TOKEN_TTL=86400 -``` +Relevant files are: -With an ephemeral tunnel, omit `CODING_TOOLS_MCP_SERVER_URL`; the server derives -the external origin from the request. For a stable hostname, pin it so issuer, -audience, resource, and discovery URLs remain constant. +- `server-settings.json` +- `mcp-servers.json` +- `oauth.sqlite3` +- `oauth-secrets.json` +- `server-secrets.json` +- `transcripts.sqlite3` -The server ignores `Forwarded` and `X-Forwarded-*` by default. Set -`CODING_TOOLS_MCP_TRUST_PROXY_HEADERS=1` only behind a proxy you control. You can -also set exact browser origins with the comma-separated -`CODING_TOOLS_MCP_ALLOWED_ORIGINS` variable. +`oauth.sqlite3` stores identifiers, status, fingerprints, client-secret digests, +and peppered refresh-token hashes—not plaintext bearer or refresh tokens. +Signing material and the authorization-page password are resolved through the +Secret Vault. If the Store, Vault, master key, or referenced secret is missing or +corrupt, OAuth startup/request processing fails closed. ### Optional pre-registered client -Dynamic registration is the default. An operator may additionally pre-register -one known client: - ```bash -CODING_TOOLS_MCP_OAUTH_CLIENT_ID= -CODING_TOOLS_MCP_OAUTH_REDIRECT_URIS=https://client.example/callback,http://127.0.0.1/callback -CODING_TOOLS_MCP_OAUTH_CLIENT_SECRET= +export CODING_TOOLS_MCP_OAUTH_CLIENT_ID='' +export CODING_TOOLS_MCP_OAUTH_REDIRECT_URIS='https://client.example/callback,http://127.0.0.1/callback' +export CODING_TOOLS_MCP_OAUTH_CLIENT_SECRET='' +export CODING_TOOLS_MCP_OAUTH_WORKSPACE_ID='' ``` -If a client ID is configured, its redirect URI list is required operational -configuration; do not rely on the loopback fallback for a production client. +Public clients omit the secret and must use PKCE. Confidential clients must use +the authentication method recorded at registration. Client secrets are returned +only at creation and stored only as digests. + +## Workspace mapping + +OAuth bearer validation produces `client_id`, `grant_id`, `workspace_id`, and +`jti`. The selected Workspace is frozen when the HTTP Session is initialized. +The Session's cwd, processes, retained output, project instructions, and local +path resolution all use that Workspace. -## HTTP session behavior +- With exactly one enabled Workspace, old unbound Clients are migrated to that + sole default. +- With multiple enabled Workspaces, a Client must have an explicit mapping in + `oauth_client_workspace_bindings` or `CODING_TOOLS_MCP_OAUTH_WORKSPACE_ID`. +- A Grant copies and freezes the Client's Workspace at authorization time. +- Missing, disabled, or unauthorized mappings reject authorization or Session + creation. +- Disabling a Workspace rejects new Sessions; existing Sessions retain their + frozen binding until they are closed. -An HTTP client initializes without `Mcp-Session-Id`. The response returns a -new, unguessable session ID. Every later request must send both: +## HTTP Session behavior + +An HTTP client initializes without `Mcp-Session-Id`. A successful response +returns a new unguessable Session ID. Later requests send: ```text Mcp-Session-Id: MCP-Protocol-Version: 2025-11-25 +Authorization: Bearer ``` -Each ID owns a separate cwd, command-session table, output cache, and runtime -directory. A second client cannot read or mutate the first client's state. -`DELETE /mcp` with the session header terminates that one runtime. Sessions are -bounded and expire after inactivity. +A second Agent cannot reuse, mutate, or delete another Agent's Session. Each +Session has an independent cwd, process table, output cache, runtime directory, +Workspace binding, and Gateway snapshot. `DELETE /mcp` terminates only the +selected Session. -This implementation returns `405` for `GET /mcp` because it does not provide an -SSE stream. It rejects JSON-RPC batches and accepts standard -`notifications/cancelled` messages using `params.requestId`. +## Gateway and remote tools -## Local checks +`mcp-servers.json` is read before Runtime initialization. Enabled servers and +allowlists are frozen into each Runtime. Tools are published as +`{alias}__{remote_name}`. Local names are reserved; a collision fails closed. +Saving Gateway configuration through Admin only sets `restart_required`; there +is no hot reload/start/stop path. -Replace `BASE_URL` with the HTTPS origin, without `/mcp`: +An upstream tool is a remote capability. Do not describe it as protected by the +local Workspace path boundary or local command permission mode. Its data access +and side effects are controlled by the upstream MCP server. -```bash -curl "$BASE_URL/.well-known/mcp.json" -curl "$BASE_URL/.well-known/oauth-protected-resource" -curl "$BASE_URL/.well-known/oauth-authorization-server" -``` +Gateway `secret_ref` values require the server Secret Vault and a valid +`CODING_TOOLS_MCP_SECRETS_KEY`; unresolved references fail closed. -For bearer mode, an unauthenticated request must return `401` and a correct token -must reach MCP initialization: +## Admin authentication -```bash -curl "$BASE_URL/mcp" \ - -H "Authorization: Bearer $CODING_TOOLS_MCP_AUTH_TOKEN" \ - -H "Accept: application/json, text/event-stream" \ - -H "Content-Type: application/json" \ - --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' -``` +`/admin` and `/admin/api` use a dedicated Admin token configured by +`--admin-token`, `CODING_TOOLS_MCP_ADMIN_TOKEN`, or `admin_token_secret_ref`. +Ordinary static MCP bearer tokens and OAuth access tokens are not Admin +credentials. The browser keeps the Admin token in page memory and never writes +it to the URL or persistent browser storage. + +## Proxy and origin settings + +For a stable hostname, set `CODING_TOOLS_MCP_SERVER_URL` so issuer, audience, +resource, and discovery URLs remain stable. Forwarded headers are ignored unless +`CODING_TOOLS_MCP_TRUST_PROXY_HEADERS=1` is set behind a proxy you control. +Browser origins must match the validated `CODING_TOOLS_MCP_ALLOWED_ORIGINS` +list exactly. ## Security notes -- Never publish `CODING_TOOLS_MCP_AUTH_MODE=noauth`. It is suitable only for a - loopback-only local process. -- Use HTTPS, rotate static bearer tokens, and keep OAuth passwords/signing keys - out of committed files. -- Keep the MCP runtime in `safe` or `trusted`; use `dangerous` only inside an - isolated container or VM with a trusted client. -- An HTTPS tunnel authenticates transport, not code execution. The server's - policy and Landlock protections do not replace an external sandbox for - untrusted repositories. -- Avoid `--dangerously-fake-readonly-annotations` on a published endpoint. It - reports mutating tools as read-only, so a client on the far side of the tunnel - cannot tell from `tools/list` that `apply_patch` and `exec_command` are exposed. - The server requires authentication before allowing it over HTTP, but on a shared - endpoint the operator who set it and the client who connects may not be the same - party. Check `server_info.annotation_override` or the server card's - `tools.annotationOverride` to see whether an endpoint is doing this. +- Never publish `CODING_TOOLS_MCP_AUTH_MODE=noauth`. +- Use HTTPS and keep bearer tokens, Admin tokens, Vault master keys, OAuth + passwords, and signing material out of repositories and logs. +- Use `dangerous` only inside an external container or VM. +- `--dangerously-fake-readonly-annotations` does not block mutation; avoid it on + shared or public endpoints. +- Back up the complete stable configuration directory before migration or key + operations. See [migration-v0.1-to-v0.2.2.md](migration-v0.1-to-v0.2.2.md). diff --git a/docs/runtime-contract-v0.2.md b/docs/runtime-contract-v0.2.md index 2433475..bc610cb 100644 --- a/docs/runtime-contract-v0.2.md +++ b/docs/runtime-contract-v0.2.md @@ -23,11 +23,14 @@ below. Unless that switch is set, the annotations in this document are what - Streamable HTTP uses `POST /mcp`. `DELETE /mcp` terminates the selected `Mcp-Session-Id`. Because this server does not provide an SSE stream, `GET /mcp` and `HEAD /mcp` return `405`. -- Each successful HTTP `initialize` creates an independent runtime. Its cwd, - process sessions, retained output, and runtime directories are not shared - with other MCP sessions. -- Subsequent HTTP messages must include the returned `Mcp-Session-Id` and the - negotiated `MCP-Protocol-Version`. Unknown or expired sessions return `404`. +- Each successful HTTP `initialize` creates an independent Runtime. Its + authorization context, Workspace binding, cwd, process sessions, retained + output, project instructions, runtime directories, and upstream-tool snapshot + are not shared with other MCP Sessions. +- Subsequent HTTP messages must include the returned `Mcp-Session-Id`, the + negotiated `MCP-Protocol-Version`, and credentials matching the Session's + initialization context. Unknown or expired Sessions return `404`; a different + Agent/Grant/Workspace context returns `403`. - JSON-RPC batches are rejected. Cancellation uses `notifications/cancelled.params.requestId`. - stdio is newline-delimited JSON-RPC. stdout contains protocol messages only; @@ -40,6 +43,21 @@ The server accepts only the protocol versions listed above. A supported version is echoed in `initialize`; arbitrary older dates and unknown future dates are rejected rather than compared lexicographically. +## Session identity and Workspace binding + +Bearer validation produces an explicit identity context only after JWT and +persistent Store checks succeed: `client_id`, `grant_id`, `workspace_id`, and +`jti`. HTTP initialization resolves that identity through the validated +Workspace Catalog and freezes the result for the lifetime of the MCP Session. +Handlers do not infer identity again from names, redirect URIs, request metadata, +or presentation fields. + +A missing, disabled, unknown, or unauthorized Workspace fails closed. Existing +Sessions intentionally retain their frozen binding after a Catalog entry is +disabled; new Sessions are rejected until a valid mapping is restored. stdio +uses the configured default Workspace and does not create a synthetic OAuth +identity. + ## Automatic project context Initialization automatically loads bounded root project instructions from @@ -51,7 +69,7 @@ depth, per-file, and total-byte limits. ## Workspace and patch guarantees -- One server runtime owns one canonical workspace root. +- One Runtime owns one immutable canonical Workspace root; HTTP creates one Runtime per MCP Session. - Direct path inputs are workspace-relative. Absolute paths, `..` traversal, NUL bytes, and symlink escapes are rejected. - `apply_patch` parses and validates every operation before committing. @@ -110,7 +128,7 @@ Tool failures keep the same envelope with `isError: true`, a readable error in Known tool error codes include: ```json -["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "SESSION_CLOSED", "SESSION_LIMIT_REACHED", "SESSION_NOT_FOUND", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] +["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "SESSION_CLOSED", "SESSION_LIMIT_REACHED", "SESSION_NOT_FOUND", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING", "UPSTREAM_TOOL_COLLISION"] ``` Error categories are `validation`, `security`, `permission`, `runtime`, @@ -150,25 +168,44 @@ of pretending pipes are a TTY. ## HTTP authentication -Non-loopback deployment requires bearer or OAuth authentication unless the -operator explicitly selects no-auth. OAuth implements Authorization Code + -PKCE S256, protected-resource metadata, authorization-server metadata, exact -redirect URI matching, one-time five-minute codes, 24-hour access tokens, and -RFC 7591 dynamic client registration at `POST /oauth/register`. Public and -confidential clients are bound to their registered authentication method. - -Dynamic registrations and authorization codes are process-local; restarting -the server requires clients to register again. Configure a stable -`CODING_TOOLS_MCP_OAUTH_TOKEN_SECRET` and public server URL only when tokens must -survive tunnel churn. Forwarded headers are ignored unless -`CODING_TOOLS_MCP_TRUST_PROXY_HEADERS=1` is explicitly set. +Non-loopback deployment requires static bearer or OAuth authentication unless +the operator explicitly selects no-auth. OAuth implements Authorization Code + +PKCE S256, protected-resource and authorization-server metadata, exact redirect +URI matching, one-time five-minute codes, RFC 7591 dynamic registration, and +`authorization_code` plus `refresh_token` grants. Registration metadata is +narrowed to supported grant/response types; public and confidential clients are +bound to their registered authentication method. + +Clients, Grants, access-token `jti` metadata, refresh-token families, and +signing-key metadata persist in `oauth.sqlite3`. Refresh-token replacement, +old-token consumption, access-token metadata, family timestamps, and issuance +audits commit atomically; a failed exchange leaves the original token retryable. +Reuse of an already-rotated token revokes its family. Access validation checks +the active Client, Grant, token, Workspace mapping, and signing key. Signing +material is +loaded from `oauth-secrets.json` through the +`CODING_TOOLS_MCP_SECRETS_KEY`-protected Secret Vault. Store or Vault failure is +fail-closed; there is no in-memory fallback. Authorization codes remain +process-local and short-lived. + +A dedicated Admin token is a separate authority. Ordinary MCP bearer and OAuth +access tokens cannot call `/admin` or `/admin/api`. Forwarded headers remain +ignored unless `CODING_TOOLS_MCP_TRUST_PROXY_HEADERS=1` is explicitly set. ## Stable tool inventory -The default catalog has 20 tools, including `view_image`. Setting +The local catalog has 20 reserved tool names, including `view_image`. Setting `CODING_TOOLS_MCP_ENABLE_VIEW_IMAGE=0` is the sole installation capability gate and removes only that optional binary-content tool. It is not a tool profile. +Optional upstream MCP tools are discovered before Runtime initialization and +frozen into that Runtime's catalog under `{alias}__{remote_name}`. Local names +remain reserved. Duplicate aliases, local-name collisions, or upstream +namespace collisions fail closed with `UPSTREAM_TOOL_COLLISION`; partial +catalogs are not published. Upstream schemas, annotations, extension fields, +`content`, `structuredContent`, and `isError` are preserved. Existing Sessions +never receive dynamic Gateway changes, so `listChanged: false` remains truthful. + Each definition below lists the live input property names and annotations. The authoritative JSON Schemas are returned by `tools/list` and checked for drift in CI. The annotations recorded here are the truthful ones and are what `server_info` @@ -341,4 +378,6 @@ subagent orchestration, or high-level prompt wrappers. `structuredContent`. The machine fields are retained where practical, while the text block is now a concise human/model summary. Removed compatibility surfaces are tool profiles, the `view_image.output` selector, duplicate image data URLs, -and JSON-RPC batches. +and JSON-RPC batches. A persisted legacy `tool_profile` key is ignored, emits +`legacy_tool_profile_ignored`, and is removed on the next successful settings +write; it never controls the catalog. diff --git a/docs/telemetry.md b/docs/telemetry.md index ccc9fab..ae72204 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,68 +1,75 @@ # Telemetry coding-tools-mcp collects anonymous usage telemetry to answer two product -questions: how many installs are active, and which tools succeed, fail, or run -slowly in the wild. Telemetry is enabled by default and can be disabled at any -time; disabling it changes nothing else about the server. +questions: which tool paths fail most often, and which releases become slower. +The upstream v0.2.2 default is enabled. Telemetry never changes MCP responses and +delivery failures are dropped silently. ## How to disable -Any one of the following turns telemetry off completely: +Any of the following turns telemetry off completely: ```bash -export CODING_TOOLS_MCP_TELEMETRY=off # also accepts 0 / false / no -export DO_NOT_TRACK=1 # the cross-tool convention +export CODING_TOOLS_MCP_TELEMETRY=off # also accepts 0, false, or no +export DO_NOT_TRACK=1 ``` -Telemetry is also disabled automatically whenever `CI` is set, and the test -suite forces it off in `tests/__init__.py`, so CI and test runs never pollute -usage data. Deleting `~/.coding-tools-mcp/id` resets the anonymous install -identity. - -To see exactly what would be sent without sending it: +Telemetry is disabled automatically when `CI` is set. Debug mode prints the same +closed-schema events to stderr instead of sending them: ```bash -export CODING_TOOLS_MCP_TELEMETRY=debug # prints events to stderr instead +export CODING_TOOLS_MCP_TELEMETRY=debug ``` -## What is collected +The authenticated Admin status response exposes only: -Events are sent to PostHog (`us.i.posthog.com`) over HTTPS using the standard -library only. The payload is a closed schema — counters, enums, durations, and -version strings assembled by one function (`coding_tools_mcp/telemetry.py`). -It is structurally incapable of carrying paths, arguments, or file contents. +```json +{"telemetry": {"mode": "on", "docs": "docs/telemetry.md"}} +``` -Every event carries: package version, OS platform and architecture, Python -`major.minor`, transport (`stdio`/`http`), permission mode, MCP protocol -version, the connecting client's `clientInfo` name and version (truncated to -64 characters), a random per-session id, and the anonymous install id. +The mode is `on`, `off`, or `debug`. Admin does not expose event payloads. + +## What is collected + +Events use a closed schema. Every event carries package version, OS platform +and architecture, Python `major.minor`, transport (`stdio` or `http`), permission +mode, MCP protocol version, the connecting MCP `clientInfo` name/version +(truncated to 64 characters), a random telemetry-session identifier, and the +anonymous install identifier. | Event | When | Additional properties | | --- | --- | --- | -| `session_start` | MCP `initialize` completes | — | +| `session_start` | MCP `initialize` completes | none | | `tool_error` | a tool call fails (max 20 per session) | tool name, error code, duration ms, consecutive-failure count | -| `tool_summary` | session ends, one per tool used | calls, ok, errors, per-error-code counts, duration buckets, truncation count | -| `session_end` | session ends | session duration, total calls, distinct tools, dropped error-event count | +| `tool_summary` | the Session ends, one per tool used | calls, successes, errors, per-error-code counts, duration buckets, truncation count | +| `session_end` | the Session ends | Session duration, total calls, distinct tool count, dropped error-event count | -A typical session produces 5–15 events totalling a few kilobytes. +The random telemetry-session identifier is not an MCP `Mcp-Session-Id`, OAuth +identity, Workspace identity, or persistent user identifier. Tool calls never +block on delivery. Events are queued in memory to a daemon thread and error +events are capped as shown above. ## What is never collected -File paths, file contents, tool arguments, command lines, environment -variables, patch bodies, diffs, repository or branch names, workspace -locations, hostnames, usernames, and IP-derived identity. The install id is a -random UUID generated locally — never derived from hardware, hostname, or any -workspace property — so it cannot be reversed into an identity. +Telemetry does not contain: + +- file or directory paths; +- repository/branch names, Workspace IDs, OAuth Agent/Client/Grant/token IDs, or MCP `Mcp-Session-Id` values; +- command lines, arguments, environment values, patch bodies, diffs, or file + contents; +- OAuth bearer/refresh tokens, Admin tokens, client secrets, signing material, + Vault references, hashes, or digests; +- upstream MCP inputs/results; +- chat/transcript content, model responses, context entries, transcript paths, + or conversation summaries. -`tests/test_telemetry.py` enforces the boundary: a probe session touches files -with distinctive path substrings and the test asserts none of them appear -anywhere in the serialized payload, and that a disabled session never reaches -the sender at all. +The install identifier is random and is not derived from a Workspace or user +property. Delete `~/.coding-tools-mcp/id` to reset it. ## Delivery guarantees -Events queue in memory on a bounded queue serviced by a daemon thread with a -3-second send timeout; failures are swallowed and overflow is dropped. Nothing -is written to stdout (over stdio that is the MCP wire), nothing telemetry- -related is stored on disk beyond the install id file, and a dead or slow -telemetry endpoint is invisible to tool calls. +- stdout is never used for telemetry, so stdio JSON-RPC cannot be polluted; +- debug events go to stderr; +- network errors do not fail tool calls; +- the telemetry endpoint is not reachable through MCP tools; +- tests enforce the closed schema and privacy boundary. diff --git a/docs/tools-and-schemas.md b/docs/tools-and-schemas.md index 3765189..6b912ff 100644 --- a/docs/tools-and-schemas.md +++ b/docs/tools-and-schemas.md @@ -32,8 +32,14 @@ The default catalog contains exactly 20 tools: - `view_image`: one MCP image content block plus structured metadata. `view_image` may be disabled when an installation cannot accept binary image -content. That capability gate is not a tool profile. The other 19 tools are -always advertised, and `listChanged` is `false`. +content. That capability gate is not a tool profile. The other 19 local tools +are always advertised. + +Optional upstream tools are appended only during Runtime initialization and use +`{alias}__{remote_name}`. Local names are permanently reserved. Upstream schema, +annotations, extension fields, `content`, `structuredContent`, and `isError` are +preserved. Namespace collisions fail closed, and an established Runtime never +changes its snapshot; therefore `listChanged` is `false`. ## Result envelope @@ -48,10 +54,10 @@ Every successful tool call has: ``` `content` is not a JSON mirror. `structuredContent` is the complete machine -interface and retains existing fields where possible. Model-facing text is -bounded at 16 KiB; if it is shortened, the full structured value is still -present. Errors use the same envelope with readable recovery guidance and -`isError: true`. +interface and retains existing fields where possible. Model-facing text follows +each tool's own byte/result limits, with an emergency safety ceiling for +pathological entries; there is no generic 16 KiB preview truncation. Errors use +the same envelope with readable recovery guidance and `isError: true`. `view_image` is the exception to text-only content: its base64 appears exactly once in one `image` block. `structuredContent` contains path, media type, byte diff --git a/npm/coding-tools-mcp/test/launcher.test.js b/npm/coding-tools-mcp/test/launcher.test.js index 34b0131..5a917a0 100644 --- a/npm/coding-tools-mcp/test/launcher.test.js +++ b/npm/coding-tools-mcp/test/launcher.test.js @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import os from "node:os"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { spawnSync } from "node:child_process"; import test from "node:test"; @@ -8,12 +7,50 @@ import { fileURLToPath } from "node:url"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const launcher = path.join(packageRoot, "bin", "coding-tools-mcp.js"); +const fixtureRoot = path.join(packageRoot, ".tmp"); + +async function createFixtureDirectory(testContext, prefix) { + await mkdir(fixtureRoot, { recursive: true }); + const directory = await mkdtemp(path.join(fixtureRoot, prefix)); + testContext.after(() => rm(directory, { recursive: true, force: true })); + return directory; +} + +async function writeExecutable(directory, name, { captureArgs = false, exitCode = 0 } = {}) { + const windows = process.platform === "win32"; + if (windows) { + const target = path.join(directory, `${name}.exe`); + const preload = path.join(directory, `${name}-stub.cjs`); + await copyFile(process.execPath, target); + await writeFile( + preload, + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const executable = path.basename(process.execPath).toLowerCase();', + 'if (executable === "uvx.exe" || executable === "pipx.exe") {', + captureArgs + ? ' fs.writeFileSync(process.env.RESULT_FILE, `${process.argv.slice(1).join("\\n")}\\n`);' + : "", + ` process.exit(${exitCode});`, + "}", + "", + ].join("\n"), + "utf8", + ); + return { NODE_OPTIONS: `--require=${preload.replaceAll("\\", "/")}` }; + } -async function writeExecutable(directory, name, body) { const target = path.join(directory, name); - await writeFile(target, `#!/bin/sh\n${body}\n`, "utf8"); + const body = [ + "#!/bin/sh", + captureArgs ? 'printf "%s\\n" "$@" > "$RESULT_FILE"' : "", + `exit ${exitCode}`, + "", + ].join("\n"); + await writeFile(target, body, "utf8"); await chmod(target, 0o755); - return target; + return {}; } function runLauncher(binDirectory, args = [], extraEnv = {}, ambientEnv = process.env) { @@ -29,18 +66,27 @@ function runLauncher(binDirectory, args = [], extraEnv = {}, ambientEnv = proces }); } -test("uvx receives the pinned Python package and forwarded arguments", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "coding-tools-mcp-uvx-")); +async function readCapturedArgs(output) { + const args = (await readFile(output, "utf8")).trim().split("\n"); + if (process.platform === "win32" && args.length > 0) { + args[0] = path.basename(args[0]); + } + return args; +} + +test("uvx receives the pinned Python package and forwarded arguments", async (t) => { + const directory = await createFixtureDirectory(t, "uvx-"); const output = path.join(directory, "args.txt"); - await writeExecutable(directory, "uvx", 'printf "%s\\n" "$@" > "$RESULT_FILE"'); + const runnerEnv = await writeExecutable(directory, "uvx", { captureArgs: true }); const result = runLauncher(directory, ["--stdio", "--workspace", "/repo"], { + ...runnerEnv, CODING_TOOLS_MCP_VERSION: "0.2.0", RESULT_FILE: output, }); assert.equal(result.status, 0, result.stderr); - assert.deepEqual((await readFile(output, "utf8")).trim().split("\n"), [ + assert.deepEqual(await readCapturedArgs(output), [ "coding-tools-mcp==0.2.0", "--stdio", "--workspace", @@ -48,28 +94,28 @@ test("uvx receives the pinned Python package and forwarded arguments", async () ]); }); -test("pipx is used when uvx is unavailable", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "coding-tools-mcp-pipx-")); +test("pipx is used when uvx is unavailable", async (t) => { + const directory = await createFixtureDirectory(t, "pipx-"); const output = path.join(directory, "args.txt"); - await writeExecutable(directory, "pipx", 'printf "%s\\n" "$@" > "$RESULT_FILE"'); + const runnerEnv = await writeExecutable(directory, "pipx", { captureArgs: true }); const result = runLauncher( directory, ["--help"], - { RESULT_FILE: output }, + { ...runnerEnv, RESULT_FILE: output }, { ...process.env, CODING_TOOLS_MCP_VERSION: "9.9.9" }, ); assert.equal(result.status, 0, result.stderr); - assert.deepEqual((await readFile(output, "utf8")).trim().split("\n"), [ + assert.deepEqual(await readCapturedArgs(output), [ "run", "coding-tools-mcp", "--help", ]); }); -test("the launcher explains how to install a supported runner", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "coding-tools-mcp-empty-")); +test("the launcher explains how to install a supported runner", async (t) => { + const directory = await createFixtureDirectory(t, "empty-"); const result = runLauncher(directory); assert.equal(result.status, 1); @@ -77,11 +123,11 @@ test("the launcher explains how to install a supported runner", async () => { assert.match(result.stderr, /pip install coding-tools-mcp/); }); -test("the child exit code is preserved", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "coding-tools-mcp-exit-")); - await writeExecutable(directory, "uvx", "exit 7"); +test("the child exit code is preserved", async (t) => { + const directory = await createFixtureDirectory(t, "exit-"); + const runnerEnv = await writeExecutable(directory, "uvx", { exitCode: 7 }); - const result = runLauncher(directory); + const result = runLauncher(directory, [], runnerEnv); assert.equal(result.status, 7); }); diff --git a/pyproject.toml b/pyproject.toml index e333f8c..a649cfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,4 +47,5 @@ where = [".", "apps/desktop-client"] include = ["coding_tools_mcp*", "mcp_desktop_client*"] [tool.setuptools.package-data] +coding_tools_mcp = ["webui_dist/*"] mcp_desktop_client = ["locales/*.qm", "locales/*.ts"] diff --git a/tests/compliance/test_chat_persistence.py b/tests/compliance/test_chat_persistence.py new file mode 100644 index 0000000..0c61cb6 --- /dev/null +++ b/tests/compliance/test_chat_persistence.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import ctypes +import inspect +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from coding_tools_mcp import chat_cli +from coding_tools_mcp.admin import AdminNotFoundError, AdminService, AdminServiceError, gateway_file_revision +from coding_tools_mcp.codex_sessions import CodexSessionError, CodexSessionScanner, ScanPolicy +from coding_tools_mcp.secret_vault import SecretVault +from coding_tools_mcp.settings_store import ServerSettingsStore +from coding_tools_mcp.transcript import TranscriptStore, WorkspaceScope +from coding_tools_mcp.workspace_catalog import WorkspaceCatalog, WorkspaceEntry + + +class ChatPersistenceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = TemporaryDirectory() + self.root = Path(self.temp.name) + self.a = self.root / "workspace-a" + self.b = self.root / "workspace-b" + self.a.mkdir() + self.b.mkdir() + self.store = TranscriptStore(self.root / "transcripts.sqlite3") + self.scope_a = WorkspaceScope.create("a", self.a) + self.scope_b = WorkspaceScope.create("b", self.b) + self.scanner = CodexSessionScanner() + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_workspace_identity_partitions_queries_cache_and_stable_deletes(self) -> None: + service_a = self.store.scoped("a", self.a) + service_b = self.store.scoped("b", self.b) + service_a.record_messages( + "same-conversation", + [{"message_id": "same-message", "role": "user", "content": "secret-a"}], + ) + service_b.record_messages( + "same-conversation", + [{"message_id": "same-message", "role": "user", "content": "secret-b"}], + ) + self.assertEqual(service_a.list_conversations()["total"], 1) + self.assertEqual(service_b.list_conversations()["total"], 1) + self.assertEqual( + service_a.conversation_detail("same-conversation")["messages"][0]["content"], + "secret-a", + ) + deleted = self.store.delete_message("a", "same-message") + self.assertEqual(deleted["affected_count"], 1) + self.assertEqual(self.store.delete_message("a", "same-message")["affected_count"], 0) + self.assertEqual( + service_b.conversation_detail("same-conversation")["messages"][0]["content"], + "secret-b", + ) + with self.assertRaises(TypeError): + service_a.list_conversations(workspace_id="b") + + def test_database_reopen_preserves_workspace_partition(self) -> None: + self.store.record_messages( + "a", + "reopen", + [{"message_id": "reopen-message", "role": "user", "content": "persisted"}], + ) + reopened = TranscriptStore(self.root / "transcripts.sqlite3") + self.assertEqual(reopened.list_conversations("a")["total"], 1) + self.assertEqual(reopened.list_conversations("b")["total"], 0) + self.assertEqual( + reopened.conversation_detail("a", "reopen")["messages"][0]["content"], + "persisted", + ) + + def test_summary_pagination_omits_full_content_until_detail(self) -> None: + for index in range(5): + self.store.record_messages( + "a", + f"conversation-{index}", + [{"message_id": f"m-{index}", "role": "user", "content": "X" * 1000}], + title=f"Title {index}", + ) + first = self.store.list_conversations("a", page=1, page_size=2) + second = self.store.list_conversations("a", page=2, page_size=2) + self.assertEqual(first["total"], 5) + self.assertEqual(len(first["items"]), 2) + self.assertEqual(len(second["items"]), 2) + serialized = json.dumps(first) + self.assertNotIn("X" * 500, serialized) + detail = self.store.conversation_detail("a", first["items"][0]["conversation_id"]) + self.assertEqual(len(detail["messages"][0]["content"]), 1000) + + def test_crlf_partial_jsonl_is_a_single_item_error_and_import_continues(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + records = [ + {"type": "session_meta", "payload": {"id": "session-1"}}, + {"type": "user_message", "timestamp": "2026-07-30T01:00:00Z", "message": "private body"}, + ] + path = sessions / "rollout.jsonl" + path.write_bytes( + ("\r\n".join(json.dumps(item) for item in records) + "\r\n{\"type\":").encode("utf-8") + ) + preview = self.scanner.scan(self.scope_a, roots=["sessions"]) + self.assertEqual(preview["candidate_count"], 1) + candidate = preview["candidates"][0] + self.assertEqual(candidate["message_count"], 1) + self.assertEqual(candidate["parse_error_count"], 1) + self.assertNotIn("private body", json.dumps(preview)) + imported = self.scanner.import_candidates( + self.store, + self.scope_a, + roots=["sessions"], + candidate_ids=[candidate["candidate_id"]], + ) + self.assertEqual(imported["inserted_count"], 1) + detail = self.store.conversation_detail("a", candidate["conversation_id"]) + self.assertEqual(detail["messages"][0]["content"], "private body") + + def test_windows_encoding_invalid_encoding_and_size_limits(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + gb = sessions / "gb.jsonl" + gb.write_bytes( + json.dumps( + {"type": "user_message", "message": "中文 Windows 编码"}, + ensure_ascii=False, + ).encode("gb18030") + ) + bad = sessions / "bad.jsonl" + bad.write_bytes(b"\xff\xff\x81") + huge = sessions / "huge.jsonl" + huge.write_bytes(b"x" * 2048) + result = self.scanner.scan( + self.scope_a, + roots=["sessions"], + policy=ScanPolicy(max_file_bytes=1024, max_total_bytes=4096), + ) + by_name = {item["relative_path"].split("/")[-1]: item for item in result["candidates"]} + self.assertEqual(by_name["gb.jsonl"]["encoding"], "gb18030") + self.assertEqual(by_name["gb.jsonl"]["message_count"], 1) + self.assertEqual(by_name["bad.jsonl"]["fatal_error"], "invalid_encoding") + self.assertEqual(by_name["huge.jsonl"]["fatal_error"], "file_too_large") + + def test_scan_rejects_absolute_parent_and_outside_paths_and_skips_symlink_escape(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + outside = self.root / "outside.jsonl" + outside.write_text(json.dumps({"type": "user_message", "message": "outside"}), encoding="utf-8") + with self.assertRaises(CodexSessionError): + self.scanner.scan(self.scope_a, roots=[str(sessions.resolve())]) + with self.assertRaises(CodexSessionError): + self.scanner.scan(self.scope_a, roots=["../"]) + from coding_tools_mcp.codex_sessions import parse_codex_session_file + + with self.assertRaises(CodexSessionError): + parse_codex_session_file(self.scope_a, outside) + link = sessions / "escape.jsonl" + try: + os.symlink(outside, link) + except OSError: + self.skipTest("Host does not permit creating a test symlink.") + result = self.scanner.scan(self.scope_a, roots=["sessions"]) + self.assertEqual(result["candidate_count"], 0) + + @unittest.skipUnless(os.name == "nt", "Windows file-sharing behavior") + def test_locked_windows_file_becomes_item_error_not_list_failure(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + path = sessions / "locked.jsonl" + path.write_text(json.dumps({"type": "user_message", "message": "locked"}), encoding="utf-8") + kernel32 = ctypes.windll.kernel32 + handle = kernel32.CreateFileW( + str(path), + 0x80000000 | 0x40000000, + 0, + None, + 3, + 0x80, + None, + ) + self.assertNotEqual(handle, -1) + try: + result = self.scanner.scan(self.scope_a, roots=["sessions"]) + self.assertEqual(result["candidate_count"], 1) + self.assertIn(result["candidates"][0]["fatal_error"], {"stat_failed", "read_failed"}) + finally: + kernel32.CloseHandle(handle) + + def test_file_count_depth_and_total_read_limits_are_enforced(self) -> None: + sessions = self.a / "sessions" + deep = sessions / "one" / "two" + deep.mkdir(parents=True) + for index in range(4): + (sessions / f"{index}.jsonl").write_text( + json.dumps({"type": "user_message", "message": str(index)}), encoding="utf-8" + ) + (deep / "deep.jsonl").write_text(json.dumps({"type": "user_message", "message": "deep"}), encoding="utf-8") + result = self.scanner.scan( + self.scope_a, + roots=["sessions"], + policy=ScanPolicy(max_depth=0, max_files=2, max_total_bytes=1024, max_file_bytes=1024), + ) + self.assertLessEqual(result["files_considered"], 2) + self.assertTrue(any(item["code"] == "file_limit" for item in result["scan_errors"])) + self.assertNotIn("deep.jsonl", json.dumps(result)) + + def test_imported_message_and_session_ids_are_path_stable_and_do_not_collide(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + for name, text in (("one.jsonl", "one"), ("two.jsonl", "two")): + (sessions / name).write_text( + "\n".join( + [ + json.dumps({"type": "session_meta", "payload": {"id": "same-source-session"}}), + json.dumps( + { + "type": "response_item", + "payload": { + "type": "message", + "id": "same-source-message", + "role": "assistant", + "content": [{"type": "output_text", "text": text}], + }, + } + ), + ] + ), + encoding="utf-8", + ) + preview = self.scanner.scan(self.scope_a, roots=["sessions"]) + ids = [item["candidate_id"] for item in preview["candidates"]] + self.assertEqual(len(set(ids)), 2) + imported = self.scanner.import_candidates( + self.store, self.scope_a, roots=["sessions"], candidate_ids=ids + ) + self.assertEqual(imported["inserted_count"], 2) + sessions_payload = self.store.list_imported_sessions("a") + self.assertEqual(sessions_payload["total"], 2) + message_ids: set[str] = set() + for item in preview["candidates"]: + detail = self.store.conversation_detail("a", item["conversation_id"]) + message_ids.add(detail["messages"][0]["message_id"]) + self.assertEqual(len(message_ids), 2) + + def test_scanner_cache_key_contains_workspace_identity(self) -> None: + for scope, root, body in ((self.scope_a, self.a, "A"), (self.scope_b, self.b, "B")): + folder = root / "sessions" + folder.mkdir() + (folder / "same.jsonl").write_text( + json.dumps({"type": "user_message", "message": body}), encoding="utf-8" + ) + self.scanner.scan(scope, roots=["sessions"]) + keys = list(self.scanner._cache) # contract-level cache-key inspection + self.assertEqual({key[0] for key in keys}, {"a", "b"}) + + def test_no_chat_or_transcript_content_is_sent_to_telemetry(self) -> None: + import coding_tools_mcp.codex_sessions as codex_sessions + import coding_tools_mcp.transcript as transcript + + sources = inspect.getsource(transcript) + inspect.getsource(codex_sessions) + self.assertNotIn("telemetry", sources.lower()) + self.assertNotIn("SessionTelemetry", sources) + + +class ChatAdminServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = TemporaryDirectory() + self.root = Path(self.temp.name) + self.a = self.root / "a" + self.b = self.root / "b" + self.a.mkdir() + self.b.mkdir() + catalog = WorkspaceCatalog( + [ + WorkspaceEntry("a", "A", self.a, True, True), + WorkspaceEntry("b", "B", self.b, True, False), + ], + "a", + ) + settings = {**catalog.settings_payload(), "workspace": str(self.a)} + self.settings_store = ServerSettingsStore(self.root / "settings.json") + self.settings_store.write(settings) + self.transcripts = TranscriptStore(self.root / "transcripts.sqlite3") + gateway = self.root / "gateway.json" + self.service = AdminService( + settings_store=self.settings_store, + active_settings=settings, + fallback_workspace=self.a, + gateway_path=gateway, + active_gateway_revision=gateway_file_revision(gateway), + secret_vault=SecretVault(self.root / "vault.json", "master-key"), + transcript_store=self.transcripts, + session_scanner=CodexSessionScanner(), + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_admin_summary_detail_and_idempotent_workspace_keyed_delete(self) -> None: + self.service.dispatch( + "POST", + "/admin/api/chat/conversations/a/c/messages", + {"messages": [{"message_id": "same", "role": "user", "content": "body-a"}]}, + {}, + ) + self.service.dispatch( + "POST", + "/admin/api/chat/conversations/b/c/messages", + {"messages": [{"message_id": "same", "role": "user", "content": "body-b"}]}, + {}, + ) + summary = self.service.dispatch("GET", "/admin/api/chat/conversations", {}, {"page_size": "1"}) + self.assertEqual(summary["total"], 2) + self.assertNotIn("body-a", json.dumps(summary)) + detail = self.service.dispatch("GET", "/admin/api/chat/conversations/a/c", {}, {}) + self.assertEqual(detail["messages"][0]["content"], "body-a") + deleted = self.service.dispatch("DELETE", "/admin/api/chat/messages/a/same", {}, {}) + self.assertEqual(deleted["affected_count"], 1) + repeated = self.service.dispatch("DELETE", "/admin/api/chat/messages/a/same", {}, {}) + self.assertEqual(repeated["affected_count"], 0) + other = self.service.dispatch("GET", "/admin/api/chat/conversations/b/c", {}, {}) + self.assertEqual(other["messages"][0]["content"], "body-b") + + def test_admin_scan_rejects_unknown_workspace_and_path_escape(self) -> None: + with self.assertRaises(AdminNotFoundError): + self.service.codex_scan({"workspace_id": "missing", "roots": ["."]}) + with self.assertRaises(AdminServiceError): + self.service.codex_scan({"workspace_id": "a", "roots": ["../"]}) + + def test_admin_import_and_session_delete_use_workspace_and_stable_id(self) -> None: + folder = self.a / "sessions" + folder.mkdir() + (folder / "one.jsonl").write_text( + json.dumps({"type": "user_message", "message": "imported body"}), encoding="utf-8" + ) + preview = self.service.codex_scan({"workspace_id": "a", "roots": ["sessions"]}) + candidate = preview["candidates"][0] + imported = self.service.codex_import( + {"workspace_id": "a", "roots": ["sessions"], "candidate_ids": [candidate["candidate_id"]]} + ) + self.assertEqual(imported["inserted_count"], 1) + sessions = self.service.codex_sessions({"workspace_id": "a"}) + self.assertEqual(sessions["total"], 1) + deleted = self.service.dispatch( + "DELETE", + f"/admin/api/codex/sessions/a/{candidate['session_id']}", + {}, + {}, + ) + self.assertEqual(deleted["deleted_session_count"], 1) + self.assertEqual(deleted["deleted_conversation_count"], 1) + self.assertEqual(deleted["deleted_message_count"], 1) + self.assertEqual(deleted["affected_count"], 3) + repeated = self.service.dispatch( + "DELETE", + f"/admin/api/codex/sessions/a/{candidate['session_id']}", + {}, + {}, + ) + self.assertEqual(repeated["affected_count"], 0) + + +class ChatCliTests(unittest.TestCase): + def test_cli_reads_gb18030_bytes_and_remains_workspace_scoped(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + db = root / "chat.sqlite3" + payload = { + "conversation_id": "conversation-1", + "message_id": "message-1", + "role": "assistant", + "content": "中文 CLI 内容\r\n第二行", + } + + class BytesStdin: + def __init__(self, raw: bytes) -> None: + self.buffer = io.BytesIO(raw) + + output = io.StringIO() + with patch.object(sys, "stdin", BytesStdin(json.dumps(payload, ensure_ascii=False).encode("gb18030"))), redirect_stdout(output): + code = chat_cli.main( + [ + "--db-path", str(db), + "--workspace-id", "workspace-a", + "--workspace-root", str(workspace), + "record-message", "--stdin-json", + ] + ) + self.assertEqual(code, 0) + recorded = json.loads(output.getvalue()) + self.assertEqual(recorded["inserted_count"], 1) + detail = TranscriptStore(db).conversation_detail("workspace-a", "conversation-1") + self.assertIn("第二行", detail["messages"][0]["content"]) + self.assertIsNone(TranscriptStore(db).conversation_detail("workspace-b", "conversation-1")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/compliance/test_docs_required.py b/tests/compliance/test_docs_required.py index 925cd63..70d4f13 100644 --- a/tests/compliance/test_docs_required.py +++ b/tests/compliance/test_docs_required.py @@ -30,6 +30,11 @@ def test_required_operator_docs_exist(self) -> None: "docs/troubleshooting.md", "docs/competitive-analysis.md", "docs/runtime-contract-v0.2.md", + "docs/remote-mcp.md", + "docs/admin-api.md", + "docs/admin-webui.md", + "docs/chat-persistence.md", + "docs/migration-v0.1-to-v0.2.2.md", "Dockerfile", ".dockerignore", "docker-compose.yml", @@ -79,6 +84,70 @@ def test_docs_contain_required_operational_topics(self) -> None: with self.subTest(path=rel_path, needle=needle): self.assertIn(needle, text) + + def test_integrated_v022_docs_cover_final_boundaries(self) -> None: + expectations = { + "README.md": [ + "Integrated v0.2.2 architecture", + "refresh_token", + "Admin WebUI", + "migration-v0.1-to-v0.2.2.md", + ], + "README.zh-CN.md": [ + "集成后的 v0.2.2 架构", + "持久化 OAuth", + "升级与回滚", + ], + "docs/runtime-contract-v0.2.md": [ + "Session identity and Workspace binding", + "listChanged: false", + "UPSTREAM_TOOL_COLLISION", + "refresh_token", + "legacy_tool_profile_ignored", + ], + "docs/remote-mcp.md": [ + "CODING_TOOLS_MCP_SECRETS_KEY", + "oauth.sqlite3", + "oauth_client_workspace_bindings", + "restart_required", + ], + "docs/admin-webui.md": [ + "stale HTTP 409", + "no upstream start, stop, reload", + "textContent", + ], + "docs/chat-persistence.md": [ + "workspace_id", + "summary", + "telemetry", + ], + "docs/migration-v0.1-to-v0.2.2.md": [ + "legacy_tool_profile_ignored", + "server-settings.json", + "oauth.sqlite3", + "oauth-secrets.json", + "full snapshot rollback", + "Refresh rotation", + "original refresh token remains retryable", + ], + "docs/telemetry.md": [ + "chat/transcript content", + "Workspace IDs, OAuth Agent/Client", + "CODING_TOOLS_MCP_TELEMETRY=off", + ], + } + for rel_path, needles in expectations.items(): + text = (ROOT / rel_path).read_text(encoding="utf-8") + for needle in needles: + with self.subTest(path=rel_path, needle=needle): + self.assertIn(needle, text) + + def test_v01_profile_doc_is_history_not_current_contract(self) -> None: + self.assertFalse((ROOT / "docs/profile-v0.1.md").exists()) + contract = (ROOT / "docs/runtime-contract-v0.2.md").read_text(encoding="utf-8") + self.assertIn("There are no\ntool profiles", contract) + self.assertNotIn("--tool-profile", contract) + def test_ci_workflows_include_required_gates(self) -> None: compliance = (ROOT / ".github/workflows/compliance.yml").read_text(encoding="utf-8") for needle in ( diff --git a/tests/compliance/test_mcp_admin.py b/tests/compliance/test_mcp_admin.py new file mode 100644 index 0000000..4e717de --- /dev/null +++ b/tests/compliance/test_mcp_admin.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +import hashlib +import inspect +import json +import threading +import time +import unittest +import urllib.error +import urllib.request +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from coding_tools_mcp.admin import ( + AdminConflictError, + AdminService, + AdminServiceError, + AdminUnavailableError, + document_revision, + gateway_file_revision, +) +from coding_tools_mcp.oauth_store import OAuthAuthorizationStore +from coding_tools_mcp.secret_vault import SecretVault +from coding_tools_mcp.server import ( + MCPHandler, + Runtime, + RuntimeHTTPServer, + configure_allowed_origins, + is_allowed_origin, + upstream_secret_resolver, +) +from coding_tools_mcp.settings_store import ServerSettingsStore +from coding_tools_mcp.transcript import TranscriptStore +from coding_tools_mcp.upstream import UpstreamConfigSnapshot, UpstreamServerConfig +from coding_tools_mcp.workspace_catalog import WorkspaceCatalog, WorkspaceEntry + + +class AdminServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = TemporaryDirectory() + self.root = Path(self.temp.name) + self.workspace_a = self.root / "workspace-a" + self.workspace_b = self.root / "workspace-b" + self.workspace_a.mkdir() + self.workspace_b.mkdir() + self.settings_path = self.root / "server-settings.json" + self.gateway_path = self.root / "mcp-servers.json" + self.vault = SecretVault(self.root / "server-secrets.json", "admin-test-master-key") + self.store = ServerSettingsStore(self.settings_path) + catalog = WorkspaceCatalog( + [WorkspaceEntry("a", "A", self.workspace_a, enabled=True, default=True)], + "a", + ) + initial = { + **catalog.settings_payload(), + "workspace": str(self.workspace_a), + "host": "127.0.0.1", + "port": 8000, + "permission_mode": "safe", + "shell_env_inherit": "core", + "allowed_origins": ["https://admin.example"], + "admin_token_secret_ref": "admin/token", + } + self.store.write(initial) + self.active = dict(initial) + self.active["port"] = 7000 + self.oauth = OAuthAuthorizationStore( + self.root / "oauth.sqlite3", + pepper=b"admin-pepper" * 4, + ) + self.oauth.upsert_client( + "agent-a", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + token_endpoint_auth_method="client_secret_post", + client_secret_digest=hashlib.sha256(b"client-secret-canary").hexdigest(), + workspace_id="a", + ) + self.oauth.register_signing_key( + "kid-a", + "fingerprint-a", + secret_ref="oauth/signing/kid-a", + ) + self.grant_id = self.oauth.create_grant("agent-a", "mcp") + self.oauth.record_access_token( + "jti-a", + self.grant_id, + "agent-a", + "kid-a", + "mcp", + issued_at=time.time(), + expires_at=time.time() + 3600, + ) + self.family_id, _refresh = self.oauth.issue_refresh_token( + self.grant_id, + "agent-a", + "mcp", + expires_at=time.time() + 7200, + ) + self.active_gateway_status = { + "enabled": True, + "tool_count": 1, + "servers": [{"alias": "active", "initialized": True}], + } + self.service = AdminService( + settings_store=self.store, + active_settings=self.active, + fallback_workspace=self.workspace_a, + gateway_path=self.gateway_path, + active_gateway_revision=gateway_file_revision(self.gateway_path), + secret_vault=self.vault, + oauth_store=self.oauth, + active_gateway_status=lambda: self.active_gateway_status, + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_status_reports_only_privacy_safe_telemetry_mode_and_docs(self) -> None: + with patch("coding_tools_mcp.admin.telemetry_mode", return_value="debug") as mode: + payload = self.service.status_payload() + + mode.assert_called_once_with() + self.assertEqual( + payload["telemetry"], + {"mode": "debug", "docs": "docs/telemetry.md"}, + ) + serialized = json.dumps(payload["telemetry"], sort_keys=True) + for forbidden in ( + "workspace_id", + "agent_id", + "client_id", + "command", + "arguments", + "file_content", + "path", + ): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, serialized) + + def test_settings_separate_active_persisted_pending_and_reject_stale_revision(self) -> None: + payload = self.service.settings_payload() + self.assertEqual(payload["active"]["port"], 7000) + self.assertEqual(payload["persisted"]["port"], 8000) + self.assertIn("port", payload["pending_restart"]) + self.assertEqual(payload["persisted"]["admin_token_secret_ref"], {"configured": True}) + revision = payload["persisted_revision"] + + saved = self.service.save_settings( + { + "expected_revision": revision, + "updates": {"port": 9000, "allowed_origins": ["https://ops.example/"]}, + } + ) + self.assertEqual(saved["persisted"]["port"], 9000) + self.assertEqual(saved["persisted"]["allowed_origins"], ["https://ops.example"]) + with self.assertRaises(AdminConflictError): + self.service.save_settings( + {"expected_revision": revision, "updates": {"port": 9001}} + ) + + def test_settings_and_http_cors_use_the_same_origin_validator(self) -> None: + validated = self.service.validate_settings( + {"updates": {"allowed_origins": ["HTTPS://Example.COM:443/"]}} + ) + origins = validated["normalized"]["allowed_origins"] + configure_allowed_origins(origins) + self.assertTrue(is_allowed_origin("https://example.com:443")) + self.assertFalse(is_allowed_origin("https://example.com/path")) + with self.assertRaisesRegex(ValueError, "Unsupported allowed origin"): + self.service.validate_settings({"updates": {"allowed_origins": ["*"]}}) + + def test_gateway_save_is_redacted_restart_only_and_never_hot_reloads(self) -> None: + self.vault.set_secret("github/token", "upstream-secret-canary") + payload = self.service.gateway_payload() + saved = self.service.save_gateway( + { + "expected_revision": payload["persisted_revision"], + "document": { + "servers": { + "github": { + "transport": "stdio", + "command": "npx", + "args": ["-y", "example-mcp"], + "enabled": True, + "env": {"GITHUB_TOKEN": {"secret_ref": "github/token"}}, + } + } + }, + } + ) + self.assertTrue(saved["restart_required"]) + self.assertFalse(saved["dynamic_reload"]) + self.assertEqual(saved["active_status"], self.active_gateway_status) + self.assertNotIn("github/token", json.dumps(saved)) + self.assertNotIn("upstream-secret-canary", self.gateway_path.read_text(encoding="utf-8")) + self.assertEqual(self.active_gateway_status["tool_count"], 1) + with self.assertRaisesRegex(AdminServiceError, "Sensitive Gateway headers"): + self.service.save_gateway( + { + "expected_revision": saved["persisted_revision"], + "document": { + "servers": { + "remote": { + "transport": "streamable_http", + "url": "http://127.0.0.1:9000/mcp", + "headers": {"X-API-Key": "plaintext-canary"}, + } + } + }, + } + ) + + def test_gateway_secret_resolver_is_vault_backed_and_fails_closed(self) -> None: + snapshot = UpstreamConfigSnapshot( + configs=( + UpstreamServerConfig( + alias="remote", + transport="stdio", + command="uvx", + args=("remote-mcp",), + env={"TOKEN": {"secret_ref": "remote/token"}}, + ), + ) + ) + self.vault.set_secret("remote/token", "resolved-secret-canary") + resolver = upstream_secret_resolver(snapshot, self.vault) + self.assertIsNotNone(resolver) + assert resolver is not None + self.assertEqual(resolver("remote/token"), "resolved-secret-canary") + with self.assertRaisesRegex(ValueError, "Gateway secret_ref requires"): + upstream_secret_resolver( + snapshot, + SecretVault(self.root / "no-key.json", None), + ) + + def test_gateway_secret_ref_fails_closed_without_vault(self) -> None: + service = AdminService( + settings_store=self.store, + active_settings=self.active, + fallback_workspace=self.workspace_a, + gateway_path=self.gateway_path, + active_gateway_revision=gateway_file_revision(self.gateway_path), + secret_vault=SecretVault(self.root / "disabled-vault.json", None), + ) + with self.assertRaises(AdminUnavailableError): + service.secrets_payload() + with self.assertRaises(AdminUnavailableError): + service.save_gateway( + { + "expected_revision": service.gateway_payload()["persisted_revision"], + "document": { + "servers": { + "remote": { + "transport": "stdio", + "command": "uvx", + "args": ["remote-mcp"], + "env": {"TOKEN": {"secret_ref": "remote/token"}}, + } + } + }, + } + ) + + def test_oauth_lists_are_redacted_and_actions_are_idempotent(self) -> None: + clients = self.service.oauth_payload("clients", {}) + encoded = json.dumps(clients) + self.assertNotIn("client-secret-canary", encoded) + self.assertNotIn("client_secret_digest", encoded) + keys = self.service.oauth_payload("signing-keys", {}) + self.assertNotIn("secret_ref", json.dumps(keys)) + + first = self.service.oauth_action("tokens", "jti-a", "revoke") + second = self.service.oauth_action("tokens", "jti-a", "revoke") + self.assertEqual(first["affected_count"], 1) + self.assertIsNotNone(first["audit_event_id"]) + self.assertEqual(second["affected_count"], 0) + self.assertIsNone(second["audit_event_id"]) + + family = self.service.oauth_action("refresh-families", self.family_id, "revoke") + grant = self.service.oauth_action("grants", self.grant_id, "revoke") + client = self.service.oauth_action("clients", "agent-a", "disable") + key = self.service.oauth_action("signing-keys", "kid-a", "revoke") + self.assertEqual( + [family["affected_count"], grant["affected_count"], client["affected_count"], key["affected_count"]], + [1, 1, 1, 1], + ) + cannot_reactivate = self.service.oauth_action( + "signing-keys", "kid-a", "activate" + ) + self.assertEqual(cannot_reactivate["affected_count"], 0) + self.assertIsNone(cannot_reactivate["audit_event_id"]) + + def test_workspace_actions_reuse_catalog_validation_and_check_only_known_ids(self) -> None: + payload = self.service.workspaces_payload() + added = self.service.workspace_add( + { + "expected_revision": payload["persisted_revision"], + "workspace": { + "id": "b", + "name": "B", + "root": str(self.workspace_b), + "enabled": True, + }, + } + ) + made_default = self.service.workspace_default( + "b", {"expected_revision": added["persisted_revision"]} + ) + self.assertEqual(made_default["default_workspace_id"], "b") + checked = self.service.workspace_check("b") + self.assertTrue(checked["check"]["is_directory"]) + disabled = self.service.workspace_disable( + "a", {"expected_revision": made_default["persisted_revision"]} + ) + self.assertFalse(next(item for item in disabled["workspace_catalog"] if item["id"] == "a")["enabled"]) + with self.assertRaisesRegex(ValueError, "not present"): + self.service.workspace_check(str(self.root / "unmanaged")) + + def test_secret_api_never_returns_secret_values(self) -> None: + result = self.service.set_secret("service/key", {"value": "vault-value-canary"}) + self.assertNotIn("vault-value-canary", json.dumps(result)) + self.assertTrue(result["created"]) + overwritten = self.service.set_secret( + "service/key", {"value": "replacement-vault-canary"} + ) + self.assertFalse(overwritten["created"]) + self.assertEqual(overwritten["affected_count"], 1) + self.assertNotIn("replacement-vault-canary", json.dumps(overwritten)) + listed = self.service.secrets_payload() + self.assertIn({"name": "service/key", "configured": True}, listed["secrets"]) + self.assertNotIn("vault-value-canary", json.dumps(listed)) + + def test_handler_source_contains_no_sql_or_gateway_reload(self) -> None: + source = inspect.getsource(MCPHandler) + self.assertNotRegex(source, r"\b(?:SELECT|INSERT|UPDATE|DELETE FROM|PRAGMA)\b") + admin_source = inspect.getsource(AdminService) + self.assertNotIn("reload_upstream", admin_source) + self.assertNotIn("start_server", admin_source) + self.assertNotIn("stop_server", admin_source) + + +class AdminHTTPAuthenticationTests(unittest.TestCase): + def test_ordinary_mcp_bearer_is_not_admin_authentication(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + settings = ServerSettingsStore(root / "settings.json") + workspace = root / "workspace" + workspace.mkdir() + settings.write({"workspace": str(workspace)}) + service = AdminService( + settings_store=settings, + active_settings={"workspace": str(workspace)}, + fallback_workspace=workspace, + gateway_path=root / "gateway.json", + active_gateway_revision=document_revision({"servers": {}}), + secret_vault=SecretVault(root / "vault.json", "key"), + transcript_store=TranscriptStore(root / "transcripts.sqlite3"), + ) + runtime = Runtime(workspace, auth_token="ordinary-mcp-token", transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda _context: Runtime(workspace, transport="http"), + admin_service=service, + admin_token="dedicated-admin-token", + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_address[1]}/admin/api/status" + try: + with self.assertRaises(urllib.error.HTTPError) as denied: + urllib.request.urlopen( + urllib.request.Request( + url, + headers={"Authorization": "Bearer ordinary-mcp-token"}, + ), + timeout=5, + ) + self.assertEqual(denied.exception.code, 401) + with self.assertRaises(urllib.error.HTTPError) as page_denied: + urllib.request.urlopen( + urllib.request.Request( + f"http://127.0.0.1:{server.server_address[1]}/admin", + headers={"Authorization": "Bearer ordinary-mcp-token"}, + ), + timeout=5, + ) + self.assertEqual(page_denied.exception.code, 401) + before = service.settings_payload()["persisted_revision"] + with self.assertRaises(urllib.error.HTTPError) as write_denied: + urllib.request.urlopen( + urllib.request.Request( + f"http://127.0.0.1:{server.server_address[1]}/admin/api/settings", + data=json.dumps( + { + "expected_revision": before, + "updates": {"port": 9999}, + } + ).encode("utf-8"), + headers={ + "Authorization": "Bearer ordinary-mcp-token", + "Content-Type": "application/json", + }, + method="PUT", + ), + timeout=5, + ) + self.assertEqual(write_denied.exception.code, 401) + self.assertEqual(service.settings_payload()["persisted_revision"], before) + with self.assertRaises(urllib.error.HTTPError) as delete_denied: + urllib.request.urlopen( + urllib.request.Request( + f"http://127.0.0.1:{server.server_address[1]}/admin/api/chat/messages/ws-any/message-any", + headers={"Authorization": "Bearer ordinary-mcp-token"}, + method="DELETE", + ), + timeout=5, + ) + self.assertEqual(delete_denied.exception.code, 401) + with urllib.request.urlopen( + urllib.request.Request( + url, + headers={"Authorization": "Bearer dedicated-admin-token"}, + ), + timeout=5, + ) as response: + payload = json.loads(response.read()) + self.assertTrue(payload["ok"]) + with urllib.request.urlopen( + urllib.request.Request( + f"http://127.0.0.1:{server.server_address[1]}/admin", + headers={"Authorization": "Bearer dedicated-admin-token"}, + ), + timeout=5, + ) as response: + page = response.read().decode("utf-8") + self.assertIn('data-build-source="admin.js"', page) + self.assertNotIn('src="./admin.js"', page) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 6179443..26cf7be 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -7,8 +7,10 @@ import os import select import signal +import shutil import subprocess import sys +import tempfile import time import urllib.error import urllib.parse @@ -414,7 +416,10 @@ def test_oauth_public_client_pkce_flow_succeeds(self) -> None: try: metadata = self.wait_for_json(f"{base_url}/.well-known/oauth-authorization-server") self.assertEqual(metadata.get("issuer"), base_url) - self.assertEqual(metadata.get("grant_types_supported"), ["authorization_code"]) + self.assertEqual( + metadata.get("grant_types_supported"), + ["authorization_code", "refresh_token"], + ) self.assertEqual(metadata.get("response_types_supported"), ["code"]) self.assertEqual( set(metadata.get("token_endpoint_auth_methods_supported", [])), @@ -698,7 +703,10 @@ def test_oauth_dynamic_registration_normalizes_unsupported_flow_metadata(self) - ) self.assertEqual(status, 201, response_body) response = json.loads(response_body) - self.assertEqual(response.get("grant_types"), ["authorization_code"]) + self.assertEqual( + response.get("grant_types"), + ["authorization_code", "refresh_token"], + ) self.assertEqual(response.get("response_types"), ["code"]) refresh_body = urllib.parse.urlencode( @@ -717,7 +725,7 @@ def test_oauth_dynamic_registration_normalizes_unsupported_flow_metadata(self) - headers={"Content-Type": "application/x-www-form-urlencoded"}, ) self.assertEqual(refresh_status, 400) - self.assertEqual(json.loads(refresh_response).get("error"), "unsupported_grant_type") + self.assertEqual(json.loads(refresh_response).get("error"), "invalid_grant") unsupported_only_body = json.dumps( { @@ -732,8 +740,11 @@ def test_oauth_dynamic_registration_normalizes_unsupported_flow_metadata(self) - body=unsupported_only_body, headers={"Content-Type": "application/json"}, ) - self.assertEqual(unsupported_status, 400) - self.assertEqual(json.loads(unsupported_response).get("error"), "invalid_client_metadata") + self.assertEqual(unsupported_status, 201) + self.assertEqual( + json.loads(unsupported_response).get("grant_types"), + ["refresh_token"], + ) unsupported_response_type_body = json.dumps( { @@ -1144,6 +1155,10 @@ def raw_post_to_auth_server( def oauth_server_env(self, **overrides: str) -> dict[str, str]: env = self.server_process_env() + config_dir = tempfile.mkdtemp(prefix="coding-tools-mcp-oauth-test-") + self.addCleanup(shutil.rmtree, config_dir, True) + env["CODING_TOOLS_MCP_CONFIG_DIR"] = config_dir + env["CODING_TOOLS_MCP_SECRETS_KEY"] = "synthetic-compliance-master-key" for name in ( "CODING_TOOLS_MCP_OAUTH_CLIENT_ID", "CODING_TOOLS_MCP_OAUTH_CLIENT_SECRET", diff --git a/tests/compliance/test_oauth_persistence.py b/tests/compliance/test_oauth_persistence.py new file mode 100644 index 0000000..b38b5f9 --- /dev/null +++ b/tests/compliance/test_oauth_persistence.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import os +import shutil +import sqlite3 +import tempfile +import time +import unittest +from contextlib import closing, contextmanager +from pathlib import Path +from typing import Iterator + +from coding_tools_mcp.oauth import ( + create_access_token, + create_authorization_grant, + exchange_refresh_token, + issue_refresh_token, + revoke_signing_key, + rotate_signing_key, + validate_access_token, +) +from coding_tools_mcp.server import build_persistent_oauth_config + + +ISSUER = "https://mcp.example" + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +class OAuthPersistenceComplianceTests(unittest.TestCase): + def test_restart_refresh_rotation_and_key_revocation_fail_closed(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-compliance-master-key", + password="synthetic-authorize-password", + server_url=ISSUER, + token_ttl=86_400, + client_id="compliance-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + grant_id = create_authorization_grant( + config, + client_id="compliance-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + old_kid = str(config.signing_kid) + old_secret = config.signing_keys[old_kid] + old_access = create_access_token( + config, + ISSUER, + client_id="compliance-agent", + grant_id=grant_id, + ) + refresh_token = issue_refresh_token( + config, + grant_id=grant_id, + client_id="compliance-agent", + scopes="mcp", + ) + rotated = rotate_signing_key(config) + new_kid = str(rotated.signing_kid) + new_secret = rotated.signing_keys[new_kid] + + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-compliance-master-key", + password="synthetic-authorize-password", + server_url=ISSUER, + token_ttl=86_400, + client_id="compliance-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + self.assertEqual(reopened.signing_kid, new_kid) + self.assertTrue(validate_access_token(old_access, reopened, ISSUER)) + exchanged = exchange_refresh_token( + reopened, + refresh_token=refresh_token, + client_id="compliance-agent", + client_secret="", + auth_method="none", + server_url=ISSUER, + ) + self.assertTrue( + validate_access_token( + str(exchanged["access_token"]), + reopened, + ISSUER, + ) + ) + self.assertNotEqual(exchanged["refresh_token"], refresh_token) + + self.assertTrue(revoke_signing_key(reopened, old_kid)) + self.assertFalse(validate_access_token(old_access, reopened, ISSUER)) + self.assertTrue( + validate_access_token( + str(exchanged["access_token"]), + reopened, + ISSUER, + ) + ) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + database_bytes = (root / "oauth.sqlite3").read_bytes() + self.assertNotIn(refresh_token.encode("utf-8"), database_bytes) + self.assertNotIn(old_secret, database_bytes) + self.assertNotIn(new_secret, database_bytes) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index c6ead07..25bed11 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -3,6 +3,7 @@ import builtins import os import signal +import shlex import shutil import subprocess import sys @@ -40,6 +41,22 @@ from tests.compliance.fixtures import git_fixture_preflight_error, init_git +def python_shell_command(source: str) -> str: + argv = [sys.executable, "-c", source] + return subprocess.list2cmdline(argv) if os.name == "nt" else shlex.join(argv) + + +def windows_shell_env() -> dict[str, str]: + if os.name != "nt": + return {} + return { + "COMSPEC": os.environ.get("COMSPEC", r"C:\Windows\System32\cmd.exe"), + "SYSTEMROOT": os.environ.get("SYSTEMROOT", r"C:\Windows"), + "WINDIR": os.environ.get("WINDIR", r"C:\Windows"), + "PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"), + } + + @contextmanager def fake_landlock_exec() -> Iterator[dict[str, object]]: """Patch landlock + Popen so exec_command runs without spawning a process. @@ -127,6 +144,11 @@ def fake_hasattr(value: object, name: str) -> bool: patch.object(processes_module.os, "name", "nt"), patch.object(processes_module, "hasattr", side_effect=fake_hasattr, create=True), patch.object(processes_module.signal, "CTRL_BREAK_EVENT", 999, create=True), + patch.object( + processes_module.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0), + ) as taskkill, ): graceful = FakeProcess() processes_module.terminate_process_group( # type: ignore[arg-type] @@ -141,7 +163,14 @@ def fake_hasattr(value: object, name: str) -> bool: ) self.assertEqual(graceful.calls, [("send_signal", 999), ("wait", 1)]) - self.assertEqual(forced.calls, ["kill", ("wait", 1)]) + self.assertEqual(forced.calls, [("wait", 2)]) + taskkill.assert_called_once_with( + ["taskkill", "/PID", "123", "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + check=False, + ) def test_atomic_patch_commit_rolls_back_all_files_after_mid_commit_failure(self) -> None: with TemporaryDirectory() as tmp: @@ -406,8 +435,7 @@ def test_allow_network_only_opens_network_gate(self) -> None: def test_command_env_core_is_not_windows_toolchain_specific(self) -> None: with TemporaryDirectory() as tmp: - workspace = Path(tmp) - runtime = Runtime(workspace) + runtime = Runtime(Path(tmp)) host_env = { "Path": r"C:\VS\VC\Tools\MSVC\bin;C:\Windows\System32", "PATHEXT": ".COM;.EXE;.BAT;.CMD", @@ -416,8 +444,8 @@ def test_command_env_core_is_not_windows_toolchain_specific(self) -> None: "INCLUDE": r"C:\VS\VC\Tools\MSVC\include;C:\SDK\Include", "LIB": r"C:\VS\VC\Tools\MSVC\lib;C:\SDK\Lib", "LIBPATH": r"C:\VS\VC\Tools\MSVC\libpath", - "WindowsSdkDir": r"C:\Program Files (x86)\Windows Kits\10\\", - "VCToolsInstallDir": r"C:\VS\VC\Tools\MSVC\14.99.99999\\", + "WindowsSdkDir": r"C:\Program Files (x86)\Windows Kits\10", + "VCToolsInstallDir": r"C:\VS\VC\Tools\MSVC\14.99.99999", "VSCMD_ARG_TGT_ARCH": "x64", "UNRELATED": "drop-me", "VSCMD_SECRET": "drop-me-too", @@ -426,29 +454,29 @@ def test_command_env_core_is_not_windows_toolchain_specific(self) -> None: patch.object(server_module.os, "name", "nt"), patch.dict(server_module.os.environ, host_env, clear=True), ): - env = runtime._command_env({"CUSTOM": "ok", "OPENAI_API_KEY": "sk-test-secret-value"}) + env = runtime._command_env( + {"CUSTOM": "ok", "OPENAI_API_KEY": "sk-test-secret-value"} + ) - self.assertEqual(env.get("Path"), host_env["Path"]) - self.assertEqual(env.get("PATHEXT"), host_env["PATHEXT"]) - self.assertEqual(env.get("SystemRoot"), host_env["SystemRoot"]) - self.assertEqual(env.get("ComSpec"), host_env["ComSpec"]) + normalized_env = {key.upper(): value for key, value in env.items()} + self.assertEqual(normalized_env.get("PATH"), host_env["Path"]) + self.assertEqual(normalized_env.get("PATHEXT"), host_env["PATHEXT"]) + self.assertEqual(normalized_env.get("SYSTEMROOT"), host_env["SystemRoot"]) + self.assertEqual(normalized_env.get("COMSPEC"), host_env["ComSpec"]) self.assertEqual(env.get("CUSTOM"), "ok") self.assertEqual(env.get("HOME"), str(runtime.command_home_dir())) self.assertEqual(env.get("TEMP"), str(runtime.command_tmp_dir())) self.assertEqual(env.get("TMP"), str(runtime.command_tmp_dir())) - self.assertNotIn("INCLUDE", env) - self.assertNotIn("LIB", env) - self.assertNotIn("LIBPATH", env) - self.assertNotIn("WindowsSdkDir", env) - self.assertNotIn("VCToolsInstallDir", env) - self.assertNotIn("VSCMD_ARG_TGT_ARCH", env) - self.assertNotIn("UNRELATED", env) - self.assertNotIn("VSCMD_SECRET", env) - self.assertNotIn("OPENAI_API_KEY", env) + for name in ( + "INCLUDE", "LIB", "LIBPATH", "WindowsSdkDir", + "VCToolsInstallDir", "VSCMD_ARG_TGT_ARCH", "UNRELATED", + "VSCMD_SECRET", "OPENAI_API_KEY", + ): + self.assertNotIn(name, env) self.assertTrue(runtime.command_home_dir().is_dir()) self.assertTrue(runtime.command_tmp_dir().is_dir()) self.assertTrue(runtime.cache_dir.is_dir()) - self.assertFalse((workspace / ".coding-tools").exists()) + runtime.close() def test_command_env_uses_external_home_tmp_and_cache_without_ecosystem_cache_vars(self) -> None: with TemporaryDirectory() as tmp: @@ -656,21 +684,38 @@ def test_command_policy_unwraps_env_before_path_checks(self) -> None: def test_exec_command_warns_and_runs_when_landlock_is_unavailable(self) -> None: with TemporaryDirectory() as tmp: - runtime = Runtime(Path(tmp)) + runtime = Runtime(Path(tmp), permission_mode="trusted") original = server_module.open_landlock_ruleset - def unavailable(_workspace: Path, _read_roots: list[str], **_kwargs: object) -> int: - raise ToolFailure("SANDBOX_UNAVAILABLE", "test landlock unavailable", category="security") + def unavailable( + _workspace: Path, _read_roots: list[str], **_kwargs: object + ) -> int: + raise ToolFailure( + "SANDBOX_UNAVAILABLE", + "test landlock unavailable", + category="security", + ) server_module.open_landlock_ruleset = unavailable try: - result = runtime.exec_command({"cmd": "printf ok", "timeout_ms": 5000, "yield_time_ms": 1000}) + result = runtime.exec_command( + { + "cmd": python_shell_command( + "import sys; sys.stdout.write('ok')" + ), + "timeout_ms": 5000, + "yield_time_ms": 1000, + } + ) finally: server_module.open_landlock_ruleset = original + runtime.close() self.assertTrue(result["ok"]) self.assertEqual(result["stdout"], "ok") - self.assertTrue(any("Landlock" in warning for warning in result.get("warnings", []))) + self.assertTrue( + any("Landlock" in warning for warning in result.get("warnings", [])) + ) def test_exec_command_uses_landlock_wrapper_without_preexec_fn(self) -> None: with TemporaryDirectory() as tmp: @@ -754,12 +799,13 @@ def test_guard_allow_roots_include_dns_toolchain_path_and_java_home(self) -> Non clear=True, ): roots = set(guard_allow_roots()) - self.assertIn("/etc/resolv.conf", roots) - self.assertIn("/etc/hosts", roots) - self.assertIn("/usr", roots) - self.assertIn("/usr/local/sdkman/candidates", roots) - self.assertIn("/etc/gitconfig", roots) - self.assertIn("/etc/gitconfig.d", roots) + if os.name != "nt": + for root in ( + "/etc/resolv.conf", "/etc/hosts", "/usr", + "/usr/local/sdkman/candidates", "/etc/gitconfig", + "/etc/gitconfig.d", + ): + self.assertIn(root, roots) self.assertIn(str(java_home.resolve()), roots) self.assertIn(str(explicit_root.resolve()), roots) self.assertNotIn(str(private_path_dir.resolve()), roots) @@ -768,9 +814,9 @@ def test_safe_exec_git_init_and_local_config_reads_system_git_config_roots(self) if shutil.which("git") is None: self.skipTest("git is not available") with TemporaryDirectory() as tmp: - workspace = Path(tmp) - runtime = Runtime(workspace) - with patch.dict(server_module.os.environ, {"PATH": os.environ.get("PATH", "")}, clear=True): + runtime = Runtime(Path(tmp)) + command_env = {"PATH": os.environ.get("PATH", ""), **windows_shell_env()} + with patch.dict(server_module.os.environ, command_env, clear=True): self.assertNotIn("GIT_CONFIG_NOSYSTEM", runtime._command_env({})) result = runtime.exec_command( { @@ -784,9 +830,12 @@ def test_safe_exec_git_init_and_local_config_reads_system_git_config_roots(self) "max_output_bytes": 20000, } ) + runtime.close() self.assertEqual(result.get("status"), "exited", result) self.assertEqual(result.get("exit_code"), 0, result) - self.assertNotIn("unable to access '/etc/gitconfig'", str(result.get("stderr", ""))) + self.assertNotIn( + "unable to access '/etc/gitconfig'", str(result.get("stderr", "")) + ) def test_exec_diagnostics_classify_common_failures(self) -> None: self.assertEqual( @@ -989,13 +1038,32 @@ def test_exec_truncation_names_both_stream_continuations(self) -> None: def test_exec_running_model_text_names_the_poll_call(self) -> None: with TemporaryDirectory() as tmp: - result = Runtime(Path(tmp), permission_mode="trusted").call_tool( + runtime = Runtime(Path(tmp), permission_mode="trusted") + result = runtime.call_tool( "exec_command", - {"cmd": "sleep 1", "timeout_ms": 10000, "yield_time_ms": 0}, + { + "cmd": python_shell_command("import time; time.sleep(0.2)"), + "timeout_ms": 10000, + "yield_time_ms": 0, + }, ) - model_text = self.agent_text(result) - self.assertIn("Status: running", model_text) - self.assertIn('write_stdin(session_id="', model_text) + try: + model_text = self.agent_text(result) + self.assertIn("Status: running", model_text) + self.assertIn('write_stdin(session_id="', model_text) + finally: + structured = result.get("structuredContent", {}) + session_id = structured.get("session_id") if isinstance(structured, dict) else None + if isinstance(session_id, str): + completed = runtime.write_stdin( + { + "session_id": session_id, + "chars": "", + "yield_time_ms": 2000, + } + ) + self.assertNotEqual(completed.get("status"), "running") + runtime.close() def test_read_file_truncation_is_visible_with_continuation(self) -> None: with TemporaryDirectory() as tmp: @@ -1142,15 +1210,24 @@ def test_active_process_limit_counts_running_commands(self) -> None: with TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="trusted") session_ids: list[str] = [] + command = python_shell_command("import time; time.sleep(5)") try: for _ in range(MAX_ACTIVE_EXEC_SESSIONS): result = runtime.exec_command( - {"cmd": "sleep 5", "timeout_ms": 10_000, "yield_time_ms": 0} + { + "cmd": command, + "timeout_ms": 10_000, + "yield_time_ms": 0, + } ) session_ids.append(str(result["session_id"])) with self.assertRaises(ToolFailure) as raised: runtime.exec_command( - {"cmd": "sleep 5", "timeout_ms": 10_000, "yield_time_ms": 0} + { + "cmd": command, + "timeout_ms": 10_000, + "yield_time_ms": 0, + } ) self.assertEqual(raised.exception.code, "SESSION_LIMIT_REACHED") finally: @@ -1183,26 +1260,33 @@ def test_exec_command_compact_preview_and_read_output(self) -> None: runtime = Runtime(Path(tmp), permission_mode="trusted") result = runtime.exec_command( { - "cmd": "printf 'alpha\nbeta\n'", + "cmd": python_shell_command( + "import sys; sys.stdout.buffer.write(b'alpha\\nbeta\\n')" + ), "timeout_ms": 5000, "yield_time_ms": 30000, "verbosity": "preview", "preview_bytes": 64, } ) - self.assertEqual(result.get("status"), "exited", result) - self.assertEqual(result.get("exit_code"), 0, result) - self.assertIn("summary", result) - self.assertIn("preview", result) - self.assertIn("output_ref", result) - self.assertIn("output_refs", result) - self.assertEqual(result.get("output_stream"), "stdout") - self.assertNotIn("stdout", result) - page = runtime.read_output({"output_ref": result["output_ref"], "offset": 0, "limit": 128}) - self.assertIn("alpha", page.get("content", "")) - self.assertIn("beta", page.get("content", "")) - self.assertEqual(page.get("stream"), "stdout") - self.assertIsNone(page.get("next_offset")) + try: + self.assertEqual(result.get("status"), "exited", result) + self.assertEqual(result.get("exit_code"), 0, result) + self.assertIn("summary", result) + self.assertIn("preview", result) + self.assertIn("output_ref", result) + self.assertIn("output_refs", result) + self.assertEqual(result.get("output_stream"), "stdout") + self.assertNotIn("stdout", result) + page = runtime.read_output( + {"output_ref": result["output_ref"], "offset": 0, "limit": 128} + ) + self.assertIn("alpha", page.get("content", "")) + self.assertIn("beta", page.get("content", "")) + self.assertEqual(page.get("stream"), "stdout") + self.assertIsNone(page.get("next_offset")) + finally: + runtime.close() @unittest.skipIf(os.name == "nt", "this build explicitly reports ConPTY as unsupported") def test_exec_command_tty_uses_a_real_pseudo_terminal(self) -> None: @@ -1267,64 +1351,99 @@ def test_completed_sessions_are_evicted_from_active_storage(self) -> None: def test_running_and_truncated_commands_return_explicit_next_actions(self) -> None: with TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="trusted") - running = runtime.exec_command( - {"cmd": "sleep 1", "timeout_ms": 5000, "yield_time_ms": 0, "max_output_bytes": 64} - ) - self.assertEqual(running.get("status"), "running") - self.assertEqual(running.get("next_action", {}).get("tool"), "write_stdin") - runtime.kill_session({"session_id": running["session_id"], "signal": "KILL"}) + try: + running = runtime.exec_command( + { + "cmd": python_shell_command("import time; time.sleep(1)"), + "timeout_ms": 5000, + "yield_time_ms": 0, + "max_output_bytes": 64, + } + ) + self.assertEqual(running.get("status"), "running") + self.assertEqual( + running.get("next_action", {}).get("tool"), "write_stdin" + ) + runtime.kill_session( + { + "session_id": running["session_id"], + "signal": "KILL", + "wait_ms": 5000, + } + ) - truncated = runtime.exec_command( - { - "cmd": "printf 'abcdefghijklmnopqrstuvwxyz'", - "timeout_ms": 5000, - "yield_time_ms": 5000, - "max_output_bytes": 8, - } - ) - self.assertTrue(truncated.get("output_truncated"), truncated) - self.assertEqual(truncated.get("next_action", {}).get("tool"), "read_output") - self.assertIn("output_ref", truncated) + truncated = runtime.exec_command( + { + "cmd": python_shell_command( + "import sys; " + "sys.stdout.buffer.write(b'abcdefghijklmnopqrstuvwxyz')" + ), + "timeout_ms": 5000, + "yield_time_ms": 5000, + "max_output_bytes": 8, + } + ) + self.assertTrue(truncated.get("output_truncated"), truncated) + self.assertEqual( + truncated.get("next_action", {}).get("tool"), "read_output" + ) + self.assertIn("output_ref", truncated) + finally: + runtime.close() def test_read_output_pages_streams_independently(self) -> None: with TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="trusted") script = ( "import sys,time;" - "sys.stderr.write('err1\\nerr2\\n'); sys.stderr.flush();" - "sys.stdout.write('out1\\n'); sys.stdout.flush();" + "sys.stderr.buffer.write(b'err1\\nerr2\\n'); sys.stderr.flush();" + "sys.stdout.buffer.write(b'out1\\n'); sys.stdout.flush();" "time.sleep(0.4);" - "sys.stdout.write('out2\\n'); sys.stdout.flush();" + "sys.stdout.buffer.write(b'out2\\n'); sys.stdout.flush();" "time.sleep(1)" ) result = runtime.exec_command( { - "cmd": f"{sys.executable} -c {script!r}", + "cmd": python_shell_command(script), "timeout_ms": 5000, "yield_time_ms": 100, "verbosity": "preview", "preview_bytes": 64, } ) - self.assertEqual(result.get("status"), "running", result) - output_refs = result.get("output_refs") - self.assertIsInstance(output_refs, dict) - stderr_ref = output_refs["stderr"] - - first: dict[str, object] = {} - for _ in range(10): - first = runtime.read_output({"output_ref": stderr_ref, "offset": 0, "limit": 5}) - if first.get("content"): - break - time.sleep(0.05) - self.assertEqual(first.get("content"), "err1\n") - self.assertEqual(first.get("next_offset"), 5) - time.sleep(0.6) - second = runtime.read_output({"output_ref": stderr_ref, "offset": first["next_offset"], "limit": 64}) - self.assertEqual(second.get("offset"), first.get("next_offset")) - self.assertEqual(second.get("content"), "err2\n") - self.assertNotIn("out2", second.get("content", "")) - runtime.kill_session({"session_id": result["session_id"], "wait_ms": 1000}) + try: + self.assertEqual(result.get("status"), "running", result) + output_refs = result.get("output_refs") + self.assertIsInstance(output_refs, dict) + assert isinstance(output_refs, dict) + stderr_ref = output_refs["stderr"] + + first: dict[str, object] = {} + for _ in range(10): + first = runtime.read_output( + {"output_ref": stderr_ref, "offset": 0, "limit": 5} + ) + if first.get("content"): + break + time.sleep(0.05) + self.assertEqual(first.get("content"), "err1\n") + self.assertEqual(first.get("next_offset"), 5) + time.sleep(0.6) + second = runtime.read_output( + { + "output_ref": stderr_ref, + "offset": first["next_offset"], + "limit": 64, + } + ) + self.assertEqual(second.get("offset"), first.get("next_offset")) + self.assertEqual(second.get("content"), "err2\n") + self.assertNotIn("out2", second.get("content", "")) + finally: + runtime.kill_session( + {"session_id": result["session_id"], "wait_ms": 1000} + ) + runtime.close() def test_read_output_uses_absolute_stream_offsets_after_buffer_drop(self) -> None: with TemporaryDirectory() as tmp: @@ -1355,7 +1474,9 @@ def test_default_cwd_and_git_convenience_tools(self) -> None: with TemporaryDirectory() as tmp: workspace = Path(tmp) (workspace / "src").mkdir() - (workspace / "src" / "hello.txt").write_text("hello\n", encoding="utf-8") + (workspace / "src" / "hello.txt").write_text( + "hello\n", encoding="utf-8", newline="\n" + ) for cmd in ( ["git", "init", "-q"], ["git", "config", "user.email", "test@example.invalid"], @@ -1363,61 +1484,114 @@ def test_default_cwd_and_git_convenience_tools(self) -> None: ["git", "add", "-A"], ["git", "commit", "-q", "-m", "initial commit"], ): - completed = subprocess.run(cmd, cwd=workspace, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + completed = subprocess.run( + cmd, + cwd=workspace, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) if completed.returncode != 0: - self.skipTest(f"git fixture setup failed: {completed.stderr.strip()}") + self.skipTest( + f"git fixture setup failed: {completed.stderr.strip()}" + ) runtime = Runtime(workspace) - cwd = runtime.set_default_cwd({"path": "src"}) - self.assertEqual(cwd.get("default_cwd"), "src") - read = runtime.read_file({"path": "hello.txt"}) - self.assertEqual(read.get("content"), "hello\n") - - log = runtime.git_log({"max_count": 5}) - self.assertTrue(log.get("is_repo")) - self.assertEqual(log.get("commits", [])[0].get("subject"), "initial commit") + try: + cwd = runtime.set_default_cwd({"path": "src"}) + self.assertEqual(cwd.get("default_cwd"), "src") + read = runtime.read_file({"path": "hello.txt"}) + self.assertEqual(read.get("content"), "hello\n") + + log = runtime.git_log({"max_count": 5}) + self.assertTrue(log.get("is_repo")) + self.assertEqual( + log.get("commits", [])[0].get("subject"), "initial commit" + ) - show = runtime.git_show({"include_diff": False, "max_bytes": 4096}) - self.assertTrue(show.get("is_repo")) - self.assertIn("initial commit", show.get("content", "")) + show = runtime.git_show({"include_diff": False, "max_bytes": 4096}) + self.assertTrue(show.get("is_repo")) + self.assertIn("initial commit", show.get("content", "")) - blame = runtime.git_blame({"path": "hello.txt", "max_lines": 5}) - self.assertTrue(blame.get("is_repo")) - self.assertEqual(blame.get("lines", [])[0].get("content"), "hello") + blame = runtime.git_blame({"path": "hello.txt", "max_lines": 5}) + self.assertTrue(blame.get("is_repo")) + self.assertEqual( + blame.get("lines", [])[0].get("content"), "hello" + ) - with self.assertRaises(ToolFailure): - runtime.set_default_cwd({"path": "../outside"}) + with self.assertRaises(ToolFailure): + runtime.set_default_cwd({"path": "../outside"}) + finally: + runtime.close() def test_boundary_regressions_for_aliases_and_command_scanning(self) -> None: with TemporaryDirectory() as tmp: workspace = Path(tmp) (workspace / "nested").mkdir() - (workspace / "sample.txt").write_text("one\ntwo\nthree\n", encoding="utf-8") + (workspace / "sample.txt").write_text( + "one\ntwo\nthree\n", encoding="utf-8", newline="\n" + ) runtime = Runtime(workspace, permission_mode="trusted") + try: + cwd_result = runtime.exec_command( + { + "cmd": python_shell_command("import os; print(os.getcwd())"), + "cwd": "nested", + "timeout_ms": 5000, + "max_output_bytes": 4096, + } + ) + self.assertEqual(cwd_result.get("exit_code"), 0) + self.assertEqual( + Path(str(cwd_result.get("stdout", "")).strip()).name, + "nested", + ) - cwd_result = runtime.exec_command( - {"cmd": "pwd", "cwd": "nested", "timeout_ms": 5000, "max_output_bytes": 4096} - ) - self.assertEqual(cwd_result.get("exit_code"), 0) - self.assertEqual(Path(str(cwd_result.get("stdout", "")).strip()).name, "nested") + with self.assertRaises(ToolFailure): + runtime.exec_command( + { + "cmd": python_shell_command( + "import os; print(os.getcwd())" + ), + "workdir": ".", + "cwd": "nested", + } + ) - with self.assertRaises(ToolFailure): - runtime.exec_command({"cmd": "pwd", "workdir": ".", "cwd": "nested"}) - - read = runtime.read_file({"path": "sample.txt", "start_line": 2, "max_lines": 1}) - self.assertEqual(read.get("content"), "two\n") - self.assertEqual(read.get("end_line"), 2) - - tag = "model" + "Version" - xml_heredoc = ( - "cat > pom.xml <<'EOF'\n" - "\n" - f" <{tag}>4.0.0\n" - "\n" - "EOF" - ) - runtime.exec_command({"cmd": xml_heredoc, "timeout_ms": 5000, "max_output_bytes": 4096}) - self.assertIn(tag, (workspace / "pom.xml").read_text(encoding="utf-8")) + read = runtime.read_file( + {"path": "sample.txt", "start_line": 2, "max_lines": 1} + ) + self.assertEqual(read.get("content"), "two\n") + self.assertEqual(read.get("end_line"), 2) + + tag = "model" + "Version" + xml_heredoc = ( + "cat > pom.xml <<'EOF'\n" + "\n" + f" <{tag}>4.0.0\n" + "\n" + "EOF" + ) + if os.name == "nt": + runtime._check_command_policy(xml_heredoc, {}) + (workspace / "pom.xml").write_text( + f"\n <{tag}>4.0.0\n\n", + encoding="utf-8", + newline="\n", + ) + else: + runtime.exec_command( + { + "cmd": xml_heredoc, + "timeout_ms": 5000, + "max_output_bytes": 4096, + } + ) + self.assertIn( + tag, (workspace / "pom.xml").read_text(encoding="utf-8") + ) + finally: + runtime.close() def test_heredoc_payload_stripping_keeps_live_shell_code_scanned(self) -> None: with TemporaryDirectory() as tmp: diff --git a/tests/compliance/test_upstream_gateway.py b/tests/compliance/test_upstream_gateway.py new file mode 100644 index 0000000..23416d6 --- /dev/null +++ b/tests/compliance/test_upstream_gateway.py @@ -0,0 +1,557 @@ +from __future__ import annotations + +import copy +import inspect +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from coding_tools_mcp import upstream as upstream_module +from coding_tools_mcp.server import ( + AuthorizationContext, + Runtime, + TOOL_REGISTRY, + build_parser, + load_upstream_startup, + server_card_payload, +) +from coding_tools_mcp.upstream import ( + BaseUpstreamClient, + HttpUpstreamClient, + UpstreamConfigError, + UpstreamError, + UpstreamManager, + UpstreamServerConfig, + base_upstream_environment, + _rpc_result, + decode_http_rpc_response, + load_upstream_config_snapshot, + normalize_tool_result, + parse_server_config, + resolve_env_config, +) +from coding_tools_mcp.workspace_binding import WorkspaceBinding + + +REMOTE_TOOLS = [ + { + "name": "search", + "title": "Remote Search", + "description": "Search remote repositories.", + "inputSchema": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + "$defs": {"query": {"type": "string"}}, + }, + "outputSchema": { + "type": "object", + "properties": {"hits": {"type": "array"}}, + }, + "annotations": { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, + }, + }, + { + "name": "create_issue", + "description": "Create an issue on the remote service.", + "inputSchema": {"type": "object", "additionalProperties": True}, + "annotations": {"readOnlyHint": False, "destructiveHint": True}, + }, +] + + +class FakeUpstreamClient(BaseUpstreamClient): + def __init__( + self, + config: UpstreamServerConfig, + protocol_version: str, + *, + tools: list[dict[str, object]] | None = None, + behavior: str = "success", + marker: str = "remote", + ) -> None: + super().__init__(config, protocol_version) + self.tools = copy.deepcopy(tools if tools is not None else REMOTE_TOOLS) + self.behavior = behavior + self.marker = marker + self.calls: list[tuple[str, dict[str, object]]] = [] + self.closed = False + + def initialize(self) -> None: + return None + + def list_tools(self) -> list[dict[str, object]]: + return copy.deepcopy(self.tools) + + def call_tool(self, name: str, arguments: dict[str, object]) -> dict[str, object]: + self.calls.append((name, copy.deepcopy(arguments))) + if self.behavior == "timeout": + raise UpstreamError( + "UPSTREAM_TIMEOUT", + "Timed out waiting for upstream MCP server.", + retryable=True, + ) + if self.behavior == "disconnect": + raise UpstreamError( + "UPSTREAM_DISCONNECTED", + "Upstream MCP server disconnected.", + retryable=True, + ) + if self.behavior == "rpc_error": + raise UpstreamError( + "UPSTREAM_RPC_ERROR", + "Remote tool rejected the request.", + category="upstream", + details={"method": "tools/call", "rpc_error": {"code": -32001, "message": "denied"}}, + ) + if self.behavior == "protocol": + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response envelope was invalid.", + category="protocol", + ) + return { + "content": [ + {"type": "text", "text": f"called {name}"}, + {"type": "resource_link", "uri": "https://remote.example/item/1", "name": "item"}, + ], + "structuredContent": { + "ok": True, + "marker": self.marker, + "remote_name": name, + "arguments": copy.deepcopy(arguments), + }, + "isError": False, + } + + def request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + raise AssertionError("Fake client does not use raw request().") + + def notify(self, method: str, params: dict[str, object] | None = None) -> None: + raise AssertionError("Fake client does not use raw notify().") + + def close(self) -> None: + self.closed = True + + +def build_manager( + configs: list[UpstreamServerConfig], + clients: list[FakeUpstreamClient], + *, + reserved_names: set[str] | None = None, +) -> UpstreamManager: + pending = list(clients) + + def fake_build_client( + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: object | None = None, + ) -> FakeUpstreamClient: + del secret_resolver + if not pending: + raise AssertionError(f"Unexpected client creation for {config.alias}") + client = pending.pop(0) + self_config = client.config + if self_config.alias != config.alias: + raise AssertionError(f"Expected {self_config.alias}, got {config.alias}") + return client + + with patch.object(upstream_module, "build_client", side_effect=fake_build_client): + manager = UpstreamManager( + configs, + reserved_names=reserved_names or set(TOOL_REGISTRY), + ) + return manager + + +class UpstreamGatewayTests(unittest.TestCase): + def test_runtime_preserves_schema_annotations_and_structured_content(self) -> None: + with TemporaryDirectory() as tmp: + config = UpstreamServerConfig( + alias="github", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25") + manager = build_manager([config], [client]) + runtime = Runtime(Path(tmp), upstream_manager=manager) + + definitions = {item["name"]: item for item in runtime.list_tools()["tools"]} + remote = definitions["github__search"] + original = REMOTE_TOOLS[0] + + self.assertIn("server_info", definitions) + self.assertEqual(remote["title"], original["title"]) + self.assertEqual(remote["description"], original["description"]) + self.assertEqual(remote["inputSchema"], original["inputSchema"]) + self.assertEqual(remote["outputSchema"], original["outputSchema"]) + self.assertEqual(remote["annotations"], original["annotations"]) + + result = runtime.call_tool("github__search", {"q": "mcp"}) + + self.assertFalse(result["isError"]) + self.assertEqual(result["structuredContent"]["remote_name"], "search") + self.assertEqual(result["content"][1]["type"], "resource_link") + self.assertEqual(client.calls, [("search", {"q": "mcp"})]) + runtime.close() + self.assertTrue(client.closed) + + def test_nested_namespace_is_stable_and_local_names_remain_reserved(self) -> None: + nested = copy.deepcopy(REMOTE_TOOLS[0]) + nested["name"] = "inner__search" + config = UpstreamServerConfig( + alias="outer", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25", tools=[nested]) + manager = build_manager([config], [client]) + self.assertEqual(manager.tool_names(), ["outer__inner__search"]) + + collision_client = FakeUpstreamClient(config, "2025-11-25", tools=[nested]) + with self.assertRaisesRegex(UpstreamConfigError, "namespace collision"): + build_manager( + [config], + [collision_client], + reserved_names={"outer__inner__search"}, + ) + self.assertTrue(collision_client.closed) + + def test_fake_readonly_never_rewrites_upstream_annotations(self) -> None: + with TemporaryDirectory() as tmp: + config = UpstreamServerConfig( + alias="github", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25") + runtime = Runtime( + Path(tmp), + permission_mode="dangerous", + fake_readonly_annotations=True, + upstream_manager=build_manager([config], [client]), + ) + definitions = {item["name"]: item for item in runtime.list_tools()["tools"]} + + self.assertTrue(definitions["exec_command"]["annotations"]["readOnlyHint"]) + self.assertEqual( + definitions["github__create_issue"]["annotations"], + REMOTE_TOOLS[1]["annotations"], + ) + card = server_card_payload(runtime) + self.assertIn("github__create_issue", card["tools"]["readOnlyHintFalse"]) + runtime.close() + + def test_http_transport_maps_timeout_disconnect_and_connection_failure(self) -> None: + config = UpstreamServerConfig( + alias="http", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = HttpUpstreamClient(config, "2025-11-25") + cases = ( + (TimeoutError("timed out"), "UPSTREAM_TIMEOUT"), + (upstream_module.RemoteDisconnected("closed"), "UPSTREAM_DISCONNECTED"), + (upstream_module.urllib.error.URLError("refused"), "UPSTREAM_CONNECTION_FAILED"), + ) + for failure, expected_code in cases: + with self.subTest(expected_code=expected_code): + with patch.object( + upstream_module.urllib.request, + "urlopen", + side_effect=failure, + ): + with self.assertRaises(UpstreamError) as caught: + client.request("tools/list", {}) + self.assertEqual(caught.exception.code, expected_code) + self.assertTrue(caught.exception.retryable) + + def test_timeout_disconnect_rpc_and_protocol_errors_are_structured(self) -> None: + for behavior, expected_code, expected_category in ( + ("timeout", "UPSTREAM_TIMEOUT", "runtime"), + ("disconnect", "UPSTREAM_DISCONNECTED", "runtime"), + ("rpc_error", "UPSTREAM_RPC_ERROR", "upstream"), + ("protocol", "UPSTREAM_PROTOCOL_ERROR", "protocol"), + ): + with self.subTest(behavior=behavior): + config = UpstreamServerConfig( + alias=behavior, + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25", behavior=behavior) + manager = build_manager([config], [client]) + + result = manager.call_tool(f"{behavior}__search", {"q": "mcp"}) + + self.assertTrue(result["isError"]) + structured = result["structuredContent"] + self.assertFalse(structured["ok"]) + self.assertEqual(structured["error"]["code"], expected_code) + self.assertEqual(structured["error"]["category"], expected_category) + self.assertEqual(structured["upstream_alias"], behavior) + + def test_snapshot_and_list_changed_false_remain_true_after_remote_changes(self) -> None: + with TemporaryDirectory() as tmp: + config = UpstreamServerConfig( + alias="github", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25") + runtime = Runtime(Path(tmp), upstream_manager=build_manager([config], [client])) + first = runtime.list_tools() + + client.tools.clear() + client.tools.append( + { + "name": "new_after_initialize", + "inputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": True}, + } + ) + second = runtime.list_tools() + initialized = runtime.initialize({"name": "test", "version": "1"}) + + self.assertEqual(first, second) + self.assertFalse(initialized["capabilities"]["tools"]["listChanged"]) + self.assertNotIn("github__new_after_initialize", runtime.exposed_tool_names()) + self.assertFalse(hasattr(runtime.upstream_manager, "start_server")) + self.assertFalse(hasattr(runtime.upstream_manager, "stop_server")) + runtime.close() + + def test_enable_state_and_allowlist_are_applied_before_initialization(self) -> None: + enabled = UpstreamServerConfig( + alias="enabled", + transport="streamable_http", + url="http://127.0.0.1/mcp", + include_tools=("search",), + ) + disabled = UpstreamServerConfig( + alias="disabled", + transport="streamable_http", + enabled=False, + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(enabled, "2025-11-25") + manager = build_manager([enabled, disabled], [client]) + + self.assertEqual(manager.tool_names(), ["enabled__search"]) + status = manager.status_payload() + disabled_status = next(item for item in status["servers"] if item["alias"] == "disabled") + self.assertFalse(disabled_status["initialized"]) + self.assertEqual(status["tool_count"], 1) + self.assertTrue(status["snapshot_immutable"]) + self.assertEqual(status["remote_capability_boundary"], "upstream_server") + + def test_two_runtimes_do_not_share_upstream_client_state_or_session_identity(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = UpstreamServerConfig( + alias="remote", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + first_client = FakeUpstreamClient(config, "2025-11-25", marker="first") + second_client = FakeUpstreamClient(config, "2025-11-25", marker="second") + first_binding = WorkspaceBinding("workspace-a", root, "oauth") + second_binding = WorkspaceBinding("workspace-a", root, "oauth") + first_context = AuthorizationContext("oauth") + second_context = AuthorizationContext("oauth") + first = Runtime( + root, + workspace_binding=first_binding, + authorization_context=first_context, + upstream_manager=build_manager([config], [first_client]), + ) + second = Runtime( + root, + workspace_binding=second_binding, + authorization_context=second_context, + upstream_manager=build_manager([config], [second_client]), + ) + first_key = first.session_authorization_key() + second_key = second.session_authorization_key() + + first_result = first.call_tool("remote__search", {"q": "one"}) + second_result = second.call_tool("remote__search", {"q": "two"}) + + self.assertEqual(first_result["structuredContent"]["marker"], "first") + self.assertEqual(second_result["structuredContent"]["marker"], "second") + self.assertEqual(first_client.calls, [("search", {"q": "one"})]) + self.assertEqual(second_client.calls, [("search", {"q": "two"})]) + self.assertEqual(first.session_authorization_key(), first_key) + self.assertEqual(second.session_authorization_key(), second_key) + first.close() + second.close() + + def test_startup_loads_default_and_explicit_gateway_config_before_runtime(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + default_path = root / "mcp-servers.json" + default_path.write_text( + json.dumps( + { + "servers": { + "default": { + "transport": "streamable_http", + "url": "http://127.0.0.1/default", + "enabled": False, + } + } + } + ), + encoding="utf-8", + ) + with patch.dict( + upstream_module.os.environ, + {"CODING_TOOLS_MCP_UPSTREAM_CONFIG": ""}, + clear=False, + ): + args = build_parser().parse_args([]) + snapshot = load_upstream_startup(args, root) + self.assertEqual(snapshot.configs[0].alias, "default") + + explicit_path = root / "explicit.json" + explicit_path.write_text( + json.dumps( + { + "servers": { + "explicit": { + "transport": "streamable_http", + "url": "http://127.0.0.1/explicit", + "enabled": False, + } + } + } + ), + encoding="utf-8", + ) + args = build_parser().parse_args(["--upstream-config", str(explicit_path)]) + explicit = load_upstream_startup(args, root) + self.assertEqual(explicit.configs[0].alias, "explicit") + + missing = build_parser().parse_args( + ["--upstream-config", str(root / "missing.json")] + ) + with self.assertRaises(UpstreamConfigError): + load_upstream_startup(missing, root) + + def test_config_is_strict_and_contains_no_runtime_profile_control(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "mcp-servers.json" + path.write_text( + json.dumps( + { + "servers": { + "github": { + "transport": "streamable_http", + "url": "http://127.0.0.1/mcp", + "enabled": True, + "include_tools": ["search"], + } + } + } + ), + encoding="utf-8", + ) + snapshot = load_upstream_config_snapshot(path) + + self.assertEqual(snapshot.configs[0].include_tools, ("search",)) + self.assertNotIn("tool_profile", inspect.getsource(upstream_module)) + self.assertNotIn("tool_profile", inspect.signature(UpstreamManager.tool_names).parameters) + self.assertNotIn("tool_profile", inspect.signature(Runtime).parameters) + with self.assertRaises(UpstreamConfigError): + parse_server_config( + "bad__alias", + {"transport": "streamable_http", "url": "http://127.0.0.1/mcp"}, + ) + with self.assertRaisesRegex(UpstreamConfigError, "include and exclude"): + parse_server_config( + "github", + { + "transport": "streamable_http", + "url": "http://127.0.0.1/mcp", + "include_tools": ["search"], + "exclude_tools": ["search"], + }, + ) + + def test_stdio_base_environment_does_not_inherit_server_secrets(self) -> None: + with patch.dict( + upstream_module.os.environ, + { + "PATH": "synthetic-path", + "HOME": "synthetic-home", + "CODING_TOOLS_MCP_OAUTH_PASSWORD": "secret-canary", + "UNRELATED_SECRET": "another-secret", + }, + clear=True, + ): + env = base_upstream_environment() + self.assertEqual(env["PATH"], "synthetic-path") + self.assertEqual(env["HOME"], "synthetic-home") + self.assertNotIn("CODING_TOOLS_MCP_OAUTH_PASSWORD", env) + self.assertNotIn("UNRELATED_SECRET", env) + self.assertNotIn("secret-canary", repr(env)) + with self.assertRaisesRegex(UpstreamConfigError, "secret_ref"): + resolve_env_config({"TOKEN": {"secret_ref": "gateway/token"}}) + + def test_json_rpc_envelopes_and_sse_are_validated_strictly(self) -> None: + invalid = [ + None, + {"jsonrpc": "1.0", "id": 7, "result": {}}, + {"jsonrpc": "2.0", "id": 8, "result": {}}, + {"jsonrpc": "2.0", "id": 7, "result": {}, "error": {"message": "both"}}, + {"jsonrpc": "2.0", "id": 7}, + {"jsonrpc": "2.0", "id": 7, "error": {"code": -1}}, + ] + for envelope in invalid: + with self.subTest(envelope=envelope): + with self.assertRaises(UpstreamError) as caught: + _rpc_result(envelope, 7, "tools/call") + self.assertEqual(caught.exception.code, "UPSTREAM_PROTOCOL_ERROR") + + with self.assertRaises(UpstreamError) as caught: + _rpc_result( + { + "jsonrpc": "2.0", + "id": 7, + "error": {"code": -32001, "message": "remote denied", "data": {"reason": "policy"}}, + }, + 7, + "tools/call", + ) + self.assertEqual(caught.exception.code, "UPSTREAM_RPC_ERROR") + self.assertEqual(caught.exception.details["rpc_error"]["data"]["reason"], "policy") + + sse = ( + b'data: {"jsonrpc":"2.0","method":"notifications/progress"}\n\n' + b'data: {"jsonrpc":"2.0","id":7,"result":{"tools":[]}}\n\n' + ) + parsed = decode_http_rpc_response(sse, "text/event-stream", expected_id=7) + self.assertEqual(parsed["id"], 7) + with self.assertRaises(json.JSONDecodeError): + decode_http_rpc_response(b"not-json", "application/json", expected_id=7) + + def test_content_boundary_does_not_serialize_structured_content_as_text(self) -> None: + result = normalize_tool_result( + { + "structuredContent": {"answer": 42}, + "isError": False, + } + ) + self.assertEqual(result["content"], []) + self.assertEqual(result["structuredContent"], {"answer": 42}) + with self.assertRaisesRegex(UpstreamError, "content"): + normalize_tool_result({"content": "not-an-array"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integration_contract_v022.py b/tests/test_integration_contract_v022.py new file mode 100644 index 0000000..57c295c --- /dev/null +++ b/tests/test_integration_contract_v022.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import inspect +import json +import re +import sqlite3 +import unittest + +from coding_tools_mcp.admin import AdminService +from pathlib import Path +from contextlib import closing +from tempfile import TemporaryDirectory + +from coding_tools_mcp import codex_sessions as codex_sessions_module +from coding_tools_mcp import transcript as transcript_module +from coding_tools_mcp import upstream as upstream_module +from coding_tools_mcp.oauth import ( + OAUTH_GRANT_TYPES_SUPPORTED, + OAUTH_RESPONSE_TYPES_SUPPORTED, + OAuthClientRegistry, + OAuthIdentity, +) +from coding_tools_mcp.protocol import PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS +from coding_tools_mcp.oauth_store import OAuthAuthorizationStore +from coding_tools_mcp.server import MCPHandler, Runtime, TOOL_REGISTRY, build_parser +from coding_tools_mcp.upstream import UpstreamManager +from coding_tools_mcp.workspace_binding import WorkspaceBindingError, WorkspaceBindingResolver +from coding_tools_mcp.workspace_catalog import WorkspaceCatalog, WorkspaceEntry +from tests.test_oauth_store import oauth_root + +from coding_tools_mcp.settings_definition import ( + LEGACY_TOOL_PROFILE_WARNING, + migrate_persisted_settings, +) + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT_PATH = ROOT / "docs" / "integration-contract-v0.2.2.md" +CONTRACT_PATTERN = re.compile( + r"\s*```json\s*(\{.*?\})\s*```\s*" + r"", + re.DOTALL, +) + + +def load_contract() -> dict[str, object]: + text = CONTRACT_PATH.read_text(encoding="utf-8") + match = CONTRACT_PATTERN.search(text) + if match is None: + raise AssertionError("integration contract JSON block is missing") + payload = json.loads(match.group(1)) + if not isinstance(payload, dict): + raise AssertionError("integration contract JSON must be an object") + return payload + + +class IntegrationContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.contract = load_contract() + + def test_protocol_target_matches_upstream_runtime(self) -> None: + protocol = self.contract["protocol"] + self.assertIsInstance(protocol, dict) + self.assertEqual(protocol["target"], PROTOCOL_VERSION) + self.assertEqual( + [protocol["target"], *protocol["compatible"]], + list(SUPPORTED_PROTOCOL_VERSIONS), + ) + self.assertEqual(self.contract["version"], {"integration": "0.2.2"}) + + def test_catalog_is_fixed_and_legacy_profiles_are_migration_only(self) -> None: + catalog = self.contract["tool_catalog"] + self.assertIsInstance(catalog, dict) + self.assertEqual(catalog["strategy"], "fixed") + self.assertEqual(catalog["source"], "coding_tools_mcp.server.TOOL_REGISTRY") + self.assertIs(catalog["legacy_tool_profile_controls_catalog"], False) + self.assertNotIn("--tool-profile", build_parser().format_help()) + + with TemporaryDirectory() as tmp: + truthful = Runtime(Path(tmp), permission_mode="dangerous") + compatibility = Runtime( + Path(tmp), + permission_mode="dangerous", + fake_readonly_annotations=True, + ) + try: + truthful_tools = truthful.list_tools()["tools"] + compatibility_tools = compatibility.list_tools()["tools"] + finally: + truthful.close() + compatibility.close() + + self.assertEqual( + {tool["name"] for tool in truthful_tools}, + {tool["name"] for tool in compatibility_tools}, + ) + self.assertEqual({tool["name"] for tool in truthful_tools}, set(TOOL_REGISTRY)) + fake_policy = catalog["fake_readonly"] + self.assertIs(fake_policy["security_boundary"], False) + self.assertIs(fake_policy["changes_catalog"], False) + self.assertIs(fake_policy["changes_handlers"], False) + for tool in compatibility_tools: + annotations = tool["annotations"] + self.assertIs(annotations["readOnlyHint"], True) + self.assertIs(annotations["destructiveHint"], False) + self.assertIs(annotations["openWorldHint"], False) + + def test_legacy_tool_profile_migration_inputs_and_outputs_are_explicit(self) -> None: + migration = self.contract["legacy_tool_profile_migration"] + self.assertIsInstance(migration, dict) + self.assertIs(migration["persist_on_next_write"], False) + self.assertEqual(migration["unknown_value"], "ignore_with_warning") + + cases = migration["cases"] + self.assertEqual( + {case["input"]["tool_profile"] for case in cases}, + {"full", "read-only", "compat-readonly-all"}, + ) + for case in cases: + with self.subTest(tool_profile=case["input"]["tool_profile"]): + output = case["output"] + self.assertIsNone(output["tool_profile"]) + self.assertEqual(output["catalog"], "fixed") + self.assertEqual(output["warning"], migration["warning_code"]) + + def test_oauth_advertising_uses_the_upstream_constant_sources(self) -> None: + oauth = self.contract["oauth"] + self.assertEqual(oauth["advertised_grant_types"], list(OAUTH_GRANT_TYPES_SUPPORTED)) + self.assertEqual(oauth["advertised_response_types"], list(OAUTH_RESPONSE_TYPES_SUPPORTED)) + self.assertEqual(oauth["grant_types_source"], "coding_tools_mcp.oauth.OAUTH_GRANT_TYPES_SUPPORTED") + self.assertEqual(oauth["response_types_source"], "coding_tools_mcp.oauth.OAUTH_RESPONSE_TYPES_SUPPORTED") + + registry = OAuthClientRegistry() + registered = registry.register( + { + "redirect_uris": ["http://127.0.0.1/callback"], + "grant_types": ["refresh_token", "authorization_code"], + "response_types": ["code", "token"], + } + ) + self.assertEqual(registered["grant_types"], list(OAUTH_GRANT_TYPES_SUPPORTED)) + self.assertEqual(registered["response_types"], ["code"]) + + metadata_source = inspect.getsource(MCPHandler.handle_oauth_as_metadata) + token_source = inspect.getsource(MCPHandler.handle_oauth_token) + self.assertIn("OAUTH_GRANT_TYPES_SUPPORTED", metadata_source) + self.assertIn("OAUTH_RESPONSE_TYPES_SUPPORTED", metadata_source) + self.assertIn("OAUTH_GRANT_TYPE_AUTHORIZATION_CODE", token_source) + + def test_later_phase_boundaries_are_machine_readable(self) -> None: + oauth = self.contract["oauth"] + workspace = self.contract["workspace_binding"] + telemetry = self.contract["telemetry"] + secret_stores = self.contract["secret_stores"] + + self.assertEqual(oauth["persistent_store_phase"], 4) + self.assertEqual(oauth["http_integration_phase"], 5) + self.assertEqual(oauth["migration"], "idempotent_transactional") + self.assertEqual(workspace["phase"], 6) + self.assertEqual(workspace["point"], "http_initialize_runtime_factory") + self.assertIs(workspace["immutable_per_session"], True) + self.assertIs(workspace["ordinary_tool_switching"], False) + self.assertEqual(workspace["invalid_mapping"], "fail_closed") + self.assertEqual(telemetry, {"default_policy": "upstream_v0.2.2", "change_during_integration": False}) + self.assertIs(secret_stores["shared"], False) + + def test_phase08_admin_contract_is_machine_readable(self) -> None: + admin = self.contract["admin_api"] + self.assertEqual(admin["phase"], 8) + self.assertEqual(admin["authentication"], "dedicated_admin_token") + self.assertIs(admin["ordinary_mcp_bearer_is_admin"], False) + self.assertIs(admin["handler_sql"], False) + self.assertIs(admin["responses_redacted"], True) + self.assertEqual( + admin["settings_views"], + ["active", "persisted", "pending_restart"], + ) + self.assertEqual(admin["stale_update"], "revision_conflict") + self.assertEqual(admin["gateway_change"], "persist_and_restart_only") + self.assertIs(admin["gateway_dynamic_reload"], False) + self.assertEqual( + admin["allowed_origins_source"], + "coding_tools_mcp.settings_definition.normalize_allowed_origins", + ) + source = inspect.getsource(AdminService) + self.assertNotRegex(source, r"\b(?:SELECT|INSERT|UPDATE|DELETE FROM|PRAGMA)\b") + self.assertNotIn("reload_upstream", source) + + def test_phase10_webui_contract_is_machine_readable(self) -> None: + webui = self.contract["webui"] + self.assertEqual(webui["phase"], 10) + self.assertEqual(webui["source_root"], "webui/src") + self.assertEqual(webui["dist"], "build_generated_only") + self.assertEqual(webui["authentication"], "dedicated_admin_token") + self.assertEqual(webui["admin_token_storage"], "page_memory_only") + self.assertIs(webui["tool_profile_controls"], False) + self.assertIs(webui["safe_mode_hides_mutation_tools"], False) + self.assertIs(webui["fake_readonly_security_boundary"], False) + self.assertEqual( + webui["settings_stale_update"], + "preserve_draft_refresh_revision_conflict", + ) + self.assertEqual(webui["gateway_change"], "persist_and_restart_only") + self.assertIs(webui["gateway_dynamic_reload"], False) + self.assertIs(webui["secret_material_displayed"], False) + self.assertEqual(webui["conversation_list"], "summary_only") + self.assertEqual(webui["conversation_detail"], "explicit_paginated") + self.assertEqual(webui["untrusted_rendering"], "dom_text_content") + + def test_phase11_admin_telemetry_status_keeps_the_privacy_boundary(self) -> None: + telemetry = self.contract["telemetry"] + self.assertEqual( + telemetry, + {"default_policy": "upstream_v0.2.2", "change_during_integration": False}, + ) + source = inspect.getsource(AdminService.status_payload) + self.assertIn("telemetry_mode", source) + self.assertIn('"docs": "docs/telemetry.md"', source) + for forbidden in ( + "workspace_id", + "agent_id", + "client_id", + "command", + "arguments", + "file_content", + ): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, source) + + def test_phase09_chat_persistence_contract_is_machine_readable(self) -> None: + chat = self.contract["chat_persistence"] + self.assertEqual(chat["phase"], 9) + self.assertIs(chat["workspace_keyed"], True) + self.assertEqual(chat["ordinary_scope"], "immutable_workspace_service") + self.assertEqual(chat["global_operations_authentication"], "dedicated_admin_token") + self.assertEqual(chat["scan_roots"], "registered_workspace_relative_only") + self.assertEqual( + chat["scan_limits"], + ["depth", "files", "file_bytes", "total_bytes", "messages"], + ) + self.assertEqual(chat["malformed_record"], "item_error_continue") + self.assertEqual(chat["list_default"], "summary_paginated") + self.assertEqual(chat["full_content"], "explicit_detail_only") + self.assertIs(chat["telemetry_content"], False) + source = inspect.getsource(transcript_module) + inspect.getsource(codex_sessions_module) + self.assertNotIn("telemetry", source.lower()) + self.assertIn("workspace_id", source) + + def test_phase03_settings_migration_drops_tool_profile(self) -> None: + migration = self.contract["legacy_tool_profile_migration"] + for case in migration["cases"]: + value = case["input"]["tool_profile"] + migrated, warnings = migrate_persisted_settings({"tool_profile": value}) + with self.subTest(tool_profile=value): + self.assertNotIn("tool_profile", migrated) + self.assertEqual(warnings, (LEGACY_TOOL_PROFILE_WARNING,)) + self.assertEqual(case["output"]["catalog"], "fixed") + self.assertEqual( + case["output"]["warning"], + LEGACY_TOOL_PROFILE_WARNING, + ) + + def test_phase04_oauth_store_reopens_after_idempotent_migration(self) -> None: + oauth = self.contract["oauth"] + self.assertEqual(oauth["persistent_store_phase"], 4) + self.assertEqual(oauth["migration"], "idempotent_transactional") + with oauth_root() as root: + path = root / "oauth.sqlite3" + first = OAuthAuthorizationStore(path, pepper=b"contract-pepper" * 2) + first.upsert_client( + "contract-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + workspace_id="contract-workspace", + ) + first.register_signing_key( + "contract-key", + "contract-fingerprint", + secret_ref="oauth-signing/contract-key", + ) + grant_id = first.create_grant("contract-agent", "mcp") + + second = OAuthAuthorizationStore(path, pepper=b"contract-pepper" * 2) + third = OAuthAuthorizationStore(path, pepper=b"contract-pepper" * 2) + self.assertEqual(second.get_client("contract-agent")["client_id"], "contract-agent") + self.assertEqual(third.get_grant(grant_id)["client_id"], "contract-agent") + with closing(sqlite3.connect(path)) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + + def test_phase07_gateway_snapshot_contract_is_machine_readable(self) -> None: + gateway = self.contract["gateway"] + self.assertEqual(gateway["phase"], 7) + self.assertEqual(gateway["namespace"], "{alias}__{remote_name}") + self.assertIs(gateway["local_names_reserved"], True) + self.assertEqual(gateway["collision"], "fail_closed") + self.assertEqual(gateway["snapshot_point"], "runtime_initialize") + self.assertIs(gateway["immutable_per_runtime"], True) + self.assertIs(gateway["list_changed"], False) + self.assertIs(gateway["config_before_initialize"], True) + self.assertEqual(gateway["schema"], "preserve_except_public_name") + self.assertEqual(gateway["annotations"], "preserve_real") + self.assertEqual(gateway["structured_content"], "preserve") + self.assertIs(gateway["remote_workspace_boundary_claim"], False) + self.assertIs(gateway["session_identity_mutation"], False) + self.assertIs(gateway["tool_profile_controls"], False) + self.assertFalse(hasattr(UpstreamManager, "start_server")) + self.assertFalse(hasattr(UpstreamManager, "stop_server")) + self.assertNotIn("tool_profile", inspect.getsource(upstream_module)) + + def test_phase06_http_session_binding_is_immutable_and_fails_closed(self) -> None: + binding = self.contract["workspace_binding"] + self.assertEqual(binding["phase"], 6) + self.assertEqual(binding["point"], "http_initialize_runtime_factory") + self.assertTrue(binding["immutable_per_session"]) + self.assertEqual(binding["invalid_mapping"], "fail_closed") + with TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + resolver = WorkspaceBindingResolver( + WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=True), + ], + "first", + ) + ) + identity = OAuthIdentity("agent", "grant", "second", "jti") + resolved = resolver.resolve_http("oauth", identity) + self.assertEqual(resolved.workspace_id, "second") + resolver.update_catalog( + WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=False), + ], + "first", + ) + ) + self.assertEqual(resolved.workspace_id, "second") + with self.assertRaises(WorkspaceBindingError): + resolver.resolve_http("oauth", identity) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_fail_closed.py b/tests/test_oauth_fail_closed.py new file mode 100644 index 0000000..93ed242 --- /dev/null +++ b/tests/test_oauth_fail_closed.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +import os +import shutil +import sqlite3 +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request +from contextlib import closing, contextmanager +from pathlib import Path +from typing import Iterator +from unittest.mock import patch + +from coding_tools_mcp.oauth_store import OAuthStoreError +from coding_tools_mcp.server import ( + MCPHandler, + Runtime, + RuntimeHTTPServer, + build_persistent_oauth_config, +) + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +class OAuthPersistenceFailClosedTests(unittest.TestCase): + def test_dcr_authorize_and_token_reject_store_failure_without_fallback(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + client_id="fail-closed-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + try: + registration = urllib.request.Request( + f"{base}/oauth/register", + data=json.dumps( + { + "redirect_uris": ["http://127.0.0.1/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with patch.object( + config.store, + "list_clients", + side_effect=OAuthStoreError("synthetic store failure"), + ): + with self.assertRaises(urllib.error.HTTPError) as dcr_error: + urllib.request.urlopen(registration, timeout=5) + self.assertEqual(dcr_error.exception.code, 503) + self.assertEqual(json.loads(dcr_error.exception.read())["error"], "server_error") + + query = urllib.parse.urlencode( + { + "response_type": "code", + "client_id": "fail-closed-agent", + "redirect_uri": "http://127.0.0.1/callback", + "code_challenge": "A" * 43, + "code_challenge_method": "S256", + "resource": base, + } + ) + with patch.object( + config.store, + "get_client", + side_effect=OAuthStoreError("synthetic store failure"), + ): + with self.assertRaises(urllib.error.HTTPError) as authorize_error: + urllib.request.urlopen(f"{base}/oauth/authorize?{query}", timeout=5) + self.assertEqual(authorize_error.exception.code, 503) + + token_request = urllib.request.Request( + f"{base}/oauth/token", + data=urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "client_id": "fail-closed-agent", + "code": "not-reached", + "redirect_uri": "http://127.0.0.1/callback", + "code_verifier": "v" * 43, + "resource": base, + } + ).encode("ascii"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with patch.object( + config.store, + "get_client", + side_effect=OAuthStoreError("synthetic store failure"), + ): + with self.assertRaises(urllib.error.HTTPError) as token_error: + urllib.request.urlopen(token_request, timeout=5) + self.assertEqual(token_error.exception.code, 503) + self.assertEqual(json.loads(token_error.exception.read())["error"], "server_error") + self.assertEqual( + [item["client_id"] for item in config.store.list_clients()], + ["fail-closed-agent"], + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_integration.py b/tests/test_oauth_integration.py new file mode 100644 index 0000000..3dfb921 --- /dev/null +++ b/tests/test_oauth_integration.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import hashlib +import io +import json +import os +import shutil +import tempfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +import jwt +import sqlite3 +import unittest +from contextlib import closing, contextmanager, redirect_stderr +from pathlib import Path +from typing import Iterator +from unittest.mock import patch + +from coding_tools_mcp.oauth import ( + PersistentOAuthClientRegistry, + authenticate_access_token, + create_access_token, + create_authorization_grant, + oauth_signing_kid, + validate_access_token, +) +from coding_tools_mcp.oauth_store import OAuthAuthorizationStore, OAuthStoreError +from coding_tools_mcp.server import ( + MCPHandler, + Runtime, + RuntimeHTTPServer, + build_persistent_oauth_config, +) + + +PEPPER = b"phase-05-registry-pepper" * 2 + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +class PersistentOAuthClientRegistryTests(unittest.TestCase): + def test_preregistered_clients_reopen_with_exact_redirect_and_auth_method(self) -> None: + with oauth_root() as root: + path = root / "oauth.sqlite3" + registry = PersistentOAuthClientRegistry( + OAuthAuthorizationStore(path, pepper=PEPPER) + ) + registry.add_preregistered( + "public-agent", + ("http://127.0.0.1/callback",), + client_secret=None, + ) + registry.add_preregistered( + "confidential-agent", + ("https://agent.example/callback",), + client_secret="synthetic-client-secret", + ) + + reopened = PersistentOAuthClientRegistry( + OAuthAuthorizationStore(path, pepper=PEPPER) + ) + self.assertTrue( + reopened.accepts_redirect( + "public-agent", "http://127.0.0.1/callback" + ) + ) + self.assertFalse( + reopened.accepts_redirect( + "public-agent", "http://127.0.0.1/other" + ) + ) + self.assertTrue(reopened.authenticates("public-agent", "", "none")) + self.assertTrue( + reopened.authenticates( + "confidential-agent", + "synthetic-client-secret", + "client_secret_post", + ) + ) + self.assertFalse( + reopened.authenticates( + "confidential-agent", "wrong", "client_secret_post" + ) + ) + record = reopened.store.get_client("confidential-agent") + self.assertEqual( + record["client_secret_digest"], + hashlib.sha256(b"synthetic-client-secret").hexdigest(), + ) + self.assertNotIn("synthetic-client-secret", str(record)) + + +class PersistentAccessTokenTests(unittest.TestCase): + def test_jti_kid_and_revocation_state_are_enforced(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url="https://mcp.example", + token_ttl=86_400, + client_id="token-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + grant_id = create_authorization_grant( + config, + client_id="token-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + kid = oauth_signing_kid(config) + config.store.register_signing_key( + kid, + hashlib.sha256(config.token_secret).hexdigest(), + secret_ref="oauth/token-secret", + ) + token = create_access_token( + config, + "https://mcp.example", + client_id="token-agent", + grant_id=grant_id, + ) + header = jwt.get_unverified_header(token) + claims = jwt.decode( + token, + config.token_secret, + algorithms=["HS256"], + audience="https://mcp.example", + issuer="https://mcp.example", + ) + self.assertEqual(header["kid"], kid) + self.assertEqual(claims["client_id"], "token-agent") + self.assertEqual(claims["grant_id"], grant_id) + self.assertEqual(claims["sub"], grant_id) + self.assertTrue(claims["jti"]) + persisted = config.store.list_access_tokens("token-agent") + self.assertEqual(persisted[0]["jti"], claims["jti"]) + self.assertNotIn(token, str(persisted)) + identity = authenticate_access_token(token, config, "https://mcp.example") + self.assertIsNotNone(identity) + self.assertEqual(identity.client_id, "token-agent") + self.assertEqual(identity.grant_id, grant_id) + self.assertEqual(identity.workspace_id, "default") + self.assertEqual(identity.jti, claims["jti"]) + self.assertTrue(validate_access_token(token, config, "https://mcp.example")) + + config.store.revoke_access_token(claims["jti"], reason="test") + self.assertFalse(validate_access_token(token, config, "https://mcp.example")) + + second_grant = create_authorization_grant( + config, + client_id="token-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + second = create_access_token( + config, + "https://mcp.example", + client_id="token-agent", + grant_id=second_grant, + ) + self.assertTrue(validate_access_token(second, config, "https://mcp.example")) + config.store.revoke_grant(second_grant, reason="test") + self.assertFalse(validate_access_token(second, config, "https://mcp.example")) + + third_grant = create_authorization_grant( + config, + client_id="token-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + third = create_access_token( + config, + "https://mcp.example", + client_id="token-agent", + grant_id=third_grant, + ) + self.assertTrue(validate_access_token(third, config, "https://mcp.example")) + config.store.set_client_enabled("token-agent", False, reason="test") + self.assertFalse(validate_access_token(third, config, "https://mcp.example")) + + +class BearerFailClosedTests(unittest.TestCase): + def test_store_failure_denies_bearer_without_logging_token(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + client_id="bearer-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + grant_id = create_authorization_grant( + config, + client_id="bearer-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + kid = oauth_signing_kid(config) + config.store.register_signing_key( + kid, + hashlib.sha256(config.token_secret).hexdigest(), + secret_ref="oauth/token-secret", + ) + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + token = create_access_token( + config, + base, + client_id="bearer-agent", + grant_id=grant_id, + ) + + def ping() -> int: + request = urllib.request.Request( + f"{base}/mcp", + data=b'{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}', + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + method="POST", + ) + return urllib.request.urlopen(request, timeout=5).status + + try: + self.assertEqual(ping(), 200) + captured = io.StringIO() + with patch.object( + config.store, + "active_access_token_identity", + side_effect=OAuthStoreError("synthetic database failure"), + ), redirect_stderr(captured): + with self.assertRaises(urllib.error.HTTPError) as caught: + ping() + self.assertEqual(caught.exception.code, 401) + self.assertIn("validation unavailable", captured.getvalue()) + self.assertNotIn(token, captured.getvalue()) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +class PersistentOAuthCompositionTests(unittest.TestCase): + def test_dcr_client_persists_across_runtime_rebuild_with_supported_grants(self) -> None: + with oauth_root() as root: + config, created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + self.assertFalse(created) + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + body = json.dumps( + { + "client_name": "Persistent DCR Agent", + "redirect_uris": ["http://127.0.0.1/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + ).encode("utf-8") + request = urllib.request.Request( + f"{base}/oauth/register", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + registered = json.loads(response.read()) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + self.assertEqual( + registered["grant_types"], + ["authorization_code", "refresh_token"], + ) + self.assertEqual(registered["response_types"], ["code"]) + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + stored = reopened.registry.get(str(registered["client_id"])) + self.assertIsNotNone(stored) + self.assertEqual(stored.client_name, "Persistent DCR Agent") + self.assertEqual( + stored.redirect_uris, + ("http://127.0.0.1/callback",), + ) + + def test_authorization_approval_persists_grant_before_issuing_code(self) -> None: + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + client_id="grant-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + body = urllib.parse.urlencode( + { + "client_id": "grant-agent", + "redirect_uri": "http://127.0.0.1/callback", + "code_challenge": "A" * 43, + "code_challenge_method": "S256", + "state": "state-a", + "resource": base, + "password": "synthetic-authorize-password", + } + ).encode("ascii") + request = urllib.request.Request( + f"{base}/oauth/authorize", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + opener = urllib.request.build_opener(NoRedirect) + try: + with self.assertRaises(urllib.error.HTTPError) as caught: + opener.open(request, timeout=5) + self.assertEqual(caught.exception.code, 302) + self.assertIn("code=", caught.exception.headers["Location"]) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + grants = config.store.list_grants("grant-agent") + self.assertEqual(len(grants), 1) + self.assertEqual(grants[0]["scopes"], "mcp") + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + client_id="grant-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + self.assertEqual(reopened.store.list_grants("grant-agent"), grants) + + def test_persistent_oauth_config_requires_secret_vault_key(self) -> None: + with oauth_root() as root: + with self.assertRaisesRegex(ValueError, "SECRETS_KEY"): + build_persistent_oauth_config( + root, + master_key=None, + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_refresh.py b/tests/test_oauth_refresh.py new file mode 100644 index 0000000..8d3bd1e --- /dev/null +++ b/tests/test_oauth_refresh.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import shutil +import sqlite3 +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request +from contextlib import closing, contextmanager +from pathlib import Path +from typing import Iterator +from unittest.mock import patch + +from coding_tools_mcp.oauth_store import OAuthAuthorizationStore +from coding_tools_mcp.server import ( + MCPHandler, + Runtime, + RuntimeHTTPServer, + build_persistent_oauth_config, +) + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +def start_server(root: Path, config): # type: ignore[no-untyped-def] + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, f"http://127.0.0.1:{server.server_address[1]}" + + +def stop_server(server: RuntimeHTTPServer, thread: threading.Thread) -> None: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def post_form(base: str, path: str, payload: dict[str, str]) -> tuple[int, dict[str, object]]: + request = urllib.request.Request( + f"{base}{path}", + data=urllib.parse.urlencode(payload).encode("ascii"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +class RefreshTokenHTTPTests(unittest.TestCase): + def test_rotation_survives_rebuild_and_reuse_revokes_family(self) -> None: + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + server, thread, base = start_server(root, config) + verifier = "r" * 43 + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + registration = urllib.request.Request( + f"{base}/oauth/register", + data=json.dumps( + { + "client_name": "Refresh Agent", + "redirect_uris": ["http://127.0.0.1/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + metadata = json.loads( + urllib.request.urlopen( + f"{base}/.well-known/oauth-authorization-server", + timeout=5, + ).read() + ) + self.assertEqual( + metadata["grant_types_supported"], + ["authorization_code", "refresh_token"], + ) + registered = json.loads( + urllib.request.urlopen(registration, timeout=5).read() + ) + self.assertEqual( + registered["grant_types"], + ["authorization_code", "refresh_token"], + ) + authorize = urllib.request.Request( + f"{base}/oauth/authorize", + data=urllib.parse.urlencode( + { + "client_id": str(registered["client_id"]), + "redirect_uri": "http://127.0.0.1/callback", + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": "state-refresh", + "resource": base, + "password": "synthetic-authorize-password", + } + ).encode("ascii"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + opener = urllib.request.build_opener(NoRedirect) + with self.assertRaises(urllib.error.HTTPError) as redirected: + opener.open(authorize, timeout=5) + self.assertEqual(redirected.exception.code, 302) + location = redirected.exception.headers["Location"] + code = urllib.parse.parse_qs( + urllib.parse.urlparse(location).query + )["code"][0] + token_status, issued = post_form( + base, + "/oauth/token", + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://127.0.0.1/callback", + "code_verifier": verifier, + "client_id": str(registered["client_id"]), + "resource": base, + }, + ) + self.assertEqual(token_status, 200) + self.assertTrue(issued["access_token"]) + original_refresh = str(issued["refresh_token"]) + finally: + stop_server(server, thread) + + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + restarted, restarted_thread, restarted_base = start_server(root, reopened) + try: + original_audit = OAuthAuthorizationStore._audit + + def fail_access_audit( + conn: sqlite3.Connection, + event_type: str, + **kwargs: object, + ) -> None: + if event_type == "access_token_issued": + raise sqlite3.IntegrityError("injected access-token audit failure") + original_audit(conn, event_type, **kwargs) # type: ignore[arg-type] + + with patch.object( + OAuthAuthorizationStore, + "_audit", + staticmethod(fail_access_audit), + ): + failed_status, failed = post_form( + restarted_base, + "/oauth/token", + { + "grant_type": "refresh_token", + "refresh_token": original_refresh, + "client_id": str(registered["client_id"]), + }, + ) + self.assertEqual(failed_status, 503) + self.assertEqual(failed["error"], "server_error") + + refresh_status, refreshed = post_form( + restarted_base, + "/oauth/token", + { + "grant_type": "refresh_token", + "refresh_token": original_refresh, + "client_id": str(registered["client_id"]), + }, + ) + self.assertEqual(refresh_status, 200) + replacement = str(refreshed["refresh_token"]) + self.assertNotEqual(replacement, original_refresh) + self.assertTrue(refreshed["access_token"]) + + replay_status, replay = post_form( + restarted_base, + "/oauth/token", + { + "grant_type": "refresh_token", + "refresh_token": original_refresh, + "client_id": str(registered["client_id"]), + }, + ) + self.assertEqual(replay_status, 400) + self.assertEqual(replay["error"], "invalid_grant") + + replacement_status, replacement_error = post_form( + restarted_base, + "/oauth/token", + { + "grant_type": "refresh_token", + "refresh_token": replacement, + "client_id": str(registered["client_id"]), + }, + ) + self.assertEqual(replacement_status, 400) + self.assertEqual(replacement_error["error"], "invalid_grant") + families = reopened.store.list_refresh_token_families( + str(registered["client_id"]) + ) + self.assertEqual(len(families), 1) + self.assertIsNotNone(families[0]["revoked_at"]) + self.assertEqual(families[0]["revoke_reason"], "refresh_token_reuse") + finally: + stop_server(restarted, restarted_thread) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_signing.py b/tests/test_oauth_signing.py new file mode 100644 index 0000000..37f4d45 --- /dev/null +++ b/tests/test_oauth_signing.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import os +import shutil +import sqlite3 +import tempfile +import time +import unittest +from contextlib import closing, contextmanager +from pathlib import Path +from typing import Iterator + +from coding_tools_mcp.oauth import ( + create_access_token, + create_authorization_grant, + revoke_signing_key, + rotate_signing_key, + validate_access_token, +) +from coding_tools_mcp.server import build_persistent_oauth_config + + +ISSUER = "https://mcp.example" + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +class SigningKeyLifecycleTests(unittest.TestCase): + def test_rotation_reopen_and_emergency_revoke_preserve_key_boundaries(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=ISSUER, + token_ttl=86_400, + client_id="signing-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + grant_id = create_authorization_grant( + config, + client_id="signing-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + old_kid = str(config.signing_kid) + old_secret = config.signing_keys[old_kid] + old_token = create_access_token( + config, + ISSUER, + client_id="signing-agent", + grant_id=grant_id, + ) + + rotated = rotate_signing_key(config) + new_kid = str(rotated.signing_kid) + new_secret = rotated.signing_keys[new_kid] + self.assertNotEqual(new_kid, old_kid) + new_token = create_access_token( + rotated, + ISSUER, + client_id="signing-agent", + grant_id=grant_id, + ) + self.assertTrue(validate_access_token(old_token, rotated, ISSUER)) + self.assertTrue(validate_access_token(new_token, rotated, ISSUER)) + states = { + item["kid"]: item["status"] + for item in rotated.store.list_signing_keys() + } + self.assertEqual(states[old_kid], "retired") + self.assertEqual(states[new_kid], "active") + + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=ISSUER, + token_ttl=86_400, + client_id="signing-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + self.assertEqual(reopened.signing_kid, new_kid) + self.assertEqual(set(reopened.signing_keys), {old_kid, new_kid}) + self.assertTrue(validate_access_token(old_token, reopened, ISSUER)) + self.assertTrue(validate_access_token(new_token, reopened, ISSUER)) + + self.assertTrue(revoke_signing_key(reopened, old_kid)) + self.assertFalse(validate_access_token(old_token, reopened, ISSUER)) + self.assertTrue(validate_access_token(new_token, reopened, ISSUER)) + refs = { + item["kid"]: item["secret_ref"] + for item in reopened.store.list_signing_keys() + } + self.assertTrue(refs[old_kid].startswith("oauth/signing/")) + self.assertTrue(refs[new_kid].startswith("oauth/signing/")) + self.assertNotIn(old_secret.hex(), str(reopened.store.list_signing_keys())) + self.assertNotIn(new_secret.hex(), str(reopened.store.list_signing_keys())) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + database_bytes = (root / "oauth.sqlite3").read_bytes() + self.assertNotIn(old_secret, database_bytes) + self.assertNotIn(new_secret, database_bytes) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_store.py b/tests/test_oauth_store.py new file mode 100644 index 0000000..5e049ba --- /dev/null +++ b/tests/test_oauth_store.py @@ -0,0 +1,639 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import shutil +import sqlite3 +import tempfile +import time +import threading +import unittest +from collections.abc import Iterator +from contextlib import closing, contextmanager +from pathlib import Path +from unittest.mock import patch + +from coding_tools_mcp.oauth_store import ( + OAuthAuthorizationStore, + OAuthStoreError, + RefreshTokenClientMismatchError, + RefreshTokenResult, +) + + +PEPPER = b"phase-04-test-pepper" * 2 +FUTURE = 4_000_000_000.0 + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +def prepared_store( + root: Path, + *, + store_type: type[OAuthAuthorizationStore] = OAuthAuthorizationStore, +) -> tuple[OAuthAuthorizationStore, str]: + store = store_type(root / "oauth.sqlite3", pepper=PEPPER) + store.upsert_client( + "agent-a", + display_name="Agent A", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + workspace_id="workspace-a", + ) + store.register_signing_key( + "key-a", + "fingerprint-a", + secret_ref="oauth-signing/key-a", + ) + grant_id = store.create_grant("agent-a", "mcp") + return store, grant_id + + +class OAuthStoreTests(unittest.TestCase): + def test_schema_contains_every_required_metadata_table_and_reopens(self) -> None: + expected = { + "oauth_clients", + "oauth_grants", + "oauth_access_tokens", + "oauth_refresh_token_families", + "oauth_refresh_tokens", + "oauth_signing_keys", + "oauth_audit_events", + } + with oauth_root() as root: + store, grant_id = prepared_store(root) + store.record_access_token( + "jti-a", + grant_id, + "agent-a", + "key-a", + "mcp", + issued_at=1, + expires_at=FUTURE, + ) + family_id, _token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + + reopened = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + self.assertEqual(reopened.get_client("agent-a")["display_name"], "Agent A") + self.assertEqual(reopened.get_grant(grant_id)["client_id"], "agent-a") + self.assertEqual(reopened.list_access_tokens()[0]["jti"], "jti-a") + self.assertEqual( + reopened.list_refresh_token_families()[0]["family_id"], + family_id, + ) + self.assertEqual(reopened.list_signing_keys()[0]["secret_ref"], "oauth-signing/key-a") + self.assertTrue(reopened.list_audit_events()) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'oauth_%'" + ) + } + version = conn.execute("PRAGMA user_version").fetchone()[0] + self.assertEqual(tables, expected) + self.assertEqual(version, OAuthAuthorizationStore.SCHEMA_VERSION) + + def test_v1_migration_is_repeatable_and_preserves_existing_rows(self) -> None: + with oauth_root() as root: + path = root / "oauth.sqlite3" + with closing(sqlite3.connect(path, isolation_level=None)) as conn: + conn.execute("BEGIN") + for statement in OAuthAuthorizationStore._schema_v1_statements(): + conn.execute(statement) + conn.execute( + """ + INSERT INTO oauth_clients( + client_id, display_name, redirect_uri, allowed_scopes, + created_at, updated_at + ) VALUES('legacy-agent','Legacy Agent','http://127.0.0.1/callback','mcp',1,1) + """ + ) + conn.execute( + """ + INSERT INTO oauth_signing_keys( + kid, algorithm, fingerprint, status, created_at, activated_at + ) VALUES('legacy-key','HS256','legacy-fingerprint','active',1,1) + """ + ) + conn.execute("PRAGMA user_version = 1") + conn.commit() + + first = OAuthAuthorizationStore(path, pepper=PEPPER) + second = OAuthAuthorizationStore(path, pepper=PEPPER) + self.assertEqual(first.list_signing_keys()[0]["kid"], "legacy-key") + self.assertIsNone(first.list_signing_keys()[0]["secret_ref"]) + self.assertEqual(second.list_signing_keys(), first.list_signing_keys()) + legacy_client = first.get_client("legacy-agent") + self.assertEqual( + legacy_client["redirect_uris"], + ["http://127.0.0.1/callback"], + ) + self.assertEqual(legacy_client["token_endpoint_auth_method"], "none") + self.assertIsNone(legacy_client["client_secret_digest"]) + with closing(sqlite3.connect(path)) as conn: + key_columns = { + row[1] for row in conn.execute("PRAGMA table_info(oauth_signing_keys)") + } + client_columns = { + row[1] for row in conn.execute("PRAGMA table_info(oauth_clients)") + } + version = conn.execute("PRAGMA user_version").fetchone()[0] + self.assertIn("secret_ref", key_columns) + self.assertIn("redirect_uris_json", client_columns) + self.assertIn("token_endpoint_auth_method", client_columns) + self.assertIn("client_secret_digest", client_columns) + self.assertEqual(version, OAuthAuthorizationStore.SCHEMA_VERSION) + + def test_workspace_binding_migration_is_explicit_and_grants_freeze_it(self) -> None: + with oauth_root() as root: + path = root / "oauth.sqlite3" + store = OAuthAuthorizationStore(path, pepper=PEPPER) + store.upsert_client( + "workspace-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + with self.assertRaisesRegex(OAuthStoreError, "no authorized Workspace binding"): + store.create_grant("workspace-agent", "mcp") + self.assertTrue(store.set_client_workspace("workspace-agent", "workspace-a")) + grant_id = store.create_grant("workspace-agent", "mcp") + self.assertEqual(store.get_client("workspace-agent")["workspace_id"], "workspace-a") + self.assertEqual(store.get_grant(grant_id)["workspace_id"], "workspace-a") + + self.assertTrue(store.set_client_workspace("workspace-agent", "workspace-b")) + second_grant = store.create_grant("workspace-agent", "mcp") + self.assertEqual(store.get_grant(grant_id)["workspace_id"], "workspace-a") + self.assertEqual(store.get_grant(second_grant)["workspace_id"], "workspace-b") + with closing(sqlite3.connect(path)) as conn: + client_columns = { + row[1] for row in conn.execute("PRAGMA table_info(oauth_clients)") + } + grant_columns = { + row[1] for row in conn.execute("PRAGMA table_info(oauth_grants)") + } + version = conn.execute("PRAGMA user_version").fetchone()[0] + self.assertIn("workspace_id", client_columns) + self.assertIn("workspace_id", grant_columns) + self.assertEqual(version, OAuthAuthorizationStore.SCHEMA_VERSION) + + def test_confidential_client_metadata_round_trips_without_plaintext_secret(self) -> None: + with oauth_root() as root: + store = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + raw_secret = "client-secret-must-not-be-stored" + digest = hashlib.sha256(raw_secret.encode()).hexdigest() + redirects = ( + "https://agent.example/callback", + "http://127.0.0.1/callback", + ) + store.upsert_client( + "confidential-agent", + display_name="Confidential Agent", + scopes="mcp", + redirect_uris=redirects, + client_type="confidential", + token_endpoint_auth_method="client_secret_basic", + client_secret_digest=digest, + ) + reopened = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + client = reopened.get_client("confidential-agent") + self.assertEqual(client["redirect_uris"], list(redirects)) + self.assertEqual( + client["token_endpoint_auth_method"], + "client_secret_basic", + ) + self.assertEqual(client["client_secret_digest"], digest) + self.assertNotIn(raw_secret, str(client)) + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + persisted = b"".join( + path.read_bytes() + for path in root.iterdir() + if path.name.startswith("oauth.sqlite3") + ) + self.assertNotIn(raw_secret.encode(), persisted) + + def test_failed_migration_rolls_back_all_schema_changes(self) -> None: + class BrokenMigrationStore(OAuthAuthorizationStore): + @classmethod + def _schema_v1_statements(cls) -> tuple[str, ...]: + valid = super()._schema_v1_statements() + return (valid[0], "CREATE TABLE broken syntax", *valid[1:]) + + with oauth_root() as root: + path = root / "oauth.sqlite3" + with self.assertRaises(OAuthStoreError): + BrokenMigrationStore(path, pepper=PEPPER) + with closing(sqlite3.connect(path)) as conn: + tables = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'oauth_%'" + ).fetchall() + version = conn.execute("PRAGMA user_version").fetchone()[0] + self.assertEqual(tables, []) + self.assertEqual(version, 0) + + def test_client_disable_and_grant_revoke_are_idempotent(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + self.assertTrue(store.revoke_grant(grant_id, reason="test")) + first_grant = store.get_grant(grant_id) + self.assertTrue(store.revoke_grant(grant_id, reason="different")) + second_grant = store.get_grant(grant_id) + self.assertEqual(first_grant["revoked_at"], second_grant["revoked_at"]) + self.assertEqual(second_grant["revoke_reason"], "test") + + self.assertTrue(store.set_client_enabled("agent-a", False, reason="test")) + first_client = store.get_client("agent-a") + self.assertTrue(store.set_client_enabled("agent-a", False, reason="different")) + second_client = store.get_client("agent-a") + self.assertEqual(first_client["revoked_at"], second_client["revoked_at"]) + self.assertFalse(bool(second_client["enabled"])) + self.assertFalse(store.set_client_enabled("missing", False)) + + def test_access_token_and_signing_key_lifecycle_fail_closed(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + store.record_access_token( + "jti-a", + grant_id, + "agent-a", + "key-a", + "mcp", + issued_at=1, + expires_at=FUTURE, + ) + self.assertTrue(store.access_token_is_active("jti-a", now=2)) + + store.register_signing_key( + "key-b", + "fingerprint-b", + secret_ref="oauth-signing/key-b", + ) + states = {item["kid"]: item["status"] for item in store.list_signing_keys()} + self.assertEqual(states, {"key-a": "retired", "key-b": "active"}) + self.assertTrue(store.access_token_is_active("jti-a", now=2)) + self.assertTrue(store.activate_signing_key("key-a")) + self.assertTrue(store.retire_signing_key("key-a")) + self.assertTrue(store.revoke_signing_key("key-a")) + first = next(item for item in store.list_signing_keys() if item["kid"] == "key-a") + self.assertTrue(store.revoke_signing_key("key-a")) + second = next(item for item in store.list_signing_keys() if item["kid"] == "key-a") + self.assertEqual(first["revoked_at"], second["revoked_at"]) + self.assertFalse(store.access_token_is_active("jti-a", now=2)) + + def test_access_token_revocation_is_idempotent(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + store.record_access_token( + "jti-a", + grant_id, + "agent-a", + "key-a", + "mcp", + issued_at=1, + expires_at=FUTURE, + ) + self.assertTrue(store.revoke_access_token("jti-a", reason="test")) + first = store.list_access_tokens()[0] + self.assertTrue(store.revoke_access_token("jti-a", reason="different")) + second = store.list_access_tokens()[0] + self.assertEqual(first["revoked_at"], second["revoked_at"]) + self.assertEqual(second["revoke_reason"], "test") + revoked_events = [ + item + for item in store.list_audit_events(limit=500) + if item["event_type"] == "access_token_revoked" + ] + self.assertEqual(len(revoked_events), 1) + + def test_refresh_token_is_stored_only_as_peppered_digest(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + expected = hmac.new(PEPPER, token.encode(), hashlib.sha256).hexdigest() + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + row = conn.execute( + "SELECT token_hash FROM oauth_refresh_tokens WHERE family_id=?", + (family_id,), + ).fetchone() + refresh_columns = { + item[1] for item in conn.execute("PRAGMA table_info(oauth_refresh_tokens)") + } + access_columns = { + item[1] for item in conn.execute("PRAGMA table_info(oauth_access_tokens)") + } + self.assertEqual(row[0], expected) + self.assertNotIn("token", refresh_columns) + self.assertNotIn("access_token", access_columns) + self.assertNotIn(token, str(store.list_audit_events(limit=500))) + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + + persisted = b"".join( + path.read_bytes() + for path in root.iterdir() + if path.name.startswith("oauth.sqlite3") + ) + self.assertNotIn(token.encode(), persisted) + self.assertNotIn("fingerprint-a".encode() + b"-secret-material", persisted) + + def test_refresh_rotation_detects_reuse_and_revokes_family(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + replacement = store.rotate_refresh_token(token, expires_at=FUTURE) + self.assertIsInstance(replacement, RefreshTokenResult) + self.assertFalse(store.refresh_family_is_revoked(family_id)) + + self.assertIsNone(store.rotate_refresh_token(token, expires_at=FUTURE)) + self.assertTrue(store.refresh_family_is_revoked(family_id)) + self.assertIsNone( + store.rotate_refresh_token(replacement.token, expires_at=FUTURE) + ) + reuse_events = [ + item + for item in store.list_audit_events(limit=500) + if item["event_type"] == "refresh_token_reuse" + ] + self.assertEqual(len(reuse_events), 1) + + def test_expired_refresh_token_is_rejected_even_when_family_is_valid(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + with patch("coding_tools_mcp.oauth_store.time.time", return_value=100.0): + _family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=200.0, + ) + replacement = store.rotate_refresh_token(token, expires_at=150.0) + self.assertIsNotNone(replacement) + + with patch("coding_tools_mcp.oauth_store.time.time", return_value=151.0): + self.assertIsNone( + store.rotate_refresh_token( + replacement.token, + expires_at=4_000_000_000.0, + ) + ) + + def test_refresh_family_revocation_is_idempotent(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, _token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + self.assertTrue(store.revoke_refresh_family(family_id, reason="test")) + first = store.list_refresh_token_families()[0] + self.assertTrue(store.revoke_refresh_family(family_id, reason="different")) + second = store.list_refresh_token_families()[0] + self.assertEqual(first["revoked_at"], second["revoked_at"]) + self.assertEqual(second["revoke_reason"], "test") + + def test_rotation_failure_rolls_back_replacement_and_old_token_state(self) -> None: + class FailingRotationStore(OAuthAuthorizationStore): + @staticmethod + def _audit( + conn: sqlite3.Connection, + event_type: str, + **kwargs: object, + ) -> None: + if event_type == "refresh_token_rotated": + raise sqlite3.IntegrityError("injected rotation audit failure") + OAuthAuthorizationStore._audit(conn, event_type, **kwargs) # type: ignore[arg-type] + + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + failing = FailingRotationStore(root / "oauth.sqlite3", pepper=PEPPER) + with self.assertRaises(OAuthStoreError): + failing.rotate_refresh_token(token, expires_at=FUTURE) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + rows = conn.execute( + """ + SELECT used_at, revoked_at, replacement_token_id + FROM oauth_refresh_tokens WHERE family_id=? + """, + (family_id,), + ).fetchall() + self.assertEqual(rows, [(None, None, None)]) + recovered = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + self.assertIsNotNone(recovered.rotate_refresh_token(token, expires_at=FUTURE)) + + def test_refresh_exchange_access_failure_rolls_back_rotation_and_metadata(self) -> None: + class FailingAccessAuditStore(OAuthAuthorizationStore): + @staticmethod + def _audit( + conn: sqlite3.Connection, + event_type: str, + **kwargs: object, + ) -> None: + if event_type == "access_token_issued": + raise sqlite3.IntegrityError("injected access-token audit failure") + OAuthAuthorizationStore._audit(conn, event_type, **kwargs) # type: ignore[arg-type] + + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + failing = FailingAccessAuditStore(root / "oauth.sqlite3", pepper=PEPPER) + binding = failing.refresh_token_binding(token) + self.assertIsNotNone(binding) + assert binding is not None + self.assertEqual(binding.client_id, "agent-a") + self.assertEqual(binding.grant_id, grant_id) + + with self.assertRaises(OAuthStoreError): + failing.rotate_refresh_token_and_record_access_token( + token, + expected_client_id="agent-a", + refresh_expires_at=FUTURE, + access_jti="jti-atomic-failure", + access_signing_kid="key-a", + access_scopes="mcp", + access_issued_at=100.0, + access_expires_at=FUTURE, + ) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + refresh_rows = conn.execute( + """ + SELECT used_at, revoked_at, replacement_token_id + FROM oauth_refresh_tokens WHERE family_id=? + """, + (family_id,), + ).fetchall() + access_count = conn.execute( + "SELECT COUNT(*) FROM oauth_access_tokens WHERE jti=?", + ("jti-atomic-failure",), + ).fetchone()[0] + self.assertEqual(refresh_rows, [(None, None, None)]) + self.assertEqual(access_count, 0) + self.assertFalse( + any( + event["event_type"] in {"refresh_token_rotated", "access_token_issued"} + for event in failing.list_audit_events(limit=500) + ) + ) + + recovered = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + rotated = recovered.rotate_refresh_token_and_record_access_token( + token, + expected_client_id="agent-a", + refresh_expires_at=FUTURE, + access_jti="jti-atomic-success", + access_signing_kid="key-a", + access_scopes="mcp", + access_issued_at=100.0, + access_expires_at=FUTURE, + ) + self.assertIsNotNone(rotated) + self.assertTrue(recovered.access_token_is_active("jti-atomic-success", now=101.0)) + + def test_refresh_exchange_client_mismatch_does_not_consume_token(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + + with self.assertRaises(RefreshTokenClientMismatchError): + store.rotate_refresh_token_and_record_access_token( + token, + expected_client_id="agent-b", + refresh_expires_at=FUTURE, + access_jti="jti-mismatch", + access_signing_kid="key-a", + access_scopes="mcp", + access_issued_at=100.0, + access_expires_at=FUTURE, + ) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + row = conn.execute( + """ + SELECT used_at, revoked_at, replacement_token_id + FROM oauth_refresh_tokens WHERE family_id=? + """, + (family_id,), + ).fetchone() + access_count = conn.execute( + "SELECT COUNT(*) FROM oauth_access_tokens WHERE jti=?", + ("jti-mismatch",), + ).fetchone()[0] + self.assertEqual(row, (None, None, None)) + self.assertEqual(access_count, 0) + + rotated = store.rotate_refresh_token_and_record_access_token( + token, + expected_client_id="agent-a", + refresh_expires_at=FUTURE, + access_jti="jti-after-mismatch", + access_signing_kid="key-a", + access_scopes="mcp", + access_issued_at=100.0, + access_expires_at=FUTURE, + ) + self.assertIsNotNone(rotated) + self.assertTrue(store.access_token_is_active("jti-after-mismatch", now=101.0)) + + def test_concurrent_refresh_rotation_has_one_winner_and_detects_reuse(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + stores = [ + OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER), + OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER), + ] + barrier = threading.Barrier(2) + results: list[RefreshTokenResult | None] = [] + errors: list[BaseException] = [] + + def rotate(candidate: OAuthAuthorizationStore) -> None: + try: + barrier.wait(timeout=5) + results.append(candidate.rotate_refresh_token(token, expires_at=FUTURE)) + except BaseException as exc: # pragma: no cover - reported below + errors.append(exc) + + threads = [threading.Thread(target=rotate, args=(candidate,)) for candidate in stores] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + self.assertEqual(errors, []) + self.assertEqual(sum(item is not None for item in results), 1) + self.assertEqual(sum(item is None for item in results), 1) + self.assertTrue(store.refresh_family_is_revoked(family_id)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_phase11_packaging.py b/tests/test_phase11_packaging.py new file mode 100644 index 0000000..0644231 --- /dev/null +++ b/tests/test_phase11_packaging.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class Phase11PackagingTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + def test_webui_and_desktop_package_data_are_both_included(self) -> None: + package_data = self.pyproject["tool"]["setuptools"]["package-data"] + self.assertEqual(package_data["coding_tools_mcp"], ["webui_dist/*"]) + self.assertEqual( + package_data["mcp_desktop_client"], + ["locales/*.qm", "locales/*.ts"], + ) + self.assertTrue((ROOT / "coding_tools_mcp" / "webui_dist" / "admin.html").is_file()) + + def test_upstream_desktop_discovery_and_entrypoint_are_preserved(self) -> None: + project = self.pyproject["project"] + discovery = self.pyproject["tool"]["setuptools"]["packages"]["find"] + self.assertIn("desktop", project["optional-dependencies"]) + self.assertEqual( + project["scripts"]["coding-tools-mcp-desktop"], + "mcp_desktop_client.app:main", + ) + self.assertEqual(discovery["where"], [".", "apps/desktop-client"]) + self.assertEqual(discovery["include"], ["coding_tools_mcp*", "mcp_desktop_client*"]) + + def test_upstream_dev_and_image_extras_are_preserved(self) -> None: + extras = self.pyproject["project"]["optional-dependencies"] + self.assertIn("dev", extras) + self.assertIn("image", extras) + self.assertIn("mypy>=2.1,<2.2", extras["dev"]) + self.assertIn("Pillow>=10.0", extras["image"]) + + def test_compliance_ci_allows_setup_node_toolchain_under_landlock(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "compliance.yml").read_text( + encoding="utf-8" + ) + setup_node = workflow.index("uses: actions/setup-node@v6") + allow_root = workflow.index("CODING_TOOLS_MCP_EXEC_ALLOW_ROOTS") + unit_discovery = workflow.index("name: Run unit discovery") + self.assertLess(setup_node, allow_root) + self.assertLess(allow_root, unit_discovery) + self.assertIn('readlink -f "$(command -v node)"', workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_checks.py b/tests/test_release_checks.py index b0e3019..96f96a2 100644 --- a/tests/test_release_checks.py +++ b/tests/test_release_checks.py @@ -10,6 +10,9 @@ from scripts.check_release_versions import validate_release +ROOT = Path(__file__).resolve().parents[1] + + class ReleaseMetadataTests(unittest.TestCase): def _write_release_tree( self, @@ -56,6 +59,10 @@ def test_release_metadata_rejects_prerelease_npm_version(self) -> None: with self.assertRaisesRegex(SystemExit, "not stable"): validate_release(root, "v0.2.0") + def test_current_integration_tree_requires_release_preparation(self) -> None: + with self.assertRaisesRegex(SystemExit, "Unreleased"): + validate_release(ROOT, "v0.2.2") + class FinalAuditTests(unittest.TestCase): def test_workflow_runs_url_filters_by_release_sha(self) -> None: diff --git a/tests/test_settings_foundation.py b/tests/test_settings_foundation.py new file mode 100644 index 0000000..039bfcf --- /dev/null +++ b/tests/test_settings_foundation.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import json +import os +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from coding_tools_mcp import secret_vault as secret_vault_module +from coding_tools_mcp import settings_store as settings_store_module +from coding_tools_mcp.secret_vault import SecretVault, SecretVaultError +from coding_tools_mcp.settings_definition import ( + LEGACY_TOOL_PROFILE_WARNING, + SettingsValidationError, + normalize_startup_settings_with_warnings, + pending_restart_fields, + schema_payload, +) +from coding_tools_mcp.settings_store import ( + SETTINGS_SCHEMA_VERSION, + ServerSettingsStore, + SettingsStoreError, + default_settings_dir, + sanitize_settings, +) +from coding_tools_mcp.workspace_catalog import ( + WorkspaceCatalog, + WorkspaceCatalogError, + WorkspaceEntry, +) + + +class SettingsStoreTests(unittest.TestCase): + def test_default_settings_dir_honors_package_config_override(self) -> None: + with TemporaryDirectory() as tmp: + configured = Path(tmp) / "configured" + with patch.dict( + os.environ, + {"CODING_TOOLS_MCP_CONFIG_DIR": str(configured)}, + clear=False, + ): + self.assertEqual(default_settings_dir(), configured) + + def test_settings_round_trip_and_schema_version(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "server-settings.json" + store = ServerSettingsStore(path) + warnings = store.write( + { + "host": "127.0.0.1", + "port": 8765, + "oauth_active_key_secret_ref": "oauth-signing/key-a", + } + ) + + self.assertEqual(warnings, ()) + persisted = store.read() + self.assertEqual(persisted["schema_version"], SETTINGS_SCHEMA_VERSION) + self.assertEqual(persisted["port"], 8765) + self.assertEqual( + persisted["oauth_active_key_secret_ref"], + "oauth-signing/key-a", + ) + + def test_legacy_schema_and_tool_profile_are_migrated_in_memory(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "server-settings.json" + path.write_text( + json.dumps( + { + "schema_version": 0, + "host": "127.0.0.1", + "tool_profile": "read-only", + } + ), + encoding="utf-8", + ) + + result = ServerSettingsStore(path).read_result() + + self.assertTrue(result.migrated) + self.assertEqual(result.warnings, (LEGACY_TOOL_PROFILE_WARNING,)) + self.assertNotIn("tool_profile", result.settings) + self.assertEqual(result.settings["schema_version"], SETTINGS_SCHEMA_VERSION) + + def test_next_successful_write_omits_legacy_tool_profile(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "server-settings.json" + store = ServerSettingsStore(path) + + warnings = store.write({"host": "localhost", "tool_profile": "unknown-profile"}) + raw = json.loads(path.read_text(encoding="utf-8")) + + self.assertEqual(warnings, (LEGACY_TOOL_PROFILE_WARNING,)) + self.assertNotIn("tool_profile", raw) + + def test_atomic_replace_failure_preserves_previous_settings(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "server-settings.json" + store = ServerSettingsStore(path) + store.write({"port": 8000}) + original = path.read_bytes() + + with patch.object( + settings_store_module.os, + "replace", + side_effect=OSError("replace failed"), + ): + with self.assertRaises(SettingsStoreError): + store.write({"port": 9000}) + + self.assertEqual(path.read_bytes(), original) + self.assertEqual(store.read()["port"], 8000) + self.assertEqual(list(path.parent.glob(f".{path.name}.*.tmp")), []) + + def test_plaintext_secrets_are_rejected_and_sanitized(self) -> None: + settings = { + "oauth_token_secret": "secret-canary", + "oauth_active_key_secret_ref": "oauth-signing/key-a", + } + sanitized = sanitize_settings(settings) + + self.assertNotIn("secret-canary", repr(sanitized)) + self.assertTrue(sanitized["oauth_token_secret_configured"]) + self.assertEqual( + sanitized["oauth_active_key_secret_ref"], + {"configured": True}, + ) + with TemporaryDirectory() as tmp: + with self.assertRaises(SettingsStoreError): + ServerSettingsStore(Path(tmp) / "server-settings.json").write(settings) + + +class SettingsDefinitionTests(unittest.TestCase): + def test_normalization_ignores_tool_profile_and_reports_warning(self) -> None: + with TemporaryDirectory() as tmp: + workspace = Path(tmp) + normalized, warnings = normalize_startup_settings_with_warnings( + {"tool_profile": "compat-readonly-all", "permission_mode": "safe"}, + { + "tool_profile": "read-only", + "host": "LOCALHOST", + "port": "8765", + "workspace": str(workspace), + }, + workspace, + ) + + self.assertEqual(warnings, (LEGACY_TOOL_PROFILE_WARNING,)) + self.assertNotIn("tool_profile", normalized) + self.assertEqual(normalized["host"], "localhost") + self.assertEqual(normalized["port"], 8765) + self.assertNotIn("tool_profile", schema_payload()) + self.assertNotIn("tool_profile", schema_payload()["restart_fields"]) + + def test_oauth_client_workspace_bindings_require_enabled_catalog_entries(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + updates = { + "workspace_catalog": [ + { + "id": "first", + "name": "First", + "root": str(first), + "enabled": True, + "default": True, + }, + { + "id": "second", + "name": "Second", + "root": str(second), + "enabled": False, + "default": False, + }, + ], + "default_workspace_id": "first", + "oauth_client_workspace_bindings": {"agent-a": "first"}, + } + normalized, _warnings = normalize_startup_settings_with_warnings( + {}, updates, first + ) + self.assertEqual( + normalized["oauth_client_workspace_bindings"], + {"agent-a": "first"}, + ) + self.assertIn( + "oauth_client_workspace_bindings", + schema_payload()["restart_fields"], + ) + + updates["oauth_client_workspace_bindings"] = {"agent-a": "second"} + with self.assertRaisesRegex(SettingsValidationError, "unknown or disabled"): + normalize_startup_settings_with_warnings({}, updates, first) + + def test_pending_restart_fields_compares_only_active_settings(self) -> None: + active = {"port": 8000, "permission_mode": "safe", "tool_profile": "read-only"} + persisted = {"port": 9000, "permission_mode": "safe"} + + self.assertEqual(pending_restart_fields(active, persisted), ["port"]) + + +class WorkspaceCatalogTests(unittest.TestCase): + def test_catalog_canonicalizes_one_default_and_hides_disabled_entries(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + catalog = WorkspaceCatalog( + [ + WorkspaceEntry("first", " First ", first), + WorkspaceEntry("second", "Second", second, enabled=False), + ], + "first", + ) + + self.assertTrue(catalog.default().default) + self.assertEqual(catalog.default().name, "First") + self.assertEqual([item.id for item in catalog.enabled_entries()], ["first"]) + self.assertEqual([item["id"] for item in catalog.payload()["workspaces"]], ["first"]) + with self.assertRaises(WorkspaceCatalogError): + catalog.get("second") + + def test_duplicate_ids_and_multiple_defaults_are_rejected(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + with self.assertRaisesRegex(WorkspaceCatalogError, "IDs must be unique"): + WorkspaceCatalog( + [ + WorkspaceEntry("same", "First", first), + WorkspaceEntry("same", "Second", second), + ], + "same", + ) + with self.assertRaisesRegex(WorkspaceCatalogError, "only one default"): + WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, default=True), + WorkspaceEntry("second", "Second", second, default=True), + ], + "first", + ) + + def test_disabled_default_nested_roots_and_missing_paths_are_rejected(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + parent = root / "parent" + child = parent / "child" + parent.mkdir() + child.mkdir() + with self.assertRaisesRegex(WorkspaceCatalogError, "default workspace must be enabled"): + WorkspaceCatalog( + [WorkspaceEntry("parent", "Parent", parent, enabled=False)], + "parent", + ) + with self.assertRaisesRegex(WorkspaceCatalogError, "Nested workspace roots"): + WorkspaceCatalog( + [ + WorkspaceEntry("parent", "Parent", parent), + WorkspaceEntry("child", "Child", child), + ], + "parent", + ) + with self.assertRaisesRegex(WorkspaceCatalogError, "cannot be resolved"): + WorkspaceCatalog.single(root / "missing") + + +class SecretVaultTests(unittest.TestCase): + def test_round_trip_is_encrypted_and_wrong_key_fails(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "oauth-secrets.json" + vault = SecretVault(path, "test-master-key") + vault.set_secret("oauth-signing/key-a", "secret-canary-value") + + raw = path.read_text(encoding="utf-8") + self.assertNotIn("secret-canary-value", raw) + self.assertEqual(vault.list_names(), ["oauth-signing/key-a"]) + self.assertEqual(vault.get_secret("oauth-signing/key-a"), "secret-canary-value") + with self.assertRaisesRegex(SecretVaultError, "incorrect|modified"): + SecretVault(path, "wrong-master-key").get_secret("oauth-signing/key-a") + self.assertTrue(vault.delete_secret("oauth-signing/key-a")) + self.assertFalse(vault.delete_secret("oauth-signing/key-a")) + + def test_atomic_replace_failure_preserves_previous_vault(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "oauth-secrets.json" + vault = SecretVault(path, "test-master-key") + vault.set_secret("stable", "old-value") + original = path.read_bytes() + + with patch.object( + secret_vault_module.os, + "replace", + side_effect=OSError("replace failed"), + ): + with self.assertRaises(SecretVaultError): + vault.set_secret("new", "new-value") + + self.assertEqual(path.read_bytes(), original) + self.assertEqual(vault.get_secret("stable"), "old-value") + self.assertEqual(list(path.parent.glob(f".{path.name}.*.tmp")), []) + + def test_unsupported_vault_version_is_rejected(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "oauth-secrets.json" + path.write_text('{"version": 99, "secrets": {}}\n', encoding="utf-8") + with self.assertRaisesRegex(SecretVaultError, "unsupported version"): + SecretVault(path, "test-master-key").list_names() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 39cc030..dcee8a8 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -248,13 +248,14 @@ def tearDown(self) -> None: def test_install_id_is_random_stable_and_resettable(self) -> None: with tempfile.TemporaryDirectory() as tmp: - with patch.dict(os.environ, {"HOME": tmp}): + with patch.object(Path, "home", return_value=Path(tmp)): first = telemetry.install_id() self.assertEqual(telemetry.install_id(), first) path = Path(tmp) / ".coding-tools-mcp" / "id" self.assertEqual(path.read_text(encoding="utf-8").strip(), first) - self.assertEqual(path.stat().st_mode & 0o777, 0o600) - self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700) + if os.name != "nt": + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700) telemetry._install_id = None path.unlink() diff --git a/tests/test_webui.py b/tests/test_webui.py new file mode 100644 index 0000000..87cee59 --- /dev/null +++ b/tests/test_webui.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from coding_tools_mcp.webui import ADMIN_HTML, WEBUI_DIST, admin_console_html + + +ROOT = Path(__file__).resolve().parents[1] +WEBUI_SRC = ROOT / "webui" / "src" + + +class WebUIBuildTests(unittest.TestCase): + def test_packaged_admin_page_is_generated_self_contained_source(self) -> None: + self.assertEqual([path.name for path in WEBUI_DIST.iterdir() if path.is_file()], ["admin.html"]) + built = ADMIN_HTML.read_text(encoding="utf-8") + self.assertEqual(admin_console_html(), built) + for name in ( + "admin.css", + "settings-copy.js", + "settings-model.js", + "workspace-editor.js", + "settings-page.js", + "admin.js", + ): + source = (WEBUI_SRC / name).read_text(encoding="utf-8").strip() + self.assertIn(f'data-build-source="{name}"', built) + self.assertIn(source, built) + self.assertIsNone(re.search(r']*href=["\'][^"\']+\.css', built, re.I)) + self.assertIsNone(re.search(r']*src=["\'][^"\']+\.js', built, re.I)) + + def test_frontend_has_no_obsolete_or_unsafe_control_paths(self) -> None: + source = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(WEBUI_SRC.iterdir()) + if path.suffix in {".html", ".js"} + ) + self.assertNotRegex(source, r"(?i)tool_profile") + self.assertNotIn("innerHTML", source) + self.assertNotRegex(source, r"localStorage|sessionStorage") + self.assertNotRegex(source, r"reload_upstream|start_server|stop_server") + self.assertIn("stale_revision", source) + self.assertIn("textContent", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace_session_binding.py b/tests/test_workspace_session_binding.py new file mode 100644 index 0000000..a90031a --- /dev/null +++ b/tests/test_workspace_session_binding.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +import http.client +import json +import os +import shutil +import sqlite3 +import tempfile +import threading +import time +import unittest +from contextlib import closing, contextmanager +from unittest.mock import patch +from pathlib import Path +from typing import Any, Iterator + +from coding_tools_mcp.oauth import ( + OAuthIdentity, + OAuthServiceError, + create_access_token, + create_authorization_grant, +) +from coding_tools_mcp.server import ( + AuthorizationContext, + BoundRuntimeFactory, + MCPHandler, + RuntimeHTTPServer, + WorkspaceBinding, + apply_oauth_workspace_bindings, + build_parser, + build_persistent_oauth_config, + build_runtime, + load_project_context, + load_workspace_startup, + runtime_policy_from_args, +) +from coding_tools_mcp.settings_store import ServerSettingsStore +from coding_tools_mcp.workspace_binding import ( + WorkspaceBindingError, + WorkspaceBindingResolver, +) +from coding_tools_mcp.workspace_catalog import WorkspaceCatalog, WorkspaceEntry + + +@contextmanager +def test_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "config" / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +def rpc( + base_port: int, + token: str, + request: dict[str, Any], + *, + session_id: str | None = None, +) -> tuple[int, dict[str, str], dict[str, Any]]: + body = json.dumps(request).encode("utf-8") + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + } + if session_id: + headers["Mcp-Session-Id"] = session_id + connection = http.client.HTTPConnection("127.0.0.1", base_port, timeout=5) + try: + connection.request("POST", "/mcp", body=body, headers=headers) + response = connection.getresponse() + raw = response.read() + response_headers = {name.lower(): value for name, value in response.getheaders()} + payload = json.loads(raw) if raw else {} + return response.status, response_headers, payload + finally: + connection.close() + + +def delete_session(port: int, token: str, session_id: str) -> tuple[int, dict[str, Any]]: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5) + try: + connection.request( + "DELETE", + "/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Mcp-Session-Id": session_id, + }, + ) + response = connection.getresponse() + raw = response.read() + return response.status, json.loads(raw) if raw else {} + finally: + connection.close() + + +def initialize(port: int, token: str, request_id: int) -> tuple[str, dict[str, Any]]: + status, headers, response = rpc( + port, + token, + { + "jsonrpc": "2.0", + "id": request_id, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": f"agent-{request_id}", "version": "1"}, + }, + }, + ) + if status != 200: + raise AssertionError(response) + session_id = headers.get("mcp-session-id") + if not session_id: + raise AssertionError("initialize did not return Mcp-Session-Id") + return session_id, response["result"] + + +def call_tool( + port: int, + token: str, + session_id: str, + request_id: int, + name: str, + arguments: dict[str, Any], +) -> tuple[int, dict[str, Any]]: + status, _headers, response = rpc( + port, + token, + { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, + session_id=session_id, + ) + return status, response + + +class WorkspaceSessionBindingTests(unittest.TestCase): + def test_oauth_sessions_bind_immutable_isolated_workspaces(self) -> None: + with test_root() as root: + first = root / "first" + second = root / "second" + config_dir = root / "config" + first.mkdir() + second.mkdir() + (first / "identity.txt").write_text("FIRST", encoding="utf-8") + (second / "identity.txt").write_text("SECOND", encoding="utf-8") + (first / "AGENTS.md").write_text("FIRST-INSTRUCTIONS", encoding="utf-8") + (second / "AGENTS.md").write_text("SECOND-INSTRUCTIONS", encoding="utf-8") + (first / "subdir").mkdir() + (second / "subdir").mkdir() + + catalog = WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=True), + ], + "first", + ) + resolver = WorkspaceBindingResolver(catalog) + oauth_config, _created = build_persistent_oauth_config( + config_dir, + master_key="synthetic-workspace-master-key", + password="synthetic-workspace-password", + server_url=None, + token_ttl=86_400, + registration_workspace_id=None, + ) + oauth_config.registry.add_preregistered( + "agent-first", + ("http://127.0.0.1/callback",), + client_secret=None, + workspace_id="first", + ) + oauth_config.registry.add_preregistered( + "agent-second", + ("http://127.0.0.1/callback",), + client_secret=None, + workspace_id="second", + ) + + args = build_parser().parse_args(["--workspace", str(first)]) + policy = runtime_policy_from_args(args) + control_binding = WorkspaceBinding("first", first, "control") + control_runtime = build_runtime( + args, + policy, + oauth_config=oauth_config, + project_context=load_project_context(first), + workspace_binding=control_binding, + authorization_context=AuthorizationContext("control"), + transport="http", + ) + factory = BoundRuntimeFactory( + args, + policy, + resolver, + auth_token=None, + oauth_config=oauth_config, + ) + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + control_runtime, + factory, + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + port = int(server.server_address[1]) + base = f"http://127.0.0.1:{port}" + + first_grant = create_authorization_grant( + oauth_config, + client_id="agent-first", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + second_grant = create_authorization_grant( + oauth_config, + client_id="agent-second", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + first_token = create_access_token( + oauth_config, + base, + client_id="agent-first", + grant_id=first_grant, + ) + second_token = create_access_token( + oauth_config, + base, + client_id="agent-second", + grant_id=second_grant, + ) + + try: + first_session, first_init = initialize(port, first_token, 1) + second_session, second_init = initialize(port, second_token, 2) + self.assertIn("FIRST-INSTRUCTIONS", first_init["instructions"]) + self.assertNotIn("SECOND-INSTRUCTIONS", first_init["instructions"]) + self.assertIn("SECOND-INSTRUCTIONS", second_init["instructions"]) + self.assertNotIn("FIRST-INSTRUCTIONS", second_init["instructions"]) + + first_status, first_read = call_tool( + port, first_token, first_session, 3, "read_file", {"path": "identity.txt"} + ) + second_status, second_read = call_tool( + port, second_token, second_session, 4, "read_file", {"path": "identity.txt"} + ) + self.assertEqual(first_status, 200) + self.assertEqual(second_status, 200) + self.assertEqual(first_read["result"]["structuredContent"]["content"], "FIRST") + self.assertEqual(second_read["result"]["structuredContent"]["content"], "SECOND") + + escape_status, escape = call_tool( + port, + first_token, + first_session, + 5, + "read_file", + {"path": str(second / "identity.txt")}, + ) + self.assertEqual(escape_status, 200) + self.assertTrue(escape["result"]["isError"]) + self.assertFalse(escape["result"]["structuredContent"]["ok"]) + + cwd_status, cwd = call_tool( + port, + first_token, + first_session, + 6, + "set_default_cwd", + {"path": "subdir"}, + ) + self.assertEqual(cwd_status, 200) + self.assertEqual(cwd["result"]["structuredContent"]["default_cwd"], "subdir") + _status, second_cwd = call_tool( + port, second_token, second_session, 7, "get_default_cwd", {} + ) + self.assertEqual( + second_cwd["result"]["structuredContent"]["default_cwd"], "." + ) + + first_runtime = server.sessions.get(first_session) + second_runtime = server.sessions.get(second_session) + self.assertIsNot(first_runtime, second_runtime) + self.assertEqual(first_runtime.workspace.root, first.resolve()) + self.assertEqual(second_runtime.workspace.root, second.resolve()) + self.assertIsNot(first_runtime.sessions, second_runtime.sessions) + self.assertIsNot(first_runtime.output_sessions, second_runtime.output_sessions) + self.assertIsNot(first_runtime.project_context, second_runtime.project_context) + + mismatch_status, _headers, mismatch = rpc( + port, + second_token, + { + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": {"name": "get_default_cwd", "arguments": {}}, + }, + session_id=first_session, + ) + self.assertEqual(mismatch_status, 403) + self.assertIn("does not match", mismatch["error"]["message"]) + delete_status, delete_response = delete_session( + port, second_token, first_session + ) + self.assertEqual(delete_status, 403) + self.assertIn("does not match", delete_response["error"]["message"]) + still_alive_status, _still_alive = call_tool( + port, first_token, first_session, 81, "get_default_cwd", {} + ) + self.assertEqual(still_alive_status, 200) + + resolver.update_catalog( + WorkspaceCatalog( + [ + WorkspaceEntry( + "first", "First", first, enabled=True, default=True + ), + WorkspaceEntry("second", "Second", second, enabled=False), + ], + "first", + ) + ) + existing_status, existing = call_tool( + port, + second_token, + second_session, + 9, + "read_file", + {"path": "identity.txt"}, + ) + self.assertEqual(existing_status, 200) + self.assertEqual( + existing["result"]["structuredContent"]["content"], "SECOND" + ) + denied_status, _headers, denied = rpc( + port, + second_token, + { + "jsonrpc": "2.0", + "id": 10, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "disabled", "version": "1"}, + }, + }, + ) + self.assertEqual(denied_status, 503) + self.assertIn("Workspace mapping", denied["error"]["message"]) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + def test_startup_binding_migrates_only_a_single_enabled_workspace(self) -> None: + with test_root() as root: + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + single = WorkspaceCatalog( + [WorkspaceEntry("first", "First", first, enabled=True, default=True)], + "first", + ) + single_config, _created = build_persistent_oauth_config( + root / "single-config", + master_key="single-master-key", + password="single-password", + server_url=None, + token_ttl=86_400, + registration_workspace_id=None, + ) + single_config.registry.add_preregistered( + "single-agent", + ("http://127.0.0.1/callback",), + client_secret=None, + ) + self.assertIsNone(single_config.store.get_client("single-agent")["workspace_id"]) + apply_oauth_workspace_bindings(single_config, single, {}) + self.assertEqual( + single_config.store.get_client("single-agent")["workspace_id"], + "first", + ) + + multiple = WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=True), + ], + "first", + ) + multi_config, _created = build_persistent_oauth_config( + root / "multi-config", + master_key="multi-master-key", + password="multi-password", + server_url=None, + token_ttl=86_400, + registration_workspace_id=None, + ) + multi_config.registry.add_preregistered( + "multi-agent", + ("http://127.0.0.1/callback",), + client_secret=None, + ) + apply_oauth_workspace_bindings(multi_config, multiple, {}) + self.assertIsNone(multi_config.store.get_client("multi-agent")["workspace_id"]) + with self.assertRaises(OAuthServiceError): + create_authorization_grant( + multi_config, + client_id="multi-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + apply_oauth_workspace_bindings( + multi_config, + multiple, + {"multi-agent": "second"}, + ) + grant_id = create_authorization_grant( + multi_config, + client_id="multi-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + self.assertEqual(multi_config.store.get_grant(grant_id)["workspace_id"], "second") + + def test_startup_loader_reads_catalog_from_server_settings(self) -> None: + with test_root() as root: + first = root / "first" + second = root / "second" + config_dir = root / "settings" + first.mkdir() + second.mkdir() + ServerSettingsStore(config_dir / "server-settings.json").write( + { + "workspace_catalog": [ + { + "id": "first", + "name": "First", + "root": str(first), + "enabled": True, + "default": True, + }, + { + "id": "second", + "name": "Second", + "root": str(second), + "enabled": True, + "default": False, + }, + ], + "default_workspace_id": "first", + "oauth_client_workspace_bindings": {"agent-second": "second"}, + } + ) + args = build_parser().parse_args(["--workspace", str(first)]) + with patch.dict( + os.environ, + {"CODING_TOOLS_MCP_CONFIG_DIR": str(config_dir)}, + clear=False, + ): + loaded_dir, settings, catalog = load_workspace_startup(args) + self.assertEqual(loaded_dir, config_dir) + self.assertEqual(catalog.default_id, "first") + self.assertEqual(catalog.get("second").root, second.resolve()) + self.assertEqual( + settings["oauth_client_workspace_bindings"], + {"agent-second": "second"}, + ) + + def test_resolver_fails_closed_and_stdio_uses_only_default_workspace(self) -> None: + with test_root() as root: + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + catalog = WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=False), + ], + "first", + ) + resolver = WorkspaceBindingResolver(catalog) + stdio = resolver.resolve_stdio() + self.assertEqual(stdio.workspace_id, "first") + self.assertEqual(stdio.authorization_method, "stdio") + with self.assertRaises(WorkspaceBindingError): + resolver.resolve_http("oauth", None) + with self.assertRaises(WorkspaceBindingError): + resolver.resolve_http( + "oauth", + OAuthIdentity("agent", "grant", "second", "jti"), + ) diff --git a/uv.lock b/uv.lock index a6bffa2..bb03c28 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ wheels = [ [[package]] name = "coding-tools-mcp" -version = "0.2.0" +version = "0.2.2" source = { editable = "." } dependencies = [ { name = "pyjwt" }, @@ -61,6 +61,7 @@ desktop = [ ] dev = [ { name = "mypy" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "typing-extensions" }, ] @@ -75,6 +76,7 @@ requires-dist = [ { name = "psutil", marker = "extra == 'desktop'", specifier = ">=7.0,<8" }, { name = "pyjwt", specifier = ">=2.8" }, { name = "pyside6", marker = "extra == 'desktop'", specifier = ">=6.8,<6.9" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15,<0.16" }, { name = "typing-extensions", marker = "extra == 'dev'", specifier = ">=4.12" }, ] @@ -391,6 +393,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0f/5d8c6da7586e57ee032643e0c0e62335ef1a1add1a980160ddd1654f1d8d/PySide6_Essentials-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07", size = 72191029, upload-time = "2025-03-27T12:15:10.425Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.15.17" diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..dfdba0a --- /dev/null +++ b/webui/package.json @@ -0,0 +1,11 @@ +{ + "name": "coding-tools-mcp-webui", + "private": true, + "type": "module", + "scripts": { + "test:models": "node --test tests/settings-model.test.mjs tests/security-model.test.mjs", + "test:dom": "node --test tests/dom-interactions.test.mjs", + "test": "npm run test:models && npm run test:dom", + "build": "node scripts/build.mjs" + } +} diff --git a/webui/scripts/build.mjs b/webui/scripts/build.mjs new file mode 100644 index 0000000..a28ee76 --- /dev/null +++ b/webui/scripts/build.mjs @@ -0,0 +1,38 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const srcDir = path.join(root, 'webui', 'src'); +const outDir = path.join(root, 'coding_tools_mcp', 'webui_dist'); +const scripts = [ + 'settings-copy.js', + 'settings-model.js', + 'workspace-editor.js', + 'settings-page.js', + 'admin.js', +]; + +const [html, css, modules] = await Promise.all([ + readFile(path.join(srcDir, 'admin.html'), 'utf8'), + readFile(path.join(srcDir, 'admin.css'), 'utf8'), + Promise.all(scripts.map(async (name) => [name, await readFile(path.join(srcDir, name), 'utf8')])), +]); + +let built = html.replace( + '', + ``, +); +for (const [name, source] of modules) { + built = built.replace( + ``, + ``, + ); +} +if (/]*href=["'][^"']+\.css|]*src=["'][^"']+\.js/i.test(built)) { + throw new Error('Build left an external WebUI asset reference in admin.html.'); +} +await rm(outDir, { recursive: true, force: true }); +await mkdir(outDir, { recursive: true }); +await writeFile(path.join(outDir, 'admin.html'), `${built.trim()}\n`, 'utf8'); +console.log('Built coding_tools_mcp/webui_dist/admin.html from webui/src/**'); diff --git a/webui/src/admin.css b/webui/src/admin.css new file mode 100644 index 0000000..e95d89f --- /dev/null +++ b/webui/src/admin.css @@ -0,0 +1,103 @@ +:root { + color-scheme: light dark; + --bg: #f4f6f8; + --surface: #ffffff; + --text: #17202a; + --muted: #56616d; + --line: #c9d1d9; + --accent: #075985; + --accent-text: #ffffff; + --danger: #b42318; + --warning: #8a4b08; + --good: #18794e; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +@media (prefers-color-scheme: dark) { + :root { --bg:#11161c; --surface:#1b232c; --text:#f4f7fa; --muted:#b6c0ca; --line:#43505d; --accent:#38bdf8; --accent-text:#06131b; --danger:#ff8a80; --warning:#ffca80; --good:#6ee7b7; } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--text); line-height: 1.5; } +button, input, select, textarea { font: inherit; } +button, input, select, textarea { min-height: 44px; } +button { border: 1px solid transparent; border-radius: .55rem; padding: .65rem .9rem; background: var(--accent); color: var(--accent-text); cursor: pointer; } +button:hover { filter: brightness(1.08); } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 2px; } +button.secondary { background: transparent; color: var(--text); border-color: var(--line); } +button.danger { background: var(--danger); color: #fff; } +button:disabled { opacity: .55; cursor: not-allowed; } +input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: .5rem; padding: .65rem; background: var(--surface); color: var(--text); } +textarea { resize: vertical; } +label { display: block; font-weight: 650; margin-bottom: .3rem; } +code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.skip-link { position: fixed; left: .5rem; top: -4rem; z-index: 100; background: var(--surface); color: var(--text); padding: .75rem; } +.skip-link:focus { top: .5rem; } +.topbar { display: flex; gap: 2rem; align-items: flex-start; justify-content: space-between; padding: 1rem 1.5rem; background: var(--surface); border-bottom: 1px solid var(--line); } +.topbar h1 { margin: 0; } +.eyebrow { margin: 0; text-transform: uppercase; letter-spacing: .08em; font-size: .75rem; color: var(--muted); } +.auth-form { display: grid; grid-template-columns: minmax(220px, 420px) auto auto; gap: .6rem; align-items: end; } +.help, .muted { color: var(--muted); font-size: .9rem; } +.help { margin: .3rem 0 0; } +.layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); min-height: calc(100vh - 100px); } +.sidebar { padding: 1rem; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: .4rem; background: var(--surface); } +.nav-item { text-align: left; background: transparent; color: var(--text); border-color: transparent; } +.nav-item.active { background: color-mix(in srgb, var(--accent) 16%, transparent); border-color: var(--accent); } +main { width: 100%; max-width: 1500px; padding: 1.25rem; } +.page-section { display: none; } +.page-section.active { display: block; } +.section-heading { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1rem; } +.section-heading h2 { margin:.1rem 0; } +.card, .workspace-card { background: var(--surface); border:1px solid var(--line); border-radius:.8rem; padding:1rem; min-width:0; } +.card h3, .workspace-card h3 { margin-top:0; } +.card-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:1rem; margin-bottom:1rem; } +.stack { display:flex; flex-direction:column; gap:.75rem; } +.form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; margin-bottom:1rem; } +.form-grid .full { grid-column:1/-1; } +.button-row, .toolbar, .pager { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem; } +.button-row.end { justify-content:flex-end; } +.toolbar { margin-bottom:1rem; } +.toolbar label { margin:0; } +.toolbar input, .toolbar select { width:auto; min-width:180px; } +.checkline { display:flex; align-items:center; gap:.6rem; font-weight:500; } +.checkline input { width:auto; min-height:auto; } +.status { min-height:2.2rem; padding:.65rem .8rem; margin-bottom:1rem; border-left:4px solid var(--accent); background:var(--surface); } +.alert { padding:.8rem; border:1px solid currentColor; border-radius:.6rem; margin-bottom:1rem; } +.alert.warning { color:var(--warning); } +.alert.danger { color:var(--danger); } +.badge { display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:.2rem .55rem; font-size:.8rem; } +.badge.good { color:var(--good); } +.badge.danger { color:var(--danger); } +.workspace-heading { display:flex; justify-content:space-between; gap:1rem; } +.compact-definition { display:grid; grid-template-columns:auto 1fr; gap:.3rem .7rem; } +.compact-definition dt { color:var(--muted); } +.compact-definition dd { margin:0; min-width:0; } +.code-block { max-height:360px; overflow:auto; white-space:pre-wrap; background:color-mix(in srgb,var(--bg) 75%,transparent); padding:.75rem; border-radius:.5rem; } +.danger-zone { border-color: color-mix(in srgb, var(--danger) 55%, var(--line)); margin-bottom:1rem; } +details > summary { cursor:pointer; font-weight:700; } +.split-view { display:grid; grid-template-columns:minmax(280px,.8fr) minmax(0,1.2fr); gap:1rem; } +.conversation-card button { width:100%; text-align:left; background:transparent; color:var(--text); border-color:var(--line); } +.conversation-card h3 { margin:.1rem 0; } +.message, .context-entry { border-left:4px solid var(--line); padding:.75rem; margin:.75rem 0; background:color-mix(in srgb,var(--surface) 92%,var(--bg)); } +.message pre, .context-entry pre { white-space:pre-wrap; overflow-wrap:anywhere; } +.data-table { width:100%; border-collapse:collapse; } +.data-table th, .data-table td { border-bottom:1px solid var(--line); padding:.55rem; text-align:left; vertical-align:top; } +.data-table th { color:var(--muted); } +dialog { border:1px solid var(--line); border-radius:.8rem; background:var(--surface); color:var(--text); max-width:560px; width:calc(100% - 2rem); } +dialog::backdrop { background:rgba(0,0,0,.55); } +.dialog-card { padding:.5rem; } +[hidden] { display:none !important; } +@media (max-width: 850px) { + .topbar { flex-direction:column; } + .auth-form { grid-template-columns:1fr auto auto; width:100%; } + .layout { grid-template-columns:1fr; } + .sidebar { position:sticky; top:0; z-index:10; flex-direction:row; overflow-x:auto; border-right:0; border-bottom:1px solid var(--line); } + .nav-item { white-space:nowrap; } + .split-view, .form-grid { grid-template-columns:1fr; } + .form-grid .full { grid-column:auto; } +} +@media (max-width: 560px) { + .auth-form { grid-template-columns:1fr 1fr; } + .auth-form > div { grid-column:1/-1; } + .section-heading { align-items:flex-start; flex-direction:column; } + main { padding:.8rem; } + .toolbar input, .toolbar select { width:100%; } +} diff --git a/webui/src/admin.html b/webui/src/admin.html new file mode 100644 index 0000000..f04384e --- /dev/null +++ b/webui/src/admin.html @@ -0,0 +1,146 @@ + + + + + + + Coding Tools MCP Admin + + + + +
+
+

coding-tools-mcp

+

Admin Console

+
+
+
+ + +

仅保存在当前页面内存中;不会写入 URL、浏览器持久化存储、日志或服务器设置。

+
+ + +
+
+ +
+ + +
+
输入专用 Admin token 后连接。
+ +
+

Runtime

概览

+
+

Admin API

未连接

普通 MCP bearer 不具备管理员权限。

+

Gateway

未连接

工具快照在 Runtime 初始化时冻结,不支持热 reload。

+

Telemetry

未报告

+

Vault

未连接

页面从不读取或显示 Secret 值。

+
+
+ + + + + + + + + + + + +
+
+ + +
+

确认操作

+

+
+
+
+ + + + + + + + diff --git a/webui/src/admin.js b/webui/src/admin.js new file mode 100644 index 0000000..5f7b549 --- /dev/null +++ b/webui/src/admin.js @@ -0,0 +1,596 @@ +class ApiError extends Error { + constructor(status, payload, message) { + super(message || payload?.error?.message || `Admin request failed with HTTP ${status}.`); + this.name = 'ApiError'; + this.status = status; + this.payload = payload; + } +} + +const FORBIDDEN_RESPONSE_KEYS = new Set([ + 'client_secret', + 'client_secret_digest', + 'refresh_token', + 'access_token', + 'token_hash', + 'signing_secret', + 'secret_ref', +]); + +function sensitiveKey(key) { + const normalized = String(key || '').toLowerCase(); + return FORBIDDEN_RESPONSE_KEYS.has(normalized) + || normalized.endsWith('_secret_ref') + || normalized.endsWith('_digest') + || normalized.endsWith('_hash'); +} + +function sanitizeAdminValue(value) { + if (Array.isArray(value)) return value.map(sanitizeAdminValue); + if (!value || typeof value !== 'object') return value; + const result = {}; + for (const [key, child] of Object.entries(value)) { + if (sensitiveKey(key)) continue; + if (key === 'source' && child === 'secret_ref') { + result.source = 'configured credential'; + continue; + } + result[key] = sanitizeAdminValue(child); + } + return result; +} + +function containsCredentialControl(value) { + if (Array.isArray(value)) return value.some(containsCredentialControl); + if (!value || typeof value !== 'object') return false; + return Object.entries(value).some(([key, child]) => { + const normalized = key.toLowerCase(); + if (normalized === 'secret_ref' || normalized === 'env_ref') return true; + if (/authorization|api[-_]?key|token|password|credential|secret/.test(normalized)) return true; + return containsCredentialControl(child); + }); +} + +function createApiClient(getToken, fetchImpl = globalThis.fetch) { + async function request(path, options = {}) { + const token = String(getToken?.() || ''); + const headers = new Headers(options.headers || {}); + headers.set('Accept', 'application/json'); + if (options.body !== undefined) headers.set('Content-Type', 'application/json'); + if (token) headers.set('Authorization', `Bearer ${token}`); + const response = await fetchImpl(`/admin/api${path}`, { + method: options.method || 'GET', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + credentials: 'same-origin', + cache: 'no-store', + }); + let payload = {}; + try { payload = await response.json(); } catch { payload = {}; } + if (!response.ok) throw new ApiError(response.status, payload); + return payload; + } + return { request }; +} + +function createNode(documentRef, tag, options = {}) { + const node = documentRef.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined) node.textContent = String(options.text); + if (options.type) node.type = options.type; + if (options.id) node.id = options.id; + return node; +} + +function appendDefinitionList(documentRef, container, value) { + const safe = sanitizeAdminValue(value); + const dl = createNode(documentRef, 'dl', { className: 'compact-definition' }); + for (const [key, child] of Object.entries(safe || {})) { + if (typeof child === 'object' && child !== null) continue; + dl.append( + createNode(documentRef, 'dt', { text: key }), + createNode(documentRef, 'dd', { text: child ?? '—' }), + ); + } + container.append(dl); +} + +function renderConversationItems(container, items, onSelect) { + const documentRef = container.ownerDocument || document; + container.replaceChildren(); + if (!items?.length) { + container.append(createNode(documentRef, 'p', { className: 'muted', text: '没有匹配的会话摘要。' })); + return; + } + for (const item of items) { + const card = createNode(documentRef, 'article', { className: 'conversation-card' }); + const button = createNode(documentRef, 'button', { type: 'button' }); + const title = createNode(documentRef, 'h3', { text: item.title || item.conversation_id || 'Untitled conversation' }); + const identity = createNode(documentRef, 'p', { className: 'muted', text: `${item.workspace_id || '—'} / ${item.conversation_id || '—'}` }); + const preview = createNode(documentRef, 'p', { text: item.preview || '无摘要正文。' }); + const counts = createNode(documentRef, 'p', { className: 'muted', text: `Messages: ${item.message_count || 0} · Context: ${item.context_count || 0}` }); + button.append(title, identity, preview, counts); + button.addEventListener('click', () => onSelect?.(item, button)); + card.append(button); + container.append(card); + } +} + +function renderConversationDetail(container, payload, handlers = {}) { + const documentRef = container.ownerDocument || document; + container.replaceChildren(); + const conversation = payload?.conversation || {}; + const heading = createNode(documentRef, 'div', { className: 'section-heading' }); + const titleWrap = createNode(documentRef, 'div'); + titleWrap.append( + createNode(documentRef, 'p', { className: 'eyebrow', text: `${conversation.workspace_id || '—'} / ${conversation.conversation_id || '—'}` }), + createNode(documentRef, 'h3', { text: conversation.title || conversation.conversation_id || 'Conversation detail' }), + ); + const deleteConversation = createNode(documentRef, 'button', { type: 'button', className: 'danger', text: '删除会话' }); + deleteConversation.addEventListener('click', () => handlers.onDeleteConversation?.(conversation, deleteConversation)); + heading.append(titleWrap, deleteConversation); + container.append(heading); + + const messagesHeading = createNode(documentRef, 'h4', { text: `Messages (${payload.messages_total || 0})` }); + container.append(messagesHeading); + for (const message of payload.messages || []) { + const card = createNode(documentRef, 'section', { className: 'message' }); + const meta = createNode(documentRef, 'p', { className: 'muted', text: `${message.role || 'unknown'} · ${message.message_id || '—'} · ${message.timestamp || '—'}` }); + const content = createNode(documentRef, 'pre', { text: message.content || '' }); + const remove = createNode(documentRef, 'button', { type: 'button', className: 'danger', text: '删除 message' }); + remove.addEventListener('click', () => handlers.onDeleteMessage?.(message, remove)); + card.append(meta, content, remove); + container.append(card); + } + const messagePager = createNode(documentRef, 'div', { className: 'pager' }); + const previousMessages = createNode(documentRef, 'button', { type: 'button', className: 'secondary', text: 'Messages 上一页' }); + previousMessages.disabled = (payload.message_page || 1) <= 1; + previousMessages.addEventListener('click', () => handlers.onMessagePage?.((payload.message_page || 1) - 1)); + const nextMessages = createNode(documentRef, 'button', { type: 'button', className: 'secondary', text: 'Messages 下一页' }); + nextMessages.disabled = (payload.message_page || 1) * (payload.message_page_size || 100) >= (payload.messages_total || 0); + nextMessages.addEventListener('click', () => handlers.onMessagePage?.((payload.message_page || 1) + 1)); + messagePager.append(previousMessages, createNode(documentRef, 'span', { text: `第 ${payload.message_page || 1} 页` }), nextMessages); + container.append(messagePager); + + container.append(createNode(documentRef, 'h4', { text: `Context (${payload.contexts_total || 0})` })); + for (const entry of payload.contexts || []) { + const card = createNode(documentRef, 'section', { className: 'context-entry' }); + card.append( + createNode(documentRef, 'p', { className: 'muted', text: `${entry.kind || 'context'} · ${entry.context_id || '—'} · ${entry.timestamp || '—'}` }), + createNode(documentRef, 'pre', { text: entry.content || '' }), + ); + const remove = createNode(documentRef, 'button', { type: 'button', className: 'danger', text: '删除 context' }); + remove.addEventListener('click', () => handlers.onDeleteContext?.(entry, remove)); + card.append(remove); + container.append(card); + } + const contextPager = createNode(documentRef, 'div', { className: 'pager' }); + const previousContext = createNode(documentRef, 'button', { type: 'button', className: 'secondary', text: 'Context 上一页' }); + previousContext.disabled = (payload.context_page || 1) <= 1; + previousContext.addEventListener('click', () => handlers.onContextPage?.((payload.context_page || 1) - 1)); + const nextContext = createNode(documentRef, 'button', { type: 'button', className: 'secondary', text: 'Context 下一页' }); + nextContext.disabled = (payload.context_page || 1) * (payload.context_page_size || 100) >= (payload.contexts_total || 0); + nextContext.addEventListener('click', () => handlers.onContextPage?.((payload.context_page || 1) + 1)); + contextPager.append(previousContext, createNode(documentRef, 'span', { text: `第 ${payload.context_page || 1} 页` }), nextContext); + container.append(contextPager); +} + +function renderOAuthItems(container, items, collection, onAction) { + const documentRef = container.ownerDocument || document; + container.replaceChildren(); + if (!items?.length) { + container.append(createNode(documentRef, 'p', { className: 'muted', text: '没有记录。' })); + return; + } + const actionMap = { + clients: ['enable', 'disable'], + grants: ['revoke'], + tokens: ['revoke'], + 'refresh-families': ['revoke'], + 'signing-keys': ['activate', 'retire', 'revoke'], + }; + const idKey = { + clients: 'client_id', grants: 'grant_id', tokens: 'jti', + 'refresh-families': 'family_id', 'signing-keys': 'kid', audit: 'event_id', + }[collection]; + for (const original of items) { + const item = sanitizeAdminValue(original); + const card = createNode(documentRef, 'article', { className: 'card' }); + card.append(createNode(documentRef, 'h3', { text: String(item?.[idKey] || `${collection} item`) })); + appendDefinitionList(documentRef, card, item); + if (Object.values(item || {}).some((value) => value && typeof value === 'object')) { + const details = createNode(documentRef, 'details'); + const summary = createNode(documentRef, 'summary', { text: '查看脱敏结构' }); + const pre = createNode(documentRef, 'pre', { className: 'code-block', text: JSON.stringify(item, null, 2) }); + details.append(summary, pre); + card.append(details); + } + const actions = createNode(documentRef, 'div', { className: 'button-row' }); + for (const action of actionMap[collection] || []) { + const button = createNode(documentRef, 'button', { type: 'button', className: action === 'enable' || action === 'activate' ? 'secondary' : 'danger', text: action }); + button.addEventListener('click', () => onAction?.(collection, String(item?.[idKey] || ''), action, button)); + actions.append(button); + } + if (actions.childNodes.length) card.append(actions); + container.append(card); + } +} + +function confirmDestructive(documentRef, { title, message, confirmLabel = '确认', returnFocus } = {}) { + const dialog = documentRef.getElementById('confirmDialog'); + if (!dialog || typeof dialog.showModal !== 'function') { + return Promise.resolve(globalThis.confirm ? globalThis.confirm(message || title || '确认操作?') : false); + } + documentRef.getElementById('confirmTitle').textContent = title || '确认操作'; + documentRef.getElementById('confirmMessage').textContent = message || ''; + documentRef.getElementById('confirmAccept').textContent = confirmLabel; + return new Promise((resolve) => { + const finish = () => { + dialog.removeEventListener('close', finish); + const accepted = dialog.returnValue === 'confirm'; + if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); + resolve(accepted); + }; + dialog.addEventListener('close', finish); + dialog.showModal(); + }); +} + +async function handleSettingsSave({ api, state, documentRef }) { + const model = globalThis.McpSettingsModel; + const page = globalThis.McpSettingsPage; + state.settings.draft = model.serializeSettings(page.collectSettingsDraft(documentRef, state.settings.draft)); + try { + const payload = await api.request('/settings', { + method: 'PUT', + body: { expected_revision: state.settings.persistedRevision, updates: state.settings.draft }, + }); + const safePayload = { ...payload, active: sanitizeAdminValue(payload.active), persisted: sanitizeAdminValue(payload.persisted) }; + state.settings = model.hydrateSettings(safePayload); + page.renderSettingsForm(documentRef, state.settings, globalThis.McpSettingsCopy.permissionPresentation); + page.renderFormError(documentRef, ''); + return { saved: true, conflict: false }; + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + const latest = await api.request('/settings'); + const safeLatest = { ...latest, active: sanitizeAdminValue(latest.active), persisted: sanitizeAdminValue(latest.persisted) }; + state.settings = model.refreshPersistedKeepingDraft(state.settings, safeLatest); + page.renderSettingsForm(documentRef, state.settings, globalThis.McpSettingsCopy.permissionPresentation); + const conflict = documentRef.getElementById('settingsConflict'); + conflict?.focus(); + return { saved: false, conflict: true }; + } + throw error; + } +} + +function initAdminApp(documentRef = document) { + const model = globalThis.McpSettingsModel; + const copy = globalThis.McpSettingsCopy; + const workspaceEditor = globalThis.McpWorkspaceEditor; + const settingsPage = globalThis.McpSettingsPage; + const state = { + token: '', settings: null, workspaces: [], workspaceRevision: '', gateway: null, + gatewayRevision: '', conversationPage: 1, conversationTotal: 0, + selectedConversation: null, messagePage: 1, contextPage: 1, + }; + const api = createApiClient(() => state.token); + const byId = (id) => documentRef.getElementById(id); + + function status(message, kind = '') { + const box = byId('globalStatus'); + if (!box) return; + box.textContent = message; + box.className = `status ${kind}`.trim(); + } + + function showSection(name) { + for (const section of documentRef.querySelectorAll('.page-section')) { + const active = section.id === `section-${name}`; + section.hidden = !active; + section.classList.toggle('active', active); + } + for (const button of documentRef.querySelectorAll('.nav-item')) { + button.classList.toggle('active', button.dataset.section === name); + } + byId('mainContent')?.focus(); + } + + async function loadOverview() { + const payload = await api.request('/status'); + byId('adminApiStatus').textContent = payload.admin_api ? '可用' : '不可用'; + byId('gatewayRuntimeStatus').textContent = payload.gateway?.available ? '已配置(快照不可变)' : '不可用'; + byId('vaultStatus').textContent = payload.vault?.enabled ? '已启用' : '未启用'; + const telemetry = copy.telemetryPresentation(payload.telemetry); + byId('telemetryStatus').textContent = telemetry.label; + byId('telemetryDetail').textContent = telemetry.detail; + byId('telemetryDisableHelp').textContent = copy.TELEMETRY_DISABLE_HELP; + byId('fakeReadonlyStatus').textContent = payload.runtime?.annotation_override === 'fake_readonly' ? '已启用' : payload.runtime ? '未启用' : '未报告'; + return payload; + } + + function safeSettingsPayload(payload) { + return { ...payload, active: sanitizeAdminValue(payload.active), persisted: sanitizeAdminValue(payload.persisted) }; + } + + async function loadSettings({ preserveDraft = false } = {}) { + const payload = safeSettingsPayload(await api.request('/settings')); + state.settings = preserveDraft && state.settings + ? model.refreshPersistedKeepingDraft(state.settings, payload) + : model.hydrateSettings(payload); + settingsPage.renderSettingsForm(documentRef, state.settings, copy.permissionPresentation); + return payload; + } + + async function loadWorkspaces() { + const payload = await api.request('/workspaces'); + state.workspaces = payload.workspace_catalog || []; + state.workspaceRevision = payload.persisted_revision || ''; + workspaceEditor.renderWorkspaceRows(byId('workspaceList'), state.workspaces, { + onCheck: async (workspace) => { + const result = await api.request(`/workspaces/${encodeURIComponent(workspace.id)}/check`); + status(`Workspace ${workspace.id}: exists=${result.check?.exists}, directory=${result.check?.is_directory}`); + }, + onDefault: async (workspace, button) => { + const accepted = await confirmDestructive(documentRef, { + title: '更改默认 Workspace', + message: `Workspace ID: ${workspace.id}\n影响:新的 Runtime/Session 将使用此默认 Workspace;已有 Session 绑定不变。`, + confirmLabel: '设为默认', returnFocus: button, + }); + if (!accepted) return; + await api.request(`/workspaces/${encodeURIComponent(workspace.id)}/default`, { method: 'POST', body: { expected_revision: state.workspaceRevision } }); + await Promise.all([loadWorkspaces(), loadSettings()]); + status(`默认 Workspace 已改为 ${workspace.id};需要按返回状态重启。`); + }, + onDisable: async (workspace, button) => { + const accepted = await confirmDestructive(documentRef, { + title: '禁用 Workspace', + message: `Workspace ID: ${workspace.id}\n影响:新的 Session 将无法绑定此 Workspace;已有 Session 保持冻结直到关闭。`, + confirmLabel: '禁用', returnFocus: button, + }); + if (!accepted) return; + await api.request(`/workspaces/${encodeURIComponent(workspace.id)}/disable`, { method: 'POST', body: { expected_revision: state.workspaceRevision } }); + await Promise.all([loadWorkspaces(), loadSettings()]); + status(`Workspace ${workspace.id} 已禁用。`); + }, + }); + workspaceEditor.populateWorkspaceSelect(byId('chatWorkspace'), state.workspaces, byId('chatWorkspace')?.value || payload.default_workspace_id); + return payload; + } + + function renderGateway(payload) { + state.gateway = payload; + state.gatewayRevision = payload.persisted_revision || ''; + byId('gatewayRestartRequired').textContent = payload.restart_required ? '是' : '否'; + byId('gatewayRevision').textContent = state.gatewayRevision || '—'; + const summary = byId('gatewaySummary'); + summary.replaceChildren(); + const servers = payload.persisted?.servers || {}; + const aliases = Object.keys(servers); + if (!aliases.length) summary.append(createNode(documentRef, 'p', { className: 'muted', text: '没有持久化 Gateway server。' })); + for (const alias of aliases) { + const raw = servers[alias] || {}; + const item = createNode(documentRef, 'article', { className: 'card' }); + item.append( + createNode(documentRef, 'h4', { text: alias }), + createNode(documentRef, 'p', { text: `Transport: ${raw.transport || 'unknown'} · Enabled: ${raw.enabled !== false}` }), + createNode(documentRef, 'p', { className: 'muted', text: 'Credential fields are configured but intentionally hidden.' }), + ); + summary.append(item); + } + } + + async function loadGateway() { const payload = await api.request('/gateway'); renderGateway(payload); return payload; } + + async function loadOAuth() { + const collection = byId('oauthCollection').value; + const payload = await api.request(`/oauth/${encodeURIComponent(collection)}`); + renderOAuthItems(byId('oauthList'), payload.items || [], collection, async (resource, id, action, button) => { + const accepted = await confirmDestructive(documentRef, { + title: `OAuth ${action}`, + message: `Resource: ${resource}\nID: ${id}\n影响:仅对该精确 ID 执行幂等状态变更。`, + confirmLabel: action, returnFocus: button, + }); + if (!accepted) return; + const result = await api.request(`/oauth/${encodeURIComponent(resource)}/${encodeURIComponent(id)}/${encodeURIComponent(action)}`, { method: 'POST', body: {} }); + status(`OAuth ${action} 完成,实际影响数量:${result.affected_count || 0}。`); + await loadOAuth(); + }); + return payload; + } + + async function loadSecrets() { + const payload = await api.request('/secrets'); + const root = byId('secretList'); + root.replaceChildren(); + for (const item of payload.secrets || []) { + const card = createNode(documentRef, 'article', { className: 'card' }); + card.append(createNode(documentRef, 'strong', { text: item.name })); + const remove = createNode(documentRef, 'button', { type: 'button', className: 'danger', text: '删除' }); + remove.addEventListener('click', async () => { + const accepted = await confirmDestructive(documentRef, { + title: '删除 Secret Vault 条目', + message: `Secret 名称: ${item.name}\n影响:删除该名称对应的一个 Vault 值;值本身不会显示。`, + confirmLabel: '删除', returnFocus: remove, + }); + if (!accepted) return; + const result = await api.request(`/secrets/${encodeURIComponent(item.name)}`, { method: 'DELETE' }); + status(`Secret ${item.name} 删除影响数量:${result.affected_count || 0}。`); + await loadSecrets(); + }); + card.append(remove); + root.append(card); + } + if (!(payload.secrets || []).length) root.append(createNode(documentRef, 'p', { className: 'muted', text: 'Vault 中没有已配置名称。' })); + } + + async function loadConversations() { + const workspaceId = byId('chatWorkspace').value; + if (!workspaceId) return; + const query = new URLSearchParams({ workspace_id: workspaceId, page: String(state.conversationPage), page_size: '20' }); + const search = byId('chatQuery').value.trim(); + if (search) query.set('query', search); + const payload = await api.request(`/chat/conversations?${query}`); + state.conversationTotal = payload.total || 0; + byId('conversationPage').textContent = `第 ${payload.page || 1} 页`; + byId('conversationPrev').disabled = state.conversationPage <= 1; + byId('conversationNext').disabled = state.conversationPage * (payload.page_size || 20) >= state.conversationTotal; + renderConversationItems(byId('conversationList'), payload.items || [], async (item) => { + state.selectedConversation = { workspaceId: item.workspace_id, conversationId: item.conversation_id }; + state.messagePage = 1; state.contextPage = 1; + await loadConversationDetail(); + }); + } + + async function deleteChatResource(resource, workspaceId, identifier, button, detailMessage) { + const accepted = await confirmDestructive(documentRef, { + title: `删除 ${resource}`, + message: `Workspace ID: ${workspaceId}\nObject ID: ${identifier}\n影响:${detailMessage}`, + confirmLabel: '删除', returnFocus: button, + }); + if (!accepted) return false; + const result = await api.request(`/chat/${resource}/${encodeURIComponent(workspaceId)}/${encodeURIComponent(identifier)}`, { method: 'DELETE' }); + status(`删除完成,实际影响数量:${result.affected_count || 0}。`); + return true; + } + + async function loadConversationDetail() { + const selected = state.selectedConversation; + if (!selected) return; + const query = new URLSearchParams({ message_page: String(state.messagePage), message_page_size: '50', context_page: String(state.contextPage), context_page_size: '50' }); + const payload = await api.request(`/chat/conversations/${encodeURIComponent(selected.workspaceId)}/${encodeURIComponent(selected.conversationId)}?${query}`); + renderConversationDetail(byId('conversationDetail'), payload, { + onDeleteMessage: async (message, button) => { + if (await deleteChatResource('messages', selected.workspaceId, message.message_id, button, '最多删除 1 条 message。')) await loadConversationDetail(); + }, + onDeleteContext: async (entry, button) => { + if (await deleteChatResource('context', selected.workspaceId, entry.context_id, button, '最多删除 1 条 context entry。')) await loadConversationDetail(); + }, + onDeleteConversation: async (_conversation, button) => { + const accepted = await confirmDestructive(documentRef, { + title: '删除 Conversation', + message: `Workspace ID: ${selected.workspaceId}\nConversation ID: ${selected.conversationId}\n影响:删除该会话以及其所有 messages 和 context entries。`, + confirmLabel: '删除会话', returnFocus: button, + }); + if (!accepted) return; + const result = await api.request(`/chat/conversations/${encodeURIComponent(selected.workspaceId)}/${encodeURIComponent(selected.conversationId)}`, { method: 'DELETE' }); + status(`会话删除:conversation=${result.affected_count || 0}, messages=${result.deleted_message_count || 0}, context=${result.deleted_context_count || 0}。`); + state.selectedConversation = null; + byId('conversationDetail').replaceChildren(createNode(documentRef, 'p', { className: 'muted', text: '会话已删除。' })); + await loadConversations(); + }, + onMessagePage: async (page) => { state.messagePage = page; await loadConversationDetail(); }, + onContextPage: async (page) => { state.contextPage = page; await loadConversationDetail(); }, + }); + } + + async function refreshAll() { + status('正在读取 Admin API…'); + await Promise.all([loadOverview(), loadSettings(), loadWorkspaces(), loadGateway(), loadSecrets()]); + await Promise.all([loadOAuth(), loadConversations()]); + status('Admin 数据已刷新。'); + } + + byId('fakeReadonlyLabel').textContent = copy.FAKE_READONLY_COPY.label; + byId('fakeReadonlyWarning').textContent = copy.FAKE_READONLY_COPY.warning; + byId('fakeReadonlyEnable').textContent = copy.FAKE_READONLY_COPY.enable; + + for (const button of documentRef.querySelectorAll('.nav-item')) { + button.addEventListener('click', () => showSection(button.dataset.section)); + } + byId('authForm').addEventListener('submit', async (event) => { + event.preventDefault(); + state.token = byId('adminToken').value; + try { await refreshAll(); } catch (error) { status(error.message, 'danger'); } + }); + byId('forgetToken').addEventListener('click', () => { + state.token = ''; + byId('adminToken').value = ''; + status('Admin token 已从页面内存清除。'); + }); + byId('refreshAll').addEventListener('click', () => refreshAll().catch((error) => status(error.message, 'danger'))); + byId('reloadSettings').addEventListener('click', () => loadSettings().then(() => status('Settings 已重新读取。')).catch((error) => status(error.message, 'danger'))); + byId('saveSettings').addEventListener('click', async () => { + try { + const result = await handleSettingsSave({ api, state, documentRef }); + status(result.conflict ? '检测到 stale revision;草稿已保留,请审阅后重新保存。' : 'Settings 已保存;查看 pending restart。', result.conflict ? 'warning' : ''); + await loadWorkspaces(); + } catch (error) { settingsPage.renderFormError(documentRef, error.message); status(error.message, 'danger'); } + }); + byId('settingsPermission').addEventListener('change', () => { + byId('permissionHelp').textContent = copy.permissionPresentation(byId('settingsPermission').value).description; + }); + byId('reloadWorkspaces').addEventListener('click', () => loadWorkspaces().catch((error) => status(error.message, 'danger'))); + byId('workspaceAddForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const workspace = { + id: byId('workspaceId').value.trim(), name: byId('workspaceName').value.trim(), root: byId('workspaceRoot').value.trim(), + enabled: byId('workspaceEnabled').checked, default: byId('workspaceDefault').checked, + }; + try { + await api.request('/workspaces', { method: 'POST', body: { expected_revision: state.workspaceRevision, workspace } }); + event.currentTarget.reset(); byId('workspaceEnabled').checked = true; + await Promise.all([loadWorkspaces(), loadSettings()]); + status(`Workspace ${workspace.id} 已添加。`); + } catch (error) { status(error.message, 'danger'); } + }); + byId('reloadGateway').addEventListener('click', () => loadGateway().catch((error) => status(error.message, 'danger'))); + byId('clearGatewayDraft').addEventListener('click', () => { byId('gatewayDocument').value = ''; }); + byId('gatewayForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const draft = byId('gatewayDocument').value; + try { + const documentValue = JSON.parse(draft || '{"servers":{}}'); + if (containsCredentialControl(documentValue)) throw new Error('Gateway WebUI 草稿不得包含 credential、secret reference 或敏感 header/env 字段。'); + const result = await api.request('/gateway', { method: 'PUT', body: { expected_revision: state.gatewayRevision, document: documentValue } }); + byId('gatewayDocument').value = ''; + renderGateway(result); + status(`Gateway 配置已持久化。restart_required=${Boolean(result.restart_required)};现有 Runtime 未热加载。`); + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + await loadGateway(); + status('Gateway revision 冲突;草稿已保留,persisted revision 已刷新。', 'warning'); + } else status(error.message, 'danger'); + } + }); + byId('oauthCollection').addEventListener('change', () => loadOAuth().catch((error) => status(error.message, 'danger'))); + byId('reloadOAuth').addEventListener('click', () => loadOAuth().catch((error) => status(error.message, 'danger'))); + byId('reloadSecrets').addEventListener('click', () => loadSecrets().catch((error) => status(error.message, 'danger'))); + byId('secretForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const name = byId('secretName').value.trim(); + const value = byId('secretValue').value; + try { + const result = await api.request(`/secrets/${encodeURIComponent(name)}`, { method: 'PUT', body: { value } }); + byId('secretValue').value = ''; + status(`Secret ${name} 已配置;实际影响数量:${result.affected_count || 0}。`); + await loadSecrets(); + } catch (error) { byId('secretValue').value = ''; status(error.message, 'danger'); } + }); + byId('chatWorkspace').addEventListener('change', () => { state.conversationPage = 1; state.selectedConversation = null; loadConversations().catch((error) => status(error.message, 'danger')); }); + byId('searchConversations').addEventListener('click', () => { state.conversationPage = 1; loadConversations().catch((error) => status(error.message, 'danger')); }); + byId('reloadConversations').addEventListener('click', () => loadConversations().catch((error) => status(error.message, 'danger'))); + byId('conversationPrev').addEventListener('click', () => { if (state.conversationPage > 1) { state.conversationPage -= 1; loadConversations().catch((error) => status(error.message, 'danger')); } }); + byId('conversationNext').addEventListener('click', () => { state.conversationPage += 1; loadConversations().catch((error) => status(error.message, 'danger')); }); + + return { state, api, refreshAll, loadSettings, loadWorkspaces, loadGateway, loadOAuth, loadSecrets, loadConversations, loadConversationDetail, showSection }; +} + +globalThis.McpAdminApp = { + ApiError, + sanitizeAdminValue, + containsCredentialControl, + createApiClient, + renderConversationItems, + renderConversationDetail, + renderOAuthItems, + confirmDestructive, + handleSettingsSave, + initAdminApp, +}; + +if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => initAdminApp(document)); +} + +export { ApiError, sanitizeAdminValue, containsCredentialControl, createApiClient, renderConversationItems, renderConversationDetail, renderOAuthItems, confirmDestructive, handleSettingsSave, initAdminApp }; diff --git a/webui/src/settings-copy.js b/webui/src/settings-copy.js new file mode 100644 index 0000000..04fe413 --- /dev/null +++ b/webui/src/settings-copy.js @@ -0,0 +1,50 @@ +const PERMISSION_COPY = Object.freeze({ + safe: Object.freeze({ + label: '安全模式', + description: '保留完整固定工具目录,同时限制网络、Shell 展开和高风险命令。它不会隐藏或禁用 mutation tools。', + }), + trusted: Object.freeze({ + label: '可信本地模式', + description: '保留完整固定工具目录,并允许更多本机网络与 Shell 能力。仅用于受信任的本地环境。', + }), + dangerous: Object.freeze({ + label: '危险模式', + description: '关闭主要命令权限门。只应在隔离容器或虚拟机中使用。', + }), +}); + +const FAKE_READONLY_COPY = Object.freeze({ + label: '伪只读 annotations 兼容覆盖', + warning: '此高级兼容选项只会向客户端伪报 readOnlyHint。它不会隐藏工具、阻止 mutation、改变 handler,或形成安全边界;并且只能配合 dangerous 模式启动。', + enable: '通过 --dangerously-fake-readonly-annotations 或 CODING_TOOLS_MCP_DANGEROUSLY_FAKE_READONLY_ANNOTATIONS=1 启用,然后重启服务。', +}); + +function permissionPresentation(mode) { + return PERMISSION_COPY[mode] || { label: String(mode || '未知'), description: '后端返回了未知 permission mode。' }; +} + +function telemetryPresentation(value) { + const mode = typeof value === 'string' ? value : value?.mode; + if (mode === 'off' || mode === 'disabled') { + return { mode: 'off', label: '关闭', detail: '不会发送匿名 telemetry。' }; + } + if (mode === 'debug') { + return { mode: 'debug', label: '调试', detail: '事件仅写入 stderr,不发送到远端。' }; + } + if (mode === 'enabled' || mode === 'on') { + return { mode: 'enabled', label: '启用', detail: '使用上游 v0.2.2 默认匿名 telemetry 策略。' }; + } + return { mode: 'unknown', label: '未报告', detail: '当前 Admin 后端尚未报告 telemetry 运行模式。' }; +} + +const TELEMETRY_DISABLE_HELP = '设置 CODING_TOOLS_MCP_TELEMETRY=off、DO_NOT_TRACK=1,或在 CI 环境中启动;修改后重启服务。'; + +globalThis.McpSettingsCopy = { + PERMISSION_COPY, + FAKE_READONLY_COPY, + TELEMETRY_DISABLE_HELP, + permissionPresentation, + telemetryPresentation, +}; + +export { PERMISSION_COPY, FAKE_READONLY_COPY, TELEMETRY_DISABLE_HELP, permissionPresentation, telemetryPresentation }; diff --git a/webui/src/settings-model.js b/webui/src/settings-model.js new file mode 100644 index 0000000..1049fe3 --- /dev/null +++ b/webui/src/settings-model.js @@ -0,0 +1,93 @@ +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +function canonicalWorkspaces(workspaces, defaultId) { + const rows = Array.isArray(workspaces) ? workspaces.map((item) => ({ ...item })) : []; + let resolved = defaultId || rows.find((item) => item.default)?.id || rows.find((item) => item.enabled !== false)?.id || rows[0]?.id || ''; + if (resolved && !rows.some((item) => item.id === resolved && item.enabled !== false)) { + resolved = rows.find((item) => item.enabled !== false)?.id || ''; + } + return rows.map((item) => ({ ...item, default: Boolean(resolved && item.id === resolved) })); +} + +function canonicalSettings(settings = {}) { + const result = clone(settings) || {}; + const catalog = canonicalWorkspaces(result.workspace_catalog, result.default_workspace_id); + if (catalog.length) { + result.workspace_catalog = catalog; + result.default_workspace_id = catalog.find((item) => item.default)?.id || ''; + result.workspace = catalog.find((item) => item.default)?.root || result.workspace || ''; + } + return result; +} + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function hydrateSettings(payload = {}) { + const active = canonicalSettings(payload.active || {}); + const persisted = canonicalSettings(payload.persisted || {}); + return { + active, + persisted, + draft: clone(persisted), + persistedRevision: String(payload.persisted_revision || ''), + pendingRestart: Array.isArray(payload.pending_restart) ? [...payload.pending_restart] : [], + restartRequired: Boolean(payload.restart_required), + conflict: null, + schema: clone(payload.schema || {}), + }; +} + +function refreshPersistedKeepingDraft(state, payload = {}) { + const draft = clone(state.draft); + const next = hydrateSettings(payload); + next.draft = draft; + next.conflict = { + code: 'stale_revision', + message: '服务器设置已被其他管理员更新。草稿已保留,请审阅最新 persisted revision 后再次保存。', + }; + return next; +} + +function computeDirty(draft, persisted) { + return stableJson(canonicalSettings(draft || {})) !== stableJson(canonicalSettings(persisted || {})); +} + +function serializeSettings(draft = {}) { + return canonicalSettings(draft); +} + +function createWorkspace(existing = []) { + const ids = new Set(existing.map((item) => item.id)); + let index = 1; + let id = 'ws-new'; + while (ids.has(id)) id = `ws-new-${index++}`; + return { id, name: 'New Workspace', root: '', enabled: true, default: existing.length === 0 }; +} + +function setDefaultWorkspace(workspaces, workspaceId) { + if (!workspaces.some((item) => item.id === workspaceId && item.enabled !== false)) { + throw new Error('Only an enabled Workspace can be the default.'); + } + return workspaces.map((item) => ({ ...item, default: item.id === workspaceId })); +} + +globalThis.McpSettingsModel = { + canonicalWorkspaces, + canonicalSettings, + hydrateSettings, + refreshPersistedKeepingDraft, + computeDirty, + serializeSettings, + createWorkspace, + setDefaultWorkspace, +}; + +export { canonicalWorkspaces, canonicalSettings, hydrateSettings, refreshPersistedKeepingDraft, computeDirty, serializeSettings, createWorkspace, setDefaultWorkspace }; diff --git a/webui/src/settings-page.js b/webui/src/settings-page.js new file mode 100644 index 0000000..ecaf871 --- /dev/null +++ b/webui/src/settings-page.js @@ -0,0 +1,82 @@ +function setControlValue(documentRef, id, value) { + const control = documentRef.getElementById(id); + if (!control) return; + if (control.type === 'checkbox') control.checked = Boolean(value); + else control.value = value ?? ''; +} + +function renderSettingsForm(documentRef, state, permissionPresentation) { + const draft = state?.draft || {}; + setControlValue(documentRef, 'settingsHost', draft.host || ''); + setControlValue(documentRef, 'settingsPort', draft.port || ''); + setControlValue(documentRef, 'settingsPermission', draft.permission_mode || 'safe'); + setControlValue(documentRef, 'settingsShellEnv', draft.shell_env_inherit || 'core'); + setControlValue(documentRef, 'settingsOauthServerUrl', draft.oauth_server_url || ''); + setControlValue(documentRef, 'settingsOauthCompatibility', draft.oauth_compatibility_mode || false); + setControlValue(documentRef, 'settingsAllowedOrigins', Array.isArray(draft.allowed_origins) ? draft.allowed_origins.join('\n') : ''); + const presentation = permissionPresentation(draft.permission_mode || 'safe'); + const help = documentRef.getElementById('permissionHelp'); + if (help) help.textContent = presentation.description; + const active = documentRef.getElementById('settingsActiveJson'); + if (active) active.textContent = JSON.stringify(state?.active || {}, null, 2); + const persisted = documentRef.getElementById('settingsPersistedJson'); + if (persisted) persisted.textContent = JSON.stringify(state?.persisted || {}, null, 2); + const pending = documentRef.getElementById('settingsPending'); + if (pending) { + pending.replaceChildren(); + const values = state?.pendingRestart || []; + if (!values.length) pending.append(documentRef.createTextNode('无待重启字段。')); + for (const field of values) { + const item = documentRef.createElement('li'); + item.textContent = field; + pending.append(item); + } + } + const revision = documentRef.getElementById('settingsRevision'); + if (revision) revision.textContent = state?.persistedRevision || '—'; + const conflict = documentRef.getElementById('settingsConflict'); + if (conflict) { + conflict.textContent = state?.conflict?.message || ''; + conflict.hidden = !state?.conflict; + } +} + +function collectSettingsDraft(documentRef, previous = {}) { + const allowedOrigins = String(documentRef.getElementById('settingsAllowedOrigins')?.value || '') + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); + return { + ...previous, + host: String(documentRef.getElementById('settingsHost')?.value || '').trim(), + port: Number(documentRef.getElementById('settingsPort')?.value || 0), + permission_mode: String(documentRef.getElementById('settingsPermission')?.value || 'safe'), + shell_env_inherit: String(documentRef.getElementById('settingsShellEnv')?.value || 'core'), + oauth_server_url: String(documentRef.getElementById('settingsOauthServerUrl')?.value || '').trim(), + oauth_compatibility_mode: Boolean(documentRef.getElementById('settingsOauthCompatibility')?.checked), + allowed_origins: allowedOrigins, + }; +} + +function renderFormError(documentRef, message, fieldId = '') { + const box = documentRef.getElementById('settingsError'); + if (box) { + box.textContent = message || ''; + box.hidden = !message; + } + for (const control of documentRef.querySelectorAll('[aria-invalid="true"]')) { + control.removeAttribute('aria-invalid'); + } + if (fieldId) { + const control = documentRef.getElementById(fieldId); + if (control) { + control.setAttribute('aria-invalid', 'true'); + control.focus(); + } + } else if (message && box) { + box.focus(); + } +} + +globalThis.McpSettingsPage = { renderSettingsForm, collectSettingsDraft, renderFormError }; +export { renderSettingsForm, collectSettingsDraft, renderFormError }; diff --git a/webui/src/workspace-editor.js b/webui/src/workspace-editor.js new file mode 100644 index 0000000..9362530 --- /dev/null +++ b/webui/src/workspace-editor.js @@ -0,0 +1,71 @@ +function element(documentRef, tag, options = {}) { + const node = documentRef.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined) node.textContent = String(options.text); + if (options.type) node.type = options.type; + if (options.id) node.id = options.id; + if (options.title) node.title = options.title; + return node; +} + +function renderWorkspaceRows(container, workspaces, handlers = {}) { + const documentRef = container.ownerDocument || document; + container.replaceChildren(); + if (!Array.isArray(workspaces) || workspaces.length === 0) { + container.append(element(documentRef, 'p', { className: 'muted', text: '尚未登记 Workspace。' })); + return; + } + for (const workspace of workspaces) { + const card = element(documentRef, 'article', { className: 'workspace-card' }); + card.dataset.workspaceId = String(workspace.id || ''); + const heading = element(documentRef, 'div', { className: 'workspace-heading' }); + const title = element(documentRef, 'h3', { text: workspace.name || workspace.id || 'Unnamed Workspace' }); + const badge = element(documentRef, 'span', { + className: `badge ${workspace.default ? 'good' : workspace.enabled === false ? 'danger' : ''}`, + text: workspace.default ? '默认' : workspace.enabled === false ? '已禁用' : '已启用', + }); + heading.append(title, badge); + card.append(heading); + + const ids = element(documentRef, 'dl', { className: 'compact-definition' }); + for (const [label, value] of [['ID', workspace.id], ['Root', workspace.root]]) { + const dt = element(documentRef, 'dt', { text: label }); + const dd = element(documentRef, 'dd'); + const code = element(documentRef, 'code', { text: value || '—' }); + dd.append(code); + ids.append(dt, dd); + } + card.append(ids); + + const actions = element(documentRef, 'div', { className: 'button-row' }); + const check = element(documentRef, 'button', { type: 'button', className: 'secondary', text: '检查' }); + check.addEventListener('click', () => handlers.onCheck?.(workspace, check)); + actions.append(check); + if (!workspace.default && workspace.enabled !== false) { + const makeDefault = element(documentRef, 'button', { type: 'button', className: 'secondary', text: '设为默认' }); + makeDefault.addEventListener('click', () => handlers.onDefault?.(workspace, makeDefault)); + actions.append(makeDefault); + const disable = element(documentRef, 'button', { type: 'button', className: 'danger', text: '禁用' }); + disable.addEventListener('click', () => handlers.onDisable?.(workspace, disable)); + actions.append(disable); + } + card.append(actions); + container.append(card); + } +} + +function populateWorkspaceSelect(select, workspaces, selectedId = '') { + const documentRef = select.ownerDocument || document; + select.replaceChildren(); + for (const workspace of workspaces || []) { + if (workspace.enabled === false) continue; + const option = documentRef.createElement('option'); + option.value = String(workspace.id || ''); + option.textContent = `${workspace.name || workspace.id} (${workspace.id})`; + option.selected = option.value === selectedId; + select.append(option); + } +} + +globalThis.McpWorkspaceEditor = { renderWorkspaceRows, populateWorkspaceSelect }; +export { renderWorkspaceRows, populateWorkspaceSelect }; diff --git a/webui/tests/dom-interactions.test.mjs b/webui/tests/dom-interactions.test.mjs new file mode 100644 index 0000000..0adb3be --- /dev/null +++ b/webui/tests/dom-interactions.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ApiError, + confirmDestructive, + createApiClient, + handleSettingsSave, + renderConversationDetail, + renderConversationItems, +} from '../src/admin.js'; +import '../src/settings-copy.js'; +import { hydrateSettings } from '../src/settings-model.js'; +import '../src/settings-page.js'; +import { renderWorkspaceRows } from '../src/workspace-editor.js'; + +class FakeClassList { + constructor(node) { this.node = node; this.values = new Set(); } + add(...values) { values.forEach((value) => this.values.add(value)); } + remove(...values) { values.forEach((value) => this.values.delete(value)); } + toggle(value, force) { + const enabled = force === undefined ? !this.values.has(value) : Boolean(force); + if (enabled) this.values.add(value); else this.values.delete(value); + return enabled; + } + contains(value) { return this.values.has(value); } +} + +class FakeNode { + constructor(documentRef, tagName = '#text', text = '') { + this.ownerDocument = documentRef; + this.tagName = tagName.toUpperCase(); + this.children = []; + this.dataset = {}; + this.attributes = new Map(); + this.listeners = new Map(); + this.classList = new FakeClassList(this); + this.className = ''; + this.disabled = false; + this.hidden = false; + this.value = ''; + this.returnValue = ''; + this._text = text; + } + get childNodes() { return this.children; } + get textContent() { return this._text + this.children.map((child) => child.textContent).join(''); } + set textContent(value) { this._text = String(value ?? ''); this.children = []; } + append(...nodes) { + for (const node of nodes) this.children.push(typeof node === 'string' ? this.ownerDocument.createTextNode(node) : node); + } + replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); } + addEventListener(type, callback) { + const values = this.listeners.get(type) || []; + values.push(callback); this.listeners.set(type, values); + } + removeEventListener(type, callback) { + this.listeners.set(type, (this.listeners.get(type) || []).filter((value) => value !== callback)); + } + dispatchEvent(event) { for (const callback of this.listeners.get(event.type) || []) callback.call(this, event); } + click() { this.dispatchEvent({ type: 'click', currentTarget: this, preventDefault() {} }); } + focus() { this.ownerDocument.activeElement = this; } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.get(name) ?? null; } + removeAttribute(name) { this.attributes.delete(name); } +} + +class FakeDialog extends FakeNode { + showModal() { this.open = true; } + close(value = '') { this.returnValue = value; this.open = false; this.dispatchEvent({ type: 'close' }); } +} + +class FakeDocument { + constructor() { this.ids = new Map(); this.activeElement = null; } + createElement(tag) { return tag === 'dialog' ? new FakeDialog(this, tag) : new FakeNode(this, tag); } + createTextNode(text) { return new FakeNode(this, '#text', String(text)); } + getElementById(id) { return this.ids.get(id) || null; } + querySelectorAll() { return []; } + register(id, node) { node.id = id; this.ids.set(id, node); return node; } +} + +function tags(node) { + return [node.tagName, ...node.children.flatMap(tags)]; +} + +test('conversation summary and detail render untrusted text without creating markup', () => { + const documentRef = new FakeDocument(); + const list = new FakeNode(documentRef, 'div'); + renderConversationItems(list, [{ + workspace_id: 'ws-a', conversation_id: 'conv-a', title: '', + preview: '', message_count: 1, context_count: 1, + }], () => {}); + assert.equal(tags(list).includes('IMG'), false); + assert.equal(tags(list).includes('SCRIPT'), false); + assert.match(list.textContent, /steal/); + + const detail = new FakeNode(documentRef, 'div'); + renderConversationDetail(detail, { + conversation: { workspace_id: 'ws-a', conversation_id: 'conv-a', title: '' }, + messages: [{ message_id: 'm1', role: 'user', content: '' }], + messages_total: 1, message_page: 1, message_page_size: 50, + contexts: [{ context_id: 'c1', kind: 'note', content: '' }], + contexts_total: 1, context_page: 1, context_page_size: 50, + }); + assert.equal(tags(detail).includes('IFRAME'), false); + assert.equal(tags(detail).includes('SVG'), false); + assert.match(detail.textContent, /