diff --git a/CHANGELOG.md b/CHANGELOG.md index 518f6ae..7932621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-06-05 + +### Added + +- **MCP server — `hscli mcp serve`.** Run hscli as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio so AI agents (Claude Desktop, Claude Code, …) drive Help Scout through native tools. One tool per command, generated from the command manifest so it stays in sync. + - **Reads by default, writes opt-in.** Only read-only tools are exposed unless started with `--allow-writes`; mutating tools carry MCP `destructiveHint`/`readOnlyHint` annotations so clients can confirm dangerous calls. + - Each tool call runs the CLI as a child process (keeping the stdio protocol channel clean) and returns the command's JSON as `structuredContent`. The `api` escape hatch, streaming `conv watch`, and bundled oclif plugin commands are not exposed. + - Reuses the existing OS-keychain OAuth token and Docs API key — the server runs as you. +- New MCP guide with Claude Desktop / Claude Code configuration. + ## [0.9.0] - 2026-06-04 ### Added diff --git a/README.md b/README.md index ce9fe18..9b36c14 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ hscli backup --out ~/hs-backup # Full account backup (incremental on # Docs knowledge base (separate per-user API key: `hscli docs auth` or HSCLI_DOCS_API_KEY) hscli docs auth # Store your Docs API key in the keychain hscli docs article search "refund" # Search the knowledge base + +# Run as an MCP server so AI agents call hscli as native tools (read-only by default) +hscli mcp serve # add --allow-writes to expose mutations ``` For CI/CD, use the non-interactive client-credentials flow: @@ -79,6 +82,7 @@ HSCLI_APP_ID=... HSCLI_APP_SECRET=... hscli auth login --client-credentials | `hscli alias` | `set`, `list`, `unset` — custom command shortcuts | | `hscli backup` | Full account dump with incremental refresh, resume, deletion detection, attachments | | `hscli api` | Raw API escape hatch: `hscli api GET /v2/conversations` (locked to `api.helpscout.net`) | +| `hscli mcp` | `serve` — run hscli as an MCP server so AI agents call commands as tools (read-only by default; `--allow-writes` to enable mutations) | | `hscli doctor` | Diagnostic checks | | `hscli version` | Version info | diff --git a/docs/commands.md b/docs/commands.md index 06fa76c..f5c268f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the hscli command-line interface. -Reference for `hscli` v0.9.0 (88 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. +Reference for `hscli` v0.10.0 (89 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. ## Top-level @@ -1258,6 +1258,25 @@ hscli mailbox list hscli mailbox list --limit 50 ``` +## hscli mcp + +### `hscli mcp serve` + +Run hscli as a Model Context Protocol (MCP) server over stdio + +``` +hscli mcp serve [flags] +``` + +- `--allow-writes` — Expose mutating tools (create/update/delete/bulk). Off by default — read-only. + +Examples: + +```bash +hscli mcp serve +hscli mcp serve --allow-writes +``` + ## hscli profile ### `hscli profile current` diff --git a/package-lock.json b/package-lock.json index add1455..dbcdd98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "hscli", - "version": "0.9.0", + "name": "@wavyx/hscli", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "hscli", - "version": "0.9.0", + "name": "@wavyx/hscli", + "version": "0.10.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "8.5.2", + "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "4.11.4", "@oclif/plugin-autocomplete": "3.2.50", @@ -24,7 +25,8 @@ "open": "11.0.0", "ora": "9.4.0", "tar": "7.5.16", - "undici": "8.3.0" + "undici": "8.3.0", + "zod": "^4.4.3" }, "bin": { "hscli": "bin/run.js" @@ -823,6 +825,18 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1294,6 +1308,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.9", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", @@ -2814,6 +2868,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2984,6 +3051,30 @@ "node": "18 || 20 || >=22" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -3031,6 +3122,15 @@ "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/cacheable-lookup": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", @@ -3060,6 +3160,35 @@ "node": ">=14.16" } }, + "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/camel-case": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", @@ -3271,11 +3400,23 @@ "upper-case": "^2.0.2" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "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==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3288,11 +3429,45 @@ "dev": true, "license": "MIT" }, + "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.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3307,14 +3482,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3471,6 +3644,15 @@ "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/detect-indent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", @@ -3548,6 +3730,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3569,6 +3771,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "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/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -3591,6 +3802,24 @@ "is-arrayish": "^0.2.1" } }, + "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-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -3598,6 +3827,24 @@ "dev": true, "license": "MIT" }, + "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/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3796,6 +4043,36 @@ "node": ">=0.10.0" } }, + "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/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3806,6 +4083,67 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3980,6 +4318,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4038,6 +4397,24 @@ "node": ">= 14.17" } }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -4068,6 +4445,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "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-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -4080,6 +4466,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -4089,6 +4499,19 @@ "node": ">=8.0.0" } }, + "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/get-stdin": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz", @@ -4158,6 +4581,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/got": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", @@ -4200,6 +4635,30 @@ "node": ">=8" } }, + "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/header-case": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", @@ -4211,6 +4670,15 @@ "tslib": "^2.0.3" } }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -4255,6 +4723,26 @@ "node": ">=8.0.0" } }, + "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/http2-wrapper": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", @@ -4314,6 +4802,12 @@ "node": ">=8" } }, + "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/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -4321,6 +4815,24 @@ "dev": true, "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4495,6 +5007,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-retry-allowed": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", @@ -4632,6 +5150,15 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -5114,6 +5641,36 @@ "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/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5128,6 +5685,31 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -5230,6 +5812,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -5276,6 +5867,15 @@ "node": ">= 18" } }, + "node_modules/node-jq/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -7109,6 +7709,27 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/object-treeify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-4.0.1.tgz", @@ -7296,6 +7917,27 @@ "node": ">=8" } }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -7480,6 +8122,15 @@ "node": ">=4" } }, + "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/pascal-case": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", @@ -7532,12 +8183,21 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -7564,6 +8224,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -7657,6 +8326,19 @@ "dev": true, "license": "ISC" }, + "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/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7667,6 +8349,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -7680,6 +8377,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/registry-auth-token": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", @@ -7785,6 +8506,22 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -7836,6 +8573,32 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/sentence-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", @@ -7848,11 +8611,35 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7865,12 +8652,83 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -7981,6 +8839,15 @@ "dev": true, "license": "MIT" }, + "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/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -8280,6 +9147,15 @@ "node": ">=8.0" } }, + "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/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8325,6 +9201,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -8378,6 +9285,15 @@ "node": ">= 4.0.0" } }, + "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/upper-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", @@ -8428,6 +9344,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.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/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -8726,6 +9651,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -8835,13 +9766,22 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index afdab35..c1e9732 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wavyx/hscli", - "version": "0.9.0", + "version": "0.10.0", "publishConfig": { "access": "public" }, @@ -94,6 +94,9 @@ }, "docs:site": { "description": "Docs sites" + }, + "mcp": { + "description": "Run hscli as an MCP server for AI agents" } }, "hooks": { @@ -109,6 +112,7 @@ }, "dependencies": { "@inquirer/prompts": "8.5.2", + "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "4.11.4", "@oclif/plugin-autocomplete": "3.2.50", @@ -123,7 +127,8 @@ "open": "11.0.0", "ora": "9.4.0", "tar": "7.5.16", - "undici": "8.3.0" + "undici": "8.3.0", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "10.0.1", diff --git a/scripts/gen-commands.mjs b/scripts/gen-commands.mjs index ae1cb86..895b818 100644 --- a/scripts/gen-commands.mjs +++ b/scripts/gen-commands.mjs @@ -108,6 +108,7 @@ const TOPIC_BADGE = { backup: ['archive', 'badge--dot'], doctor: ['diagnostics', 'badge--dot'], docs: ['knowledge base', 'badge--dot'], + mcp: ['AI agents', 'badge--accent badge--dot'], } const TOPIC_BLURB = { @@ -132,6 +133,7 @@ const TOPIC_BLURB = { 'Dump your whole account to JSON with incremental refresh, resume, deletion detection, and attachments. See the [Backups guide](/guides/backups/).', doctor: 'Diagnose your environment, auth, and connectivity.', docs: 'Manage your Help Scout Docs knowledge base — sites, collections, categories, and articles. Uses a separate Docs API key (`hscli docs auth`). See the [Docs guide](/guides/docs/).', + mcp: 'Run hscli as a Model Context Protocol server so AI agents can call it as native tools. Reads by default; pass `--allow-writes` to expose mutating tools. See the [MCP guide](/automation/mcp/).', } const renderTopicTable = (list) => { diff --git a/src/commands/mcp/serve.js b/src/commands/mcp/serve.js new file mode 100644 index 0000000..a4da845 --- /dev/null +++ b/src/commands/mcp/serve.js @@ -0,0 +1,77 @@ +import { Flags } from '@oclif/core' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import BaseCommand from '../../base-command.js' +import { buildServer } from '../../lib/mcp/server.js' +import { makeExec } from '../../lib/mcp/invoke.js' + +/** + * Build and connect an MCP server. Extracted from run() so it can be tested + * without a real stdio transport. + * @param {object} o + * @param {{commands: Array, version: string}} o.config + * @param {boolean} o.allowWrites + * @param {Function} o.exec executor for tool calls + * @param {(server: object) => Promise} o.connect transport connector + * @param {(msg: string) => void} [o.log] startup logger (stderr) + */ +export async function startMcpServer({ + config, + allowWrites, + exec, + connect, + log, +}) { + // Only expose hscli's own commands as tools — never bundled oclif plugin + // commands (plugins:*, help). + const commands = config.commands.filter((c) => c.pluginName === config.name) + const { server, tools } = buildServer({ + commands, + version: config.version, + allowWrites, + exec, + }) + log?.( + `hscli MCP server ready — ${tools.length} tools` + + (allowWrites ? ' (writes enabled)' : ' (read-only)'), + ) + await connect(server) + return { server, tools } +} + +export default class MCPServeCommand extends BaseCommand { + static description = + 'Run hscli as a Model Context Protocol (MCP) server over stdio' + + static skipAuth = true + + static examples = [ + '<%= config.bin %> mcp serve', + '<%= config.bin %> mcp serve --allow-writes', + ] + + static flags = { + ...BaseCommand.baseFlags, + 'allow-writes': Flags.boolean({ + description: + 'Expose mutating tools (create/update/delete/bulk). Off by default — read-only.', + default: false, + }), + } + + async run() { + const { flags } = await this.parse(MCPServeCommand) + // Each tool call re-invokes this same CLI as a child process, keeping the + // parent's stdout (the MCP stdio channel) free of command output. + const exec = makeExec({ + command: process.execPath, + args: [process.argv[1]], + }) + await startMcpServer({ + config: this.config, + allowWrites: flags['allow-writes'], + exec, + connect: (server) => server.connect(new StdioServerTransport()), + log: (msg) => process.stderr.write(msg + '\n'), + }) + } +} diff --git a/src/lib/mcp/catalog.js b/src/lib/mcp/catalog.js new file mode 100644 index 0000000..182afb9 --- /dev/null +++ b/src/lib/mcp/catalog.js @@ -0,0 +1,71 @@ +/** + * Maps oclif commands to an MCP tool catalog, classifying each by how much it + * can mutate so the server can gate writes behind `--allow-writes`. + */ + +// Commands never exposed as MCP tools: +// - `api` is an arbitrary-request escape hatch that bypasses per-tool gating. +// - `conv:watch` is a long-running stream that doesn't fit request/response. +export const EXCLUDED = new Set(['api', 'conv:watch']) + +// Topics whose every command is read-only. +const READ_TOPICS = new Set(['report', 'beacon']) + +// Leaf verbs that never mutate remote or local state. +const READ_LEAVES = new Set([ + 'list', + 'get', + 'search', + 'count', + 'version', + 'doctor', + 'current', + 'threads', + 'attachments', + 'conversations', + 'fields', + 'folders', + 'usage', + 'me', + 'validate', + 'status', +]) + +// Read-leaf commands that can actually mutate and must stay gated. +const WRITE_OVERRIDE = new Set(['conv:status']) + +/** + * @param {string} id oclif command id (e.g. `docs:article:delete-draft`) + * @returns {'read'|'write'|'destructive'} + */ +export function classifyKind(id) { + if (/(^|:)(delete|remove)(-|$)/.test(id)) return 'destructive' + if (WRITE_OVERRIDE.has(id)) return 'write' + const [topic] = id.split(':') + const leaf = id.split(':').pop() + if (READ_TOPICS.has(topic) || READ_LEAVES.has(leaf)) return 'read' + return 'write' +} + +/** Turn a command id into a valid MCP tool name. */ +export function toolName(id) { + return id.replace(/[:-]/g, '_') +} + +/** + * Build the tool catalog from a list of oclif command descriptors. + * @param {Array<{id: string, summary?: string, description?: string, hidden?: boolean, flags?: object, args?: object}>} commands + */ +export function buildCatalog(commands) { + return commands + .filter((c) => !c.hidden && !EXCLUDED.has(c.id)) + .map((c) => ({ + id: c.id, + toolName: toolName(c.id), + summary: c.summary || c.description || c.id, + kind: classifyKind(c.id), + args: c.args || {}, + flags: c.flags || {}, + })) + .sort((a, b) => (a.id < b.id ? -1 : 1)) +} diff --git a/src/lib/mcp/invoke.js b/src/lib/mcp/invoke.js new file mode 100644 index 0000000..f6cd450 --- /dev/null +++ b/src/lib/mcp/invoke.js @@ -0,0 +1,107 @@ +import { spawn } from 'node:child_process' + +/** + * Turn a validated tool input into an argv for the hscli CLI. Positional args + * first (in definition order), then flags, then a forced `--output json`. For + * commands that support `--yes`, it is appended so confirm prompts never block + * (there is no TTY in MCP stdio mode). + * @param {{id: string, args?: object, flags?: object}} entry + * @param {Record} input + * @returns {string[]} + */ +export function toArgv(entry, input) { + const argv = entry.id.split(':') + + for (const name of Object.keys(entry.args || {})) { + const v = input[name] + if (v !== undefined && v !== null) argv.push(String(v)) + } + + for (const [name, flag] of Object.entries(entry.flags || {})) { + if (name === 'yes') continue // forced below + const v = input[name] + if (v === undefined || v === null) continue + if (flag.type === 'boolean') { + if (v) argv.push(`--${name}`) + } else if (Array.isArray(v)) { + for (const item of v) argv.push(`--${name}`, String(item)) + } else { + argv.push(`--${name}`, String(v)) + } + } + + argv.push('--output', 'json') + if (entry.flags && 'yes' in entry.flags) argv.push('--yes') + return argv +} + +function wrap(data) { + if (Array.isArray(data)) return { results: data } + if (data && typeof data === 'object') return data + return { value: data } +} + +/** + * Run a tool by executing the underlying command and shaping its output into an + * MCP tool result. + * @param {object} entry catalog entry + * @param {Record} input + * @param {(argv: string[]) => Promise<{stdout: string, stderr: string, code: number}>} exec + */ +export async function runTool(entry, input, exec) { + const argv = toArgv(entry, input) + const { stdout, stderr, code } = await exec(argv) + + if (code !== 0) { + return { + content: [ + { type: 'text', text: (stderr || stdout || `exited ${code}`).trim() }, + ], + isError: true, + } + } + + const text = stdout.trim() + const result = { content: [{ type: 'text', text: text || 'OK' }] } + try { + result.structuredContent = wrap(JSON.parse(text)) + } catch { + // Plain-text output (e.g. "Deleted article 5") — no structured content. + } + return result +} + +/** + * Build an executor that spawns the hscli CLI as a child process. Keeping + * command output in a child process keeps the parent's stdout (the MCP stdio + * channel) clean. + * @param {{command: string, args?: string[]}} options + */ +/** A process killed by a signal reports a null exit code; treat that as 0. */ +export function normalizeExit(code) { + return code ?? 0 +} + +/** Best-effort string for a spawn error. */ +export function errMessage(e) { + return String(e?.message || e) +} + +export function makeExec({ command, args = [] }) { + return (argv) => + new Promise((resolve) => { + const child = spawn(command, [...args, ...argv], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (d) => (stdout += d)) + child.stderr.on('data', (d) => (stderr += d)) + child.on('error', (e) => + resolve({ stdout: '', stderr: errMessage(e), code: 1 }), + ) + child.on('close', (code) => + resolve({ stdout, stderr, code: normalizeExit(code) }), + ) + }) +} diff --git a/src/lib/mcp/schema.js b/src/lib/mcp/schema.js new file mode 100644 index 0000000..034903f --- /dev/null +++ b/src/lib/mcp/schema.js @@ -0,0 +1,61 @@ +import { z } from 'zod' + +/** + * Global/output-shaping flags that are noise for an MCP tool — the server + * forces `--output json` and runs under its own profile, so these never + * belong in a tool's input schema. + */ +export const NOISE_FLAGS = new Set([ + 'output', + 'jq', + 'fields', + 'no-color', + 'verbose', + 'no-retry', + 'timeout', + 'profile', + 'api-key', + 'help', +]) + +function flagSchema(flag) { + let s + if (flag.type === 'boolean') { + // A boolean flag is presence-based, so it is always optional. + s = z.boolean() + if (flag.description) s = s.describe(flag.description) + return s.optional() + } + if (Array.isArray(flag.options) && flag.options.length) { + s = z.enum(flag.options) + } else { + s = z.string() + } + if (flag.multiple) s = z.array(s) + if (flag.description) s = s.describe(flag.description) + return flag.required ? s : s.optional() +} + +function argSchema(arg) { + let s = z.string() + if (arg.description) s = s.describe(arg.description) + return arg.required ? s : s.optional() +} + +/** + * Build a zod raw shape (the object MCP's registerTool expects as + * `inputSchema`) from a catalog entry's args + flags. + * @param {{args?: object, flags?: object}} entry + * @returns {Record} + */ +export function buildInputSchema(entry) { + const shape = {} + for (const [name, arg] of Object.entries(entry.args || {})) { + shape[name] = argSchema(arg) + } + for (const [name, flag] of Object.entries(entry.flags || {})) { + if (NOISE_FLAGS.has(name)) continue + shape[name] = flagSchema(flag) + } + return shape +} diff --git a/src/lib/mcp/server.js b/src/lib/mcp/server.js new file mode 100644 index 0000000..50695b9 --- /dev/null +++ b/src/lib/mcp/server.js @@ -0,0 +1,49 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { buildCatalog } from './catalog.js' +import { buildInputSchema } from './schema.js' +import { runTool } from './invoke.js' + +/** MCP tool annotations derived from a catalog entry's kind. */ +export function annotationsFor(entry) { + return { + title: entry.summary, + readOnlyHint: entry.kind === 'read', + destructiveHint: entry.kind === 'destructive', + idempotentHint: false, + openWorldHint: true, + } +} + +/** Filter the catalog by the write gate: reads always, writes only when allowed. */ +export function selectTools(catalog, { allowWrites }) { + return catalog.filter((e) => allowWrites || e.kind === 'read') +} + +/** + * Build an McpServer that exposes hscli commands as tools. + * @param {object} options + * @param {Array} options.commands oclif command descriptors + * @param {string} options.version server version + * @param {boolean} options.allowWrites expose mutating tools + * @param {Function} options.exec executor passed to runTool + * @returns {{server: McpServer, tools: Array}} + */ +export function buildServer({ commands, version, allowWrites, exec }) { + const server = new McpServer({ name: 'hscli', version }) + const tools = selectTools(buildCatalog(commands), { allowWrites }) + + for (const entry of tools) { + server.registerTool( + entry.toolName, + { + title: entry.summary, + description: entry.summary, + inputSchema: buildInputSchema(entry), + annotations: annotationsFor(entry), + }, + (input) => runTool(entry, input, exec), + ) + } + + return { server, tools } +} diff --git a/test/commands/mcp/serve.test.js b/test/commands/mcp/serve.test.js new file mode 100644 index 0000000..3723fc5 --- /dev/null +++ b/test/commands/mcp/serve.test.js @@ -0,0 +1,94 @@ +import { runCmd } from '../../helpers.js' + +vi.mock('../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +// Replace the stdio transport so run() doesn't block on a real socket. +vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: class { + async start() {} + async send() {} + async close() {} + }, +})) + +const { default: Cmd, startMcpServer } = + await import('../../../src/commands/mcp/serve.js') + +const config = { + name: '@wavyx/hscli', + version: '0.10.0', + commands: [ + { + id: 'conv:list', + pluginName: '@wavyx/hscli', + summary: 'List', + flags: {}, + args: {}, + }, + { + id: 'conv:delete', + pluginName: '@wavyx/hscli', + summary: 'Delete', + flags: { yes: { type: 'boolean' } }, + args: { id: { required: true } }, + }, + // foreign plugin command — must be filtered out: + { + id: 'plugins:install', + pluginName: '@oclif/plugin-plugins', + summary: 'Install', + flags: {}, + args: {}, + }, + ], +} + +describe('startMcpServer', () => { + it('builds a read-only server and connects, logging the tool count', async () => { + const connect = vi.fn().mockResolvedValue() + const log = vi.fn() + const { tools } = await startMcpServer({ + config, + allowWrites: false, + exec: async () => ({ stdout: '[]', stderr: '', code: 0 }), + connect, + log, + }) + expect(tools.map((t) => t.id)).toEqual(['conv:list']) + expect(connect).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith(expect.stringContaining('read-only')) + }) + + it('builds a write-enabled server when allowWrites is set', async () => { + const log = vi.fn() + const { tools } = await startMcpServer({ + config, + allowWrites: true, + exec: async () => ({ stdout: '[]', stderr: '', code: 0 }), + connect: vi.fn().mockResolvedValue(), + log, + }) + expect(tools.map((t) => t.id).sort()).toEqual(['conv:delete', 'conv:list']) + expect(log).toHaveBeenCalledWith(expect.stringContaining('writes enabled')) + }) + + it('tolerates a missing log callback', async () => { + await expect( + startMcpServer({ + config, + allowWrites: false, + exec: async () => ({ stdout: '[]', stderr: '', code: 0 }), + connect: vi.fn().mockResolvedValue(), + }), + ).resolves.toBeDefined() + }) +}) + +describe('mcp serve (run)', () => { + it('parses flags and connects a stdio server', async () => { + // Should resolve (not hang) thanks to the mocked transport. + await expect(runCmd(Cmd, ['--allow-writes'])).resolves.toBeDefined() + }) +}) diff --git a/test/lib/mcp/catalog.test.js b/test/lib/mcp/catalog.test.js new file mode 100644 index 0000000..1a0c5e8 --- /dev/null +++ b/test/lib/mcp/catalog.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { + classifyKind, + toolName, + buildCatalog, + EXCLUDED, +} from '../../../src/lib/mcp/catalog.js' + +describe('classifyKind', () => { + it.each([ + ['conv:delete', 'destructive'], + ['docs:article:delete-draft', 'destructive'], + ['webhook:delete', 'destructive'], + ['conv:status', 'write'], // can mutate via --set, so gated + ['conv:reply', 'write'], + ['backup', 'write'], + ['docs:collection:create', 'write'], + ['report:conversations', 'read'], // report topic is always read + ['beacon:sign', 'read'], // beacon utils are pure/local + ['conv:list', 'read'], + ['user:me', 'read'], + ['config:validate', 'read'], + ['auth:status', 'read'], + ['customer:conversations', 'read'], + ])('classifies %s as %s', (id, kind) => { + expect(classifyKind(id)).toBe(kind) + }) +}) + +describe('toolName', () => { + it('replaces colons and dashes with underscores', () => { + expect(toolName('conv:bulk-status')).toBe('conv_bulk_status') + expect(toolName('docs:article:save-draft')).toBe('docs_article_save_draft') + expect(toolName('version')).toBe('version') + }) +}) + +describe('buildCatalog', () => { + const commands = [ + { id: 'conv:list', summary: 'List', flags: {}, args: {} }, + { id: 'conv:delete', description: 'Delete', flags: {}, args: {} }, + { id: 'api', summary: 'escape hatch', flags: {}, args: {} }, + { id: 'conv:watch', summary: 'watch', flags: {}, args: {} }, + { id: 'secret', summary: 'hidden one', hidden: true, flags: {}, args: {} }, + { id: 'doctor' }, // bare: no summary/description/flags/args + ] + + it('excludes hidden + escape-hatch + streaming commands, sorts by id', () => { + const cat = buildCatalog(commands) + expect(cat.map((t) => t.id)).toEqual(['conv:delete', 'conv:list', 'doctor']) + expect(EXCLUDED.has('api')).toBe(true) + expect(EXCLUDED.has('conv:watch')).toBe(true) + }) + + it('maps id, toolName, summary and kind', () => { + const cat = buildCatalog(commands) + const list = cat.find((t) => t.id === 'conv:list') + expect(list).toMatchObject({ + id: 'conv:list', + toolName: 'conv_list', + summary: 'List', + kind: 'read', + }) + // falls back to description when summary is absent + expect(cat.find((t) => t.id === 'conv:delete').summary).toBe('Delete') + }) + + it('falls back to the id for summary and defaults flags/args when absent', () => { + const doctor = buildCatalog(commands).find((t) => t.id === 'doctor') + expect(doctor.summary).toBe('doctor') + expect(doctor.flags).toEqual({}) + expect(doctor.args).toEqual({}) + }) +}) diff --git a/test/lib/mcp/invoke.test.js b/test/lib/mcp/invoke.test.js new file mode 100644 index 0000000..6b76830 --- /dev/null +++ b/test/lib/mcp/invoke.test.js @@ -0,0 +1,214 @@ +import { describe, it, expect } from 'vitest' +import { + toArgv, + runTool, + makeExec, + normalizeExit, + errMessage, +} from '../../../src/lib/mcp/invoke.js' + +const readEntry = { + id: 'conv:list', + flags: { + status: { type: 'option', options: ['active'] }, + embed: { type: 'option', multiple: true }, + verbose: { type: 'boolean' }, + }, + args: {}, +} +const writeEntry = { + id: 'docs:article:delete', + flags: { yes: { type: 'boolean' } }, + args: { id: { required: true } }, +} + +describe('toArgv', () => { + it('splits the id into a command path and forces --output json', () => { + const argv = toArgv(readEntry, { status: 'active' }) + expect(argv.slice(0, 2)).toEqual(['conv', 'list']) + expect(argv).toContain('--status') + expect(argv).toContain('active') + expect(argv.slice(-2)).toEqual(['--output', 'json']) + }) + + it('emits a boolean flag only when true', () => { + expect(toArgv(readEntry, { verbose: true })).toContain('--verbose') + expect(toArgv(readEntry, { verbose: false })).not.toContain('--verbose') + }) + + it('repeats a multiple flag per value', () => { + const argv = toArgv(readEntry, { embed: ['threads', 'tags'] }) + expect(argv.filter((a) => a === '--embed')).toHaveLength(2) + expect(argv).toContain('threads') + expect(argv).toContain('tags') + }) + + it('passes positional args and auto-appends --yes when the command supports it', () => { + const argv = toArgv(writeEntry, { id: '5abc' }) + expect(argv).toEqual([ + 'docs', + 'article', + 'delete', + '5abc', + '--output', + 'json', + '--yes', + ]) + }) + + it('omits an optional arg that was not provided', () => { + const argv = toArgv({ id: 'conv:get', flags: {}, args: { id: {} } }, {}) + expect(argv).toEqual(['conv', 'get', '--output', 'json']) + }) + + it('tolerates entries with no flags/args maps', () => { + expect(toArgv({ id: 'conv:list' }, { status: null })).toEqual([ + 'conv', + 'list', + '--output', + 'json', + ]) + }) + + it('skips flags and args explicitly set to null', () => { + const argv = toArgv( + { + id: 'conv:get', + flags: { status: { type: 'option' } }, + args: { id: {} }, + }, + { id: null, status: null }, + ) + expect(argv).toEqual(['conv', 'get', '--output', 'json']) + }) +}) + +describe('runTool', () => { + const fakeExec = (result) => async () => result + + it('parses a JSON object into structuredContent', async () => { + const res = await runTool( + readEntry, + {}, + fakeExec({ stdout: '{"a":1}', stderr: '', code: 0 }), + ) + expect(res.isError).toBeFalsy() + expect(res.structuredContent).toEqual({ a: 1 }) + expect(res.content[0].text).toBe('{"a":1}') + }) + + it('wraps a JSON array under results', async () => { + const res = await runTool( + readEntry, + {}, + fakeExec({ stdout: '[1,2]', stderr: '', code: 0 }), + ) + expect(res.structuredContent).toEqual({ results: [1, 2] }) + }) + + it('wraps a primitive JSON value under value', async () => { + const res = await runTool( + readEntry, + {}, + fakeExec({ stdout: '42', stderr: '', code: 0 }), + ) + expect(res.structuredContent).toEqual({ value: 42 }) + }) + + it('wraps a null JSON value under value', async () => { + const res = await runTool( + readEntry, + {}, + fakeExec({ stdout: 'null', stderr: '', code: 0 }), + ) + expect(res.structuredContent).toEqual({ value: null }) + }) + + it('returns plain text with no structuredContent for non-JSON output', async () => { + const res = await runTool( + writeEntry, + { id: 'x' }, + fakeExec({ stdout: 'Deleted article x', stderr: '', code: 0 }), + ) + expect(res.structuredContent).toBeUndefined() + expect(res.content[0].text).toBe('Deleted article x') + }) + + it('falls back to OK for empty output', async () => { + const res = await runTool( + readEntry, + {}, + fakeExec({ stdout: ' ', stderr: '', code: 0 }), + ) + expect(res.content[0].text).toBe('OK') + }) + + it('marks a non-zero exit as an error using stderr', async () => { + const res = await runTool( + readEntry, + {}, + fakeExec({ stdout: '', stderr: 'boom', code: 77 }), + ) + expect(res.isError).toBe(true) + expect(res.content[0].text).toBe('boom') + }) + + it('uses a generic message when a failed run produced no output', async () => { + const res = await runTool( + readEntry, + {}, + fakeExec({ stdout: '', stderr: '', code: 5 }), + ) + expect(res.isError).toBe(true) + expect(res.content[0].text).toBe('exited 5') + }) +}) + +describe('makeExec', () => { + it('captures stdout and a zero exit', async () => { + const exec = makeExec({ + command: process.execPath, + args: ['-e', 'process.stdout.write("hi")'], + }) + const r = await exec([]) + expect(r).toMatchObject({ stdout: 'hi', code: 0 }) + }) + + it('captures stderr and a non-zero exit', async () => { + const exec = makeExec({ + command: process.execPath, + args: ['-e', 'process.stderr.write("err");process.exit(3)'], + }) + const r = await exec([]) + expect(r.stderr).toBe('err') + expect(r.code).toBe(3) + }) + + it('resolves with code 1 when the process cannot be spawned', async () => { + const exec = makeExec({ + command: 'definitely-not-a-real-binary-xyz', + args: [], + }) + const r = await exec([]) + expect(r.code).toBe(1) + expect(r.stderr).toBeTruthy() + }) +}) + +describe('normalizeExit', () => { + it('passes through a numeric code', () => { + expect(normalizeExit(3)).toBe(3) + }) + it('treats a null (signal) code as 0', () => { + expect(normalizeExit(null)).toBe(0) + }) +}) + +describe('errMessage', () => { + it('prefers the error message', () => { + expect(errMessage(new Error('boom'))).toBe('boom') + }) + it('falls back to the value itself', () => { + expect(errMessage('plain')).toBe('plain') + }) +}) diff --git a/test/lib/mcp/schema.test.js b/test/lib/mcp/schema.test.js new file mode 100644 index 0000000..d701ea6 --- /dev/null +++ b/test/lib/mcp/schema.test.js @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import { buildInputSchema, NOISE_FLAGS } from '../../../src/lib/mcp/schema.js' + +const entry = { + args: { + id: { description: 'Conversation id', required: true }, + note: { description: 'Optional note' }, + }, + flags: { + status: { + type: 'option', + options: ['active', 'closed'], + description: 'St', + }, + tag: { type: 'option', description: 'Tag', required: true }, + yes: { type: 'boolean', description: 'Skip prompt' }, + embed: { type: 'option', multiple: true, description: 'Embed' }, + // noise — must be dropped: + output: { type: 'option', options: ['json', 'table'] }, + fields: { type: 'option' }, + 'no-color': { type: 'boolean' }, + profile: { type: 'option' }, + }, +} + +describe('buildInputSchema', () => { + const shape = buildInputSchema(entry) + + it('drops global/noise flags', () => { + for (const n of ['output', 'fields', 'no-color', 'profile']) { + expect(Object.keys(shape)).not.toContain(n) + } + expect(NOISE_FLAGS.has('output')).toBe(true) + }) + + it('includes args and meaningful flags', () => { + expect(Object.keys(shape).sort()).toEqual( + ['embed', 'id', 'note', 'status', 'tag', 'yes'].sort(), + ) + }) + + it('makes a required arg non-optional and an optional arg optional', () => { + expect(shape.id.safeParse(undefined).success).toBe(false) + expect(shape.note.safeParse(undefined).success).toBe(true) + expect(shape.id.safeParse('5abc').success).toBe(true) + }) + + it('maps an options flag to an enum', () => { + expect(shape.status.safeParse('active').success).toBe(true) + expect(shape.status.safeParse('nope').success).toBe(false) + }) + + it('maps a plain option flag to a string and required flag stays required', () => { + expect(shape.tag.safeParse('vip').success).toBe(true) + expect(shape.tag.safeParse(undefined).success).toBe(false) + }) + + it('maps a boolean flag (always optional)', () => { + expect(shape.yes.safeParse(true).success).toBe(true) + expect(shape.yes.safeParse('x').success).toBe(false) + expect(shape.yes.safeParse(undefined).success).toBe(true) + }) + + it('maps a multiple option flag to a string array', () => { + expect(shape.embed.safeParse(['threads', 'tags']).success).toBe(true) + expect(shape.embed.safeParse('threads').success).toBe(false) + }) +}) + +describe('buildInputSchema edge cases', () => { + it('returns an empty shape for an entry with no args or flags', () => { + expect(buildInputSchema({})).toEqual({}) + }) + + it('handles description-less args and flags', () => { + const shape = buildInputSchema({ + args: { x: {} }, + flags: { b: { type: 'boolean' }, o: { type: 'option' } }, + }) + expect(shape.x.safeParse(undefined).success).toBe(true) + expect(shape.b.safeParse(true).success).toBe(true) + expect(shape.o.safeParse('v').success).toBe(true) + }) +}) diff --git a/test/lib/mcp/server.test.js b/test/lib/mcp/server.test.js new file mode 100644 index 0000000..5e4d3bf --- /dev/null +++ b/test/lib/mcp/server.test.js @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { + buildServer, + selectTools, + annotationsFor, +} from '../../../src/lib/mcp/server.js' + +const commands = [ + { + id: 'conv:list', + summary: 'List convs', + flags: { + status: { + type: 'option', + options: ['active', 'closed'], + description: 's', + }, + }, + args: {}, + }, + { + id: 'docs:article:create', + summary: 'Create article', + flags: { name: { type: 'option', required: true } }, + args: {}, + }, + { + id: 'conv:delete', + summary: 'Delete conv', + flags: { yes: { type: 'boolean' } }, + args: { id: { required: true } }, + }, +] + +describe('selectTools', () => { + const cat = [{ kind: 'read' }, { kind: 'write' }, { kind: 'destructive' }] + it('keeps only reads when writes are disallowed', () => { + expect(selectTools(cat, { allowWrites: false })).toHaveLength(1) + }) + it('keeps everything when writes are allowed', () => { + expect(selectTools(cat, { allowWrites: true })).toHaveLength(3) + }) +}) + +describe('annotationsFor', () => { + it('flags reads read-only', () => { + expect(annotationsFor({ kind: 'read', summary: 'x' })).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }) + }) + it('flags destructive tools destructive', () => { + expect(annotationsFor({ kind: 'destructive', summary: 'x' })).toMatchObject( + { + readOnlyHint: false, + destructiveHint: true, + }, + ) + }) + it('flags writes as neither read-only nor destructive', () => { + expect(annotationsFor({ kind: 'write', summary: 'x' })).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + }) + }) +}) + +async function connect({ allowWrites, exec }) { + const { server } = buildServer({ + commands, + version: '0.10.0', + allowWrites, + exec, + }) + const [clientT, serverT] = InMemoryTransport.createLinkedPair() + await server.connect(serverT) + const client = new Client({ name: 'test', version: '1.0.0' }) + await client.connect(clientT) + return client +} + +describe('buildServer', () => { + it('exposes only read tools when writes are disallowed', async () => { + const client = await connect({ + allowWrites: false, + exec: async () => ({ stdout: '[]', stderr: '', code: 0 }), + }) + const { tools } = await client.listTools() + expect(tools.map((t) => t.name)).toEqual(['conv_list']) + await client.close() + }) + + it('exposes writes + destructive tools (with hints) when allowed', async () => { + const client = await connect({ + allowWrites: true, + exec: async () => ({ stdout: '[]', stderr: '', code: 0 }), + }) + const { tools } = await client.listTools() + expect(tools.map((t) => t.name).sort()).toEqual( + ['conv_delete', 'conv_list', 'docs_article_create'].sort(), + ) + const del = tools.find((t) => t.name === 'conv_delete') + expect(del.annotations.destructiveHint).toBe(true) + expect(del.inputSchema.properties.id).toBeDefined() + await client.close() + }) + + it('routes a tool call through exec into structuredContent', async () => { + let seenArgv + const exec = async (argv) => { + seenArgv = argv + return { stdout: '[{"id":1}]', stderr: '', code: 0 } + } + const client = await connect({ allowWrites: false, exec }) + const res = await client.callTool({ + name: 'conv_list', + arguments: { status: 'active' }, + }) + expect(res.structuredContent).toEqual({ results: [{ id: 1 }] }) + expect(seenArgv).toContain('--status') + await client.close() + }) +}) diff --git a/website/astro.config.mjs b/website/astro.config.mjs index a95c506..212e08d 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -90,6 +90,7 @@ export default defineConfig({ { label: 'Output & filtering', slug: 'automation/output' }, { label: 'Exit codes', slug: 'automation/exit-codes' }, { label: 'Using with agents', slug: 'automation/agents' }, + { label: 'MCP server', slug: 'automation/mcp' }, { label: 'CI pipelines', slug: 'automation/ci' }, { label: 'Webhooks & workflows', slug: 'automation/webhooks' }, ], diff --git a/website/src/content/docs/automation/mcp.mdx b/website/src/content/docs/automation/mcp.mdx new file mode 100644 index 0000000..6f54fe8 --- /dev/null +++ b/website/src/content/docs/automation/mcp.mdx @@ -0,0 +1,80 @@ +--- +title: MCP server +description: Run hscli as a Model Context Protocol server so AI agents can drive Help Scout through native tools. +--- + +`hscli mcp serve` turns the whole CLI into a [Model Context Protocol](https://modelcontextprotocol.io) +server. Point Claude (Desktop, Code, or any MCP client) at it and the agent gets one +tool per command — `conv_list`, `docs_article_create`, `report_conversations` — with typed +inputs and structured JSON results. No glue code, no wrapper API. + +## Connect it + +The server speaks MCP over **stdio**: the client launches `hscli mcp serve` as a subprocess. + +**Claude Code:** + +```bash frame="terminal" +claude mcp add hscli -- hscli mcp serve +``` + +**Claude Desktop** — add to `claude_desktop_config.json`: + +```json title="claude_desktop_config.json" +{ + "mcpServers": { + "hscli": { "command": "hscli", "args": ["mcp", "serve"] } + } +} +``` + +The server runs as **you** — it reuses the OAuth token and Docs API key already in your OS +keychain. Run `hscli auth login` (and `hscli docs auth` for knowledge-base tools) first. + +## Reads by default, writes on request + +By default only **read-only** tools are exposed (`list`, `get`, `search`, `report`, …) — an +agent can observe your help desk but not change it. To enable mutating tools +(`create`/`update`/`delete`/`bulk`), start the server with `--allow-writes`: + +```bash frame="terminal" +claude mcp add hscli -- hscli mcp serve --allow-writes +``` + +```json title="claude_desktop_config.json" +{ + "mcpServers": { + "hscli": { "command": "hscli", "args": ["mcp", "serve", "--allow-writes"] } + } +} +``` + +`--allow-writes` is a one-time setup choice — the agent never passes it. When enabled, every +tool is available; when not, write tools simply aren't listed. + +:::note[Two layers of safety] +Write tools are gated by `--allow-writes`, **and** each carries an MCP `destructiveHint` so +the client (e.g. Claude Desktop) prompts you to approve a `delete` or `bulk` call before it +runs. Reads are marked `readOnlyHint` and run without prompting. +::: + +## How it maps + +- **One tool per command.** `conv:bulk-status` → `conv_bulk_status`. The escape-hatch `api` + command and the streaming `conv watch` are not exposed. +- **Flags become typed inputs.** Required flags/args are required; `--status active|closed` + becomes an enum; repeatable flags become arrays. Output-shaping flags (`--output`, `--jq`, + `--fields`) are dropped — the server always requests JSON. +- **Structured results.** A command's JSON is returned as `structuredContent` (arrays are + wrapped under `results`); plain-text confirmations (“Deleted article 5”) come back as text. + +## Try it + +Once connected, ask the agent things like: + +> “List the active conversations tagged _billing_ and summarize the top themes.” + +With `--allow-writes` on, the full loop from the [home page](/) works — the agent reads the +inbox, drafts an article, and publishes it with `docs_article_create`. + +See the [command reference](/reference/commands/#mcp) for the `mcp serve` flags. diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index 1b3c8be..ca1289a 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -12,7 +12,7 @@ hscli [target] [flags] ``` Run `hscli --help` for the live, self-describing version of any command. -This page lists all 88 commands in `hscli` v0.9.0. +This page lists all 89 commands in `hscli` v0.10.0. ## alias @@ -151,6 +151,16 @@ Inspect mailboxes, folders, and custom fields. | `mailbox get ` | Get a mailbox by ID | — | | `mailbox list` | List mailboxes | `--limit` | +## mcp + +AI agents + +Run hscli as a Model Context Protocol server so AI agents can call it as native tools. Reads by default; pass `--allow-writes` to expose mutating tools. See the [MCP guide](/automation/mcp/). + +| Command | Description | Key flags | +| --- | --- | --- | +| `mcp serve` | Run hscli as a Model Context Protocol (MCP) server over stdio | `--allow-writes` | + ## profile profiles