diff --git a/README.md b/README.md index d35e177..107e246 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,9 @@ sources: overlap_lines: 10 ``` -### Tools +### Search Tools -Each tool maps to a source and defines the MCP tool interface: +Each search tool maps to a source and defines the MCP tool interface: ```yaml tools: @@ -83,6 +83,36 @@ tools: result_format: docs ``` +### Collect Tools + +Collect tools let agents write structured data back to the server. Unlike search tools, they don't query anything — they validate the agent's input against a YAML-defined schema and store it as JSONB in the database. Use them to gather signal from agents without writing any code. + +The first built-in use case is search feedback: agents report whether search results were helpful, what they tried, and what went wrong. This surfaces broken or misleading documentation quickly. But collect tools are generic — you can define any schema for any use case (e.g., broken link reporting, feature requests, error logging). + +```yaml +tools: + - name: submit-feedback + type: collect + description: "Submit feedback on whether search results were helpful." + response: "Feedback recorded. Thank you." + schema: + tool_name: + type: string + description: "Which search tool was used" + required: true + rating: + type: enum + values: ["helpful", "not_helpful"] + description: "Whether the results were helpful" + required: true + comment: + type: string + description: "What worked or didn't work" + required: true +``` + +Each field in `schema` supports `type` (`string`, `number`, or `enum`), an optional `description` (shown to the agent), `required` (defaults to false), and `values` (required for `enum` fields). The validated input is written as JSONB to the `collected_data` table along with the tool name and a timestamp. + ### Built-in Chunker Types | Type | Best For | Splits On | @@ -187,6 +217,9 @@ docker compose up # Seed index docker compose exec app npx tsx scripts/seed-index.ts +# Run unit tests +npm test + # Test search docker compose exec app npx tsx scripts/test-search.ts "your query" diff --git a/mcp-docs.example.yaml b/mcp-docs.example.yaml index a5b42e6..8d556d6 100644 --- a/mcp-docs.example.yaml +++ b/mcp-docs.example.yaml @@ -24,6 +24,27 @@ tools: max_limit: 20 result_format: docs + # Collect tools write structured data from agents to the database. + # Define input schema, description, and a canned response — no code needed. + - name: submit-feedback + type: collect + description: "Submit feedback on whether search results were helpful." + response: "Feedback recorded. Thank you." + schema: + tool_name: + type: string + description: "Which search tool was used" + required: true + rating: + type: enum + values: ["helpful", "not_helpful"] + description: "Whether the results were helpful" + required: true + comment: + type: string + description: "What worked or didn't work" + required: true + embedding: provider: openai model: text-embedding-3-small diff --git a/mcp-docs.yaml b/mcp-docs.yaml index c514be4..0fb8621 100644 --- a/mcp-docs.yaml +++ b/mcp-docs.yaml @@ -118,6 +118,29 @@ tools: max_limit: 20 result_format: code + - name: submit-feedback + type: collect + description: "After using search results, you can submit feedback on whether the advice worked or not. Report what you tried and whether it succeeded or failed. If something failed but you later found the missing information elsewhere, mention that too. This feedback helps improve search quality." + response: "Feedback recorded. Thank you." + schema: + tool_name: + type: string + description: "Which search tool was used" + required: true + query: + type: string + description: "The original search query" + required: true + rating: + type: enum + values: ["helpful", "not_helpful"] + description: "Whether the results were helpful" + required: true + comment: + type: string + description: "What was tried, what failed/worked, what info was missing" + required: true + embedding: provider: openai model: text-embedding-3-small diff --git a/package-lock.json b/package-lock.json index 192cfba..4346803 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,45 @@ "@types/node": "^25.0.6", "@types/pg": "^8.11.10", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.2" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -482,6 +520,13 @@ "hono": "^4" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -537,6 +582,315 @@ } } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -548,6 +902,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -568,6 +933,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -643,27 +1022,140 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/abort-controller": { @@ -736,6 +1228,16 @@ } } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -804,6 +1306,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -838,6 +1350,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -922,6 +1441,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -981,6 +1510,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1056,6 +1592,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1095,6 +1641,16 @@ "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", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1178,6 +1734,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1467,47 +2041,318 @@ "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", + "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-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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "license": "MIT", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -1570,6 +2415,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1640,6 +2504,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1734,6 +2609,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -1832,6 +2714,26 @@ "node": ">= 18" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "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", @@ -1841,6 +2743,35 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1951,6 +2882,40 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2117,6 +3082,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-git": { "version": "3.33.0", "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", @@ -2132,6 +3104,16 @@ "url": "https://github.com/steveukx/git-js?sponsor=1" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2141,6 +3123,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2150,6 +3139,57 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2165,6 +3205,14 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2237,6 +3285,166 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", @@ -2277,6 +3485,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index f8b495a..527451a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dev": "tsx watch src/index.ts", "seed-index": "tsx scripts/seed-index.ts", "test-search": "tsx scripts/test-search.ts", - "integration-test": "tsx scripts/integration-test.ts" + "integration-test": "tsx scripts/integration-test.ts", + "test": "vitest run" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", @@ -30,6 +31,7 @@ "@types/node": "^25.0.6", "@types/pg": "^8.11.10", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.2" } } diff --git a/src/__tests__/collect-mcp.test.ts b/src/__tests__/collect-mcp.test.ts new file mode 100644 index 0000000..b8ddffa --- /dev/null +++ b/src/__tests__/collect-mcp.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { registerCollectTool } from '../mcp/tools/collect.js'; +import type { CollectToolConfig } from '../types.js'; + +vi.mock('../db/queries.js', () => ({ + insertCollectedData: vi.fn(), +})); + +import { insertCollectedData } from '../db/queries.js'; + +const toolConfig: CollectToolConfig = { + name: 'submit-feedback', + type: 'collect', + description: 'Submit feedback on search results.', + response: 'Feedback recorded. Thank you.', + schema: { + tool_name: { type: 'string', description: 'Which search tool', required: true }, + query: { type: 'string', description: 'The query', required: true }, + rating: { type: 'enum', values: ['helpful', 'not_helpful'], description: 'Rating', required: true }, + comment: { type: 'string', description: 'Details', required: true }, + }, +}; + +describe('collect tool via MCP protocol', () => { + let client: Client; + let server: McpServer; + + beforeAll(async () => { + server = new McpServer({ name: 'test', version: '1.0.0' }); + registerCollectTool(server, toolConfig); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + client = new Client({ name: 'test-client', version: '1.0.0' }); + await client.connect(clientTransport); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterAll(async () => { + await client.close(); + await server.close(); + }); + + it('lists the collect tool', async () => { + const { tools } = await client.listTools(); + const tool = tools.find(t => t.name === 'submit-feedback'); + + expect(tool).toBeDefined(); + expect(tool!.description).toBe(toolConfig.description); + expect(tool!.inputSchema.properties).toHaveProperty('tool_name'); + expect(tool!.inputSchema.properties).toHaveProperty('rating'); + }); + + it('calls the tool, returns canned response, and writes to DB', async () => { + const args = { + tool_name: 'search-docs', + query: 'how to auth', + rating: 'not_helpful', + comment: 'Docs referenced a deprecated API', + }; + + const result = await client.callTool({ + name: 'submit-feedback', + arguments: args, + }); + + expect(result.isError).toBeFalsy(); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toBe('Feedback recorded. Thank you.'); + + expect(insertCollectedData).toHaveBeenCalledWith('submit-feedback', args); + }); + + it('returns generic error on DB failure', async () => { + vi.mocked(insertCollectedData).mockRejectedValueOnce( + new Error('connection refused to 10.0.0.5:5432'), + ); + + const result = await client.callTool({ + name: 'submit-feedback', + arguments: { + tool_name: 'search-docs', + query: 'test', + rating: 'helpful', + comment: 'worked', + }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + expect(text).toBe('Error: Failed to store data. Please try again later.'); + expect(text).not.toContain('10.0.0.5'); + }); + + it('rejects invalid input', async () => { + const result = await client.callTool({ + name: 'submit-feedback', + arguments: { + tool_name: 'search-docs', + // missing required fields + }, + }); + + expect(result.isError).toBe(true); + }); +}); diff --git a/src/__tests__/collect.test.ts b/src/__tests__/collect.test.ts new file mode 100644 index 0000000..8bf7e48 --- /dev/null +++ b/src/__tests__/collect.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { yamlSchemaToZod } from '../mcp/tools/collect.js'; +import type { CollectToolConfig } from '../types.js'; + +type Schema = CollectToolConfig['schema']; + +describe('yamlSchemaToZod', () => { + it('converts string fields', () => { + const schema: Schema = { + name: { type: 'string', required: true }, + }; + const shape = yamlSchemaToZod(schema); + const zod = z.object(shape); + + expect(zod.parse({ name: 'hello' })).toEqual({ name: 'hello' }); + expect(() => zod.parse({ name: 123 })).toThrow(); + expect(() => zod.parse({})).toThrow(); + }); + + it('converts number fields', () => { + const schema: Schema = { + count: { type: 'number', required: true }, + }; + const shape = yamlSchemaToZod(schema); + const zod = z.object(shape); + + expect(zod.parse({ count: 42 })).toEqual({ count: 42 }); + expect(() => zod.parse({ count: 'not a number' })).toThrow(); + }); + + it('converts enum fields', () => { + const schema: Schema = { + rating: { type: 'enum', values: ['good', 'bad'], required: true }, + }; + const shape = yamlSchemaToZod(schema); + const zod = z.object(shape); + + expect(zod.parse({ rating: 'good' })).toEqual({ rating: 'good' }); + expect(() => zod.parse({ rating: 'neutral' })).toThrow(); + }); + + it('makes fields optional when required is false', () => { + const schema: Schema = { + comment: { type: 'string', required: false }, + }; + const shape = yamlSchemaToZod(schema); + const zod = z.object(shape); + + expect(zod.parse({})).toEqual({}); + expect(zod.parse({ comment: 'hi' })).toEqual({ comment: 'hi' }); + }); + + it('makes fields optional when required is omitted', () => { + const schema: Schema = { + comment: { type: 'string' }, + }; + const shape = yamlSchemaToZod(schema); + const zod = z.object(shape); + + expect(zod.parse({})).toEqual({}); + }); + + it('attaches descriptions', () => { + const schema: Schema = { + query: { type: 'string', description: 'The search query', required: true }, + }; + const shape = yamlSchemaToZod(schema); + + expect(shape.query.description).toBe('The search query'); + }); + + it('rejects invalid inputs against the feedback schema', () => { + const schema: Schema = { + tool_name: { type: 'string', required: true }, + query: { type: 'string', required: true }, + rating: { type: 'enum', values: ['helpful', 'not_helpful'], required: true }, + comment: { type: 'string', required: true }, + }; + const shape = yamlSchemaToZod(schema); + const zod = z.object(shape); + + // completely empty + expect(zod.safeParse({}).success).toBe(false); + + // missing required fields + expect(zod.safeParse({ tool_name: 'search-docs' }).success).toBe(false); + + // wrong type for string field + expect(zod.safeParse({ + tool_name: 123, query: 'test', rating: 'helpful', comment: 'ok', + }).success).toBe(false); + + // invalid enum value + expect(zod.safeParse({ + tool_name: 'search-docs', query: 'test', rating: 'meh', comment: 'ok', + }).success).toBe(false); + + // wrong type for enum field + expect(zod.safeParse({ + tool_name: 'search-docs', query: 'test', rating: 42, comment: 'ok', + }).success).toBe(false); + }); + + it('throws on unsupported field type', () => { + const schema = { + flag: { type: 'boolean' as unknown as 'string', required: true }, + }; + expect(() => yamlSchemaToZod(schema)).toThrow('Unsupported field type "boolean" for field "flag"'); + }); + + it('handles a full feedback schema', () => { + const schema: Schema = { + tool_name: { type: 'string', description: 'Which tool', required: true }, + query: { type: 'string', description: 'The query', required: true }, + rating: { type: 'enum', values: ['helpful', 'not_helpful'], description: 'Rating', required: true }, + comment: { type: 'string', description: 'Details', required: true }, + }; + const shape = yamlSchemaToZod(schema); + const zod = z.object(shape); + + const valid = { tool_name: 'search-docs', query: 'how to auth', rating: 'helpful', comment: 'worked great' }; + expect(zod.parse(valid)).toEqual(valid); + + expect(() => zod.parse({ ...valid, rating: 'meh' })).toThrow(); + expect(() => zod.parse({ tool_name: 'search-docs', query: 'test', rating: 'helpful' })).toThrow(); + }); +}); diff --git a/src/__tests__/tool-config.test.ts b/src/__tests__/tool-config.test.ts new file mode 100644 index 0000000..7f4a235 --- /dev/null +++ b/src/__tests__/tool-config.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest'; +import { CollectToolConfigSchema, AnyToolConfigSchema, ServerConfigSchema } from '../types.js'; + +describe('CollectToolConfigSchema', () => { + const validCollect = { + name: 'submit-feedback', + type: 'collect' as const, + description: 'Submit feedback', + response: 'Thanks!', + schema: { + rating: { type: 'enum' as const, values: ['good', 'bad'], required: true }, + }, + }; + + it('parses a valid collect tool config', () => { + const result = CollectToolConfigSchema.safeParse(validCollect); + expect(result.success).toBe(true); + }); + + it('rejects missing name', () => { + const { name, ...rest } = validCollect; + expect(CollectToolConfigSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing description', () => { + const { description, ...rest } = validCollect; + expect(CollectToolConfigSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing response', () => { + const { response, ...rest } = validCollect; + expect(CollectToolConfigSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects missing schema', () => { + const { schema, ...rest } = validCollect; + expect(CollectToolConfigSchema.safeParse(rest).success).toBe(false); + }); + + it('rejects enum field without values', () => { + const config = { + ...validCollect, + schema: { + rating: { type: 'enum' as const, required: true }, + }, + }; + expect(CollectToolConfigSchema.safeParse(config).success).toBe(false); + }); + + it('rejects enum field with empty values', () => { + const config = { + ...validCollect, + schema: { + rating: { type: 'enum' as const, values: [], required: true }, + }, + }; + expect(CollectToolConfigSchema.safeParse(config).success).toBe(false); + }); + + it('rejects unknown field type', () => { + const config = { + ...validCollect, + schema: { + data: { type: 'boolean', required: true }, + }, + }; + expect(CollectToolConfigSchema.safeParse(config).success).toBe(false); + }); + + it('rejects values on non-enum fields', () => { + const config = { + ...validCollect, + schema: { + name: { type: 'string' as const, values: ['a', 'b'], required: true }, + }, + }; + expect(CollectToolConfigSchema.safeParse(config).success).toBe(false); + }); +}); + +describe('AnyToolConfigSchema', () => { + it('parses a search tool with explicit type', () => { + const config = { + name: 'search-docs', + type: 'search', + description: 'Search docs', + source: 'docs', + default_limit: 5, + max_limit: 20, + result_format: 'docs', + }; + const result = AnyToolConfigSchema.safeParse(config); + expect(result.success).toBe(true); + if (result.success) expect(result.data.type).toBe('search'); + }); + + it('parses a collect tool', () => { + const config = { + name: 'feedback', + type: 'collect', + description: 'Give feedback', + response: 'OK', + schema: { note: { type: 'string' } }, + }; + const result = AnyToolConfigSchema.safeParse(config); + expect(result.success).toBe(true); + if (result.success) expect(result.data.type).toBe('collect'); + }); + + it('rejects unknown tool type', () => { + const config = { + name: 'mystery', + type: 'magic', + description: 'Does magic', + }; + expect(AnyToolConfigSchema.safeParse(config).success).toBe(false); + }); + + it('rejects collect tool with empty schema', () => { + const config = { + name: 'feedback', + type: 'collect', + description: 'Give feedback', + response: 'OK', + schema: {}, + }; + expect(AnyToolConfigSchema.safeParse(config).success).toBe(false); + }); + + it('discriminates correctly between search and collect fields', () => { + // A collect tool should not need source/limits + const collect = { + name: 'feedback', + type: 'collect', + description: 'Give feedback', + response: 'OK', + schema: { note: { type: 'string' } }, + }; + expect(AnyToolConfigSchema.safeParse(collect).success).toBe(true); + + // A search tool should not need response/schema + const search = { + name: 'search', + type: 'search', + description: 'Search', + source: 'docs', + default_limit: 5, + max_limit: 20, + result_format: 'docs', + }; + expect(AnyToolConfigSchema.safeParse(search).success).toBe(true); + }); +}); + +describe('backwards-compat config defaulting', () => { + it('injects type "search" for tools missing a type field', () => { + // Mirrors the defaulting loop in loadServerConfig() from config.ts + const tools: Record[] = [ + { + name: 'search-docs', + description: 'Search docs', + source: 'docs', + default_limit: 5, + max_limit: 20, + result_format: 'docs', + }, + ]; + + for (const tool of tools) { + if (typeof tool === 'object' && tool !== null && !('type' in tool)) { + (tool as Record).type = 'search'; + } + } + + const result = AnyToolConfigSchema.safeParse(tools[0]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('search'); + } + }); + + it('does not overwrite an explicit type field', () => { + const tools: Record[] = [ + { + name: 'feedback', + type: 'collect', + description: 'Give feedback', + response: 'OK', + schema: { note: { type: 'string' } }, + }, + ]; + + for (const tool of tools) { + if (typeof tool === 'object' && tool !== null && !('type' in tool)) { + (tool as Record).type = 'search'; + } + } + + const result = AnyToolConfigSchema.safeParse(tools[0]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('collect'); + } + }); +}); + +describe('ServerConfigSchema', () => { + const minimalConfig = { + server: { name: 'test', version: '1.0.0' }, + sources: [{ + name: 'docs', + type: 'markdown', + repo: 'https://github.com/test/test.git', + path: 'docs/', + file_patterns: ['**/*.md'], + chunk: { target_tokens: 600, overlap_tokens: 50 }, + }], + embedding: { provider: 'openai', model: 'text-embedding-3-small', dimensions: 1536 }, + indexing: { auto_reindex: true, reindex_hour_utc: 3, stale_threshold_hours: 24 }, + }; + + it('rejects search tool where default_limit > max_limit', () => { + const config = { + ...minimalConfig, + tools: [{ + name: 'search-docs', + type: 'search', + description: 'Search', + source: 'docs', + default_limit: 30, + max_limit: 10, + result_format: 'docs', + }], + }; + const result = ServerConfigSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('accepts search tool where default_limit <= max_limit', () => { + const config = { + ...minimalConfig, + tools: [{ + name: 'search-docs', + type: 'search', + description: 'Search', + source: 'docs', + default_limit: 5, + max_limit: 20, + result_format: 'docs', + }], + }; + const result = ServerConfigSchema.safeParse(config); + expect(result.success).toBe(true); + }); +}); diff --git a/src/config.ts b/src/config.ts index 55962de..317811c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -99,6 +99,16 @@ function loadServerConfig(): ServerConfig { const raw = readFileSync(configPath, 'utf-8'); const parsed = parseYaml(raw); + // Default tool type to 'search' for backwards compatibility + if (Array.isArray(parsed?.tools)) { + for (const tool of parsed.tools) { + if (tool && typeof tool === 'object' && !('type' in tool)) { + console.warn(`[config] Tool "${tool.name}" has no type field — defaulting to "search". Add "type: search" explicitly to silence this warning.`); + tool.type = 'search'; + } + } + } + const result = ServerConfigSchema.safeParse(parsed); if (!result.success) { const issues = result.error.issues @@ -113,8 +123,15 @@ function loadServerConfig(): ServerConfig { throw new Error('Duplicate source names found in sources configuration.'); } - // Cross-validate: every tool.source must reference an existing source name - for (const tool of result.data.tools) { + // Validate tool name uniqueness + const toolNames = new Set(result.data.tools.map(t => t.name)); + if (toolNames.size !== result.data.tools.length) { + throw new Error('Duplicate tool names found in tools configuration.'); + } + + // Cross-validate: every search tool's source must reference an existing source name + const searchTools = result.data.tools.filter(t => t.type === 'search'); + for (const tool of searchTools) { if (!sourceNames.has(tool.source)) { throw new Error( `Tool "${tool.name}" references source "${tool.source}" which is not defined in sources.` diff --git a/src/db/queries.ts b/src/db/queries.ts index 5e32b92..b0b72e0 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -226,6 +226,24 @@ export async function upsertIndexState(state: IndexState): Promise { ]); } +// --------------------------------------------------------------------------- +// Collected data +// --------------------------------------------------------------------------- + +/** + * Insert a row into the collected_data table. + */ +export async function insertCollectedData( + toolName: string, + data: Record, +): Promise { + const pool = getPool(); + await pool.query( + "INSERT INTO collected_data (tool_name, data) VALUES ($1, $2)", + [toolName, JSON.stringify(data)], + ); +} + // --------------------------------------------------------------------------- // Statistics // --------------------------------------------------------------------------- diff --git a/src/db/schema.ts b/src/db/schema.ts index 5164e7b..47062b8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -42,6 +42,13 @@ CREATE TABLE IF NOT EXISTS index_state ( CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON chunks USING hnsw (embedding vector_cosine_ops); CREATE INDEX IF NOT EXISTS idx_chunks_source_name ON chunks (source_name); CREATE INDEX IF NOT EXISTS idx_chunks_repo_url ON chunks (repo_url); + +CREATE TABLE IF NOT EXISTS collected_data ( + id SERIAL PRIMARY KEY, + tool_name TEXT NOT NULL, + data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); `; } diff --git a/src/index.ts b/src/index.ts index a863aea..f68bd75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,10 +93,16 @@ app.post("/mcp", async (req: Request, res: Response) => { const params = req.body?.params as Record | undefined; const toolName = params?.name ?? 'unknown'; const args = params?.arguments as Record | undefined; - const query = args?.query ?? ''; - const limit = args?.limit; - const extra = limit ? ` limit=${limit}` : ''; - console.log(`[mcp] ${toolName}("${query}"${extra}) [${ip}]`); + const toolCfg = getServerConfig().tools.find(t => t.name === toolName); + if (toolCfg?.type === 'collect') { + const dataPreview = JSON.stringify(args ?? {}).slice(0, 200); + console.log(`[mcp] ${toolName}(${dataPreview}) [${ip}]`); + } else { + const query = args?.query ?? ''; + const limit = args?.limit; + const extra = limit ? ` limit=${limit}` : ''; + console.log(`[mcp] ${toolName}("${query}"${extra}) [${ip}]`); + } } else if (method === 'tools/list') { console.log(`[mcp] tools/list [${ip}]`); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4bc5af0..a47bdbc 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { EmbeddingClient } from '../indexing/embeddings.js'; import { getConfig, getServerConfig } from '../config.js'; import { registerSearchTool } from './tools/search.js'; +import { registerCollectTool } from './tools/collect.js'; /** * Creates a new McpServer instance with all tools registered. @@ -23,7 +24,19 @@ export function createMcpServer(): McpServer { }); for (const tool of serverCfg.tools) { - registerSearchTool(server, embeddingClient, tool); + const toolType = tool.type; + switch (toolType) { + case 'collect': + registerCollectTool(server, tool); + break; + case 'search': + registerSearchTool(server, embeddingClient, tool); + break; + default: { + const _exhaustive: never = toolType; + throw new Error(`Unknown tool type "${_exhaustive}" for tool "${(tool as any).name}"`); + } + } } return server; diff --git a/src/mcp/tools/collect.ts b/src/mcp/tools/collect.ts new file mode 100644 index 0000000..3698557 --- /dev/null +++ b/src/mcp/tools/collect.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CollectToolConfig } from '../../types.js'; +import { insertCollectedData } from '../../db/queries.js'; + +type FieldDef = CollectToolConfig['schema'][string]; + +/** + * Convert a YAML-defined schema (record of field definitions) into a Zod shape record. + */ +export function yamlSchemaToZod(schema: CollectToolConfig['schema']): Record { + const shape: Record = {}; + + for (const [fieldName, field] of Object.entries(schema) as [string, FieldDef][]) { + let fieldSchema: z.ZodTypeAny; + + switch (field.type) { + case 'string': + fieldSchema = z.string(); + break; + case 'number': + fieldSchema = z.number(); + break; + case 'enum': + fieldSchema = z.enum(field.values as [string, ...string[]]); + break; + default: + throw new Error(`Unsupported field type "${field.type}" for field "${fieldName}"`); + } + + if (field.description) { + fieldSchema = fieldSchema.describe(field.description); + } + + if (!field.required) { + fieldSchema = fieldSchema.optional(); + } + + shape[fieldName] = fieldSchema; + } + + return shape; +} + +/** + * Register a collect tool on the MCP server. + * The tool validates inputs against the YAML-defined schema and writes to the DB. + * On DB failure, logs the error detail server-side and returns a generic error to the caller. + */ +export function registerCollectTool( + server: McpServer, + toolConfig: CollectToolConfig, +): void { + const zodShape = yamlSchemaToZod(toolConfig.schema); + + server.tool( + toolConfig.name, + toolConfig.description, + zodShape, + async (input) => { + try { + await insertCollectedData(toolConfig.name, input); + return { + content: [{ type: "text" as const, text: toolConfig.response }], + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error(`[${toolConfig.name}] Error inserting collected data: ${detail}`); + return { + content: [{ type: "text" as const, text: "Error: Failed to store data. Please try again later." }], + isError: true, + }; + } + }, + ); +} diff --git a/src/mcp/tools/search.ts b/src/mcp/tools/search.ts index ac13803..6c7845f 100644 --- a/src/mcp/tools/search.ts +++ b/src/mcp/tools/search.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { EmbeddingClient } from '../../indexing/embeddings.js'; -import type { ToolConfig, ChunkResult } from '../../types.js'; +import type { SearchToolConfig, ChunkResult } from '../../types.js'; import { searchChunks } from '../../db/queries.js'; function formatDocsResults(results: ChunkResult[]): string { @@ -47,7 +47,7 @@ function formatResults(results: ChunkResult[], format: string): string { export function registerSearchTool( server: McpServer, embeddingClient: EmbeddingClient, - toolConfig: ToolConfig, + toolConfig: SearchToolConfig, ): void { const inputSchema = { query: z.string().describe("The search query"), @@ -69,10 +69,10 @@ export function registerSearchTool( content: [{ type: "text" as const, text: formatResults(results, toolConfig.result_format) }], }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[${toolConfig.name}] Error: ${message}`); + const detail = error instanceof Error ? error.message : String(error); + console.error(`[${toolConfig.name}] Error: ${detail}`); return { - content: [{ type: "text" as const, text: `Error: ${message}` }], + content: [{ type: "text" as const, text: "Error: Search failed. Please try again later." }], isError: true, }; } diff --git a/src/types.ts b/src/types.ts index d49ae18..2dbd4af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,17 +39,47 @@ export const SourceConfigSchema = z.object({ // ── Tool configuration schemas ──────────────────────────────────────────────── -export const ToolConfigSchema = z.object({ +const SearchToolConfigObjectSchema = z.object({ name: z.string().min(1), + type: z.literal('search'), description: z.string().min(1), source: z.string().min(1), default_limit: z.number().int().positive(), max_limit: z.number().int().positive(), result_format: z.enum(['docs', 'code', 'raw']), -}).refine(t => t.default_limit <= t.max_limit, { - message: 'default_limit must not exceed max_limit', }); +// SearchToolConfig type is inferred from the object schema directly. +// Cross-field validation (default_limit <= max_limit) lives in ServerConfigSchema.superRefine. +export const SearchToolConfigSchema = SearchToolConfigObjectSchema; + +export const CollectToolConfigSchema = z.object({ + name: z.string().min(1), + type: z.literal('collect'), + description: z.string().min(1), + response: z.string().min(1), + schema: z.record(z.string(), z.object({ + type: z.enum(['string', 'number', 'enum']), + description: z.string().optional(), + required: z.boolean().optional(), + values: z.array(z.string()).optional(), + }).refine(f => f.type !== 'enum' || (f.values && f.values.length > 0), { + message: 'enum fields must have a non-empty values array', + }).refine(f => f.type === 'enum' || !f.values, { + message: 'values is only valid for enum fields', + })).refine( + s => Object.keys(s).length > 0, + { message: 'collect tool schema must define at least one field' }, + ), +}); + +// Cross-field constraints (e.g. default_limit <= max_limit for search tools) +// are enforced in ServerConfigSchema.superRefine, not here. +export const AnyToolConfigSchema = z.discriminatedUnion('type', [ + SearchToolConfigObjectSchema, + CollectToolConfigSchema, +]); + // ── Embedding configuration schemas ─────────────────────────────────────────── export const EmbeddingConfigSchema = z.object({ @@ -81,10 +111,20 @@ export const ServerConfigSchema = z.object({ version: z.string().min(1), }), sources: z.array(SourceConfigSchema).min(1), - tools: z.array(ToolConfigSchema).min(1), + tools: z.array(AnyToolConfigSchema).min(1), embedding: EmbeddingConfigSchema, indexing: IndexingConfigSchema, webhook: WebhookConfigSchema.optional(), +}).superRefine((cfg, ctx) => { + for (const tool of cfg.tools) { + if (tool.type === 'search' && tool.default_limit > tool.max_limit) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Tool "${tool.name}": default_limit must not exceed max_limit`, + path: ['tools'], + }); + } + } }); // ── Inferred TypeScript types from Zod schemas ──────────────────────────────── @@ -92,7 +132,8 @@ export const ServerConfigSchema = z.object({ export type UrlDerivationConfig = z.infer; export type ChunkConfig = z.infer; export type SourceConfig = z.infer; -export type ToolConfig = z.infer; +export type SearchToolConfig = z.infer; +export type CollectToolConfig = z.infer; export type EmbeddingConfig = z.infer; export type IndexingConfig = z.infer; export type WebhookConfig = z.infer;