From d818dc62d7672843e4bfa91cac3627a4d6cff0b6 Mon Sep 17 00:00:00 2001 From: Weilu Jia Date: Wed, 5 Aug 2026 17:46:58 +0000 Subject: [PATCH] [verified] feat: convert plugin to MCP server --- README.md | 147 +++++++++++++++++------------------ bun.lock | 187 +++++++++++++++++++++++++++++++++++++-------- bundle.ts | 9 ++- bunfig.toml | 3 + package.json | 30 ++++---- src/index.ts | 167 ++++++---------------------------------- src/scope.ts | 7 +- src/server.ts | 110 +++++++++++++++++++++++++++ test/ast.test.ts | 125 +----------------------------- test/mcp.test.ts | 194 +++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 590 insertions(+), 389 deletions(-) create mode 100644 bunfig.toml create mode 100644 src/server.ts create mode 100644 test/mcp.test.ts diff --git a/README.md b/README.md index b6677fd..04f64c8 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,110 @@ # opencode-ast -AST plugin for [OpenCode](https://github.com/anomalyco/opencode) that parses source files with [tree-sitter](https://tree-sitter.github.io/) (via WASM) and exposes structural queries: outline, extract, deps, and scope. +A local [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for tree-sitter-powered source-code analysis. It exposes structural outline, symbol extraction, dependency, and scope queries without requiring a language server. -Handles broken syntax fine since tree-sitter is error-tolerant. No language server involved. +Tree-sitter is error-tolerant, so the tools continue to work on partially edited or syntactically broken files. ## Supported languages -TypeScript, JavaScript, Python, Go, Rust, Java, C, C++, Ruby, C#, JSON, JSONC, YAML, Terraform, Markdown. +TypeScript, JavaScript, Python, Go, Rust, Java, C, C++, Ruby, C#, JSON, JSONC, YAML, Terraform, and Markdown. -## Operations +## MCP tool -### outline +The server exposes one `ast` tool with four operations: -Structural overview of a file — functions, classes, types with line ranges and export status. +- `outline` — list functions, classes, types, and other symbols with line ranges. +- `extract` — return one symbol's line-numbered source, or only its signature. +- `deps` — list imports and classify them as local or external. +- `scope` — show the enclosing scope chain for a line. -``` -ast { operation: "outline", filePath: "src/parser.ts" } -``` - -Output looks like: +Example arguments: +```json +{ "operation": "outline", "filePath": "src/parser.ts" } ``` -Functions: - parse (lines 57-76, exported) - query (lines 78-84, exported) -Variables: - ready (lines 24-32) - load (lines 18-22) +```json +{ + "operation": "extract", + "filePath": "src/parser.ts", + "name": "parse", + "kind": "function", + "signature": true +} ``` -You can pass `filter: "struct,function"` to limit output to specific kinds. - -### extract - -Pull one symbol's source by name. Optionally pass `kind` to disambiguate, or `signature: true` to get just the declaration line. - +```json +{ "operation": "deps", "filePath": "src/parser.ts" } ``` -ast { operation: "extract", filePath: "src/parser.ts", name: "parse" } -ast { operation: "extract", filePath: "src/parser.ts", name: "Parser", kind: "class" } + +```json +{ "operation": "scope", "filePath": "src/parser.ts", "line": 15 } ``` -### deps +`outline` accepts an optional comma-separated `filter`, such as `"struct,trait,enum"`. `outline` and `deps` skip test-scoped symbols by default; pass `includeTests: true` to include them. -List imports, classified as local or external. +## Install and configure -``` -ast { operation: "deps", filePath: "src/parser.ts" } -``` +[Bun](https://bun.sh/) is required. -### scope - -Given a line number, returns the enclosing scope chain. +Add the server to any stdio-compatible MCP client. Use an absolute workspace path so relative `filePath` values resolve predictably: +```json +{ + "mcpServers": { + "opencode-ast": { + "command": "bunx", + "args": [ + "opencode-ast", + "--root", + "/absolute/path/to/your/project" + ] + } + } +} ``` -ast { operation: "scope", filePath: "src/parser.ts", line: 15 } -Line 15 is inside: - process (function, lines 13-18) - if block (lines 14-16) +To run a local checkout instead: + +```json +{ + "mcpServers": { + "opencode-ast": { + "command": "bun", + "args": [ + "/absolute/path/to/opencode-ast/src/index.ts", + "--root", + "/absolute/path/to/your/project" + ] + } + } +} ``` -Both outline and deps skip test-scoped symbols by default — set `includeTests: true` to include them. +When `--root` is omitted, the server uses its current working directory. + +## Workspace safety -## Install +The server only reads files inside its configured workspace root. Relative or absolute paths that resolve inside the root are accepted; `..` traversal and symlinks that resolve outside the root are rejected. -Build and copy to the plugins directory: +Source parsing is limited to 8 MiB per file by default. Override the limit with: ```sh -bun run bundle.ts -cp dist/ast.js ~/.config/opencode/plugins/ast.js +OPENCODE_AST_MAX_SOURCE_BYTES= ``` -OpenCode auto-loads `.js` files from `~/.config/opencode/plugins/`. - -For per-project use, put the bundle in `.opencode/plugins/ast.js` and add `"plugin": ["file://.opencode/plugins/ast.js"]` to `opencode.json`. - ## Grammars -WASM grammar files are fetched from tree-sitter GitHub releases on first use and cached in `~/.cache/opencode-ast/`. - -Behavior details: +WASM grammar files are downloaded from pinned tree-sitter release URLs on first use and cached in `~/.cache/opencode-ast/`. -- Downloads use a 15s timeout and up to 3 attempts for transient failures. -- In-flight downloads are deduplicated per process to avoid duplicate fetches. +- Downloads use a 15-second timeout and up to three attempts for transient failures. - Runtime and grammar WASM files are pinned with SHA-256 checksums. -- Downloaded and cached WASM files are checksum-verified before load; mismatches fail closed. -- Query objects are cached with an LRU cap of 100 entries per language. -- Source parsing is limited to 8 MiB per file by default. - Override with `OPENCODE_AST_MAX_SOURCE_BYTES=`. -- `.h` files are auto-routed to C or C++ grammar based on lightweight syntax heuristics. +- Cached files are checksum-verified before loading; mismatches fail closed. +- In-flight downloads are deduplicated per process. +- Compiled queries use an LRU cache capped at 100 entries per language. +- `.h` files are routed to C or C++ using lightweight syntax heuristics. -When upgrading grammar/runtime URLs, refresh checksums: +Refresh pinned checksums after changing grammar/runtime URLs: ```sh bun run update:wasm-manifest @@ -99,31 +112,21 @@ bun run update:wasm-manifest Optional flags: -- Bump all selected WASM URLs to latest before hashing: - ```sh bun run update:wasm-manifest -- --bump-latest -``` - -- Bump/hash only one target (`runtime` or one grammar id such as `typescript`): - -```sh bun run update:wasm-manifest -- --only typescript bun run update:wasm-manifest -- --bump-latest --only runtime ``` -## Safety - -- `filePath` can resolve outside the active workspace/worktree. - OpenCode may prompt for permission before reading external paths. - ## Development -Requires [Bun](https://bun.sh). +This repository uses Bun. Dependency installation enforces a 14-day minimum package age through `bunfig.toml`. ```sh bun install bun test -bunx tsc --noEmit -bun run bundle.ts +bun run typecheck +bun run bundle ``` + +The production stdio bundle is written to `dist/opencode-ast.js`. diff --git a/bun.lock b/bun.lock index 236c78b..e24c650 100644 --- a/bun.lock +++ b/bun.lock @@ -5,90 +5,213 @@ "": { "name": "opencode-ast", "dependencies": { - "effect": "4.0.0-beta.59", + "@modelcontextprotocol/sdk": "^1.29.0", "web-tree-sitter": "0.26.8", + "zod": "^4.4.3", }, "devDependencies": { - "@opencode-ai/plugin": "1.14.41", "@types/bun": "1.3.13", "typescript": "^6.0.3", }, - "peerDependencies": { - "@opencode-ai/plugin": "*", - }, }, }, "packages": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "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" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.41", "", { "dependencies": { "@opencode-ai/sdk": "1.14.41", "effect": "4.0.0-beta.59", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.2", "@opentui/solid": ">=0.2.2" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-Q/QdDKSfHyYX+Xqd79o4XgyZKqF8h5qgqgfmOQbKVLhbduc9zMYdpV2yvWT6gaJPrpOftpka/kpr56PCqzetYQ=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.41", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-RYb2dCUv0TWIvBNnnO6ANbAPYri6rKuWizSoVFw/Pw+SCDj9ASHM5gAZ+jkskp8gYMfLLHe/Fpkun/9mr8m0IQ=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "express": ["express@5.2.1", "", { "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" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "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" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], + "hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - "msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "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" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "side-channel": ["side-channel@1.1.1", "", { "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" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "web-tree-sitter": ["web-tree-sitter@0.26.8", "", {}, "sha512-4sUwi7ZyOrIk5KLgYLkc2A/F0LFMQnBhfb+2Cdl7ik4ePJ6JD+fk4ofI2sA5eGawBKBaK4Vntt7Ww5KcEsay4A=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], } } diff --git a/bundle.ts b/bundle.ts index d2e1c9e..49d3ca0 100644 --- a/bundle.ts +++ b/bundle.ts @@ -1,13 +1,17 @@ #!/usr/bin/env bun import path from "node:path" +import { chmod, mkdir, rm } from "node:fs/promises" + +const dist = path.join(import.meta.dir, "dist") +await rm(dist, { recursive: true, force: true }) +await mkdir(dist, { recursive: true }) const out = await Bun.build({ entrypoints: [path.join(import.meta.dir, "src/index.ts")], target: "bun", format: "esm", minify: false, - external: ["@opencode-ai/plugin"], }) if (!out.success) { @@ -23,6 +27,7 @@ if (!artifact) { } const result = await artifact.text() -const dest = path.join(import.meta.dir, "dist", "ast.js") +const dest = path.join(dist, "opencode-ast.js") await Bun.write(dest, result) +await chmod(dest, 0o755) console.log(`Bundled to ${dest} (${(result.length / 1024).toFixed(1)} KB)`) diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..8a68000 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ +[install] +# Only install package versions published at least 14 days ago. +minimumReleaseAge = 1209600 diff --git a/package.json b/package.json index f30aa63..f80871f 100644 --- a/package.json +++ b/package.json @@ -1,37 +1,39 @@ { "name": "opencode-ast", - "version": "0.2.0", - "description": "AST plugin for OpenCode — tree-sitter powered code structure analysis", + "version": "0.3.0", + "description": "Tree-sitter powered AST analysis MCP server", "type": "module", - "main": "src/index.ts", + "main": "dist/opencode-ast.js", "exports": { ".": { - "import": "./src/index.ts", - "default": "./src/index.ts" + "import": "./dist/opencode-ast.js", + "default": "./dist/opencode-ast.js" } }, + "bin": { + "opencode-ast": "./dist/opencode-ast.js" + }, "files": [ - "src" + "dist" ], "scripts": { + "start": "bun run src/index.ts", "test": "bun test", - "typecheck": "bunx tsc --noEmit", + "typecheck": "bun x tsc --noEmit", "bundle": "bun run bundle.ts", "update:wasm-manifest": "bun run update-wasm-manifest.ts", - "prepublishOnly": "bun run bundle" + "prepack": "bun run bundle", + "prepublishOnly": "bun test && bun run typecheck && bun run bundle" }, "engines": { "bun": ">=1.0" }, - "peerDependencies": { - "@opencode-ai/plugin": "*" - }, "dependencies": { - "effect": "4.0.0-beta.59", - "web-tree-sitter": "0.26.8" + "@modelcontextprotocol/sdk": "^1.29.0", + "web-tree-sitter": "0.26.8", + "zod": "^4.4.3" }, "devDependencies": { - "@opencode-ai/plugin": "1.14.41", "@types/bun": "1.3.13", "typescript": "^6.0.3" } diff --git a/src/index.ts b/src/index.ts index 6ad24cd..01f0a92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,148 +1,31 @@ -import path from "node:path" -import { realpath } from "node:fs/promises" -import type { Plugin } from "@opencode-ai/plugin" -import { tool } from "@opencode-ai/plugin" -import { Effect } from "effect" -import { deps } from "./deps" -import { extract } from "./extract" -import { outline } from "./outline" -import { parse } from "./parser" -import { scope } from "./scope" -import { validKinds } from "./util" -import { resolveWorkspaceFile } from "./workspace" - -const isInside = (base: string, target: string) => { - const rel = path.relative(base, target) - return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) -} +#!/usr/bin/env bun -const isExternalPath = async ( - file: string, - directory: string, - worktree: string, -) => { - const dirRoot = await realpath(directory).catch(() => path.resolve(directory)) - const worktreeRoot = await realpath(worktree).catch(() => - path.resolve(worktree), - ) - if (isInside(dirRoot, file)) return false - if (worktreeRoot === "/") return true - return !isInside(worktreeRoot, file) -} - -const isPromiseLike = (value: unknown): value is PromiseLike => - !!value && typeof (value as PromiseLike).then === "function" - -const runAsk = async (value: unknown) => { - if (isPromiseLike(value)) { - await value - return +import path from "node:path" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { createMcpServer } from "./server" + +export const workspaceRootFromArgs = ( + args: string[], + cwd = process.cwd(), +): string => { + const rootIndex = args.indexOf("--root") + if (rootIndex === -1) return path.resolve(cwd) + const value = args[rootIndex + 1] + if (!value || value.startsWith("--")) { + throw new Error("--root requires a directory path") } - await Effect.runPromise(value as Effect.Effect) + return path.resolve(cwd, value) } -const plugin: Plugin = async () => { - return { - tool: { - ast: tool({ - description: `Analyze source code structure using AST parsing. Supports 14 languages/formats: TypeScript, JavaScript, Python, Go, Rust, Java, C, C++, Ruby, C#, JSON, JSONC, YAML, Markdown. - -Prefer outline + extract over reading entire large files. Prefer scope over reading surrounding context manually after grep results. - -Operations: -- outline: Get a structural overview of a file (functions, classes, types, keys with line ranges). Use this BEFORE reading large files to understand their structure and then read only the parts you need. Use optional \`filter\` (comma-separated kinds like "struct,trait,enum") to limit output to specific symbol kinds. Valid kinds: ${validKinds.join(", ")}. On files with many symbols, set \`filter\` to avoid noisy output. IMPORTANT: For files over ~200 lines, you MUST use outline before reading the full file — get the structure first, then extract only what you need. For small files (<200 lines), Read is fine. -- extract: Extract the source of a specific symbol by name and optional kind. Returns line-numbered source. Use this when you know the symbol name and want to read a single function or class without reading the entire file. Use kind to disambiguate when a file has both a function and type with the same name. Use optional \`signature\` (boolean) to return only the function/method signature instead of the full body — prefer this first to check types before pulling full implementations. \`kind\` uses the same values as outline/filter. -- deps: List all imports/dependencies of a file, distinguishing local vs external imports. Useful for understanding a file's dependency graph before refactoring. Avoid on large files unless you need the full import graph — for a single import, grep is cheaper. -- scope: Given a line number, show the full enclosing scope chain (not just the immediate parent — the entire nesting from outermost to innermost). Useful when landing on a line from grep results to understand the surrounding context. Very compact output (~5 lines) — ALWAYS prefer this over reading a wide range of lines when you just need to know what surrounds a line. -- outline and deps skip test-scoped symbols by default. Set \`includeTests\` to true to include them.`, - args: { - operation: tool.schema.enum(["outline", "extract", "deps", "scope"]), - filePath: tool.schema.string().describe("Path to source file"), - name: tool.schema.string().optional(), - kind: tool.schema.string().optional(), - filter: tool.schema.string().optional(), - includeTests: tool.schema.boolean().optional(), - signature: tool.schema.boolean().optional(), - line: tool.schema.number().int().positive().optional(), - }, - async execute(args, ctx) { - const line = args.line - if (args.operation === "scope" && line === undefined) { - return "`line` is required for `scope`" - } - if (args.operation === "extract" && !args.kind && !args.name) { - return "Provide `name` or `kind` to extract a symbol" - } - const resolved = await resolveWorkspaceFile( - args.filePath, - ctx.directory, - ctx.worktree, - ) - const file = resolved.file - const rel = resolved.rel - - if (await isExternalPath(file, ctx.directory, ctx.worktree)) { - const parentDir = path.dirname(file) - const glob = path.join(parentDir, "*").replaceAll("\\", "/") - await runAsk( - ctx.ask({ - permission: "external_directory", - patterns: [glob], - always: [glob], - metadata: { - filepath: file, - parentDir, - }, - }), - ) - } - - const op = { - outline: "Outline", - extract: "Extract", - deps: "Deps", - scope: "Scope", - }[args.operation] - const detail = - args.operation === "extract" - ? args.name || args.kind || "" - : args.operation === "scope" - ? `line ${line}` - : args.filter || "" - ctx.metadata({ - title: `${op} ${rel}${detail ? ` (${detail})` : ""}`, - }) - const out = await parse(file) - try { - switch (args.operation) { - case "outline": - return outline( - out.tree, - out.lang, - args.filter, - args.includeTests, - ) - case "extract": - return extract( - out.tree, - out.src, - out.lang, - args.kind, - args.name, - args.signature, - ) - case "deps": - return deps(out.tree, out.lang, args.includeTests) - case "scope": - return scope(out.tree, out.lang, line!) - } - } finally { - out.tree.delete() - } - }, - }), - }, - } +export const main = async (args = process.argv.slice(2)) => { + const root = workspaceRootFromArgs(args) + const server = createMcpServer(root) + await server.connect(new StdioServerTransport()) } -export default plugin +if (import.meta.main) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) +} diff --git a/src/scope.ts b/src/scope.ts index 2d53503..0814e2b 100644 --- a/src/scope.ts +++ b/src/scope.ts @@ -23,9 +23,10 @@ const name = (node: Node) => { return firstId(node)?.text } -const text = (node: Node) => { +const text = (node: Node, lang: string) => { const tag = kinds.get(node.type) ?? "scope" - if (tag.endsWith("block") || tag.endsWith("loop")) { + const namedTerraformBlock = lang === "terraform" && node.type === "block" + if (!namedTerraformBlock && (tag.endsWith("block") || tag.endsWith("loop"))) { return `${tag} (lines ${node.startPosition.row + 1}-${node.endPosition.row + 1})` } const id = name(node) @@ -75,7 +76,7 @@ export const scope = (tree: Tree, lang: string, line: number): string => { return a.endIndex - b.endIndex }) const out = all - .map((node, idx) => `${" ".repeat(idx + 1)}${text(node)}`) + .map((node, idx) => `${" ".repeat(idx + 1)}${text(node, lang)}`) .join("\n") return `Line ${line} is inside:\n${out}` } diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..5b84d5b --- /dev/null +++ b/src/server.ts @@ -0,0 +1,110 @@ +import path from "node:path" +import { realpath } from "node:fs/promises" +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import * as z from "zod/v4" +import { deps } from "./deps" +import { extract } from "./extract" +import { outline } from "./outline" +import { parse } from "./parser" +import { scope } from "./scope" + +const toolDescription = `Analyze source code structure with tree-sitter. + +Operations: +- outline: list symbols and line ranges; optionally filter by comma-separated kinds. +- extract: return a symbol's line-numbered source or signature. +- deps: list local and external imports. +- scope: show the enclosing scope chain for a line. + +Prefer outline followed by extract over reading entire large files.` + +export const createMcpServer = (root = process.cwd()) => { + const workspaceRoot = path.resolve(root) + const server = new McpServer({ name: "opencode-ast", version: "0.3.0" }) + + server.registerTool( + "ast", + { + title: "AST code analysis", + description: toolDescription, + inputSchema: z.discriminatedUnion("operation", [ + z.object({ + operation: z.literal("outline"), + filePath: z.string().describe("Path relative to the workspace root"), + filter: z.string().optional(), + includeTests: z.boolean().optional(), + }), + z.object({ + operation: z.literal("extract"), + filePath: z.string().describe("Path relative to the workspace root"), + name: z.string().optional(), + kind: z.string().optional(), + signature: z.boolean().optional(), + }), + z.object({ + operation: z.literal("deps"), + filePath: z.string().describe("Path relative to the workspace root"), + includeTests: z.boolean().optional(), + }), + z.object({ + operation: z.literal("scope"), + filePath: z.string().describe("Path relative to the workspace root"), + line: z.number().int().positive(), + }), + ]), + }, + async (args) => { + const { filePath } = args + const rootPath = await realpath(workspaceRoot).catch(() => workspaceRoot) + const input = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(rootPath, filePath) + const file = await realpath(input).catch(() => input) + const relative = path.relative(rootPath, file) + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`file is outside workspace root: ${filePath}`) + } + const out = await parse(file) + try { + let text: string + switch (args.operation) { + case "outline": + text = outline(out.tree, out.lang, args.filter, args.includeTests) + break + case "extract": + text = extract( + out.tree, + out.src, + out.lang, + args.kind, + args.name, + args.signature, + ) + break + case "deps": + text = deps(out.tree, out.lang, args.includeTests) + break + case "scope": + text = scope(out.tree, out.lang, args.line) + break + } + return { + content: [ + { + type: "text" as const, + text, + }, + ], + } + } finally { + out.tree.delete() + } + }, + ) + + return server +} diff --git a/test/ast.test.ts b/test/ast.test.ts index 14540eb..d311ae9 100644 --- a/test/ast.test.ts +++ b/test/ast.test.ts @@ -1,11 +1,8 @@ import path from "node:path" -import { mkdtemp, realpath, rm, unlink } from "node:fs/promises" -import { tmpdir } from "node:os" +import { unlink } from "node:fs/promises" import { describe, expect, test } from "bun:test" -import { Effect } from "effect" import { deps } from "../src/deps" import { extract } from "../src/extract" -import plugin from "../src/index" import { resolveWorkspaceFile } from "../src/workspace" import { outline } from "../src/outline" import { @@ -164,126 +161,6 @@ describe("workspace", () => { }) }) -describe("permissions", () => { - test("ast asks external_directory for paths outside directory/worktree", async () => { - const hooks = await plugin({} as any) - const astTool = hooks.tool?.ast - expect(astTool).toBeDefined() - - const dir = await mkdtemp(path.join(tmpdir(), "opencode-ast-test-")) - const external = path.join(dir, "external.json") - await Bun.write(external, '{"x":1}\n') - - const asks: Array<{ permission: string; patterns: string[] }> = [] - try { - await astTool!.execute( - { - operation: "outline", - filePath: external, - }, - { - sessionID: "session_test", - messageID: "message_test", - agent: "test", - directory: import.meta.dir, - worktree: process.cwd(), - abort: new AbortController().signal, - metadata() {}, - async ask(input) { - asks.push({ - permission: input.permission, - patterns: input.patterns, - }) - }, - }, - ) - - const externalReq = asks.find( - (x) => x.permission === "external_directory", - ) - expect(externalReq).toBeDefined() - const resolvedDir = await realpath(dir).catch(() => dir) - expect(externalReq!.patterns[0]).toBe( - path.join(resolvedDir, "*").split("\\").join("/"), - ) - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - test("ast does not ask external_directory for paths inside workspace", async () => { - const hooks = await plugin({} as any) - const astTool = hooks.tool?.ast - expect(astTool).toBeDefined() - - const asks: Array<{ permission: string; patterns: string[] }> = [] - await astTool!.execute( - { - operation: "outline", - filePath: ts, - }, - { - sessionID: "session_test", - messageID: "message_test", - agent: "test", - directory: import.meta.dir, - worktree: process.cwd(), - abort: new AbortController().signal, - metadata() {}, - async ask(input) { - asks.push({ permission: input.permission, patterns: input.patterns }) - }, - }, - ) - - const externalReq = asks.find((x) => x.permission === "external_directory") - expect(externalReq).toBeUndefined() - }) - - test("ast runs effect-based external_directory permission requests", async () => { - const hooks = await plugin({} as any) - const astTool = hooks.tool?.ast - expect(astTool).toBeDefined() - - const dir = await mkdtemp(path.join(tmpdir(), "opencode-ast-test-")) - const external = path.join(dir, "external.json") - await Bun.write(external, '{"x":1}\n') - - const asks: Array<{ permission: string; patterns: string[] }> = [] - try { - await astTool!.execute( - { - operation: "outline", - filePath: external, - }, - { - sessionID: "session_test", - messageID: "message_test", - agent: "test", - directory: import.meta.dir, - worktree: process.cwd(), - abort: new AbortController().signal, - metadata() {}, - ask(input) { - return Effect.sync(() => { - asks.push({ - permission: input.permission, - patterns: input.patterns, - }) - }) - }, - }, - ) - - expect( - asks.find((x) => x.permission === "external_directory"), - ).toBeDefined() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) -}) - describe("wasm manifest", () => { test("pins checksums for runtime and grammars", async () => { const json = (await Bun.file(wasmManifest).json()) as { diff --git a/test/mcp.test.ts b/test/mcp.test.ts new file mode 100644 index 0000000..738fa46 --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,194 @@ +import path from "node:path" +import { mkdtemp, rm, symlink } from "node:fs/promises" +import { tmpdir } from "node:os" +import { afterEach, describe, expect, test } from "bun:test" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { createMcpServer } from "../src/server" + +const root = path.join(import.meta.dir, "..") +const clients: Client[] = [] +const servers: McpServer[] = [] + +const connect = async (workspaceRoot = root) => { + const server = createMcpServer(workspaceRoot) + const client = new Client({ name: "opencode-ast-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]) + clients.push(client) + servers.push(server) + return client +} + +const textContent = (result: Awaited>) => + result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n") + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())) + await Promise.all(servers.splice(0).map((server) => server.close())) +}) + +describe("MCP server", () => { + test("registers the ast tool and runs outline requests", async () => { + const client = await connect() + + const tools = await client.listTools() + expect(tools.tools.map((tool) => tool.name)).toEqual(["ast"]) + + const result = await client.callTool({ + name: "ast", + arguments: { + operation: "outline", + filePath: "test/fixtures/example.ts", + }, + }) + + expect(result.isError).not.toBe(true) + expect(textContent(result)).toContain("process (lines 13-18, exported)") + }) + + test("runs extract requests", async () => { + const client = await connect() + + const result = await client.callTool({ + name: "ast", + arguments: { + operation: "extract", + filePath: "test/fixtures/example.ts", + name: "process", + kind: "function", + }, + }) + + expect(result.isError).not.toBe(true) + expect(textContent(result)).toContain( + "13: export function process(input: string): Config {", + ) + }) + + test("runs dependency requests", async () => { + const client = await connect() + + const result = await client.callTool({ + name: "ast", + arguments: { + operation: "deps", + filePath: "test/fixtures/example.ts", + }, + }) + + expect(result.isError).not.toBe(true) + expect(textContent(result)).toContain("./foo") + }) + + test("runs scope requests", async () => { + const client = await connect() + + const result = await client.callTool({ + name: "ast", + arguments: { + operation: "scope", + filePath: "test/fixtures/example.ts", + line: 15, + }, + }) + + expect(result.isError).not.toBe(true) + expect(textContent(result)).toContain("process (function, lines 13-18)") + }) + + test("rejects files outside the configured workspace root", async () => { + const client = await connect() + const dir = await mkdtemp(path.join(tmpdir(), "opencode-ast-mcp-")) + const external = path.join(dir, "external.ts") + await Bun.write(external, "export const secret = 1\n") + + try { + const result = await client.callTool({ + name: "ast", + arguments: { + operation: "outline", + filePath: external, + }, + }) + + expect(result.isError).toBe(true) + expect(textContent(result)).toContain("outside workspace root") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("rejects parent-directory traversal outside the workspace root", async () => { + const client = await connect(path.join(root, "test", "fixtures")) + + const result = await client.callTool({ + name: "ast", + arguments: { + operation: "outline", + filePath: "../../src/index.ts", + }, + }) + + expect(result.isError).toBe(true) + expect(textContent(result)).toContain("outside workspace root") + }) + + test("rejects symlinks that resolve outside the workspace root", async () => { + const workspace = await mkdtemp(path.join(tmpdir(), "opencode-ast-root-")) + const externalDir = await mkdtemp(path.join(tmpdir(), "opencode-ast-external-")) + const external = path.join(externalDir, "external.ts") + const link = path.join(workspace, "linked.ts") + await Bun.write(external, "export const secret = 1\n") + await symlink(external, link) + + try { + const client = await connect(workspace) + const result = await client.callTool({ + name: "ast", + arguments: { + operation: "outline", + filePath: "linked.ts", + }, + }) + + expect(result.isError).toBe(true) + expect(textContent(result)).toContain("outside workspace root") + } finally { + await Promise.all([ + rm(workspace, { recursive: true, force: true }), + rm(externalDir, { recursive: true, force: true }), + ]) + } + }) + + test("serves MCP over stdio from the command-line entrypoint", async () => { + const client = new Client({ name: "opencode-ast-cli-test", version: "1.0.0" }) + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(root, "src", "index.ts"), "--root", root], + stderr: "pipe", + }) + await client.connect(transport) + clients.push(client) + + const result = await client.callTool({ + name: "ast", + arguments: { + operation: "outline", + filePath: "test/fixtures/example.ts", + }, + }) + + expect(result.isError).not.toBe(true) + expect(textContent(result)).toContain("process (lines 13-18, exported)") + }) +})