diff --git a/.dockerignore b/.dockerignore index 874d17c..39e35f1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,9 +3,6 @@ !Dockerfile !entrypoint.sh -!mcp-bridge/ -!mcp-bridge/package.json -!mcp-bridge/package-lock.json !pyproject.toml !uv.lock !src/ diff --git a/.env.example b/.env.example index 7406b09..d4de40d 100644 --- a/.env.example +++ b/.env.example @@ -117,17 +117,6 @@ TELEGRAM_ENABLED=false # 3. Copy the token you receive # See docs/telegram-setup.md for detailed instructions -# --------------------------------------------------------------------------- -# Zepto MCP (optional, one shared account) -# --------------------------------------------------------------------------- -# Authenticate first: uv run python -m blacki.zepto.auth login -# Then allow positive private Telegram chat IDs only (comma-separated). -ZEPTO_MCP_ENABLED=false -# ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS=123456789 -# The host and container both resolve this through the ./data volume. -# ZEPTO_MCP_CONFIG_DIR=data/credentials/zepto-mcp-remote -# See docs/zepto-mcp.md for security, OAuth, and verification details. - # --------------------------------------------------------------------------- # Gmail API connector (private Telegram users only) # --------------------------------------------------------------------------- diff --git a/.github/workflows/developer-experience.yml b/.github/workflows/developer-experience.yml index d685a80..b1529df 100644 --- a/.github/workflows/developer-experience.yml +++ b/.github/workflows/developer-experience.yml @@ -18,7 +18,6 @@ on: - "docs/**" - "entrypoint.sh" - "mkdocs.yml" - - "mcp-bridge/**" - "pyproject.toml" - "scripts/write_compose_env.py" - "setup.sh" @@ -43,7 +42,6 @@ on: - "docs/**" - "entrypoint.sh" - "mkdocs.yml" - - "mcp-bridge/**" - "pyproject.toml" - "scripts/write_compose_env.py" - "setup.sh" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index dfee28f..0769b0d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -114,9 +114,6 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} KOKORO_TTS_BASE_URL: ${{ secrets.KOKORO_TTS_BASE_URL }} KOKORO_TTS_VOICE: ${{ secrets.KOKORO_TTS_VOICE }} - ZEPTO_MCP_ENABLED: ${{ secrets.ZEPTO_MCP_ENABLED }} - ZEPTO_MCP_CONFIG_DIR: ${{ secrets.ZEPTO_MCP_CONFIG_DIR }} - ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS: ${{ secrets.ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS }} EXA_API_KEY: ${{ secrets.EXA_API_KEY }} BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }} BROWSER_USE_API_KEY: ${{ secrets.BROWSER_USE_API_KEY }} @@ -195,9 +192,6 @@ jobs: CLOUDFLARE_API_TOKEN \ KOKORO_TTS_BASE_URL \ KOKORO_TTS_VOICE \ - ZEPTO_MCP_ENABLED \ - ZEPTO_MCP_CONFIG_DIR \ - ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS \ EXA_API_KEY \ BRAVE_SEARCH_API_KEY \ BROWSER_USE_API_KEY \ diff --git a/AGENTS.md b/AGENTS.md index dbac354..aff64e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ capability boundaries: a sandbox. The root agent may receive private, user-scoped integrations such as Google -Health, Gmail, Zepto, TTS, and durable user files. Public ADK requests receive +Health, Gmail, TTS, and durable user files. Public ADK requests receive only the tools explicitly allowed for their boundary. Shared session-sandbox access does not grant access to another user's accounts. diff --git a/Dockerfile b/Dockerfile index 6ccc3f3..03e0c2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,5 @@ # syntax=docker/dockerfile:1 -# ============================================================================ -# MCP Bridge Stage: Install the exact locked Zepto transport dependency -# ============================================================================ -FROM node:22-bookworm-slim AS mcp-bridge - -WORKDIR /opt/blacki-mcp-bridge - -COPY mcp-bridge/package.json mcp-bridge/package-lock.json ./ - -RUN --mount=type=cache,target=/root/.npm \ - npm ci --omit=dev --ignore-scripts - # ============================================================================ # Builder Stage: Install dependencies with optimal caching # ============================================================================ @@ -48,20 +36,6 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # ============================================================================ FROM python:3.13-slim-bookworm AS runtime -# Node is copied from the matching Debian base so the production runtime never -# downloads npm packages. libatomic1 is the only extra shared library required -# by the official Node binary on the slim image. -RUN apt-get update && \ - apt-get install -y --no-install-recommends libatomic1 && \ - rm -rf /var/lib/apt/lists/* - -COPY --from=mcp-bridge /usr/local/bin/node /usr/local/bin/node -COPY --from=mcp-bridge /opt/blacki-mcp-bridge /opt/blacki-mcp-bridge -RUN ln -s /opt/blacki-mcp-bridge/node_modules/.bin/mcp-remote \ - /usr/local/bin/mcp-remote && \ - node --version && \ - node -e "const p=require('/opt/blacki-mcp-bridge/node_modules/mcp-remote/package.json'); if(p.version!=='0.1.38') process.exit(1)" - # Create non-root user for security (matching common host UID 1000) RUN groupadd -g 1000 app && \ useradd -u 1000 -g app -s /bin/sh -m app diff --git a/docs/architecture.md b/docs/architecture.md index fbdd62b..754b585 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,7 +129,6 @@ Blacki uses different stores for different responsibilities: | Telegram photo parts in ADK history | SQLite-backed session events | Yes | | Optional Mem0 memory with local Qdrant | `/app/data` | Yes with the Compose volume | | Optional Mem0 memory with Qdrant Cloud | Managed Qdrant | Provider-managed | -| Zepto OAuth credentials | `/app/data/credentials/zepto-mcp-remote/` | Yes with the Compose volume | | Google Health refresh tokens and normalized summaries | SQLite (`tools.db`), tokens encrypted at rest | Yes with the Compose volume | | Google Health meal export revisions and retry state | SQLite (`tools.db`), payloads account-bound and sent over HTTPS | Yes with the Compose volume | | Application logs and traces | JSON files under `/app/logs` | Yes with the Compose volume | @@ -161,9 +160,7 @@ Optional tools follow the project's cloud-first principle: and screenshots remain in the temporary sandbox, and this tool adds no saved-login service or durable file retention; - speech synthesis can use a private Kokoro API reachable over Tailscale; -- grocery shopping can use Zepto's hosted MCP server for one allowlisted, - shared account; -- vector memory can use Qdrant Cloud; and +- vector memory can use Qdrant Cloud; - code execution can use an OpenSandbox server; and - health summaries can use Google Health API after private Telegram OAuth. @@ -179,16 +176,6 @@ does not retry Telegram delivery automatically. Configuring the tool disables content-rich ADK and OpenInference logging, although the local ADK session database still retains tool calls and arguments as normal conversation history. -Zepto is registered only on a Telegram-specific root-agent runner. The public -ADK HTTP runner never receives the Zepto toolset. -Unauthorized Telegram identities are rejected before Blacki opens an MCP -connection. ADK confirmation is required only when an order or payment tool -uses `confirmOrder=true`; other individual Zepto tools run directly. Its OAuth -files are plaintext protected by a `0700` directory and `0600` file -permissions; they are not encrypted. Shopping prompts, tool calls, and results -remain in the local ADK session database and are sent to the configured model -as part of normal agent execution. - Google Health summaries are a separate read-only boundary. The connector uses the current Google Health API, not the legacy Fitbit Web API. It requests the current read-only activity/fitness, measurements, and sleep scopes plus diff --git a/docs/base-infra/environment-variables.md b/docs/base-infra/environment-variables.md index cb3d0f8..20125c7 100644 --- a/docs/base-infra/environment-variables.md +++ b/docs/base-infra/environment-variables.md @@ -245,19 +245,6 @@ If R2 is unavailable, Telegram processing can continue with an explicit temporary-storage warning. If the sandbox is unavailable, a successfully stored object remains available for a later restore. -## Zepto MCP - -| Variable | Default | Purpose | -| --- | --- | --- | -| `ZEPTO_MCP_ENABLED` | `false` | Enable the root-only Zepto skill after OAuth | -| `ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS` | unset | Comma-separated positive private chat IDs allowed to use the shared account | -| `ZEPTO_MCP_CONFIG_DIR` | `data/credentials/zepto-mcp-remote` | Permission-protected bridge credential directory shared by host and container | - -The allowlist deliberately rejects Telegram groups, topics, and arbitrary HTTP -user IDs. All allowed chats share one Zepto account and cart. Complete the -one-time OAuth flow before enabling the integration; see -[Zepto MCP](../zepto-mcp.md). - ## Observability | Variable | Default | Purpose | @@ -276,9 +263,9 @@ Blacki always exports spans to local JSON. A validated trace-specific or global endpoint adds gRPC or HTTP/protobuf OTLP export; trace-specific values take precedence. See [Observability](observability.md). -When Zepto is enabled, Blacki forces both content-capture variables to `false` -and disables the OpenInference Google ADK instrumentor because it otherwise -records raw tool parameters. +When any private-tool integration is configured, Blacki forces both +content-capture variables to `false` and disables the OpenInference Google ADK +instrumentor because it otherwise records raw tool parameters. ## Precedence diff --git a/docs/base-infra/observability.md b/docs/base-infra/observability.md index ee7098b..f5293a1 100644 --- a/docs/base-infra/observability.md +++ b/docs/base-infra/observability.md @@ -47,10 +47,9 @@ Both `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false` and and legacy ADK content capture. Capturing prompts and responses can expose personal data, credentials, and tool results. -When `ZEPTO_MCP_ENABLED=true` or `KOKORO_TTS_BASE_URL` is configured, Blacki -forces both values to `false` and disables the OpenInference Google ADK -instrumentor. Zepto additionally prevents MCP body-debug logging. The ADK -session database still retains tool calls and results so interrupted turns and +When any private-tool integration is configured, Blacki forces both values to +`false` and disables the OpenInference Google ADK instrumentor. The ADK session +database still retains tool calls and results so interrupted turns and confirmations can resume correctly. ## Remote OTLP export diff --git a/docs/documentation-map.md b/docs/documentation-map.md index 8cf7c4c..2d92783 100644 --- a/docs/documentation-map.md +++ b/docs/documentation-map.md @@ -6,8 +6,6 @@ The documentation is organized around the lifecycle of a Blacki installation. - [First VPS deployment](DEPLOYMENT.md) is the supported Docker Compose path. - [Telegram setup](telegram-setup.md) connects the primary chat interface. -- [Zepto MCP](zepto-mcp.md) authenticates and protects the optional shared - Zepto account. - [Local development](development.md) covers uv, tests, and MkDocs. ## Operate diff --git a/docs/zepto-mcp.md b/docs/zepto-mcp.md deleted file mode 100644 index 014e76c..0000000 --- a/docs/zepto-mcp.md +++ /dev/null @@ -1,105 +0,0 @@ -# Zepto MCP - -Blacki connects to Zepto's hosted -[MCP server](https://github.com/zeptonow/mcp) through the supported -[`mcp-remote`](https://github.com/geelen/mcp-remote) bridge. Zepto rejects -Blacki's generic dynamic OAuth registration with HTTP 403 during validation, -so Blacki uses the same standards-based bridge path documented for other MCP -clients. - -## Security model - -- One private bridge directory represents one Zepto account and shared cart. -- Only explicitly allowlisted positive private Telegram chat IDs can connect. - Groups, topics, and arbitrary HTTP user IDs are rejected. -- Zepto is loaded into a Telegram-only ADK runner. The unauthenticated ADK HTTP - runner never receives the Zepto skill or toolset, even if a caller spoofs an - allowlisted Telegram user ID. -- Only final order or payment calls with `confirmOrder=true` require ADK - confirmation. Reads, cart changes, address changes, and order previews run - without approval. -- Credentials are plaintext protected by `0700` directories and `0600` files. -- ADK session history retains shopping prompts, tool arguments, and results. - The configured model also receives tool data needed to answer the request. -- Secure Zepto mode disables content-rich ADK tracing and OpenInference ADK - instrumentation, removes the ADK content logging plugin, and suppresses - user/model/tool payloads from Blacki's lifecycle and Telegram preview logs. - It does not erase session history. - -## Authenticate locally - -From the repository root, leave `ZEPTO_MCP_ENABLED=false` and run: - -```bash -uv run python -m blacki.zepto.auth login -``` - -Blacki starts the exact locked `mcp-remote` bridge, which opens Zepto's -mobile-number and OTP flow in the browser. The bridge requests Zepto's -`tools:read` MCP scope with PKCE. That scope authorizes access to the tool -server; Blacki separately confirms only final order or payment placement. The -command then lists the complete tool manifest without calling any shopping -tool. - -The default credential directory is: - -```text -data/credentials/zepto-mcp-remote/ -``` - -The existing `./data:/app/data` Compose mount makes that same directory -available to the container. Do not copy its contents into `.env`, logs, chat, -or source control. - -Verify the stored credentials without opening a browser: - -```bash -uv run python -m blacki.zepto.auth status -uv run python -m blacki.zepto.auth probe -``` - -If Zepto access is revoked or you intentionally want to switch accounts, use: - -```bash -uv run python -m blacki.zepto.auth login --force -``` - -The force option deletes only the four known Zepto bridge files inside the -dedicated credential directory, then starts a fresh browser login. - -## Enable the root-agent skill - -Set the positive numeric ID of the private Telegram chat: - -```dotenv -ZEPTO_MCP_ENABLED=true -ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS=123456789 -ZEPTO_MCP_CONFIG_DIR=data/credentials/zepto-mcp-remote -``` - -Restart Blacki, then ask it to search Zepto or inspect the cart. The first -request loads the Zepto skill and non-final tools run directly. When a final -order or payment call is ready, Blacki shows its exact tool name and arguments; -reply exactly `yes` or `no`. - -## Verification boundary - -Tool discovery and read calls are safe local verification. Order and payment -tools can generate a preview with `confirmOrder=false`; `confirmOrder=true` -stops at the confirmation interrupt before the real final action. Cart, -address, store, and profile mutations execute without approval and affect the -shared account immediately. Never place, pay for, cancel, or reorder a real -order only to test this integration. - -## Runtime and deployment notes - -The production image includes Node 22 and installs `mcp-remote` 0.1.38 from the -committed npm lockfile. It never downloads npm packages at runtime. Local -development falls back to the same pinned package through `npx` when no global -bridge exists. - -Blacki is intentionally single-process and single-replica when Zepto is -enabled: all allowlisted chats share one account, cart, bridge session, and -credential store. If credentials are revoked, the runtime connection fails -within 15 seconds. Run the login command locally again; do not try to complete -OAuth inside the long-running server. diff --git a/mcp-bridge/package-lock.json b/mcp-bridge/package-lock.json deleted file mode 100644 index 1ee3848..0000000 --- a/mcp-bridge/package-lock.json +++ /dev/null @@ -1,1008 +0,0 @@ -{ - "name": "blacki-mcp-bridge", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "blacki-mcp-bridge", - "version": "1.0.0", - "license": "UNLICENSED", - "dependencies": { - "mcp-remote": "0.1.38" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mcp-remote": { - "version": "0.1.38", - "resolved": "https://registry.npmjs.org/mcp-remote/-/mcp-remote-0.1.38.tgz", - "integrity": "sha512-w+JU4U3CfG29TawXR4JLNQ9d1Un5nT8AGI65f/juCaqUdF/V6fS7wE4o7xNPbB8X58o46hRXEJgYglQMAKQs4w==", - "license": "MIT", - "dependencies": { - "express": "^4.21.2", - "open": "^10.1.0", - "strict-url-sanitise": "^0.0.1", - "undici": "^7.12.0" - }, - "bin": { - "mcp-remote": "dist/proxy.js", - "mcp-remote-client": "dist/client.js" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/strict-url-sanitise": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/strict-url-sanitise/-/strict-url-sanitise-0.0.1.tgz", - "integrity": "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==", - "license": "MIT" - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/mcp-bridge/package.json b/mcp-bridge/package.json deleted file mode 100644 index 18d1160..0000000 --- a/mcp-bridge/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "blacki-mcp-bridge", - "private": true, - "version": "1.0.0", - "description": "Locked runtime dependency for Blacki remote MCP servers", - "license": "UNLICENSED", - "dependencies": { - "mcp-remote": "0.1.38" - } -} diff --git a/mkdocs.yml b/mkdocs.yml index 2e14d15..a6de858 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,7 +49,6 @@ nav: - Get started: - First VPS deployment: DEPLOYMENT.md - Telegram setup: telegram-setup.md - - Zepto MCP: zepto-mcp.md - Gmail API: gmail.md - Local development: development.md - Operate: diff --git a/src/blacki/runtime/adk.py b/src/blacki/runtime/adk.py index 7ca0c8d..4406e1f 100644 --- a/src/blacki/runtime/adk.py +++ b/src/blacki/runtime/adk.py @@ -191,7 +191,7 @@ def _format_confirmation( ensure_ascii=False, ) confirmation_text = ( - f"Confirm Zepto tool `{confirmation.tool_name}` with these arguments?\n\n" + f"Confirm tool `{confirmation.tool_name}` with these arguments?\n\n" f"```json\n{serialized_args}\n```\n\nReply exactly `yes` or `no`." ) suffix = "" diff --git a/src/blacki/security/tool_privacy.py b/src/blacki/security/tool_privacy.py index 1d9e465..e1b0ddd 100644 --- a/src/blacki/security/tool_privacy.py +++ b/src/blacki/security/tool_privacy.py @@ -2,14 +2,12 @@ from __future__ import annotations -import logging import os from google.adk.tools.base_tool import BaseTool -_ENABLED_VALUES = frozenset({"1", "true", "yes"}) -_ZEPTO_TOOL_PREFIX = "zepto_" _GMAIL_TOOL_PREFIX = "gmail_" +_ENABLED_VALUES = frozenset({"1", "true", "yes"}) _PRIVATE_TOOL_NAMES = frozenset( { "log_meal", @@ -28,11 +26,6 @@ ) -def zepto_mcp_enabled() -> bool: - """Return whether secure Zepto mode is explicitly enabled.""" - return os.getenv("ZEPTO_MCP_ENABLED", "false").strip().lower() in _ENABLED_VALUES - - def gmail_configured() -> bool: """Return whether shared Gmail OAuth settings are present.""" from ..gmail.config import GmailConfig @@ -65,40 +58,24 @@ def google_health_enabled() -> bool: def private_tool_privacy_enabled() -> bool: """Return whether any configured tool needs content-level redaction.""" return ( - zepto_mcp_enabled() - or gmail_configured() + gmail_configured() or kokoro_tts_enabled() or google_health_enabled() or r2_files_enabled() ) -def configure_zepto_privacy() -> bool: - """Disable content-rich tracing and MCP body debug in secure Zepto mode.""" - if not zepto_mcp_enabled(): - return False - os.environ["ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS"] = "false" - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "false" - logging.getLogger("google_adk.google.adk.tools.mcp_tool").setLevel(logging.INFO) - logging.getLogger("google.adk.tools.mcp_tool").setLevel(logging.INFO) - return True - - def configure_private_tool_privacy() -> bool: """Disable content-rich tracing when any private tool is configured.""" if not private_tool_privacy_enabled(): return False os.environ["ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS"] = "false" os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "false" - if zepto_mcp_enabled(): - logging.getLogger("google_adk.google.adk.tools.mcp_tool").setLevel(logging.INFO) - logging.getLogger("google.adk.tools.mcp_tool").setLevel(logging.INFO) return True def is_private_tool(tool: BaseTool) -> bool: """Return whether a tool can expose private account or message data.""" - is_zepto = tool.name.startswith(_ZEPTO_TOOL_PREFIX) is_gmail = tool.name.startswith(_GMAIL_TOOL_PREFIX) is_named_private = tool.name in _PRIVATE_TOOL_NAMES - return is_zepto or is_gmail or is_named_private + return is_gmail or is_named_private diff --git a/src/blacki/skills/zepto/SKILL.md b/src/blacki/skills/zepto/SKILL.md deleted file mode 100644 index 546f98f..0000000 --- a/src/blacki/skills/zepto/SKILL.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: zepto -description: Search Zepto, manage the shared account and cart, and place explicitly confirmed final orders for the authorized private Telegram chat. ---- - -# Zepto shopping - -Use the `zepto_*` tools for Zepto requests only after this skill is loaded. - -- This is one shared Zepto account and cart. -- Use search, account, cart, address, store, history, and payment-status tools - directly without asking for approval. -- For an order or payment tool, first call it with `confirmOrder=false` to get - the final preview. Then call the chosen tool with `confirmOrder=true`; ADK - will request the one required confirmation. Do not ask separately. -- Treat search, account, cart, address, order, and payment data as private. -- Never claim a cart or order change succeeded unless the Zepto tool returned - success. -- Use the individual `zepto_*` tools. The unsupported conversational - `zepto_zepto_shop` wrapper is intentionally unavailable. -- Do not retry a cart, checkout, payment, order, cancellation, or reorder call - after an ambiguous failure. -- Never place, pay for, cancel, or reorder a real order merely to test the - integration. diff --git a/src/blacki/tools/registry.py b/src/blacki/tools/registry.py index 43ce022..3eb5de0 100644 --- a/src/blacki/tools/registry.py +++ b/src/blacki/tools/registry.py @@ -47,9 +47,6 @@ class ToolConfig: kokoro_tts_base_url: str | None = None kokoro_tts_voice: str = "af_heart" google_health_enabled: bool = False - zepto_mcp_enabled: bool = False - zepto_mcp_config_dir: Path = Path("data/credentials/zepto-mcp-remote") - zepto_mcp_allowed_chat_ids: frozenset[str] = frozenset() gmail_config: GmailConfig | None = field(default=None, repr=False) r2_files_enabled: bool = False @@ -276,22 +273,6 @@ def _build_skill_tools( logger.info("%s skill enabled", skill_name) loaded_skills.append((skill, None)) - if include_user_scoped_tools and config.zepto_mcp_enabled: - from blacki.tools.zepto import create_zepto_toolset - from blacki.zepto import ZeptoCredentialError - - try: - zepto_toolset = create_zepto_toolset( - config_dir=config.zepto_mcp_config_dir, - allowed_chat_ids=config.zepto_mcp_allowed_chat_ids, - ) - zepto_skill = load_skill_from_dir(skills_dir / "zepto") - if zepto_skill: - logger.info("Zepto MCP skill enabled for the root agent") - loaded_skills.append((zepto_skill, zepto_toolset)) - except ZeptoCredentialError as exc: - logger.warning("Zepto MCP disabled: %s", exc) - if include_user_scoped_tools and config.gmail_config is not None: from blacki.gmail import GmailCredentialError from blacki.tools.gmail import create_gmail_toolset @@ -424,11 +405,6 @@ def build_tool_config_from_env() -> ToolConfig: default_sqlite_path = str(application_data_directory() / "tools.db") sqlite_path = os.getenv("SQLITE_PATH", "").strip() or default_sqlite_path - allowed_zepto_chat_ids = frozenset( - item.strip() - for item in os.getenv("ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS", "").split(",") - if item.strip() - ) gmail_config = None try: gmail_config = GmailConfig.from_environment() @@ -450,15 +426,6 @@ def build_tool_config_from_env() -> ToolConfig: kokoro_tts_voice=os.getenv("KOKORO_TTS_VOICE", "af_heart").strip() or "af_heart", google_health_enabled=_google_health_configured(), - zepto_mcp_enabled=os.getenv("ZEPTO_MCP_ENABLED", "false").strip().lower() - in ("true", "1", "yes"), - zepto_mcp_config_dir=Path( - os.getenv( - "ZEPTO_MCP_CONFIG_DIR", - "data/credentials/zepto-mcp-remote", - ).strip() - ), - zepto_mcp_allowed_chat_ids=allowed_zepto_chat_ids, gmail_config=gmail_config, r2_files_enabled=os.getenv("R2_FILES_ENABLED", "false").strip().lower() in ("true", "1", "yes"), diff --git a/src/blacki/tools/zepto.py b/src/blacki/tools/zepto.py deleted file mode 100644 index 2a29e5d..0000000 --- a/src/blacki/tools/zepto.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Private Zepto MCP toolset composition.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any, cast - -from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.tools.base_tool import BaseTool -from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams -from google.adk.tools.mcp_tool.mcp_toolset import McpToolset - -from blacki.zepto.client import ( - BRIDGE_CONNECT_TIMEOUT_SECONDS, - ZEPTO_TOOL_PREFIX, - McpRemoteCredentialStore, - ZeptoCredentialError, - create_bridge_server_parameters, -) - - -def _is_allowed_private_telegram_user( - readonly_context: ReadonlyContext | None, - allowed_chat_ids: frozenset[str], -) -> bool: - if readonly_context is None: - return False - user_id = readonly_context.user_id - prefix = "telegram-chat-" - if not user_id.startswith(prefix): - return False - chat_id = user_id.removeprefix(prefix) - return chat_id.isdigit() and chat_id in allowed_chat_ids - - -def _is_supported_zepto_tool( - tool: BaseTool, - readonly_context: ReadonlyContext | None = None, -) -> bool: - """Exclude Zepto's conversational wrapper from individual MCP tool use.""" - del readonly_context - return tool.name != "zepto_shop" - - -def _requires_zepto_order_confirmation( - confirmOrder: object = False, # noqa: N803 - external Zepto MCP field -) -> bool: - """Confirm only calls that can finalize a real Zepto order or payment.""" - return confirmOrder is not False - - -class AuthorizedZeptoToolset(McpToolset): - """MCP toolset that refuses bridge startup for unauthorized identities.""" - - def __init__(self, *, allowed_chat_ids: frozenset[str], **kwargs: Any) -> None: - super().__init__(**kwargs) - self._allowed_chat_ids = allowed_chat_ids - - async def get_tools( - self, readonly_context: ReadonlyContext | None = None - ) -> list[BaseTool]: - if not _is_allowed_private_telegram_user( - readonly_context, self._allowed_chat_ids - ): - return [] - return cast(list[BaseTool], await super().get_tools(readonly_context)) - - -def create_zepto_toolset( - *, - config_dir: Path, - allowed_chat_ids: frozenset[str], -) -> AuthorizedZeptoToolset: - """Build the private, root-only Zepto MCP toolset.""" - if not allowed_chat_ids: - raise ZeptoCredentialError( - "At least one private Telegram chat ID must be allowed for Zepto." - ) - if any(not chat_id.isdigit() or int(chat_id) <= 0 for chat_id in allowed_chat_ids): - raise ZeptoCredentialError( - "Zepto allowlist accepts positive private Telegram chat IDs only." - ) - - store = McpRemoteCredentialStore(config_dir) - store.validate_runtime_ready() - in_docker = Path("/.dockerenv").exists() - server_parameters = create_bridge_server_parameters( - config_dir=store.config_dir, - auth_timeout_seconds=2, - allow_npx=not in_docker, - ) - return AuthorizedZeptoToolset( - allowed_chat_ids=allowed_chat_ids, - connection_params=StdioConnectionParams( - server_params=server_parameters, - timeout=BRIDGE_CONNECT_TIMEOUT_SECONDS, - ), - tool_name_prefix=ZEPTO_TOOL_PREFIX, - tool_filter=_is_supported_zepto_tool, - require_confirmation=_requires_zepto_order_confirmation, - ) diff --git a/src/blacki/zepto/__init__.py b/src/blacki/zepto/__init__.py deleted file mode 100644 index e5f9899..0000000 --- a/src/blacki/zepto/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Zepto MCP integration for Blacki.""" - -from .client import ( - DEFAULT_CONFIG_DIR, - ZEPTO_MCP_URL, - McpRemoteCredentialStore, - ZeptoCredentialError, -) - -__all__ = [ - "DEFAULT_CONFIG_DIR", - "ZEPTO_MCP_URL", - "McpRemoteCredentialStore", - "ZeptoCredentialError", -] diff --git a/src/blacki/zepto/auth.py b/src/blacki/zepto/auth.py deleted file mode 100644 index fd90ba0..0000000 --- a/src/blacki/zepto/auth.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Interactive login and non-mutating manifest probe for Zepto MCP.""" - -from __future__ import annotations - -import argparse -import asyncio -import json -from collections.abc import Sequence -from contextlib import suppress -from pathlib import Path - -from mcp import ClientSession -from mcp.client.stdio import stdio_client - -from .client import ( - BRIDGE_CONNECT_TIMEOUT_SECONDS, - DEFAULT_CONFIG_DIR, - McpRemoteCredentialStore, - ZeptoCredentialError, - create_bridge_server_parameters, -) - - -async def _list_tools( - config_dir: Path, - *, - interactive_login: bool, -) -> list[str]: - store = McpRemoteCredentialStore(config_dir) - if interactive_login: - store.prepare_for_login() - else: - store.validate_runtime_ready() - server = create_bridge_server_parameters( - config_dir=store.config_dir, - auth_timeout_seconds=300 if interactive_login else 2, - allow_npx=True, - ) - try: - async with asyncio.timeout( - 310 if interactive_login else BRIDGE_CONNECT_TIMEOUT_SECONDS - ): - async with ( - stdio_client(server) as (read_stream, write_stream), - ClientSession(read_stream, write_stream) as session, - ): - await session.initialize() - manifest = await session.list_tools() - except Exception as exc: - with suppress(ZeptoCredentialError): - store.tighten_permissions() - raise ZeptoCredentialError( - "Zepto bridge could not connect. Run the login command again." - ) from exc - - store.tighten_permissions() - store.validate_runtime_ready() - return sorted(tool.name for tool in manifest.tools) - - -async def login(config_dir: Path, *, force: bool = False) -> list[str]: - """Open Zepto OAuth through the supported bridge and list all tools.""" - if force: - McpRemoteCredentialStore(config_dir).clear_authentication() - return await _list_tools(config_dir, interactive_login=True) - - -async def probe(config_dir: Path) -> list[str]: - """List tools using stored credentials without calling a Zepto tool.""" - return await _list_tools(config_dir, interactive_login=False) - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Authenticate or probe Zepto MCP.") - parser.add_argument( - "--config-dir", - type=Path, - default=DEFAULT_CONFIG_DIR, - help="Private mcp-remote directory shared with the Blacki runtime.", - ) - subparsers = parser.add_subparsers(dest="command", required=True) - login_parser = subparsers.add_parser( - "login", help="Run one-time Zepto OAuth in a browser." - ) - login_parser.add_argument( - "--force", - action="store_true", - help="Delete only the stored Zepto bridge credentials before login.", - ) - subparsers.add_parser("probe", help="List tools with stored credentials.") - subparsers.add_parser("status", help="Validate local credentials only.") - return parser - - -def main(argv: Sequence[str] | None = None) -> int: - args = _build_parser().parse_args(argv) - try: - if args.command == "login": - tools = asyncio.run(login(args.config_dir, force=args.force)) - print(json.dumps({"authenticated": True, "tools": tools}, indent=2)) - elif args.command == "probe": - tools = asyncio.run(probe(args.config_dir)) - print(json.dumps({"authenticated": True, "tools": tools}, indent=2)) - else: - McpRemoteCredentialStore(args.config_dir).validate_runtime_ready() - print(json.dumps({"authenticated": True})) - except ZeptoCredentialError as exc: - if args.command == "status": - print(json.dumps({"authenticated": False})) - else: - print(f"Zepto authentication error: {exc}") - return 1 - return 0 - - -if __name__ == "__main__": # pragma: no cover - exercised as a module command - raise SystemExit(main()) diff --git a/src/blacki/zepto/client.py b/src/blacki/zepto/client.py deleted file mode 100644 index a1fa4d9..0000000 --- a/src/blacki/zepto/client.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Permission-checked stdio bridge for Zepto's hosted MCP server.""" - -from __future__ import annotations - -import hashlib -import json -import os -import shutil -import stat -from pathlib import Path -from typing import Any, cast - -from mcp import StdioServerParameters -from mcp.client.stdio import get_default_environment - -ZEPTO_MCP_URL = "https://mcp.zepto.co.in/mcp" -ZEPTO_TOOL_PREFIX = "zepto" -FINAL_ORDER_PAYMENT_TOOL_NAMES = frozenset( - { - "create_online_payment_order", - "create_order", - "create_upi_reserve_pay_order", - "create_wallet_order", - } -) -REQUIRED_SCOPE = "tools:read" -MCP_REMOTE_PACKAGE_VERSION = "0.1.38" -# mcp-remote 0.1.38's bundled storage namespace still reports 0.1.37. -MCP_REMOTE_STORAGE_VERSION = "0.1.37" -DEFAULT_CONFIG_DIR = Path("data/credentials/zepto-mcp-remote") -BRIDGE_CONNECT_TIMEOUT_SECONDS = 15.0 -_SERVER_HASH = hashlib.md5( # noqa: S324 - compatibility identifier, not crypto - ZEPTO_MCP_URL.encode(), - usedforsecurity=False, -).hexdigest() -_CREDENTIAL_FILENAMES = frozenset( - { - f"{_SERVER_HASH}_client_info.json", - f"{_SERVER_HASH}_tokens.json", - } -) -_ALLOWED_FILENAMES = _CREDENTIAL_FILENAMES | { - f"{_SERVER_HASH}_code_verifier.txt", - f"{_SERVER_HASH}_lock.json", -} - - -class ZeptoCredentialError(RuntimeError): - """Raised when the bridge or its private credentials are not ready.""" - - -def _absolute(path: Path) -> Path: - """Return an absolute path without resolving away symlink evidence.""" - return path if path.is_absolute() else Path.cwd() / path - - -def _reject_symlink_chain(path: Path) -> None: - for candidate in (path, *path.parents): - if candidate.is_symlink(): - raise ZeptoCredentialError( - "Zepto credential directories must not contain symlinks." - ) - - -def _validate_owner_and_mode( - path: Path, - *, - expected_mode: int, - label: str, -) -> os.stat_result: - if path.is_symlink(): - raise ZeptoCredentialError(f"Zepto {label} must not be a symlink.") - try: - result = path.stat() - except OSError as exc: - raise ZeptoCredentialError(f"Zepto {label} is missing or unreadable.") from exc - if hasattr(os, "geteuid") and result.st_uid != os.geteuid(): - raise ZeptoCredentialError(f"Zepto {label} must be owned by Blacki.") - if stat.S_IMODE(result.st_mode) != expected_mode: - raise ZeptoCredentialError(f"Zepto {label} must use mode {expected_mode:04o}.") - return result - - -def _read_private_object(path: Path, label: str) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ZeptoCredentialError(f"Zepto {label} is corrupt.") from exc - if not isinstance(value, dict): - raise ZeptoCredentialError(f"Zepto {label} is corrupt.") - return cast(dict[str, Any], value) - - -class McpRemoteCredentialStore: - """Validate mcp-remote's dedicated, permission-protected Zepto state.""" - - def __init__(self, config_dir: Path = DEFAULT_CONFIG_DIR) -> None: - self.config_dir = _absolute(config_dir) - self.version_dir = self.config_dir / f"mcp-remote-{MCP_REMOTE_STORAGE_VERSION}" - - def prepare_for_login(self) -> None: - """Create only the private directories needed by interactive OAuth.""" - _reject_symlink_chain(self.config_dir) - for directory in (self.config_dir, self.version_dir): - try: - directory.mkdir(parents=True, exist_ok=True, mode=0o700) - directory.chmod(0o700) - except OSError as exc: - raise ZeptoCredentialError( - "Could not prepare the private Zepto credential directory." - ) from exc - - def validate_runtime_ready(self) -> None: - """Require exact Zepto files, private permissions, and valid JSON shape.""" - _reject_symlink_chain(self.config_dir) - config_stat = _validate_owner_and_mode( - self.config_dir, - expected_mode=0o700, - label="credential directory", - ) - version_stat = _validate_owner_and_mode( - self.version_dir, - expected_mode=0o700, - label="versioned credential directory", - ) - if not stat.S_ISDIR(config_stat.st_mode) or not stat.S_ISDIR( - version_stat.st_mode - ): - raise ZeptoCredentialError("Zepto credential paths must be directories.") - - try: - entries = list(self.version_dir.iterdir()) - except OSError as exc: - raise ZeptoCredentialError( - "Zepto credential directory is unreadable." - ) from exc - names = {entry.name for entry in entries} - if not _CREDENTIAL_FILENAMES.issubset(names): - raise ZeptoCredentialError( - "Zepto is not authenticated. Run the Zepto login command." - ) - unexpected = names - _ALLOWED_FILENAMES - if unexpected: - raise ZeptoCredentialError( - "Zepto credential directory contains unexpected files." - ) - - for entry in entries: - result = _validate_owner_and_mode( - entry, - expected_mode=0o600, - label="credential file", - ) - if not stat.S_ISREG(result.st_mode): - raise ZeptoCredentialError( - "Zepto credential entries must be regular files." - ) - - client = _read_private_object( - self.version_dir / f"{_SERVER_HASH}_client_info.json", - "client registration", - ) - tokens = _read_private_object( - self.version_dir / f"{_SERVER_HASH}_tokens.json", - "OAuth tokens", - ) - if ( - not isinstance(client.get("client_id"), str) - or not client["client_id"] - or client.get("token_endpoint_auth_method") != "none" - ): - raise ZeptoCredentialError("Zepto client registration is corrupt.") - token_scope = tokens.get("scope") - if ( - not isinstance(tokens.get("access_token"), str) - or not tokens["access_token"] - or not isinstance(tokens.get("refresh_token"), str) - or not tokens["refresh_token"] - or str(tokens.get("token_type", "")).lower() != "bearer" - or not isinstance(token_scope, str) - or REQUIRED_SCOPE not in token_scope.split() - ): - raise ZeptoCredentialError("Zepto OAuth tokens are corrupt.") - - def tighten_permissions(self) -> None: - """Restore mcp-remote's state to the documented private modes.""" - _reject_symlink_chain(self.config_dir) - try: - for directory in (self.config_dir, self.version_dir): - if directory.exists(): - directory.chmod(0o700) - if self.version_dir.is_dir(): - for entry in self.version_dir.iterdir(): - if entry.is_symlink(): - raise ZeptoCredentialError( - "Zepto credential files must not be symlinks." - ) - if entry.is_file(): - entry.chmod(0o600) - except OSError as exc: - raise ZeptoCredentialError( - "Could not protect Zepto credential permissions." - ) from exc - - def clear_authentication(self) -> None: - """Delete only Zepto bridge state after an explicit force-login request.""" - if not self.config_dir.exists(): - return - self.tighten_permissions() - config_stat = _validate_owner_and_mode( - self.config_dir, - expected_mode=0o700, - label="credential directory", - ) - if not stat.S_ISDIR(config_stat.st_mode): - raise ZeptoCredentialError("Zepto credential path must be a directory.") - if not self.version_dir.exists(): - return - version_stat = _validate_owner_and_mode( - self.version_dir, - expected_mode=0o700, - label="versioned credential directory", - ) - if not stat.S_ISDIR(version_stat.st_mode): - raise ZeptoCredentialError("Zepto credential path must be a directory.") - entries = list(self.version_dir.iterdir()) - if {entry.name for entry in entries} - _ALLOWED_FILENAMES: - raise ZeptoCredentialError( - "Zepto credential directory contains unexpected files." - ) - for entry in entries: - result = _validate_owner_and_mode( - entry, - expected_mode=0o600, - label="credential file", - ) - if not stat.S_ISREG(result.st_mode): - raise ZeptoCredentialError( - "Zepto credential entries must be regular files." - ) - for entry in entries: - entry.unlink() - - -def _locked_checkout_bridge() -> Path | None: - candidate = ( - Path(__file__).resolve().parents[3] - / "mcp-bridge" - / "node_modules" - / ".bin" - / "mcp-remote" - ) - return candidate if candidate.exists() else None - - -def _verified_bridge_binary(path: Path) -> str: - """Resolve a bridge executable and require the exact pinned package.""" - try: - resolved = path.resolve(strict=True) - package_path = resolved.parent.parent / "package.json" - package = json.loads(package_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ZeptoCredentialError( - "Installed mcp-remote package metadata is unreadable." - ) from exc - if ( - not isinstance(package, dict) - or package.get("name") != "mcp-remote" - or package.get("version") != MCP_REMOTE_PACKAGE_VERSION - ): - raise ZeptoCredentialError( - f"mcp-remote must be exactly version {MCP_REMOTE_PACKAGE_VERSION}." - ) - if not resolved.is_file() or not os.access(resolved, os.X_OK): - raise ZeptoCredentialError("Pinned mcp-remote executable is not runnable.") - return str(resolved) - - -def _resolve_bridge_command(*, allow_npx: bool) -> tuple[str, list[str]]: - locked_checkout = _locked_checkout_bridge() - if locked_checkout is not None: - return _verified_bridge_binary(locked_checkout), [] - installed = shutil.which("mcp-remote") - if installed: - return _verified_bridge_binary(Path(installed)), [] - if allow_npx: - npx = shutil.which("npx") - if npx: - return ( - str(_absolute(Path(npx))), - [ - "--yes", - "--package", - f"mcp-remote@{MCP_REMOTE_PACKAGE_VERSION}", - "mcp-remote", - ], - ) - raise ZeptoCredentialError( - "Pinned mcp-remote is unavailable. Install the documented bridge." - ) - - -def _bridge_environment(config_dir: Path) -> dict[str, str]: - environment = get_default_environment() - environment["MCP_REMOTE_CONFIG_DIR"] = str(_absolute(config_dir)) - # Local npx fallback must not read or forward credentials from ~/.npmrc. - environment["npm_config_userconfig"] = os.devnull - return environment - - -def create_bridge_server_parameters( - *, - config_dir: Path, - auth_timeout_seconds: int, - allow_npx: bool, -) -> StdioServerParameters: - """Build the pinned bridge process without inheriting Blacki secrets.""" - command, prefix_args = _resolve_bridge_command(allow_npx=allow_npx) - return StdioServerParameters( - command=command, - args=[ - *prefix_args, - ZEPTO_MCP_URL, - "--transport", - "http-only", - "--auth-timeout", - str(auth_timeout_seconds), - "--silent", - ], - env=_bridge_environment(config_dir), - ) diff --git a/tests/test_adk_runtime.py b/tests/test_adk_runtime.py index c8906ed..cfd8d78 100644 --- a/tests/test_adk_runtime.py +++ b/tests/test_adk_runtime.py @@ -956,7 +956,7 @@ def _confirmation_event( *, interrupt_id: str = "confirm-1", original_id: str = "tool-1", - tool_name: str = "zepto_update_cart", + tool_name: str = "sample_update_cart", tool_args: dict[str, object] | None = None, ) -> Event: original = types.FunctionCall( @@ -1094,7 +1094,7 @@ def test_pending_confirmation_ignores_malformed_calls() -> None: types.FunctionCall( id="bad-args", name="adk_request_confirmation", - args={"originalFunctionCall": {"name": "zepto_tool", "args": "bad"}}, + args={"originalFunctionCall": {"name": "sample_tool", "args": "bad"}}, ), ] event = Event( @@ -1143,7 +1143,7 @@ def test_confirmation_prompt_shows_exact_first_call_and_multiple_warning() -> No _confirmation_event( interrupt_id="confirm-2", original_id="tool-2", - tool_name="zepto_place_order", + tool_name="sample_place_order", tool_args={"total": 999}, ), ] @@ -1152,10 +1152,11 @@ def test_confirmation_prompt_shows_exact_first_call_and_multiple_warning() -> No prompt = _format_confirmation(pending[0], pending_count=len(pending)) - assert "zepto_update_cart" in prompt + assert "Confirm tool `sample_update_cart` with these arguments?" in prompt + assert "sample_update_cart" in prompt assert '"quantity": 1' in prompt assert "2 pending calls" in prompt - assert "zepto_place_order" not in prompt + assert "sample_place_order" not in prompt def test_gmail_confirmation_shows_only_confirmed_send_metadata() -> None: @@ -1178,8 +1179,6 @@ def test_gmail_confirmation_shows_only_confirmed_send_metadata() -> None: assert "person@example.com" in prompt assert "Quarterly update" in prompt assert "private body must not be shown" not in prompt - assert "Zepto" not in prompt - without_optional_recipients = PendingConfirmation( interrupt_id="confirm-gmail-minimal", tool_name="gmail_send_draft", @@ -1220,7 +1219,7 @@ async def fake_run_async(**kwargs: object) -> AsyncIterator[Event]: message_text="Add milk", ) - assert "zepto_update_cart" in response.content + assert "sample_update_cart" in response.content assert "Reply exactly `yes` or `no`" in response.content @@ -1285,7 +1284,7 @@ async def test_pending_confirmation_reprompts_or_resumes_same_call() -> None: ), ) - assert "zepto_update_cart" in response.content + assert "sample_update_cart" in response.content run_async.assert_not_called() async def resumed_run(**kwargs: object) -> AsyncIterator[Event]: diff --git a/tests/test_deployment_contract.py b/tests/test_deployment_contract.py index 119bcf7..602d9d0 100644 --- a/tests/test_deployment_contract.py +++ b/tests/test_deployment_contract.py @@ -1,6 +1,5 @@ """Contract tests for the documented Docker Compose deployment path.""" -import json import os import shutil import subprocess @@ -62,9 +61,6 @@ def test_docker_context_is_an_explicit_allowlist() -> None: "*", "!Dockerfile", "!entrypoint.sh", - "!mcp-bridge/", - "!mcp-bridge/package.json", - "!mcp-bridge/package-lock.json", "!pyproject.toml", "!uv.lock", "!src/", @@ -103,7 +99,6 @@ def test_full_environment_does_not_activate_fake_optional_credentials() -> None: assert "TELEGRAM_BOT_TOKEN" not in values assert values["HOST_BIND_IP"] == "127.0.0.1" assert values["HOST"] == "127.0.0.1" - assert values["ZEPTO_MCP_ENABLED"] == "false" assert values["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] == "false" assert values["ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS"] == "false" @@ -145,25 +140,6 @@ def test_runtime_prepares_every_persistent_mount() -> None: assert 'exec runuser -u app -- "$@"' in entrypoint -def test_zepto_bridge_is_locked_and_baked_into_the_image() -> None: - """Production must not download or resolve the Zepto bridge at runtime.""" - package = json.loads(_read("mcp-bridge/package.json")) - lock = json.loads(_read("mcp-bridge/package-lock.json")) - dockerfile = _read("Dockerfile") - - assert package["dependencies"] == {"mcp-remote": "0.1.38"} - assert lock["packages"][""]["dependencies"] == {"mcp-remote": "0.1.38"} - assert lock["packages"]["node_modules/mcp-remote"]["version"] == "0.1.38" - for required in ( - "FROM node:22-bookworm-slim AS mcp-bridge", - "npm ci --omit=dev --ignore-scripts", - "COPY --from=mcp-bridge /usr/local/bin/node /usr/local/bin/node", - "/usr/local/bin/mcp-remote", - ): - assert required in dockerfile - assert "npx" not in dockerfile - - def test_mkdocs_navigation_targets_existing_files() -> None: """Every page in the declared information architecture must exist.""" config = _load_yaml("mkdocs.yml") @@ -373,7 +349,6 @@ def test_deployment_ci_covers_the_contract_and_native_image_build() -> None: '"compose.prod.yaml"', '"compose.smoke.yaml"', '"docs/**"', - '"mcp-bridge/**"', '"scripts/write_compose_env.py"', '"setup.sh"', '"src/**"', @@ -557,28 +532,6 @@ def test_production_deployment_shell_is_valid_bash() -> None: ) -def test_production_deployment_serializes_zepto_settings() -> None: - """Zepto feature flags and paths must survive automated deployments.""" - workflow = _load_yaml(".github/workflows/docker-publish.yml") - deploy_step = next( - step - for step in workflow["jobs"]["deploy"]["steps"] - if step["name"] == "Deploy to Server via Tailscale" - ) - deploy_script = deploy_step["run"] - writer_start = deploy_script.index("python3 scripts/write_compose_env.py") - writer_end = deploy_script.index("printf '%s' \"$GH_TOKEN\"") - writer_names = deploy_script[writer_start:writer_end].split() - - for setting in ( - "ZEPTO_MCP_ENABLED", - "ZEPTO_MCP_CONFIG_DIR", - "ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS", - ): - assert deploy_step["env"][setting] == f"${{{{ secrets.{setting} }}}}" - assert setting in writer_names - - def test_production_deployment_serializes_kokoro_tts_settings() -> None: """The private Kokoro endpoint and voice must survive deployment.""" workflow = _load_yaml(".github/workflows/docker-publish.yml") diff --git a/tests/test_logging_callbacks.py b/tests/test_logging_callbacks.py index 1f72e8a..e856295 100644 --- a/tests/test_logging_callbacks.py +++ b/tests/test_logging_callbacks.py @@ -118,13 +118,13 @@ def test_before_agent_without_user_content( assert "*** Starting agent 'test_agent'" in caplog.text assert "User Content:" not in caplog.text - def test_secure_zepto_mode_redacts_all_lifecycle_content( + def test_private_tool_mode_redacts_all_lifecycle_content( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Prompts, responses, and all tool payloads stay out of secure logs.""" - monkeypatch.setenv("ZEPTO_MCP_ENABLED", "true") + monkeypatch.setenv("R2_FILES_ENABLED", "true") caplog.set_level(logging.DEBUG) callbacks = LoggingCallbacks() context = MockLoggingCallbackContext( @@ -396,15 +396,15 @@ def test_after_tool_without_user_content( assert "User Content:" not in caplog.text assert "Tool response: {'status': 'success'}" in caplog.text - def test_zepto_tool_payloads_are_redacted( + def test_gmail_tool_payloads_are_redacted( self, mock_tool_context: MockToolContext, caplog: pytest.LogCaptureFixture, ) -> None: - """Private Zepto arguments, responses, and user content stay out of logs.""" + """Private Gmail arguments, responses, and user content stay out of logs.""" caplog.set_level(logging.DEBUG) callbacks = LoggingCallbacks() - tool = MockBaseTool(name="zepto_update_cart") + tool = MockBaseTool(name="gmail_send_draft") cast(Any, callbacks.before_tool)( tool, @@ -418,7 +418,7 @@ def test_zepto_tool_payloads_are_redacted( {"phone": "private-phone"}, ) - assert "zepto_update_cart" in caplog.text + assert "gmail_send_draft" in caplog.text assert "Private tool payload redacted" in caplog.text assert "private-address" not in caplog.text assert "private-phone" not in caplog.text diff --git a/tests/test_privacy.py b/tests/test_privacy.py index 1272cdc..054c4fb 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from unittest.mock import MagicMock, patch import pytest @@ -11,12 +10,10 @@ from blacki.observability.privacy_logging import PrivacyAwareLoggingPlugin from blacki.security.tool_privacy import ( configure_private_tool_privacy, - configure_zepto_privacy, gmail_configured, is_private_tool, kokoro_tts_enabled, private_tool_privacy_enabled, - zepto_mcp_enabled, ) @@ -54,8 +51,7 @@ def test_gmail_privacy_controls(monkeypatch: pytest.MonkeyPatch) -> None: assert gmail_configured() is True -def test_private_tool_identification_uses_zepto_and_gmail_prefix() -> None: - assert is_private_tool(_tool("zepto_search_products")) is True +def test_private_tool_identification_uses_gmail_and_private_names() -> None: assert is_private_tool(_tool("gmail_search_messages")) is True assert is_private_tool(_tool("gmail_create_draft")) is True assert is_private_tool(_tool("send_text_to_speech")) is True @@ -63,36 +59,6 @@ def test_private_tool_identification_uses_zepto_and_gmail_prefix() -> None: assert is_private_tool(_tool("get_health_summary")) is True -def test_configure_zepto_privacy_is_explicit_and_forces_safe_values( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("ZEPTO_MCP_ENABLED", "false") - assert zepto_mcp_enabled() is False - assert configure_zepto_privacy() is False - - monkeypatch.setenv("ZEPTO_MCP_ENABLED", "yes") - monkeypatch.setenv("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "true") - monkeypatch.setenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "true") - - assert zepto_mcp_enabled() is True - assert configure_zepto_privacy() is True - assert ( - logging.getLogger("google_adk.google.adk.tools.mcp_tool").level == logging.INFO - ) - assert logging.getLogger("google.adk.tools.mcp_tool").level == logging.INFO - import os - - assert os.environ["ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS"] == "false" - assert os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] == "false" - - -def test_private_tool_identification_uses_zepto_prefix() -> None: - assert is_private_tool(_tool("zepto_search_products")) is True - assert is_private_tool(_tool("send_text_to_speech")) is True - assert is_private_tool(_tool("search_products")) is False - assert is_private_tool(_tool("get_health_summary")) is True - - @pytest.mark.parametrize( "tool_name", [ @@ -112,8 +78,8 @@ def test_calorie_tools_are_private(tool_name: str) -> None: def test_kokoro_tts_enables_content_redaction( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("ZEPTO_MCP_ENABLED", "false") monkeypatch.delenv("KOKORO_TTS_BASE_URL", raising=False) + monkeypatch.delenv("R2_FILES_ENABLED", raising=False) for name in ( "GOOGLE_HEALTH_CLIENT_ID", "GOOGLE_HEALTH_CLIENT_SECRET", @@ -137,13 +103,13 @@ def test_kokoro_tts_enables_content_redaction( assert os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] == "false" -def test_secure_zepto_app_removes_content_logging_plugin( +def test_private_tool_app_removes_content_logging_plugin( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Secure mode must not register ADK's content-printing plugin.""" + """Private-tool mode must not register ADK's content-printing plugin.""" from blacki.agent import create_app, root_agent - monkeypatch.setenv("ZEPTO_MCP_ENABLED", "true") + monkeypatch.setenv("KOKORO_TTS_BASE_URL", "http://kokoro.internal") secure_app = create_app(root_agent) assert secure_app.plugins is not None @@ -151,7 +117,7 @@ def test_secure_zepto_app_removes_content_logging_plugin( @pytest.mark.asyncio -async def test_privacy_logging_plugin_redacts_private_payloads( +async def test_privacy_logging_plugin_redacts_gmail_payloads( capsys: pytest.CaptureFixture[str], ) -> None: plugin = PrivacyAwareLoggingPlugin() @@ -161,7 +127,7 @@ async def test_privacy_logging_plugin_redacts_private_payloads( secret_args = {"address": "private-address"} secret_result = {"phone": "private-phone"} secret_error = RuntimeError("private-error") - tool = _tool("zepto_update_cart") + tool = _tool("gmail_send_draft") assert ( await plugin.before_tool_callback( @@ -191,7 +157,7 @@ async def test_privacy_logging_plugin_redacts_private_payloads( ) output = capsys.readouterr().out - assert "zepto_update_cart" in output + assert "gmail_send_draft" in output for private_value in ("private-address", "private-phone", "private-error"): assert private_value not in output diff --git a/tests/test_registry.py b/tests/test_registry.py index 4fe8433..00b3074 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -37,8 +37,6 @@ def test_default_values(self) -> None: assert config.legacy_workout_tools_enabled is False assert config.kokoro_tts_base_url is None assert config.kokoro_tts_voice == "af_heart" - assert config.zepto_mcp_enabled is False - assert config.zepto_mcp_allowed_chat_ids == frozenset() assert config.gmail_config is None def test_custom_values(self) -> None: @@ -53,9 +51,6 @@ def test_custom_values(self) -> None: legacy_workout_tools_enabled=True, kokoro_tts_base_url="http://kokoro.internal:8880", kokoro_tts_voice="hf_alpha", - zepto_mcp_enabled=True, - zepto_mcp_config_dir=Path("/tmp/zepto"), - zepto_mcp_allowed_chat_ids=frozenset({"123"}), gmail_config=_gmail_config(), ) @@ -67,9 +62,6 @@ def test_custom_values(self) -> None: assert config.legacy_workout_tools_enabled is True assert config.kokoro_tts_base_url == "http://kokoro.internal:8880" assert config.kokoro_tts_voice == "hf_alpha" - assert config.zepto_mcp_enabled is True - assert config.zepto_mcp_config_dir == Path("/tmp/zepto") - assert config.zepto_mcp_allowed_chat_ids == frozenset({"123"}) assert config.gmail_config is not None @@ -162,41 +154,6 @@ def test_all_tools_with_full_config(self) -> None: assert len(tools) > 15 - def test_zepto_skill_is_root_only(self) -> None: - """Zepto should join the root skill toolset but never the public agent.""" - skills_dir = Path(__file__).parent.parent / "src" / "blacki" / "skills" - config = ToolConfig( - skills_dir=skills_dir, - weather_enabled=False, - zepto_mcp_enabled=True, - zepto_mcp_allowed_chat_ids=frozenset({"123"}), - ) - zepto_toolset = patch( - "blacki.tools.zepto.create_zepto_toolset", - autospec=True, - ) - with zepto_toolset as create: - create.return_value = create_autospec( - McpToolset, spec_set=True, instance=True - ) - root_tools = build_tools(config, include_user_scoped_tools=True) - worker_tools = build_tools(config, include_user_scoped_tools=False) - default_tools = build_tools(config) - - root_skills = next( - tool for tool in root_tools if isinstance(tool, McpSkillToolset) - ) - worker_skills = next( - tool for tool in worker_tools if isinstance(tool, McpSkillToolset) - ) - default_skills = next( - tool for tool in default_tools if isinstance(tool, McpSkillToolset) - ) - assert "zepto" in root_skills._skills - assert "zepto" not in worker_skills._skills - assert "zepto" not in default_skills._skills - create.assert_called_once() - def test_gmail_skill_is_root_only(self) -> None: """Gmail should join the root skill toolset but never the public agent.""" skills_dir = Path(__file__).parent.parent / "src" / "blacki" / "skills" @@ -319,30 +276,6 @@ def test_invalid_kokoro_tts_config_disables_only_tts( assert len(tools) == 6 assert "Kokoro TTS disabled" in caplog.text - def test_invalid_zepto_credentials_disable_only_zepto( - self, - caplog: pytest.LogCaptureFixture, - ) -> None: - """Invalid private credentials should fail closed without losing skills.""" - from blacki.zepto import ZeptoCredentialError - - skills_dir = Path(__file__).parent.parent / "src" / "blacki" / "skills" - config = ToolConfig( - skills_dir=skills_dir, - weather_enabled=False, - zepto_mcp_enabled=True, - zepto_mcp_allowed_chat_ids=frozenset({"123"}), - ) - with patch( - "blacki.tools.zepto.create_zepto_toolset", - side_effect=ZeptoCredentialError("authenticate first"), - ): - tools = build_tools(config, include_user_scoped_tools=True) - - skills = next(tool for tool in tools if isinstance(tool, McpSkillToolset)) - assert "zepto" not in skills._skills - assert "Zepto MCP disabled" in caplog.text - def test_invalid_gmail_credentials_disable_only_gmail( self, caplog: pytest.LogCaptureFixture ) -> None: @@ -365,30 +298,6 @@ def test_invalid_gmail_credentials_disable_only_gmail( assert "gmail" not in skills._skills assert "Gmail API disabled" in caplog.text - def test_missing_zepto_skill_file_does_not_register_toolset(self) -> None: - """A missing packaged skill should not expose an orphan MCP toolset.""" - config = ToolConfig( - skills_dir=Path("src/blacki/skills"), - weather_enabled=False, - zepto_mcp_enabled=True, - zepto_mcp_allowed_chat_ids=frozenset({"123"}), - ) - with ( - patch("blacki.tools.skills.load_skill_from_dir", return_value=None), - patch( - "blacki.tools.zepto.create_zepto_toolset", - return_value=create_autospec( - McpToolset, - spec_set=True, - instance=True, - ), - ) as create, - ): - tools = build_tools(config, include_user_scoped_tools=True) - - assert not any(isinstance(tool, McpSkillToolset) for tool in tools) - create.assert_called_once() - def test_missing_gmail_skill_file_does_not_register_toolset(self) -> None: """A missing packaged skill should not expose an orphan MCP toolset.""" config = ToolConfig( @@ -444,12 +353,8 @@ def test_empty_env(self) -> None: assert config.sqlite_path.endswith(".adk/tools.db") assert config.sandbox_enabled is False assert config.skills_dir is not None - assert config.zepto_mcp_enabled is False assert config.kokoro_tts_base_url is None assert config.kokoro_tts_voice == "af_heart" - assert config.zepto_mcp_config_dir == Path( - "data/credentials/zepto-mcp-remote" - ) def test_brave_search_api_key_from_env(self) -> None: """Should read BRAVE_SEARCH_API_KEY from env.""" @@ -531,23 +436,6 @@ def test_legacy_workout_tools_flag_from_env(self) -> None: ): assert build_tool_config_from_env().legacy_workout_tools_enabled is False - def test_zepto_settings_from_env(self) -> None: - """Should parse the explicit Zepto flag, path, and private chat allowlist.""" - with patch.dict( - "os.environ", - { - "ZEPTO_MCP_ENABLED": "true", - "ZEPTO_MCP_CONFIG_DIR": "/tmp/zepto", - "ZEPTO_MCP_ALLOWED_TELEGRAM_CHAT_IDS": " 123,456, ", - }, - clear=False, - ): - config = build_tool_config_from_env() - - assert config.zepto_mcp_enabled is True - assert config.zepto_mcp_config_dir == Path("/tmp/zepto") - assert config.zepto_mcp_allowed_chat_ids == frozenset({"123", "456"}) - def test_invalid_gmail_settings_from_env_disable_only_gmail(self) -> None: """Invalid shared Gmail settings should disable only Gmail.""" with patch.dict( diff --git a/tests/test_server_config.py b/tests/test_server_config.py index 129b1fc..698728b 100644 --- a/tests/test_server_config.py +++ b/tests/test_server_config.py @@ -228,18 +228,12 @@ def _has_route(app: FastAPI, path: str) -> bool: return False -@pytest.mark.parametrize( - "setting", - ["ZEPTO_MCP_ENABLED", "KOKORO_TTS_BASE_URL"], -) def test_server_skips_openinference_in_private_tool_mode( mock_dependencies: MagicMock, monkeypatch: pytest.MonkeyPatch, - setting: str, ) -> None: """Private tools must take the no-content-instrumentation startup branch.""" - value = "true" if setting == "ZEPTO_MCP_ENABLED" else "http://kokoro.internal" - monkeypatch.setenv(setting, value) + monkeypatch.setenv("KOKORO_TTS_BASE_URL", "http://kokoro.internal") if "blacki.server" in sys.modules: del sys.modules["blacki.server"] diff --git a/tests/test_telegram_tool_progress.py b/tests/test_telegram_tool_progress.py index 5427fb7..b3492d7 100644 --- a/tests/test_telegram_tool_progress.py +++ b/tests/test_telegram_tool_progress.py @@ -4,16 +4,10 @@ import pytest -from blacki.security.tool_privacy import is_private_tool from blacki.telegram.formatting import MARKDOWN_SPECIAL_CHARS from blacki.telegram.progress import describe_tool -class _DummyTool: - def __init__(self, name: str) -> None: - self.name = name - - def validate_markdown_v2(text: str) -> None: """Validate that text adheres strictly to Telegram MarkdownV2 syntax. @@ -437,10 +431,6 @@ def test_describe_tool_unknown_fallback() -> None: validate_markdown_v2(result2) # Dotted and hyphenated MCP tool symbols - result_mcp1 = describe_tool("zepto-search-products", {}, private=False) - assert result_mcp1 == "Working on zepto search products…" - validate_markdown_v2(result_mcp1) - result_mcp2 = describe_tool("some.mcp.tool-v2", {}, private=False) assert result_mcp2 == "Working on some mcp tool v2…" validate_markdown_v2(result_mcp2) @@ -530,16 +520,3 @@ def test_all_tools_emit_strictly_valid_markdown_v2( def test_elapsed_collapse_text_is_valid_markdown_v2() -> None: """Assert final elapsed collapse text is strictly valid MarkdownV2.""" validate_markdown_v2("✓ Worked for 2m 5s") - - -def test_is_private_tool_helper() -> None: - """is_private_tool correctly identifies private tools.""" - tool_health = _DummyTool("get_health_summary") - tool_tts = _DummyTool("send_text_to_speech") - tool_zepto = _DummyTool("zepto_create_cart") - tool_search = _DummyTool("brave_search") - - assert is_private_tool(tool_health) # type: ignore[arg-type] - assert is_private_tool(tool_tts) # type: ignore[arg-type] - assert is_private_tool(tool_zepto) # type: ignore[arg-type] - assert not is_private_tool(tool_search) # type: ignore[arg-type] diff --git a/tests/test_tools_boundary.py b/tests/test_tools_boundary.py index 95c001c..51e0baa 100644 --- a/tests/test_tools_boundary.py +++ b/tests/test_tools_boundary.py @@ -32,7 +32,6 @@ def test_importing_tools_does_not_import_runtime_or_domain_barrels() -> None: "blacki.reminders", "blacki.sandbox", "blacki.user_files", - "blacki.zepto", ): assert not any( module == prefix or module.startswith(prefix + ".") diff --git a/tests/zepto/test_auth.py b/tests/zepto/test_auth.py deleted file mode 100644 index d4576a6..0000000 --- a/tests/zepto/test_auth.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Tests for Zepto bridge login and manifest probing.""" - -from __future__ import annotations - -import json -import os -import sys -from collections.abc import Awaitable, Callable -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock, patch - -import pytest -from mcp import StdioServerParameters - -from blacki.zepto.auth import _list_tools, login, main, probe -from blacki.zepto.client import ( - _SERVER_HASH, - REQUIRED_SCOPE, - McpRemoteCredentialStore, - ZeptoCredentialError, -) - - -def _write_private(path: Path, value: object) -> None: - path.write_text(json.dumps(value), encoding="utf-8") - path.chmod(0o600) - - -def _make_ready(config_dir: Path) -> McpRemoteCredentialStore: - store = McpRemoteCredentialStore(config_dir) - store.prepare_for_login() - _write_private( - store.version_dir / f"{_SERVER_HASH}_client_info.json", - {"client_id": "client-id", "token_endpoint_auth_method": "none"}, - ) - _write_private( - store.version_dir / f"{_SERVER_HASH}_tokens.json", - { - "access_token": "access-token", - "refresh_token": "refresh-token", - "token_type": "Bearer", - "scope": REQUIRED_SCOPE, - }, - ) - return store - - -class _AsyncContext: - def __init__( - self, - value: Any, - *, - on_enter: Callable[[], Awaitable[None]] | None = None, - ) -> None: - self.value = value - self.on_enter = on_enter - self.exited = False - - async def __aenter__(self) -> Any: - if self.on_enter is not None: - await self.on_enter() - return self.value - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - traceback: Any, - ) -> None: - del exc_type, exc, traceback - self.exited = True - - -class _FakeSession(_AsyncContext): - def __init__(self, tool_names: list[str], *, fail: bool = False) -> None: - super().__init__(self) - self.tool_names = tool_names - self.fail = fail - self.initialized = False - - async def initialize(self) -> None: - self.initialized = True - - async def list_tools(self) -> SimpleNamespace: - if self.fail: - raise RuntimeError("secret remote failure") - assert self.initialized is True - return SimpleNamespace( - tools=[SimpleNamespace(name=name) for name in self.tool_names] - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("interactive", [True, False]) -async def test_list_tools_uses_bridge_and_closes_both_contexts( - tmp_path: Path, - interactive: bool, -) -> None: - config_dir = tmp_path / str(interactive) - if not interactive: - _make_ready(config_dir) - stream_context = _AsyncContext((object(), object())) - session = _FakeSession(["update_cart", "search_products"]) - - async def make_credentials() -> None: - _make_ready(config_dir) - - if interactive: - stream_context.on_enter = make_credentials - - with ( - patch( - "blacki.zepto.auth.create_bridge_server_parameters", - return_value=StdioServerParameters(command="/bin/false"), - ) as create_bridge, - patch("blacki.zepto.auth.stdio_client", return_value=stream_context), - patch("blacki.zepto.auth.ClientSession", return_value=session), - ): - tools = await _list_tools(config_dir, interactive_login=interactive) - - assert tools == ["search_products", "update_cart"] - assert stream_context.exited is True - assert session.exited is True - assert create_bridge.call_args.kwargs["auth_timeout_seconds"] == ( - 300 if interactive else 2 - ) - - -@pytest.mark.asyncio -async def test_list_tools_hides_bridge_failure_and_closes_process( - tmp_path: Path, -) -> None: - config_dir = tmp_path / "zepto" - _make_ready(config_dir) - stream_context = _AsyncContext((object(), object())) - session = _FakeSession([], fail=True) - with ( - patch( - "blacki.zepto.auth.create_bridge_server_parameters", - return_value=StdioServerParameters(command="/bin/false"), - ), - patch("blacki.zepto.auth.stdio_client", return_value=stream_context), - patch("blacki.zepto.auth.ClientSession", return_value=session), - pytest.raises(ZeptoCredentialError, match="could not connect") as error, - ): - await probe(config_dir) - - assert "secret remote failure" not in str(error.value) - assert stream_context.exited is True - assert session.exited is True - - -@pytest.mark.asyncio -async def test_interrupted_login_restores_private_permissions(tmp_path: Path) -> None: - config_dir = tmp_path / "zepto" - - async def write_partial_state() -> None: - store = _make_ready(config_dir) - store.config_dir.chmod(0o755) - store.version_dir.chmod(0o755) - for entry in store.version_dir.iterdir(): - entry.chmod(0o644) - - stream_context = _AsyncContext( - (object(), object()), - on_enter=write_partial_state, - ) - with ( - patch( - "blacki.zepto.auth.create_bridge_server_parameters", - return_value=StdioServerParameters(command="/bin/false"), - ), - patch("blacki.zepto.auth.stdio_client", return_value=stream_context), - patch( - "blacki.zepto.auth.ClientSession", return_value=_FakeSession([], fail=True) - ), - pytest.raises(ZeptoCredentialError, match="could not connect"), - ): - await _list_tools(config_dir, interactive_login=True) - - store = McpRemoteCredentialStore(config_dir) - store.validate_runtime_ready() - - -@pytest.mark.asyncio -async def test_login_and_probe_delegate_to_manifest_flow(tmp_path: Path) -> None: - with patch( - "blacki.zepto.auth._list_tools", - new=AsyncMock(return_value=["search_products"]), - ) as list_tools: - assert await login(tmp_path) == ["search_products"] - assert await probe(tmp_path) == ["search_products"] - - assert list_tools.await_args_list[0].kwargs == {"interactive_login": True} - assert list_tools.await_args_list[1].kwargs == {"interactive_login": False} - - -@pytest.mark.asyncio -async def test_force_login_clears_only_bridge_state(tmp_path: Path) -> None: - store = _make_ready(tmp_path) - with ( - patch.object(store, "clear_authentication") as clear, - patch("blacki.zepto.auth.McpRemoteCredentialStore", return_value=store), - patch( - "blacki.zepto.auth._list_tools", - new=AsyncMock(return_value=["search_products"]), - ), - ): - assert await login(tmp_path, force=True) == ["search_products"] - clear.assert_called_once_with() - - -@pytest.mark.asyncio -async def test_real_stdio_process_is_terminated_after_probe(tmp_path: Path) -> None: - config_dir = tmp_path / "credentials" - _make_ready(config_dir) - marker = tmp_path / "child-closed" - server_script = tmp_path / "fake_mcp.py" - server_script.write_text( - "import json, pathlib, sys\n" - f"marker = pathlib.Path({str(marker)!r})\n" - "for line in sys.stdin:\n" - " message = json.loads(line)\n" - " method = message.get('method')\n" - " if method == 'initialize':\n" - " result = {'protocolVersion': '2025-06-18', " - "'capabilities': {}, 'serverInfo': {'name': 'fake', 'version': '1'}}\n" - " elif method == 'tools/list':\n" - " result = {'tools': []}\n" - " else:\n" - " continue\n" - " print(json.dumps({'jsonrpc': '2.0', 'id': message['id'], " - "'result': result}), flush=True)\n" - "marker.write_text('closed')\n", - encoding="utf-8", - ) - parameters = StdioServerParameters( - command=os.fspath(Path(sys.executable)), - args=[str(server_script)], - ) - - with patch( - "blacki.zepto.auth.create_bridge_server_parameters", - return_value=parameters, - ): - assert await probe(config_dir) == [] - - assert marker.read_text(encoding="utf-8") == "closed" - - -def test_main_status_error_and_success( - tmp_path: Path, - capsys: pytest.CaptureFixture[str], -) -> None: - config_dir = tmp_path / "credentials" - assert main(["--config-dir", str(config_dir), "status"]) == 1 - assert json.loads(capsys.readouterr().out) == {"authenticated": False} - - _make_ready(config_dir) - assert main(["--config-dir", str(config_dir), "status"]) == 0 - assert json.loads(capsys.readouterr().out) == {"authenticated": True} - - with patch( - "blacki.zepto.auth.probe", - new=AsyncMock(side_effect=ZeptoCredentialError("authenticate again")), - ): - assert main(["--config-dir", str(config_dir), "probe"]) == 1 - output = capsys.readouterr().out - assert "authenticate again" in output - assert "access-token" not in output - - -def test_main_login_and_probe_success( - tmp_path: Path, - capsys: pytest.CaptureFixture[str], -) -> None: - config_dir = tmp_path / "credentials" - with patch( - "blacki.zepto.auth.login", - new=AsyncMock(return_value=["search_products"]), - ) as login_mock: - assert main(["--config-dir", str(config_dir), "login", "--force"]) == 0 - login_mock.assert_awaited_once_with(config_dir, force=True) - assert json.loads(capsys.readouterr().out) == { - "authenticated": True, - "tools": ["search_products"], - } - - with patch( - "blacki.zepto.auth.probe", - new=AsyncMock(return_value=["update_cart"]), - ): - assert main(["--config-dir", str(config_dir), "probe"]) == 0 - assert json.loads(capsys.readouterr().out) == { - "authenticated": True, - "tools": ["update_cart"], - } diff --git a/tests/zepto/test_client.py b/tests/zepto/test_client.py deleted file mode 100644 index 871c8f0..0000000 --- a/tests/zepto/test_client.py +++ /dev/null @@ -1,707 +0,0 @@ -"""Tests for the private Zepto mcp-remote bridge.""" - -from __future__ import annotations - -import json -import os -import stat -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.tools.base_tool import BaseTool -from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams -from google.adk.tools.mcp_tool.mcp_tool import McpTool -from google.adk.tools.mcp_tool.mcp_toolset import McpToolset -from google.adk.tools.tool_context import ToolContext -from mcp import StdioServerParameters -from mcp.types import ListToolsResult, Tool - -from blacki.tools.zepto import ( - AuthorizedZeptoToolset, - _is_allowed_private_telegram_user, - _is_supported_zepto_tool, - _requires_zepto_order_confirmation, - create_zepto_toolset, -) -from blacki.zepto.client import ( - _ALLOWED_FILENAMES, - _SERVER_HASH, - BRIDGE_CONNECT_TIMEOUT_SECONDS, - FINAL_ORDER_PAYMENT_TOOL_NAMES, - MCP_REMOTE_PACKAGE_VERSION, - REQUIRED_SCOPE, - ZEPTO_MCP_URL, - McpRemoteCredentialStore, - ZeptoCredentialError, - _absolute, - _bridge_environment, - _locked_checkout_bridge, - _read_private_object, - _resolve_bridge_command, - _validate_owner_and_mode, - _verified_bridge_binary, - create_bridge_server_parameters, -) - -ZEPTO_TOOL_NAMES = frozenset( - { - "add_saved_address", - "check_payment_status", - "create_online_payment_order", - "create_order", - "create_upi_reserve_pay_order", - "create_wallet_order", - "get_location_serviceability", - "get_order_detail", - "get_past_order_items", - "get_payment_methods", - "get_product_details", - "get_user_details", - "list_order_history", - "list_saved_addresses", - "search_multiple_products", - "search_products", - "select_saved_address", - "select_store", - "update_cart", - "update_drop_zone", - "update_user_name", - "view_cart", - "zepto_shop", - } -) - - -def _write_private(path: Path, value: object) -> None: - path.write_text(json.dumps(value), encoding="utf-8") - path.chmod(0o600) - - -def _ready_store(tmp_path: Path) -> McpRemoteCredentialStore: - store = McpRemoteCredentialStore(tmp_path / "zepto") - store.prepare_for_login() - _write_private( - store.version_dir / f"{_SERVER_HASH}_client_info.json", - { - "client_id": "client-id", - "token_endpoint_auth_method": "none", - }, - ) - _write_private( - store.version_dir / f"{_SERVER_HASH}_tokens.json", - { - "access_token": "access-token", - "refresh_token": "refresh-token", - "token_type": "Bearer", - "scope": REQUIRED_SCOPE, - }, - ) - return store - - -def _readonly_context(user_id: str) -> MagicMock: - context = MagicMock(spec=ReadonlyContext) - context.user_id = user_id - return context - - -def test_store_prepares_and_validates_exact_private_state(tmp_path: Path) -> None: - store = _ready_store(tmp_path) - for suffix in ("code_verifier.txt", "lock.json"): - _write_private(store.version_dir / f"{_SERVER_HASH}_{suffix}", {}) - - store.validate_runtime_ready() - - assert stat.S_IMODE(store.config_dir.stat().st_mode) == 0o700 - assert stat.S_IMODE(store.version_dir.stat().st_mode) == 0o700 - assert {entry.name for entry in store.version_dir.iterdir()} == (_ALLOWED_FILENAMES) - - -def test_store_reports_missing_and_unreadable_directories(tmp_path: Path) -> None: - store = McpRemoteCredentialStore(tmp_path / "missing") - with pytest.raises(ZeptoCredentialError, match="missing or unreadable"): - store.validate_runtime_ready() - - store.prepare_for_login() - with pytest.raises(ZeptoCredentialError, match="not authenticated"): - store.validate_runtime_ready() - - store.version_dir.chmod(0o755) - with pytest.raises(ZeptoCredentialError, match="0700"): - store.validate_runtime_ready() - - -def test_store_rejects_non_directories_and_iteration_failure(tmp_path: Path) -> None: - store = McpRemoteCredentialStore(tmp_path / "credentials") - non_directory = SimpleNamespace(st_mode=stat.S_IFREG) - with ( - patch( - "blacki.zepto.client._validate_owner_and_mode", - return_value=non_directory, - ), - pytest.raises(ZeptoCredentialError, match="must be directories"), - ): - store.validate_runtime_ready() - - store = _ready_store(tmp_path / "ready") - with ( - patch.object(Path, "iterdir", side_effect=OSError("unreadable")), - pytest.raises(ZeptoCredentialError, match="directory is unreadable"), - ): - store.validate_runtime_ready() - - -def test_store_rejects_symlinked_path_chain_and_entries(tmp_path: Path) -> None: - target = tmp_path / "target" - target.mkdir() - linked = tmp_path / "linked" - linked.symlink_to(target, target_is_directory=True) - with pytest.raises(ZeptoCredentialError, match="symlinks"): - McpRemoteCredentialStore(linked / "zepto").prepare_for_login() - - store = _ready_store(tmp_path / "files") - tokens = store.version_dir / f"{_SERVER_HASH}_tokens.json" - tokens.unlink() - tokens.symlink_to(store.version_dir / f"{_SERVER_HASH}_client_info.json") - with pytest.raises(ZeptoCredentialError, match="symlink"): - store.validate_runtime_ready() - with pytest.raises(ZeptoCredentialError, match="symlinks"): - store.tighten_permissions() - - -def test_store_rejects_unsafe_entries_permissions_and_ownership( - tmp_path: Path, -) -> None: - store = _ready_store(tmp_path) - unexpected = store.version_dir / "other.json" - _write_private(unexpected, {}) - with pytest.raises(ZeptoCredentialError, match="unexpected"): - store.validate_runtime_ready() - unexpected.unlink() - - token_path = store.version_dir / f"{_SERVER_HASH}_tokens.json" - token_path.chmod(0o644) - with pytest.raises(ZeptoCredentialError, match="0600"): - store.validate_runtime_ready() - token_path.chmod(0o600) - - lock_path = store.version_dir / f"{_SERVER_HASH}_lock.json" - lock_path.mkdir(mode=0o600) - with pytest.raises(ZeptoCredentialError, match="regular files"): - store.validate_runtime_ready() - lock_path.rmdir() - - with ( - patch("blacki.zepto.client.os.geteuid", return_value=os.geteuid() + 1), - pytest.raises(ZeptoCredentialError, match="owned by Blacki"), - ): - store.validate_runtime_ready() - - -@pytest.mark.parametrize( - ("filename", "value", "message"), - [ - ("client_info.json", [], "client registration is corrupt"), - ( - "client_info.json", - {"client_id": "", "token_endpoint_auth_method": "none"}, - "client registration is corrupt", - ), - ( - "client_info.json", - {"client_id": "id", "token_endpoint_auth_method": "client_secret_post"}, - "client registration is corrupt", - ), - ("tokens.json", "bad", "OAuth tokens is corrupt"), - ( - "tokens.json", - { - "access_token": "", - "refresh_token": "refresh", - "token_type": "Bearer", - "scope": REQUIRED_SCOPE, - }, - "OAuth tokens are corrupt", - ), - ( - "tokens.json", - { - "access_token": "access", - "refresh_token": "", - "token_type": "Bearer", - "scope": REQUIRED_SCOPE, - }, - "OAuth tokens are corrupt", - ), - ( - "tokens.json", - { - "access_token": "access", - "refresh_token": "refresh", - "token_type": "Basic", - "scope": REQUIRED_SCOPE, - }, - "OAuth tokens are corrupt", - ), - ( - "tokens.json", - { - "access_token": "access", - "refresh_token": "refresh", - "token_type": "Bearer", - "scope": "openid", - }, - "OAuth tokens are corrupt", - ), - ], -) -def test_store_rejects_corrupt_json_shapes( - tmp_path: Path, - filename: str, - value: object, - message: str, -) -> None: - store = _ready_store(tmp_path) - _write_private(store.version_dir / f"{_SERVER_HASH}_{filename}", value) - - with pytest.raises(ZeptoCredentialError, match=message): - store.validate_runtime_ready() - - -def test_store_rejects_invalid_json_and_prepare_failure(tmp_path: Path) -> None: - store = _ready_store(tmp_path) - client = store.version_dir / f"{_SERVER_HASH}_client_info.json" - client.write_text("{", encoding="utf-8") - with pytest.raises(ZeptoCredentialError, match="corrupt"): - store.validate_runtime_ready() - - blocked = tmp_path / "blocked" - blocked.write_text("not a directory", encoding="utf-8") - with pytest.raises(ZeptoCredentialError, match="Could not prepare"): - McpRemoteCredentialStore(blocked).prepare_for_login() - - with pytest.raises(ZeptoCredentialError, match="corrupt"): - _read_private_object(tmp_path / "absent.json", "test file") - - -def test_store_tightens_bridge_permissions(tmp_path: Path) -> None: - store = _ready_store(tmp_path) - store.config_dir.chmod(0o755) - store.version_dir.chmod(0o755) - for entry in store.version_dir.iterdir(): - entry.chmod(0o644) - - store.tighten_permissions() - store.validate_runtime_ready() - - absent = McpRemoteCredentialStore(tmp_path / "absent") - absent.tighten_permissions() - - directory_entry = store.version_dir / f"{_SERVER_HASH}_lock.json" - directory_entry.mkdir() - store.tighten_permissions() - - with ( - patch.object(Path, "chmod", side_effect=OSError("permission denied")), - pytest.raises(ZeptoCredentialError, match="Could not protect"), - ): - store.tighten_permissions() - - -def test_store_clear_authentication_is_narrow_and_explicit(tmp_path: Path) -> None: - absent = McpRemoteCredentialStore(tmp_path / "absent") - absent.clear_authentication() - - partial = McpRemoteCredentialStore(tmp_path / "partial") - partial.config_dir.mkdir(mode=0o700) - partial.clear_authentication() - - store = _ready_store(tmp_path / "ready") - for suffix in ("code_verifier.txt", "lock.json"): - _write_private(store.version_dir / f"{_SERVER_HASH}_{suffix}", {}) - store.clear_authentication() - assert list(store.version_dir.iterdir()) == [] - - store = _ready_store(tmp_path / "unexpected") - _write_private(store.version_dir / "other.json", {}) - with pytest.raises(ZeptoCredentialError, match="unexpected files"): - store.clear_authentication() - - store = _ready_store(tmp_path / "directory-entry") - lock_path = store.version_dir / f"{_SERVER_HASH}_lock.json" - lock_path.mkdir(mode=0o600) - with pytest.raises(ZeptoCredentialError, match="regular files"): - store.clear_authentication() - - -def test_store_clear_rejects_non_directory_paths(tmp_path: Path) -> None: - config_file = tmp_path / "config-file" - config_file.write_text("x", encoding="utf-8") - config_file.chmod(0o700) - with pytest.raises(ZeptoCredentialError, match="must be a directory"): - McpRemoteCredentialStore(config_file).clear_authentication() - - store = McpRemoteCredentialStore(tmp_path / "version-file") - store.config_dir.mkdir(mode=0o700) - store.version_dir.write_text("x", encoding="utf-8") - store.version_dir.chmod(0o700) - with pytest.raises(ZeptoCredentialError, match="must be a directory"): - store.clear_authentication() - - -def test_low_level_path_validation_requires_directory(tmp_path: Path) -> None: - file_path = tmp_path / "file" - file_path.write_text("x", encoding="utf-8") - file_path.chmod(0o700) - assert _absolute(file_path) == file_path - result = _validate_owner_and_mode( - file_path, - expected_mode=0o700, - label="test path", - ) - assert stat.S_ISREG(result.st_mode) - - store = McpRemoteCredentialStore(file_path) - with pytest.raises(ZeptoCredentialError, match="missing or unreadable"): - store.validate_runtime_ready() - - -def _fake_bridge_package(tmp_path: Path, *, version: str = "0.1.38") -> Path: - package_dir = tmp_path / "node_modules" / "mcp-remote" - executable = package_dir / "dist" / "proxy.js" - executable.parent.mkdir(parents=True) - executable.write_text("#!/usr/bin/env node\n", encoding="utf-8") - executable.chmod(0o755) - (package_dir / "package.json").write_text( - json.dumps({"name": "mcp-remote", "version": version}), - encoding="utf-8", - ) - return executable - - -def test_bridge_binary_validation_requires_exact_runnable_package( - tmp_path: Path, -) -> None: - executable = _fake_bridge_package(tmp_path / "valid") - assert _verified_bridge_binary(executable) == str(executable) - - wrong = _fake_bridge_package(tmp_path / "wrong", version="0.1.37") - with pytest.raises(ZeptoCredentialError, match="exactly version"): - _verified_bridge_binary(wrong) - - executable.chmod(0o600) - with pytest.raises(ZeptoCredentialError, match="not runnable"): - _verified_bridge_binary(executable) - - with pytest.raises(ZeptoCredentialError, match="metadata is unreadable"): - _verified_bridge_binary(tmp_path / "missing") - - -def test_bridge_resolution_prefers_locked_install_then_pinned_npx( - tmp_path: Path, -) -> None: - locked = _fake_bridge_package(tmp_path / "locked") - with ( - patch("blacki.zepto.client._locked_checkout_bridge", return_value=locked), - patch("blacki.zepto.client.shutil.which") as which, - ): - assert _resolve_bridge_command(allow_npx=True) == (str(locked), []) - which.assert_not_called() - - installed = _fake_bridge_package(tmp_path / "installed") - with ( - patch("blacki.zepto.client._locked_checkout_bridge", return_value=None), - patch("blacki.zepto.client.shutil.which", return_value=str(installed)), - ): - assert _resolve_bridge_command(allow_npx=True) == ( - str(installed), - [], - ) - - def only_npx(command: str) -> str | None: - return "/usr/bin/npx" if command == "npx" else None - - with ( - patch("blacki.zepto.client._locked_checkout_bridge", return_value=None), - patch("blacki.zepto.client.shutil.which", side_effect=only_npx), - ): - command, args = _resolve_bridge_command(allow_npx=True) - assert command == "/usr/bin/npx" - assert args == [ - "--yes", - "--package", - f"mcp-remote@{MCP_REMOTE_PACKAGE_VERSION}", - "mcp-remote", - ] - - with ( - patch("blacki.zepto.client._locked_checkout_bridge", return_value=None), - patch("blacki.zepto.client.shutil.which", return_value=None), - pytest.raises(ZeptoCredentialError, match="unavailable"), - ): - _resolve_bridge_command(allow_npx=True) - with ( - patch("blacki.zepto.client._locked_checkout_bridge", return_value=None), - patch("blacki.zepto.client.shutil.which", return_value=None), - pytest.raises(ZeptoCredentialError, match="unavailable"), - ): - _resolve_bridge_command(allow_npx=False) - - -def test_bridge_parameters_are_pinned_silent_and_secret_minimal( - tmp_path: Path, -) -> None: - with ( - patch( - "blacki.zepto.client._resolve_bridge_command", - return_value=("/usr/local/bin/mcp-remote", []), - ), - patch( - "blacki.zepto.client.get_default_environment", - return_value={"HOME": "/home/app", "PATH": "/usr/bin"}, - ), - ): - parameters = create_bridge_server_parameters( - config_dir=tmp_path, - auth_timeout_seconds=2, - allow_npx=False, - ) - - assert parameters.command == "/usr/local/bin/mcp-remote" - assert parameters.args == [ - ZEPTO_MCP_URL, - "--transport", - "http-only", - "--auth-timeout", - "2", - "--silent", - ] - assert parameters.env == { - "HOME": "/home/app", - "PATH": "/usr/bin", - "MCP_REMOTE_CONFIG_DIR": str(tmp_path), - "npm_config_userconfig": os.devnull, - } - assert "OPENROUTER_API_KEY" not in parameters.env - assert _bridge_environment(tmp_path)["MCP_REMOTE_CONFIG_DIR"] == str(tmp_path) - - -def test_locked_checkout_bridge_is_optional() -> None: - with patch.object(Path, "exists", return_value=False): - assert _locked_checkout_bridge() is None - with patch.object(Path, "exists", return_value=True): - assert _locked_checkout_bridge() is not None - - -@pytest.mark.parametrize( - ("user_id", "expected"), - [ - ("telegram-chat-123", True), - ("telegram-chat--123", False), - ("telegram-chat-123-thread-4", False), - ("web-user", False), - ], -) -def test_private_telegram_allowlist(user_id: str, expected: bool) -> None: - assert ( - _is_allowed_private_telegram_user( - _readonly_context(user_id), - frozenset({"123"}), - ) - is expected - ) - assert _is_allowed_private_telegram_user(None, frozenset({"123"})) is False - - -@pytest.mark.asyncio -async def test_authorized_toolset_short_circuits_before_bridge() -> None: - params = StdioConnectionParams( - server_params=StdioServerParameters(command="/bin/false") - ) - toolset = AuthorizedZeptoToolset( - allowed_chat_ids=frozenset({"123"}), - connection_params=params, - tool_name_prefix="zepto", - require_confirmation=True, - ) - inherited_tool = MagicMock(spec=BaseTool) - with patch.object( - McpToolset, - "get_tools", - new=AsyncMock(return_value=[inherited_tool]), - ) as inherited: - assert await toolset.get_tools(_readonly_context("web-user")) == [] - assert await toolset.get_tools(_readonly_context("telegram-chat-999")) == [] - assert await toolset.get_tools(None) == [] - assert await toolset.get_tools(_readonly_context("telegram-chat-123")) == [ - inherited_tool - ] - inherited.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_zepto_manifest_uses_final_order_confirmation_policy() -> None: - toolset = AuthorizedZeptoToolset( - allowed_chat_ids=frozenset({"123"}), - connection_params=StdioConnectionParams( - server_params=StdioServerParameters(command="/bin/false") - ), - tool_name_prefix="zepto", - tool_filter=_is_supported_zepto_tool, - require_confirmation=_requires_zepto_order_confirmation, - ) - manifest = ListToolsResult( - tools=[ - Tool( - name=name, - description=name, - inputSchema={ - "type": "object", - "properties": ( - {"confirmOrder": {"type": "boolean"}} - if name in FINAL_ORDER_PAYMENT_TOOL_NAMES - else {} - ), - }, - ) - for name in sorted(ZEPTO_TOOL_NAMES) - ] - ) - with patch.object( - toolset, - "_execute_with_session", - new=AsyncMock(return_value=manifest), - ): - tools = await toolset.get_tools_with_prefix( - _readonly_context("telegram-chat-123") - ) - create_session = AsyncMock() - - assert {tool.name for tool in tools} == { - f"zepto_{name}" for name in ZEPTO_TOOL_NAMES - {"zepto_shop"} - } - assert "zepto_zepto_shop" not in {tool.name for tool in tools} - with patch.object( - toolset._mcp_session_manager, - "create_session", - new=create_session, - ): - for tool in tools: - assert isinstance(tool, McpTool) - context = MagicMock(spec=ToolContext) - context.tool_confirmation = None - assert await tool.check_require_confirmation({}, context) is False - - tool_name = tool.name.removeprefix("zepto_") - if tool_name not in FINAL_ORDER_PAYMENT_TOOL_NAMES: - continue - - assert ( - await tool.check_require_confirmation( - {"confirmOrder": False}, - context, - ) - is False - ) - assert ( - await tool.check_require_confirmation( - {"confirmOrder": True}, - context, - ) - is True - ) - result = await tool.run_async( - args={"confirmOrder": True}, - tool_context=context, - ) - assert result == { - "error": ( - "This tool call requires confirmation, please approve or reject." - ) - } - context.request_confirmation.assert_called_once() - create_session.assert_not_awaited() - - -@pytest.mark.parametrize("malformed_value", [None, 0, "false", {}, []]) -def test_order_confirmation_fails_closed_for_malformed_values( - malformed_value: object, -) -> None: - assert _requires_zepto_order_confirmation() is False - assert _requires_zepto_order_confirmation(confirmOrder=False) is False - assert _requires_zepto_order_confirmation(confirmOrder=True) is True - assert _requires_zepto_order_confirmation(confirmOrder=malformed_value) is True - - -def test_zepto_skill_uses_only_adk_final_confirmation() -> None: - skill_path = ( - Path(__file__).parents[2] / "src" / "blacki" / "skills" / "zepto" / "SKILL.md" - ) - instructions = skill_path.read_text(encoding="utf-8") - - assert "Every Zepto tool call requires" not in instructions - assert "without asking for approval" in instructions - assert "`confirmOrder=false`" in instructions - assert "`confirmOrder=true`" in instructions - assert "Do not ask separately" in instructions - - -def test_create_toolset_requires_private_allowlist_and_ready_store( - tmp_path: Path, -) -> None: - store = _ready_store(tmp_path) - with pytest.raises(ZeptoCredentialError, match="At least one"): - create_zepto_toolset(config_dir=store.config_dir, allowed_chat_ids=frozenset()) - with pytest.raises(ZeptoCredentialError, match="positive private"): - create_zepto_toolset( - config_dir=store.config_dir, - allowed_chat_ids=frozenset({"-123"}), - ) - - server = StdioServerParameters(command="/usr/local/bin/mcp-remote") - with patch( - "blacki.tools.zepto.create_bridge_server_parameters", - return_value=server, - ) as create_bridge: - toolset = create_zepto_toolset( - config_dir=store.config_dir, - allowed_chat_ids=frozenset({"123"}), - ) - - assert isinstance(toolset, AuthorizedZeptoToolset) - assert toolset.tool_name_prefix == "zepto" - assert toolset.tool_filter is _is_supported_zepto_tool - assert toolset._require_confirmation is _requires_zepto_order_confirmation - connection = toolset._connection_params - assert isinstance(connection, StdioConnectionParams) - assert connection.server_params == server - assert connection.timeout == BRIDGE_CONNECT_TIMEOUT_SECONDS - assert create_bridge.call_args.kwargs["auth_timeout_seconds"] == 2 - - -def test_create_toolset_forbids_npx_fallback_in_docker(tmp_path: Path) -> None: - store = _ready_store(tmp_path) - server = StdioServerParameters(command="/usr/local/bin/mcp-remote") - original_exists = Path.exists - - def docker_exists(path: Path) -> bool: - if str(path) == "/.dockerenv": - return True - return original_exists(path) - - with ( - patch("blacki.zepto.client.Path.exists", docker_exists), - patch( - "blacki.tools.zepto.create_bridge_server_parameters", - return_value=server, - ) as create_bridge, - ): - create_zepto_toolset( - config_dir=store.config_dir, - allowed_chat_ids=frozenset({"123"}), - ) - - assert create_bridge.call_args.kwargs["allow_npx"] is False