diff --git a/client-tests/mcp-ts/package-lock.json b/client-tests/mcp-ts/package-lock.json new file mode 100644 index 0000000000..2264cc73a1 --- /dev/null +++ b/client-tests/mcp-ts/package-lock.json @@ -0,0 +1,2809 @@ +{ + "name": "mcp-ts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-ts", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "^22.19.15", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "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/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/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/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "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/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "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", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "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", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "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", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "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", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.10", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz", + "integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "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", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "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/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/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", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "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", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "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", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/client-tests/mcp-ts/package.json b/client-tests/mcp-ts/package.json new file mode 100644 index 0000000000..24cbc3ca30 --- /dev/null +++ b/client-tests/mcp-ts/package.json @@ -0,0 +1,20 @@ +{ + "name": "mcp-ts", + "version": "1.0.0", + "description": "MCP OAuth scope enforcement E2E tests using the official MCP TypeScript SDK", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest watch" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "packageManager": "pnpm@9.12.3", + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "^22.19.15", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts b/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts new file mode 100644 index 0000000000..98e71c0e95 --- /dev/null +++ b/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts @@ -0,0 +1,641 @@ +/** + * MCP OAuth Scope Enforcement — E2E Tests + * + * Validates the Cosmo Router's MCP OAuth 2.1 scope enforcement from an MCP + * client's perspective, using raw HTTP requests and the official MCP TypeScript + * SDK. The router enforces scopes at five additive levels; these tests verify + * each level returns the correct HTTP status and WWW-Authenticate challenge. + * + * ## Test Sections + * + * A. Metadata Discovery + * - Protected Resource Metadata (RFC 9728) exposes scopes_supported + * - Authorization Server Metadata (RFC 8414) exposes endpoints + * + * B. Dynamic Client Registration (DCR) + * - Register a new client via RFC 7591 and obtain tokens + * + * C. Operation-Level Scope Enforcement + * - initialize requires mcp:connect + * - tools/list requires mcp:tools:read + * - tools/list rejected without mcp:tools:read (403) + * + * D. Per-Tool Scope Enforcement (@requiresScopes) + * - Scoped tools rejected without per-tool scopes (403) + * - Scoped tools allowed with correct OR-of-AND scope group + * - Unscoped tools allowed with base scopes + * - scope_challenge_include_token_scopes includes held scopes in challenge + * + * E. tools_call Gate + * - tools/call rejected without mcp:tools:call (403) + * + * F. Built-in Tool Scope Enforcement + * - execute_graphql requires mcp:graphql:execute + * - get_schema requires mcp:schema:read + * - get_operation_info requires mcp:ops:read + * - Each tested with reject (403) and allow (200) + * + * G. MCP SDK Client E2E (ClientCredentialsProvider) + * - Full connect → list → call flow via the MCP TypeScript SDK + * - Verifies scoped tool throws 403 when scopes are missing + * + * ## Scope Hierarchy (mcp.test.config.yaml) + * + * | Level | Scopes | Gates | + * |--------------------|-------------------------|------------------------------------| + * | Initialize | mcp:connect | All HTTP requests | + * | tools/list | mcp:tools:read | Discovering tools | + * | tools/call (any) | mcp:tools:call | Calling any tool | + * | execute_graphql | mcp:graphql:execute | Arbitrary GraphQL queries | + * | get_schema | mcp:schema:read | Introspecting the schema | + * | get_operation_info | mcp:ops:read | Viewing operation metadata | + * | Per-tool | @requiresScopes scopes | Calling specific scoped operations | + * + * ## Prerequisites + * + * 1. Start the test OAuth server: + * go run ./router-tests/cmd/oauth-server + * 2. Edit router/mcp.oauth.config.yaml to point at your own execution + * config and MCP operations directory (see the EDIT ME markers), then + * start the router (from the router/ directory): + * go run ./cmd/router -config mcp.oauth.config.yaml + * 3. Run the tests: + * cd client-tests/mcp-ts && pnpm test + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { ClientCredentialsProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; +import { describe, it, expect, beforeAll } from 'vitest'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const MCP_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:5026/mcp'; +const MCP_BASE_URL = MCP_SERVER_URL.replace(/\/mcp$/, ''); +const OAUTH_SERVER_URL = process.env.OAUTH_SERVER_URL || 'http://localhost:9099'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Fetch JSON from a URL. */ +async function fetchJSON(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${url}`); + return res.json() as Promise; +} + +/** Register a new OAuth client via DCR. Returns client_id + client_secret. */ +async function registerClient(registrationEndpoint: string): Promise<{ clientId: string; clientSecret: string }> { + const res = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'MCP E2E Test (DCR)', + redirect_uris: ['http://localhost:6274/oauth/callback'], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'client_secret_basic', + }), + }); + if (!res.ok) throw new Error(`DCR failed: ${res.status}`); + const body = (await res.json()) as { client_id: string; client_secret: string }; + return { clientId: body.client_id, clientSecret: body.client_secret }; +} + +/** Get an access token via client_credentials grant (Basic auth). */ +async function getToken( + tokenEndpoint: string, + clientId: string, + clientSecret: string, + scope: string, +): Promise<{ access_token: string; scope: string }> { + const res = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + btoa(`${clientId}:${clientSecret}`), + }, + body: new URLSearchParams({ grant_type: 'client_credentials', scope }), + }); + if (!res.ok) throw new Error(`Token request failed: ${res.status}`); + return res.json() as Promise<{ access_token: string; scope: string }>; +} + +/** + * Send a raw JSON-RPC request to the MCP endpoint and return the HTTP response. + * Does NOT follow the SSE stream — returns the raw response for header inspection. + */ +async function rawMcpRequest(token: string, sessionId: string | null, body: object): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + if (sessionId) headers['Mcp-Session-Id'] = sessionId; + + return fetch(MCP_SERVER_URL, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +} + +/** Extract the Mcp-Session-Id from an initialize response. */ +function getSessionId(res: Response): string { + const sid = res.headers.get('mcp-session-id'); + if (!sid) throw new Error('No Mcp-Session-Id header in response'); + return sid; +} + +/** Parse the WWW-Authenticate header into key-value pairs. */ +function parseWWWAuthenticate(res: Response): Record { + const header = res.headers.get('www-authenticate'); + if (!header) return {}; + const params: Record = {}; + for (const match of header.matchAll(/(\w+)="([^"]*)"/g)) { + params[match[1]] = match[2]; + } + return params; +} + +/** Initialize an MCP session and return the session ID. */ +async function initSession(token: string): Promise { + const res = await rawMcpRequest(token, null, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, + }); + if (res.status !== 200) throw new Error(`Initialize failed: ${res.status}`); + return getSessionId(res); +} + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +let tokenEndpoint: string; +let registrationEndpoint: string; +let clientId: string; +let clientSecret: string; + +// ========================================================================== +// Test Suites +// ========================================================================== + +describe('MCP OAuth Scope Enforcement E2E', () => { + // ------------------------------------------------------------------------ + // Setup: discover endpoints + register a client via DCR + // ------------------------------------------------------------------------ + beforeAll(async () => { + // 1. Discover protected resource metadata + const resourceMeta = await fetchJSON<{ + authorization_servers: string[]; + scopes_supported: string[]; + }>(`${MCP_BASE_URL}/.well-known/oauth-protected-resource/mcp`); + + expect(resourceMeta.authorization_servers).toContain(OAUTH_SERVER_URL); + expect(resourceMeta.scopes_supported).toEqual(expect.arrayContaining(['mcp:connect', 'mcp:tools:read'])); + + // 2. Discover authorization server metadata + const asMeta = await fetchJSON<{ + token_endpoint: string; + registration_endpoint: string; + }>(`${OAUTH_SERVER_URL}/.well-known/oauth-authorization-server`); + + tokenEndpoint = asMeta.token_endpoint; + registrationEndpoint = asMeta.registration_endpoint; + + // 3. Dynamic Client Registration + const client = await registerClient(registrationEndpoint); + clientId = client.clientId; + clientSecret = client.clientSecret; + }); + + // ======================================================================== + // A. Metadata Discovery + // ======================================================================== + + describe('Metadata Discovery', () => { + it('should expose protected resource metadata with all scopes', async () => { + const meta = await fetchJSON<{ + resource: string; + authorization_servers: string[]; + scopes_supported: string[]; + bearer_methods_supported: string[]; + }>(`${MCP_BASE_URL}/.well-known/oauth-protected-resource/mcp`); + + expect(meta.resource).toBe(MCP_SERVER_URL); + expect(meta.authorization_servers).toEqual([OAUTH_SERVER_URL]); + expect(meta.bearer_methods_supported).toContain('header'); + expect(meta.scopes_supported).toEqual(expect.arrayContaining(['mcp:connect', 'mcp:tools:read'])); + }); + + it('should expose authorization server metadata', async () => { + const meta = await fetchJSON<{ + issuer: string; + token_endpoint: string; + registration_endpoint: string; + grant_types_supported: string[]; + }>(`${OAUTH_SERVER_URL}/.well-known/oauth-authorization-server`); + + expect(meta.issuer).toBe(OAUTH_SERVER_URL); + expect(meta.token_endpoint).toBeTruthy(); + expect(meta.registration_endpoint).toBeTruthy(); + expect(meta.grant_types_supported).toContain('client_credentials'); + }); + }); + + // ======================================================================== + // B. Dynamic Client Registration + Token Acquisition + // ======================================================================== + + describe('Dynamic Client Registration', () => { + it('should register a new client and obtain a token', async () => { + const newClient = await registerClient(registrationEndpoint); + expect(newClient.clientId).toMatch(/^dyn-/); + expect(newClient.clientSecret).toBeTruthy(); + + // Verify the DCR client can obtain a token + const token = await getToken(tokenEndpoint, newClient.clientId, newClient.clientSecret, 'mcp:connect'); + expect(token.access_token).toBeTruthy(); + expect(token.scope).toContain('mcp:connect'); + }); + + it('should obtain a token with multiple scopes', async () => { + const token = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect mcp:tools:read read:all'); + expect(token.access_token).toBeTruthy(); + expect(token.scope).toContain('mcp:connect'); + expect(token.scope).toContain('mcp:tools:read'); + expect(token.scope).toContain('read:all'); + }); + }); + + // ======================================================================== + // C. MCP Operation-Level Scope Enforcement (raw HTTP) + // ======================================================================== + + describe('Operation-Level Scope Enforcement', () => { + it('should allow initialize with mcp:connect scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect'); + + const res = await rawMcpRequest(access_token, null, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get('mcp-session-id')).toBeTruthy(); + }); + + it('should reject tools/list with only mcp:connect (missing mcp:tools:read)', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect'); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:tools:read'); + expect(auth.resource_metadata).toContain('.well-known/oauth-protected-resource'); + }); + + it('should allow tools/list with mcp:connect + mcp:tools:read', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect mcp:tools:read'); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }); + + expect(res.status).toBe(200); + }); + }); + + // ======================================================================== + // D. Per-Tool Scope Enforcement (raw HTTP) + // ======================================================================== + + describe('Per-Tool Scope Enforcement', () => { + const BASE_CALL_SCOPES = 'mcp:connect mcp:tools:read mcp:tools:call'; + + it('should reject scoped tool with only base scopes', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employee_start_date', arguments: { id: 1 } }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.error_description).toContain('get_employee_start_date'); + expect(auth.scope).toBeTruthy(); + }); + + it('should allow scoped tool with read:all', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, `${BASE_CALL_SCOPES} read:all`); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employee_start_date', arguments: { id: 1 } }, + }); + + expect(res.status).toBe(200); + }); + + it('should allow scoped tool with read:employee + read:private (alternative AND-group)', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} read:employee read:private`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employee_start_date', arguments: { id: 1 } }, + }); + + expect(res.status).toBe(200); + }); + + it('should allow unscoped tool with just base scopes', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employees', arguments: {} }, + }); + + expect(res.status).toBe(200); + }); + + it('should include existing scopes in challenge when partially scoped', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} read:employee`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employee_start_date', arguments: { id: 1 } }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + // With scope_challenge_include_token_scopes: true, the challenge includes + // both the held scope (read:employee) and the missing one (read:private) + const challengedScopes = auth.scope?.split(' ') ?? []; + expect(challengedScopes).toContain('read:employee'); + expect(challengedScopes).toContain('read:private'); + }); + }); + + // ======================================================================== + // E. tools_call Gate (raw HTTP) + // ======================================================================== + + describe('tools_call Gate', () => { + it('should reject tools/call without mcp:tools:call scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect mcp:tools:read'); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employees', arguments: {} }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:tools:call'); + }); + }); + + // ======================================================================== + // F. Built-in Tool Scope Enforcement (raw HTTP) + // ======================================================================== + + describe('Built-in Tool Scope Enforcement', () => { + const BASE_CALL_SCOPES = 'mcp:connect mcp:tools:read mcp:tools:call'; + + // -- execute_graphql -------------------------------------------------- + + it('should reject execute_graphql without mcp:graphql:execute scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'execute_graphql', arguments: { query: '{ employees { id } }' } }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:graphql:execute'); + }); + + it('should allow execute_graphql with mcp:graphql:execute scope', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} mcp:graphql:execute`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'execute_graphql', arguments: { query: '{ employees { id } }' } }, + }); + + expect(res.status).toBe(200); + }); + + // -- get_schema ------------------------------------------------------- + + it('should reject get_schema without mcp:schema:read scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_schema', arguments: {} }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:schema:read'); + }); + + it('should allow get_schema with mcp:schema:read scope', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} mcp:schema:read`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_schema', arguments: {} }, + }); + + expect(res.status).toBe(200); + }); + + // -- get_operation_info ------------------------------------------------ + + it('should reject get_operation_info without mcp:ops:read scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_operation_info', arguments: { operationName: 'GetEmployees' } }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:ops:read'); + }); + + it('should allow get_operation_info with mcp:ops:read scope', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} mcp:ops:read`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_operation_info', arguments: { operationName: 'GetEmployees' } }, + }); + + expect(res.status).toBe(200); + }); + }); + + // ======================================================================== + // G. MCP SDK Client — Full E2E with ClientCredentialsProvider + // ======================================================================== + + describe('MCP SDK Client E2E', () => { + /** Helper to create a connected MCP client with given scopes. */ + async function createClient(scope: string): Promise { + const provider = new ClientCredentialsProvider({ + clientId, + clientSecret, + scope, + }); + + const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL), { + authProvider: provider, + }); + + const client = new Client({ name: 'e2e-sdk-test', version: '1.0.0' }, { capabilities: {} }); + + await client.connect(transport); + return client; + } + + it('should connect, list tools, and call an unscoped tool', async () => { + const client = await createClient('mcp:connect mcp:tools:read mcp:tools:call'); + + const tools = await client.listTools(); + expect(tools.tools.length).toBeGreaterThan(0); + + const toolNames = tools.tools.map((t) => t.name); + expect(toolNames).toContain('get_employees'); + expect(toolNames).toContain('get_employee_start_date'); + + const result = await client.callTool({ name: 'get_employees', arguments: {} }); + expect(result.content).toBeDefined(); + + await client.close(); + }); + + it('should connect and call a scoped tool with sufficient scopes', async () => { + const client = await createClient('mcp:connect mcp:tools:read mcp:tools:call read:all'); + + const result = await client.callTool({ + name: 'get_employee_start_date', + arguments: { id: 1 }, + }); + + expect(result.content).toBeDefined(); + + await client.close(); + }); + + it('should surface a 403 error when calling a scoped tool without per-tool scopes', async () => { + const client = await createClient('mcp:connect mcp:tools:read mcp:tools:call'); + + // Calling a scoped tool without per-tool scopes should throw. + // The MCP client is responsible for handling this 403 and acquiring + // the additional scopes indicated in the WWW-Authenticate header. + await expect(client.callTool({ name: 'get_employee_start_date', arguments: { id: 1 } })).rejects.toThrow(/403/); + + await client.close(); + }); + }); +}); diff --git a/client-tests/mcp-ts/tsconfig.json b/client-tests/mcp-ts/tsconfig.json new file mode 100644 index 0000000000..b00c98512e --- /dev/null +++ b/client-tests/mcp-ts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node", "vitest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/client-tests/mcp-ts/vitest.config.ts b/client-tests/mcp-ts/vitest.config.ts new file mode 100644 index 0000000000..2655f53739 --- /dev/null +++ b/client-tests/mcp-ts/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.ts'], + testTimeout: 30_000, + }, +}); diff --git a/demo/go.mod b/demo/go.mod index 0bef81ddf0..208d1cdb72 100644 --- a/demo/go.mod +++ b/demo/go.mod @@ -95,7 +95,7 @@ require ( github.com/logrusorgru/aurora/v4 v4.0.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mark3labs/mcp-go v0.36.0 // indirect + github.com/mark3labs/mcp-go v0.43.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect diff --git a/demo/go.sum b/demo/go.sum index f9a9527b44..def336f529 100644 --- a/demo/go.sum +++ b/demo/go.sum @@ -222,8 +222,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= -github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= diff --git a/router-tests/cmd/mcp-debug-proxy/main.go b/router-tests/cmd/mcp-debug-proxy/main.go new file mode 100644 index 0000000000..ed196afb5f --- /dev/null +++ b/router-tests/cmd/mcp-debug-proxy/main.go @@ -0,0 +1,95 @@ +// MCP Debug Proxy is a tiny logging reverse proxy for eyeballing traffic +// between an MCP client (Claude Desktop, Cursor, ...) and the router's MCP +// endpoint during local development. +// +// go run ./router-tests/cmd/mcp-debug-proxy -listen :5026 -target http://127.0.0.1:5025 +// +// Point the client at http://localhost:/mcp. +package main + +import ( + "bytes" + "encoding/json" + "flag" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" +) + +func main() { + listen := flag.String("listen", ":5026", "address to listen on") + target := flag.String("target", "http://127.0.0.1:5025", "upstream MCP server URL") + maxBody := flag.Int("max-body", 4096, "truncate logged bodies to N bytes") + flag.Parse() + + upstream, err := url.Parse(*target) + if err != nil { + log.Fatalf("invalid -target: %v", err) + } + + proxy := httputil.NewSingleHostReverseProxy(upstream) + proxy.ModifyResponse = func(resp *http.Response) error { + logResponse(resp, *maxBody) + return nil + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("proxy error: %v", err) + http.Error(w, "bad gateway", http.StatusBadGateway) + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logRequest(r, *maxBody) + proxy.ServeHTTP(w, r) + }) + + log.Printf("listening on %s, forwarding to %s", *listen, upstream) + if err := http.ListenAndServe(*listen, handler); err != nil { + log.Fatal(err) + } +} + +func logRequest(r *http.Request, maxBody int) { + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(body)) + + log.Printf("▶ %s %s", r.Method, r.URL.RequestURI()) + logHeaders(" →", r.Header) + if len(body) > 0 { + log.Printf(" → body: %s", formatBody(body, maxBody)) + } +} + +func logResponse(resp *http.Response, maxBody int) { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + + log.Printf("◀ %d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + logHeaders(" ←", resp.Header) + if len(body) > 0 { + log.Printf(" ← body: %s", formatBody(body, maxBody)) + } +} + +func logHeaders(prefix string, h http.Header) { + for k, vs := range h { + for _, v := range vs { + log.Printf("%s %s: %s", prefix, k, v) + } + } +} + +func formatBody(b []byte, maxLen int) string { + out := b + var pretty bytes.Buffer + if json.Indent(&pretty, b, "", " ") == nil { + out = pretty.Bytes() + } + if len(out) > maxLen { + return string(out[:maxLen]) + "…" + } + return string(out) +} diff --git a/router-tests/cmd/oauth-server/main.go b/router-tests/cmd/oauth-server/main.go new file mode 100644 index 0000000000..cfabe91e6c --- /dev/null +++ b/router-tests/cmd/oauth-server/main.go @@ -0,0 +1,489 @@ +/* +Standalone OAuth 2.1 Authorization Server for local MCP development and testing. + +Provides all endpoints needed by the official MCP TypeScript SDK's ClientCredentialsProvider: + - /.well-known/oauth-authorization-server (AS metadata, RFC 8414) + - /.well-known/jwks.json (JWKS for token verification) + - /token (client_credentials + authorization_code) + - /register (dynamic client registration, RFC 7591) + - /authorize (auto-approve for testing) + +Usage: + + go run ./cmd/oauth-server + + # or with options + go run ./cmd/oauth-server -port 9099 -client-id test-mcp-client -client-secret test-mcp-secret -scopes "mcp:connect mcp:tools:read mcp:tools:write" + +Then configure router/mcp.config.yaml: + + mcp: + oauth: + enabled: true + authorization_server_url: "http://localhost:9099" + jwks: + - url: "http://localhost:9099/.well-known/jwks.json" + refresh_interval: 1m + algorithms: ["RS256"] + +Run with the MCP TypeScript SDK client: + + MCP_SERVER_URL=http://localhost:5025/mcp \ + MCP_CLIENT_ID=test-mcp-client \ + MCP_CLIENT_SECRET=test-mcp-secret \ + pnpm test +*/ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router-tests/jwks" +) + +var ( + portFlag = flag.String("port", "9099", "Port to listen on") + clientIDFlag = flag.String("client-id", "test-mcp-client", "Pre-registered client ID") + clientSecretFlag = flag.String("client-secret", "test-mcp-secret", "Pre-registered client secret") + scopesFlag = flag.String("scopes", "mcp:connect mcp:tools:read mcp:tools:write", "Default scopes for the pre-registered client (space-separated)") +) + +func main() { + flag.Parse() + + srv, err := newOAuthServer(*portFlag, *clientIDFlag, *clientSecretFlag, *scopesFlag) + if err != nil { + log.Fatalf("Failed to create OAuth server: %v", err) + } + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) + + go func() { + log.Printf("OAuth server listening on http://localhost:%s", *portFlag) + log.Printf(" JWKS: http://localhost:%s/.well-known/jwks.json", *portFlag) + log.Printf(" Metadata: http://localhost:%s/.well-known/oauth-authorization-server", *portFlag) + log.Printf(" Token: http://localhost:%s/token", *portFlag) + log.Printf(" Register: http://localhost:%s/register", *portFlag) + log.Printf(" Client: %s / %s (scopes: %s)", *clientIDFlag, *clientSecretFlag, *scopesFlag) + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Server error: %v", err) + } + }() + + // Print a sample token for manual testing + token, err := srv.handler.createToken(*clientIDFlag, *scopesFlag) + if err == nil { + log.Printf("\nSample Bearer token (for manual curl/playground testing):\n%s\n", token) + } + + <-sigs + log.Println("Shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +type oauthHandler struct { + provider jwks.Crypto + keyID string + issuer string + jwksURL string + storage jwkset.Storage + + mu sync.RWMutex + clients map[string]*client + codes map[string]*authCode +} + +type client struct { + id string + secret string + scope string + redirectURIs []string +} + +type authCode struct { + clientID string + scope string + createdAt time.Time +} + +type serverWithHandler struct { + *http.Server + handler *oauthHandler +} + +func newOAuthServer(port, clientID, clientSecret, defaultScopes string) (*serverWithHandler, error) { + cryptoProvider, err := jwks.NewRSACrypto("test_rsa", jwkset.AlgRS256, 2048) + if err != nil { + return nil, fmt.Errorf("RSA keygen: %w", err) + } + + jwkStorage := jwkset.NewMemoryStorage() + jwk, err := cryptoProvider.MarshalJWK() + if err != nil { + return nil, fmt.Errorf("marshal JWK: %w", err) + } + if err := jwkStorage.KeyWrite(context.Background(), jwk); err != nil { + return nil, fmt.Errorf("store JWK: %w", err) + } + + baseURL := fmt.Sprintf("http://localhost:%s", port) + + h := &oauthHandler{ + provider: cryptoProvider, + keyID: "test_rsa", + issuer: baseURL, + jwksURL: baseURL + "/.well-known/jwks.json", + storage: jwkStorage, + clients: make(map[string]*client), + codes: make(map[string]*authCode), + } + + // Pre-register client + h.clients[clientID] = &client{id: clientID, secret: clientSecret, scope: defaultScopes} + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/jwks.json", h.handleJWKS) + mux.HandleFunc("/.well-known/oauth-authorization-server", h.handleASMetadata) + mux.HandleFunc("/token", h.handleToken) + mux.HandleFunc("/register", h.handleRegister) + mux.HandleFunc("/authorize", h.handleAuthorize) + + return &serverWithHandler{ + Server: &http.Server{Addr: ":" + port, Handler: withDebugLog(withCORS(mux))}, + handler: h, + }, nil +} + +// --------------------------------------------------------------------------- +// Endpoints +// --------------------------------------------------------------------------- + +func (h *oauthHandler) handleJWKS(w http.ResponseWriter, _ *http.Request) { + raw, err := h.storage.JSON(context.Background()) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(raw) +} + +func (h *oauthHandler) handleASMetadata(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": h.issuer, + "token_endpoint": h.issuer + "/token", + "authorization_endpoint": h.issuer + "/authorize", + "registration_endpoint": h.issuer + "/register", + "jwks_uri": h.jwksURL, + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"client_credentials", "authorization_code"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, + "code_challenge_methods_supported": []string{"S256"}, + }) +} + +func (h *oauthHandler) handleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + tokenError(w, "invalid_request", "POST required", http.StatusMethodNotAllowed) + return + } + _ = r.ParseForm() + + switch r.FormValue("grant_type") { + case "client_credentials": + h.handleClientCredentials(w, r) + case "authorization_code": + h.handleCodeExchange(w, r) + default: + tokenError(w, "unsupported_grant_type", "unsupported grant_type", http.StatusBadRequest) + } +} + +func (h *oauthHandler) handleClientCredentials(w http.ResponseWriter, r *http.Request) { + clientID, clientSecret, ok := authenticateClient(r) + if !ok { + tokenError(w, "invalid_client", "client authentication failed", http.StatusUnauthorized) + return + } + + h.mu.RLock() + c, exists := h.clients[clientID] + h.mu.RUnlock() + + if !exists || c.secret != clientSecret { + tokenError(w, "invalid_client", "unknown client or bad secret", http.StatusUnauthorized) + return + } + + scope := r.FormValue("scope") + if scope == "" { + scope = c.scope + } + + h.issueTokenResponse(w, clientID, scope) +} + +func (h *oauthHandler) handleCodeExchange(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") + if code == "" { + tokenError(w, "invalid_request", "missing code", http.StatusBadRequest) + return + } + + h.mu.Lock() + pending, exists := h.codes[code] + if exists { + delete(h.codes, code) + } + h.mu.Unlock() + + if !exists || time.Since(pending.createdAt) > 60*time.Second { + tokenError(w, "invalid_grant", "unknown or expired code", http.StatusBadRequest) + return + } + + clientID, clientSecret, ok := authenticateClient(r) + if !ok { + tokenError(w, "invalid_client", "client authentication failed", http.StatusUnauthorized) + return + } + + h.mu.RLock() + c, clientExists := h.clients[clientID] + h.mu.RUnlock() + + if !clientExists || c.secret != clientSecret || pending.clientID != clientID { + tokenError(w, "invalid_client", "client mismatch", http.StatusUnauthorized) + return + } + + h.issueTokenResponse(w, clientID, pending.scope) +} + +func (h *oauthHandler) handleAuthorize(w http.ResponseWriter, r *http.Request) { + clientID := r.URL.Query().Get("client_id") + redirectURI := r.URL.Query().Get("redirect_uri") + scope := r.URL.Query().Get("scope") + state := r.URL.Query().Get("state") + + if clientID == "" || redirectURI == "" { + http.Error(w, "missing client_id or redirect_uri", http.StatusBadRequest) + return + } + + // Validate that the redirect URI matches one of the client's registered redirect URIs. + h.mu.RLock() + c, ok := h.clients[clientID] + h.mu.RUnlock() + if !ok { + http.Error(w, "unknown client_id", http.StatusBadRequest) + return + } + + if len(c.redirectURIs) > 0 { + redirectAllowed := false + for _, allowed := range c.redirectURIs { + if allowed == redirectURI { + redirectAllowed = true + break + } + } + if !redirectAllowed { + http.Error(w, "unregistered redirect_uri", http.StatusBadRequest) + return + } + } + + code := randomHex(32) + h.mu.Lock() + h.codes[code] = &authCode{clientID: clientID, scope: scope, createdAt: time.Now()} + h.mu.Unlock() + + location := fmt.Sprintf("%s?code=%s", redirectURI, code) + if state != "" { + location += "&state=" + state + } + http.Redirect(w, r, location, http.StatusFound) +} + +func (h *oauthHandler) handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + var req struct { + ClientName string `json:"client_name"` + GrantTypes []string `json:"grant_types"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad JSON", http.StatusBadRequest) + return + } + + id := "dyn-" + randomHex(16) + secret := "secret-" + randomHex(24) + + h.mu.Lock() + h.clients[id] = &client{id: id, secret: secret, scope: req.Scope, redirectURIs: req.RedirectURIs} + h.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "client_id": id, + "client_secret": secret, + "client_name": req.ClientName, + "grant_types": req.GrantTypes, + "redirect_uris": req.RedirectURIs, + "token_endpoint_auth_method": req.TokenEndpointAuthMethod, + }) +} + +// --------------------------------------------------------------------------- +// Token helpers +// --------------------------------------------------------------------------- + +func (h *oauthHandler) issueTokenResponse(w http.ResponseWriter, sub, scope string) { + accessToken, err := h.createToken(sub, scope) + if err != nil { + tokenError(w, "server_error", "token signing failed", http.StatusInternalServerError) + return + } + + resp := map[string]any{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": 3600, + } + if scope != "" { + resp["scope"] = scope + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +func (h *oauthHandler) createToken(sub, scope string) (string, error) { + now := time.Now() + claims := jwt.MapClaims{ + "iss": h.issuer, + "aud": "test-audience", + "sub": sub, + "iat": now.Unix(), + "exp": now.Add(1 * time.Hour).Unix(), + "client_id": sub, + } + if scope != "" { + claims["scope"] = scope + } + + token := jwt.NewWithClaims(h.provider.SigningMethod(), claims) + token.Header[jwkset.HeaderKID] = h.keyID + return token.SignedString(h.provider.PrivateKey()) +} + +func authenticateClient(r *http.Request) (string, string, bool) { + if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Basic ") { + decoded, err := base64.StdEncoding.DecodeString(authHeader[6:]) + if err == nil { + if parts := strings.SplitN(string(decoded), ":", 2); len(parts) == 2 { + return parts[0], parts[1], true + } + } + } + id, secret := r.FormValue("client_id"), r.FormValue("client_secret") + if id != "" && secret != "" { + return id, secret, true + } + return "", "", false +} + +func tokenError(w http.ResponseWriter, errCode, desc string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": errCode, "error_description": desc}) +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// withCORS wraps an http.Handler with permissive CORS headers for browser-based +// MCP clients (e.g. MCP Inspector). The TypeScript SDK fetches +// /.well-known/oauth-authorization-server cross-origin from the browser. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, MCP-Protocol-Version") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +// withDebugLog wraps an http.Handler to dump full request and response details. +func withDebugLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqDump, err := httputil.DumpRequest(r, true) + if err != nil { + log.Printf("[DEBUG] ▶ REQUEST dump error: %v", err) + } else { + log.Printf("[DEBUG] ▶ REQUEST %s %s\n%s", r.Method, r.URL.Path, string(reqDump)) + } + + rec := httptest.NewRecorder() + next.ServeHTTP(rec, r) + + log.Printf("[DEBUG] ◀ RESPONSE %s %s → %d\n Headers: %v\n Body: %s", + r.Method, r.URL.Path, rec.Code, rec.Header(), rec.Body.String()) + + // Copy recorded response to the real writer + for k, v := range rec.Header() { + w.Header()[k] = v + } + w.WriteHeader(rec.Code) + _, _ = w.Write(rec.Body.Bytes()) + }) +} diff --git a/router-tests/go.mod b/router-tests/go.mod index e4ded31396..8b6cf45645 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -1,20 +1,21 @@ module github.com/wundergraph/cosmo/router-tests -go 1.25 +go 1.25.0 require ( connectrpc.com/connect v1.19.1 github.com/MicahParks/jwkset v0.11.0 github.com/buger/jsonparser v1.1.2 github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-containerregistry v0.20.3 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.1 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hasura/go-graphql-client v0.14.3 - github.com/mark3labs/mcp-go v0.36.0 + github.com/mark3labs/mcp-go v0.43.2 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/nats-io/nats.go v1.35.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 @@ -24,9 +25,9 @@ require ( github.com/twmb/franz-go v1.16.1 github.com/twmb/franz-go/pkg/kadm v1.11.0 github.com/wundergraph/astjson v1.1.0 - github.com/wundergraph/cosmo/demo v0.0.0-20260319123623-f186a0f724f6 + github.com/wundergraph/cosmo/demo v0.0.0-20260213130455-6e3277e7b850 github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e - github.com/wundergraph/cosmo/router v0.0.0-20260319123623-f186a0f724f6 + github.com/wundergraph/cosmo/router v0.0.0-20260318232543-0e5fa811a191 github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.269 go.opentelemetry.io/otel v1.39.0 @@ -36,8 +37,8 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.0 go.uber.org/zap v1.27.0 - golang.org/x/net v0.48.0 - golang.org/x/sys v0.39.0 + golang.org/x/net v0.49.0 + golang.org/x/sys v0.40.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 @@ -89,6 +90,7 @@ require ( github.com/goccy/go-yaml v1.17.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect @@ -133,6 +135,8 @@ require ( github.com/rs/xid v1.5.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shirou/gopsutil/v3 v3.24.3 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect @@ -171,13 +175,14 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect go.withmatt.com/connect-brotli v0.4.0 // indirect - golang.org/x/crypto v0.46.0 // indirect + golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect - golang.org/x/mod v0.30.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/tools v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect diff --git a/router-tests/go.sum b/router-tests/go.sum index 010c871ff9..a54b295867 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -126,8 +126,8 @@ github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -139,6 +139,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -206,8 +208,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= -github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -228,6 +230,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.35.0 h1:XFNqNM7v5B+MQMKqVGAyHwYhyKb48jrenXNxIU20ULk= @@ -287,6 +291,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+x github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E= github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= @@ -417,20 +425,22 @@ go.withmatt.com/connect-brotli v0.4.0 h1:7ObWkYmEbUXK3EKglD0Lgj0BBnnD3jNdAxeDRct go.withmatt.com/connect-brotli v0.4.0/go.mod h1:c2eELz56za+/Mxh1yJrlglZ4VM9krpOCPqS2Vxf8NVk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -450,19 +460,19 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/router-tests/protocol/mcp_auth_client_test.go b/router-tests/protocol/mcp_auth_client_test.go new file mode 100644 index 0000000000..09a03a0b5c --- /dev/null +++ b/router-tests/protocol/mcp_auth_client_test.go @@ -0,0 +1,178 @@ +package integration + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/wundergraph/cosmo/router-tests/testutil" +) + +// authRoundTripper wraps an http.RoundTripper and adds Authorization headers +// It also captures the last HTTP response for error analysis +type authRoundTripper struct { + base http.RoundTripper + token string + lastResponse *http.Response +} + +func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original + req = req.Clone(req.Context()) + + // Add Authorization header if token is set + if a.token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token)) + } + + resp, err := a.base.RoundTrip(req) + // Capture auth-error responses only; subsequent successful requests + // on the same session (e.g. SSE GETs) must not overwrite a prior 401/403. + if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) { + a.lastResponse = resp + } + return resp, err +} + +// MCPAuthClient wraps the official MCP client with authorization support +type MCPAuthClient struct { + endpoint string + transport *mcp.StreamableClientTransport + roundTripper *authRoundTripper + client *mcp.Client + session *mcp.ClientSession +} + +// AuthError represents an HTTP authentication/authorization error +type AuthError struct { + StatusCode int + ErrorCode string + RequiredScopes []string + ResourceMetadataURL string + ErrorDescription string +} + +func (e *AuthError) Error() string { + if e.ErrorCode == "insufficient_scope" { + return fmt.Sprintf("HTTP %d: insufficient scope - required scopes: %v", e.StatusCode, e.RequiredScopes) + } + return fmt.Sprintf("HTTP %d: %s - %s", e.StatusCode, e.ErrorCode, e.ErrorDescription) +} + +// NewMCPAuthClient creates a new MCP client with authorization support +func NewMCPAuthClient(endpoint string, initialToken string) *MCPAuthClient { + roundTripper := &authRoundTripper{ + base: http.DefaultTransport, + token: initialToken, + } + + httpClient := &http.Client{ + Transport: roundTripper, + } + + transport := &mcp.StreamableClientTransport{ + Endpoint: endpoint, + HTTPClient: httpClient, + } + + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, nil) + + return &MCPAuthClient{ + endpoint: endpoint, + transport: transport, + roundTripper: roundTripper, + client: client, + } +} + +// Connect establishes the MCP connection and initializes the session. +// Returns *AuthError if the server responds with 401/403, surfacing the +// parsed WWW-Authenticate header so callers can assert OAuth discovery +// and scope challenge behavior per the MCP authorization spec. +func (c *MCPAuthClient) Connect(ctx context.Context) error { + session, err := c.client.Connect(ctx, c.transport, nil) + if err != nil { + if authErr := c.checkAuthError(); authErr != nil { + return authErr + } + return fmt.Errorf("failed to connect: %w", err) + } + c.session = session + return nil +} + +// SetToken updates the authorization token without reconnecting +func (c *MCPAuthClient) SetToken(token string) { + c.roundTripper.token = token +} + +// CallTool calls an MCP tool. +// Returns *AuthError if the request fails due to HTTP 401/403. +func (c *MCPAuthClient) CallTool(ctx context.Context, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + params := &mcp.CallToolParams{ + Name: toolName, + Arguments: arguments, + } + + // Reset so a prior 401/403 can't shadow this call's outcome. + c.roundTripper.lastResponse = nil + result, err := c.session.CallTool(ctx, params) + if err != nil { + if authErr := c.checkAuthError(); authErr != nil { + return nil, authErr + } + return nil, err + } + + return result, nil +} + +// checkAuthError checks if the last HTTP response was an auth error (401/403) +// and returns an AuthError with parsed WWW-Authenticate header information +func (c *MCPAuthClient) checkAuthError() *AuthError { + if c.roundTripper.lastResponse == nil { + return nil + } + + resp := c.roundTripper.lastResponse + + if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden { + return nil + } + + authHeader := resp.Header.Get("WWW-Authenticate") + if authHeader == "" { + return &AuthError{ + StatusCode: resp.StatusCode, + ErrorCode: "authentication_required", + } + } + + params := testutil.ParseWWWAuthenticateParams(authHeader) + + authErr := &AuthError{ + StatusCode: resp.StatusCode, + ErrorCode: params["error"], + ResourceMetadataURL: params["resource_metadata"], + ErrorDescription: params["error_description"], + } + + if scopeStr := params["scope"]; scopeStr != "" { + authErr.RequiredScopes = strings.Fields(scopeStr) + } + + return authErr +} + +// Close closes the MCP session +func (c *MCPAuthClient) Close() error { + if c.session != nil { + return c.session.Close() + } + return nil +} diff --git a/router-tests/protocol/mcp_oauth_e2e_test.go b/router-tests/protocol/mcp_oauth_e2e_test.go new file mode 100644 index 0000000000..2503061f86 --- /dev/null +++ b/router-tests/protocol/mcp_oauth_e2e_test.go @@ -0,0 +1,195 @@ +package integration + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wundergraph/cosmo/router-tests/testenv" + "github.com/wundergraph/cosmo/router-tests/testutil" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +func TestMCPOAuthAuthentication(t *testing.T) { + oauthServer, err := testutil.NewOAuthTestServer(t, nil) + require.NoError(t, err, "failed to start OAuth server") + defer oauthServer.Close() //nolint:errcheck + + validToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) + require.NoError(t, err, "failed to create valid token") + + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + OAuth: config.MCPOAuthConfiguration{ + Enabled: true, + JWKS: []config.JWKSConfiguration{ + {URL: oauthServer.JWKSURL()}, + }, + AuthorizationServerURL: oauthServer.Issuer(), + }, + }, + MCPAuthToken: validToken, + MCPOperationsPath: "testdata/mcp_operations", + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + t.Run("returns 401 with resource metadata when token is invalid", func(t *testing.T) { + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "invalid-jwt-token") + + err := client.Connect(ctx) + require.Error(t, err, "should fail to connect with invalid token") + + authErr, ok := err.(*AuthError) + require.True(t, ok, "expected *AuthError but got %T: %v", err, err) + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + }) + + t.Run("returns 401 with resource metadata when token is missing", func(t *testing.T) { + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "") + + err := client.Connect(ctx) + require.Error(t, err, "should fail to connect without token") + + authErr, ok := err.(*AuthError) + require.True(t, ok, "expected *AuthError but got %T: %v", err, err) + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + }) + }) +} + +func TestMCPOAuthPerToolScopes(t *testing.T) { + oauthServer, err := testutil.NewOAuthTestServer(t, nil) + require.NoError(t, err, "failed to start OAuth server") + defer oauthServer.Close() //nolint:errcheck + + initToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect"}) + require.NoError(t, err, "failed to create init token") + + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + ExposeSchema: true, + EnableArbitraryOperations: true, + OAuth: config.MCPOAuthConfiguration{ + Enabled: true, + JWKS: []config.JWKSConfiguration{ + {URL: oauthServer.JWKSURL()}, + }, + AuthorizationServerURL: oauthServer.Issuer(), + Scopes: config.MCPOAuthScopesConfiguration{ + Initialize: []string{"mcp:connect"}, + GetSchema: []string{"mcp:tools:read"}, + ExecuteGraphQL: []string{"mcp:tools:write"}, + }, + ScopeChallengeIncludeTokenScopes: true, + }, + }, + MCPAuthToken: initToken, + MCPOperationsPath: "testdata/mcp_operations", + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + t.Run("returns error when token is missing HTTP-level scopes", func(t *testing.T) { + noConnectToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), noConnectToken) + err = client.Connect(ctx) + require.Error(t, err, "should fail to connect without HTTP-level scopes") + + authErr, ok := err.(*AuthError) + require.True(t, ok, "expected *AuthError but got %T: %v", err, err) + assert.True(t, authErr.StatusCode == http.StatusUnauthorized || authErr.StatusCode == http.StatusForbidden) + }) + + t.Run("returns 403 with insufficient_scope when tool call is missing per-tool scopes", func(t *testing.T) { + connectOnlyToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), connectOnlyToken) + err = client.Connect(ctx) + require.NoError(t, err, "should connect with HTTP-level scopes") + defer client.Close() //nolint:errcheck + + _, err = client.CallTool(ctx, "get_schema", nil) + require.Error(t, err, "should fail without per-tool scopes") + + authErr, ok := err.(*AuthError) + require.True(t, ok, "should return AuthError but got %T: %v", err, err) + assert.Equal(t, http.StatusForbidden, authErr.StatusCode, "should return HTTP 403") + assert.Equal(t, "insufficient_scope", authErr.ErrorCode) + assert.Contains(t, authErr.RequiredScopes, "mcp:tools:read") + }) + + t.Run("allows tool call when token has required per-tool scope", func(t *testing.T) { + readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + err = client.Connect(ctx) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + result, err := client.CallTool(ctx, "get_schema", nil) + require.NoError(t, err, "should succeed with correct scopes") + require.NotNil(t, result) + }) + + t.Run("challenges with different scopes for different tools", func(t *testing.T) { + readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + err = client.Connect(ctx) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + _, err = client.CallTool(ctx, "get_schema", nil) + require.NoError(t, err, "read tool should succeed") + + _, err = client.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { __typename }", + }) + require.Error(t, err, "write tool should fail without write scopes") + + authErr, ok := err.(*AuthError) + require.True(t, ok) + assert.Equal(t, http.StatusForbidden, authErr.StatusCode) + assert.Contains(t, authErr.RequiredScopes, "mcp:tools:write") + }) + + t.Run("allows tool call after reconnecting with upgraded scopes", func(t *testing.T) { + // The MCP SDK closes the session on HTTP 403, so clients must + // reconnect after re-authorizing for broader scopes (per OAuth spec). + readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) + require.NoError(t, err) + + readClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + require.NoError(t, readClient.Connect(ctx)) + + _, err = readClient.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { __typename }", + }) + require.Error(t, err, "should fail without write scopes") + readClient.Close() //nolint:errcheck + + writeToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read", "mcp:tools:write"}) + require.NoError(t, err) + + writeClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), writeToken) + require.NoError(t, writeClient.Connect(ctx)) + defer writeClient.Close() //nolint:errcheck + + result, err := writeClient.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { __typename }", + }) + require.NoError(t, err, "should succeed after reconnecting with upgraded scopes") + require.NotNil(t, result) + }) + }) +} diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index ac9bac48d5..ded7b0fc17 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -3,6 +3,7 @@ package integration import ( "encoding/json" "fmt" + "io" "net/http" "os" "path/filepath" @@ -87,6 +88,8 @@ func TestMCP(t *testing.T) { }) // Verify execute tool with proper schema + // Note: IdempotentHint is a bool (not *bool) in the new SDK, so false + omitempty + // means it's omitted from JSON, and the old client deserializes it as nil. require.Contains(t, resp.Tools, mcp.Tool{ Name: "execute_graphql", Description: "Executes a GraphQL query or mutation.", @@ -110,7 +113,6 @@ func TestMCP(t *testing.T) { Title: "Execute GraphQL Query", DestructiveHint: mcp.ToBoolPtr(true), OpenWorldHint: mcp.ToBoolPtr(true), - IdempotentHint: mcp.ToBoolPtr(false), }, }) @@ -148,15 +150,15 @@ func TestMCP(t *testing.T) { }) // Verify UpdateMood operation + // Note: ReadOnlyHint and IdempotentHint are bool (not *bool) in the new SDK, + // so false + omitempty means they're omitted from JSON, and the old client gets nil. require.Contains(t, resp.Tools, mcp.Tool{ Name: "execute_operation_update_mood", Description: "This mutation update the mood of an employee.", InputSchema: mcp.ToolInputSchema{Type: "object", Properties: map[string]interface{}{"employeeID": map[string]interface{}{"type": "integer"}, "mood": map[string]interface{}{"enum": []interface{}{"HAPPY", "SAD"}, "type": "string"}}, Required: []string{"employeeID", "mood"}}, RawInputSchema: json.RawMessage(nil), Annotations: mcp.ToolAnnotation{ - Title: "Execute operation UpdateMood", - OpenWorldHint: mcp.ToBoolPtr(true), - ReadOnlyHint: mcp.ToBoolPtr(false), - IdempotentHint: mcp.ToBoolPtr(false), + Title: "Execute operation UpdateMood", + OpenWorldHint: mcp.ToBoolPtr(true), }, }) }) @@ -289,8 +291,48 @@ func TestMCP(t *testing.T) { assert.Equal(t, content.Type, "text") - // Set up expected text with the static endpoint - expectedContent := "Operation: MyEmployees\nType: query\nDescription: This is a GraphQL query that retrieves a list of employees.\n\nInput Schema:\n```json\n{\"additionalProperties\":false,\"description\":\"This is a GraphQL query that retrieves a list of employees.\",\"nullable\":true,\"properties\":{\"criteria\":{\"additionalProperties\":false,\"description\":\"Allows to filter employees by their details.\",\"nullable\":false,\"properties\":{\"hasPets\":{\"nullable\":true,\"type\":\"boolean\"},\"nationality\":{\"enum\":[\"AMERICAN\",\"DUTCH\",\"ENGLISH\",\"GERMAN\",\"INDIAN\",\"SPANISH\",\"UKRAINIAN\"],\"nullable\":true,\"type\":\"string\"},\"nested\":{\"additionalProperties\":false,\"nullable\":true,\"properties\":{\"hasChildren\":{\"nullable\":true,\"type\":\"boolean\"},\"maritalStatus\":{\"enum\":[\"ENGAGED\",\"MARRIED\"],\"nullable\":true,\"type\":\"string\"}},\"type\":\"object\"}},\"type\":\"object\"}},\"type\":\"object\"}\n```\n\nGraphQL Query:\n```\nquery MyEmployees($criteria: SearchInput) {\n findEmployees(criteria: $criteria) {\n id\n isAvailable\n currentMood\n products\n details {\n forename\n nationality\n }\n }\n}\n```\n\nUsage Instructions:\n1. Endpoint: https://api.example.com/graphql\n2. HTTP Method: POST\n3. Headers Required:\n - Content-Type: application/json; charset=utf-8\n\nRequest Format:\n```json\n{\n \"query\": \"\",\n \"variables\": \n}\n```\n\nImportant Notes:\n1. Use the query string exactly as provided above\n2. Do not modify or reformat the query string" + bt := "```" + expectedContent := `Operation: MyEmployees +Type: query +Description: This is a GraphQL query that retrieves a list of employees. + +Input Schema: +` + bt + `json +{"additionalProperties":false,"description":"This is a GraphQL query that retrieves a list of employees.","nullable":true,"properties":{"criteria":{"additionalProperties":false,"description":"Allows to filter employees by their details.","nullable":false,"properties":{"hasPets":{"nullable":true,"type":"boolean"},"nationality":{"enum":["AMERICAN","DUTCH","ENGLISH","GERMAN","INDIAN","SPANISH","UKRAINIAN"],"nullable":true,"type":"string"},"nested":{"additionalProperties":false,"nullable":true,"properties":{"hasChildren":{"nullable":true,"type":"boolean"},"maritalStatus":{"enum":["ENGAGED","MARRIED"],"nullable":true,"type":"string"}},"type":"object"}},"type":"object"}},"type":"object"} +` + bt + ` + +GraphQL Query: +` + bt + ` +query MyEmployees($criteria: SearchInput) { + findEmployees(criteria: $criteria) { + id + isAvailable + currentMood + products + details { + forename + nationality + } + } +} +` + bt + ` + +Usage Instructions: +1. Endpoint: https://api.example.com/graphql +2. HTTP Method: POST +3. Headers Required: + - Content-Type: application/json; charset=utf-8 + +Request Format: +` + bt + `json +{ + "query": "", + "variables": +} +` + bt + ` +Important Notes: +1. Use the query string exactly as provided above +2. Do not modify or reformat the query string` assert.Equal(t, expectedContent, content.Text) }) @@ -347,7 +389,7 @@ func TestMCP(t *testing.T) { assert.True(t, ok) assert.Equal(t, content.Type, "text") - assert.Equal(t, content.Text, "Input validation Error: validation error: at '/criteria': got null, want object") + assert.Equal(t, content.Text, "Input validation error: validation error: at '/criteria': got null, want object") }) }) }) @@ -473,7 +515,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify response status assert.Equal(t, http.StatusNoContent, resp.StatusCode) @@ -530,7 +572,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify CORS headers are present in the response assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) @@ -564,7 +606,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify CORS headers are present in the response assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) @@ -602,7 +644,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify CORS headers are present assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) @@ -643,7 +685,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify CORS headers are present in the response assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) @@ -939,20 +981,28 @@ input UserInput { // Add various headers to test forwarding req.Header.Set("Content-Type", "application/json") - req.Header.Set("foo", "bar") // Non-standard header - req.Header.Set("X-Custom-Header", "custom-value") // Custom X- header - req.Header.Set("X-Trace-Id", "trace-123") // Tracing header - req.Header.Set("Authorization", "Bearer test-token") // Auth header + req.Header.Set("Accept", "application/json, text/event-stream") // Required by Streamable HTTP transport + req.Header.Set("foo", "bar") // Non-standard header + req.Header.Set("X-Custom-Header", "custom-value") // Custom X- header + req.Header.Set("X-Trace-Id", "trace-123") // Tracing header + req.Header.Set("Authorization", "Bearer test-token") // Auth header // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // With stateless mode, the request should succeed t.Logf("Response Status: %d", resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode, "Request should succeed in stateless mode") + // Read the full response body - with StreamableHTTP, the response is an SSE stream + // and the tool execution completes within it. We must consume the stream fully + // before the subgraph request is guaranteed to have been made. + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + t.Logf("Response Body: %s", string(body)) + // Verify headers reached subgraph subgraphMutex.Lock() defer subgraphMutex.Unlock() @@ -1042,9 +1092,9 @@ input UserInput { // Set headers that should be filtered req.Header.Set("Proxy-Authenticate", "Basic") req.Header.Set("Proxy-Authorization", "Basic YWxhZGRpbjpvcGVuc2VzYW1l") - req.Header.Set("Content-Type", "application/json; foo=bar") // Custom param that should be stripped - req.Header.Set("Accept", "application/json") - req.Header.Set("Accept-Encoding", "br") // Request brotli (which go client doesn't support by default) + req.Header.Set("Content-Type", "application/json") // New SDK rejects non-standard content type params + req.Header.Set("Accept", "application/json, text/event-stream") // Required by Streamable HTTP transport + req.Header.Set("Accept-Encoding", "br") // Request brotli (which go client doesn't support by default) req.Header.Set("Alt-Svc", "h2=\":443\"; ma=2592000") req.Header.Set("Proxy-Connection", "keep-alive") @@ -1053,13 +1103,18 @@ input UserInput { resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { t.Logf("Response Status: %d", resp.StatusCode) } require.Equal(t, http.StatusOK, resp.StatusCode) + // Consume the full SSE stream so the tool execution completes + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + t.Logf("Response Body: %s", string(body)) + subgraphMutex.Lock() defer subgraphMutex.Unlock() @@ -1072,10 +1127,9 @@ input UserInput { assert.NotEqual(t, "Basic", capturedSubgraphRequest.Header.Get("Proxy-Authenticate")) assert.NotEqual(t, "Basic YWxhZGRpbjpvcGVuc2VzYW1l", capturedSubgraphRequest.Header.Get("Proxy-Authorization")) - // Content-Type should be set by MCP server to application/json (and stripped of custom params) + // Content-Type should be set by MCP server to application/json ct := capturedSubgraphRequest.Header.Get("Content-Type") assert.True(t, strings.HasPrefix(ct, "application/json"), "Content-Type should start with application/json") - assert.False(t, strings.Contains(ct, "foo=bar"), "Content-Type should not contain forwarded parameters") // Accept should be set by MCP server assert.Equal(t, "application/json", capturedSubgraphRequest.Header.Get("Accept")) @@ -1089,6 +1143,51 @@ input UserInput { assert.Empty(t, capturedSubgraphRequest.Header.Get("Alt-Svc")) assert.Empty(t, capturedSubgraphRequest.Header.Get("Proxy-Connection")) }) + + // Breaking change from SDK migration (mark3labs/mcp-go -> modelcontextprotocol/go-sdk): + // The old SDK accepted non-standard Content-Type params (e.g., "application/json; foo=bar") + // and silently stripped them. The new SDK's StreamableHTTPHandler rejects them with 415 + // Unsupported Media Type at the transport level before our code runs. + // + // This is the correct behavior per the MCP spec which requires "application/json". + // No legitimate MCP client sends custom content-type params. + t.Run("Non-standard Content-Type params are rejected by the SDK", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + Session: config.MCPSessionConfig{ + Stateless: true, + }, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + mcpAddr := xEnv.GetMCPServerAddr() + + mcpRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]interface{}{ + "name": "execute_operation_my_employees", + "arguments": map[string]interface{}{}, + }, + } + + requestBody, err := json.Marshal(mcpRequest) + require.NoError(t, err) + + req, err := http.NewRequest("POST", mcpAddr, strings.NewReader(string(requestBody))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json; foo=bar") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := xEnv.RouterClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, http.StatusUnsupportedMediaType, resp.StatusCode, + "New SDK rejects non-standard Content-Type params with 415") + }) + }) }) }) } diff --git a/router-tests/testenv/testenv.go b/router-tests/testenv/testenv.go index 93da790b38..117c16b22f 100644 --- a/router-tests/testenv/testenv.go +++ b/router-tests/testenv/testenv.go @@ -31,6 +31,7 @@ import ( "github.com/cloudflare/backoff" mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" "github.com/golang-jwt/jwt/v5" @@ -365,6 +366,7 @@ type Config struct { NoShutdownTestServer bool MCP config.MCPConfiguration MCPOperationsPath string + MCPAuthToken string // Optional Bearer token for MCP authentication EnableRedis bool EnableRedisCluster bool Plugins PluginConfig @@ -688,6 +690,9 @@ func CreateTestSupervisorEnv(t testing.TB, cfg *Config) (*Environment, error) { if cfg.MCP.Enabled { cfg.MCP.Server.ListenAddr = fmt.Sprintf("localhost:%d", freeport.GetOne(t)) + if cfg.MCP.OAuth.Enabled && cfg.MCP.Server.BaseURL == "" { + cfg.MCP.Server.BaseURL = fmt.Sprintf("http://%s", cfg.MCP.Server.ListenAddr) + } } listenerAddr := fmt.Sprintf("localhost:%d", freeport.GetOne(t)) @@ -851,7 +856,17 @@ func CreateTestSupervisorEnv(t testing.TB, cfg *Config) (*Environment, error) { if cfg.MCP.Enabled { // Create MCP client connecting to the MCP server mcpAddr := fmt.Sprintf("http://%s/mcp", cfg.MCP.Server.ListenAddr) - client, err := mcpclient.NewStreamableHttpClient(mcpAddr) + + // Add authentication headers if token is provided + var clientOpts []transport.StreamableHTTPCOption + if cfg.MCPAuthToken != "" { + headers := map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", cfg.MCPAuthToken), + } + clientOpts = append(clientOpts, transport.WithHTTPHeaders(headers)) + } + + client, err := mcpclient.NewStreamableHttpClient(mcpAddr, clientOpts...) if err != nil { t.Fatalf("Failed to create MCP client: %v", err) } @@ -1118,6 +1133,9 @@ func CreateTestEnv(t testing.TB, cfg *Config) (*Environment, error) { if cfg.MCP.Enabled { cfg.MCP.Server.ListenAddr = fmt.Sprintf("localhost:%d", freeport.GetOne(t)) + if cfg.MCP.OAuth.Enabled && cfg.MCP.Server.BaseURL == "" { + cfg.MCP.Server.BaseURL = fmt.Sprintf("http://%s", cfg.MCP.Server.ListenAddr) + } } listenerAddr := fmt.Sprintf("localhost:%d", freeport.GetOne(t)) @@ -1279,7 +1297,17 @@ func CreateTestEnv(t testing.TB, cfg *Config) (*Environment, error) { if cfg.MCP.Enabled { // Create MCP client connecting to the MCP server mcpAddr := fmt.Sprintf("http://%s/mcp", cfg.MCP.Server.ListenAddr) - client, err := mcpclient.NewStreamableHttpClient(mcpAddr) + + // Add authentication headers if token is provided + var clientOpts []transport.StreamableHTTPCOption + if cfg.MCPAuthToken != "" { + headers := map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", cfg.MCPAuthToken), + } + clientOpts = append(clientOpts, transport.WithHTTPHeaders(headers)) + } + + client, err := mcpclient.NewStreamableHttpClient(mcpAddr, clientOpts...) if err != nil { t.Fatalf("Failed to create MCP client: %v", err) } diff --git a/router-tests/testutil/auth_helpers.go b/router-tests/testutil/auth_helpers.go new file mode 100644 index 0000000000..3b8cd2dcd8 --- /dev/null +++ b/router-tests/testutil/auth_helpers.go @@ -0,0 +1,130 @@ +package testutil + +import "strings" + +// ParseWWWAuthenticateParams parses the WWW-Authenticate header from HTTP +// responses and returns its auth-params. The auth-scheme (e.g. "Bearer") is +// discarded; callers in these tests only care about the parameters. +// +// Example input: `Bearer error="insufficient_scope", scope="read write"` +// Example output: map[string]string{"error": "insufficient_scope", "scope": "read write"} +// +// The parser below is adapted from containers/image (Apache-2.0): +// https://github.com/containers/image/blob/main/docker/wwwauthenticate.go +// which itself was derived from docker/distribution. Inlined here rather +// than pulled as a dependency — it's ~50 lines and only used by tests. +// +// NOTE: Not fully RFC 7235 compliant; in particular it only handles a single +// challenge per header. Sufficient for asserting on router responses in tests. +func ParseWWWAuthenticateParams(header string) map[string]string { + _, params := parseValueAndParams(header) + return params +} + +type octetType byte + +const ( + isToken octetType = 1 << iota + isSpace +) + +var octetTypes [256]octetType + +func init() { + for c := 0; c < 256; c++ { + var t octetType + isCtl := c <= 31 || c == 127 + isChar := c <= 127 + isSeparator := strings.ContainsRune(" \t\"(),/:;<=>?@[]\\{}", rune(c)) + if strings.ContainsRune(" \t\r\n", rune(c)) { + t |= isSpace + } + if isChar && !isCtl && !isSeparator { + t |= isToken + } + octetTypes[c] = t + } +} + +func parseValueAndParams(header string) (value string, params map[string]string) { + params = make(map[string]string) + value, s := expectToken(header) + if value == "" { + return + } + value = strings.ToLower(value) + s = "," + skipSpace(s) + for strings.HasPrefix(s, ",") { + var pkey string + pkey, s = expectToken(skipSpace(s[1:])) + if pkey == "" { + return + } + if !strings.HasPrefix(s, "=") { + return + } + var pvalue string + pvalue, s = expectTokenOrQuoted(s[1:]) + if pvalue == "" { + return + } + params[strings.ToLower(pkey)] = pvalue + s = skipSpace(s) + } + return +} + +func skipSpace(s string) string { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isSpace == 0 { + break + } + } + return s[i:] +} + +func expectToken(s string) (token, rest string) { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isToken == 0 { + break + } + } + return s[:i], s[i:] +} + +func expectTokenOrQuoted(s string) (value, rest string) { + if !strings.HasPrefix(s, "\"") { + return expectToken(s) + } + s = s[1:] + for i := 0; i < len(s); i++ { + switch s[i] { + case '"': + return s[:i], s[i+1:] + case '\\': + p := make([]byte, len(s)-1) + j := copy(p, s[:i]) + escape := true + for i++; i < len(s); i++ { + b := s[i] + switch { + case escape: + escape = false + p[j] = b + j++ + case b == '\\': + escape = true + case b == '"': + return string(p[:j]), s[i+1:] + default: + p[j] = b + j++ + } + } + return "", "" + } + } + return "", "" +} diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go new file mode 100644 index 0000000000..1698ac5944 --- /dev/null +++ b/router-tests/testutil/oauth_server.go @@ -0,0 +1,512 @@ +package testutil + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router-tests/jwks" +) + +// OAuthClient represents a registered OAuth client. +type OAuthClient struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + GrantTypes []string `json:"grant_types"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// authCode is a pending authorization code waiting to be exchanged. +type authCode struct { + clientID string + scope string + redirectURI string + createdAt time.Time +} + +// OAuthTestServer is a minimal OAuth 2.1 Authorization Server for integration tests. +// It issues real signed JWTs that can be validated by any consumer fetching the +// JWKS endpoint, making it suitable for end-to-end testing with the official MCP +// TypeScript SDK's ClientCredentialsProvider. +type OAuthTestServer struct { + t *testing.T + provider jwks.Crypto + keyID string + issuer string + audience string + jwksURL string + server *httptest.Server + storage jwkset.Storage + + mu sync.RWMutex + clients map[string]*OAuthClient // clientID -> client + codes map[string]*authCode // code -> pending auth code + + // DefaultScopes assigned to tokens when the client doesn't request specific scopes. + DefaultScopes string +} + +// OAuthTestServerOptions configures the test OAuth server. +type OAuthTestServerOptions struct { + DefaultScopes string + PreRegisteredClients []*OAuthClient +} + +// NewOAuthTestServer creates and starts a minimal OAuth 2.1 AS on a random port. +func NewOAuthTestServer(t *testing.T, opts *OAuthTestServerOptions) (*OAuthTestServer, error) { + t.Helper() + + if opts == nil { + opts = &OAuthTestServerOptions{} + } + + cryptoProvider, err := jwks.NewRSACrypto("test_rsa", jwkset.AlgRS256, 2048) + if err != nil { + return nil, fmt.Errorf("failed to create RSA crypto: %w", err) + } + + jwkStorage := jwkset.NewMemoryStorage() + jwk, err := cryptoProvider.MarshalJWK() + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK: %w", err) + } + if err := jwkStorage.KeyWrite(context.Background(), jwk); err != nil { + return nil, fmt.Errorf("failed to write key to storage: %w", err) + } + + s := &OAuthTestServer{ + t: t, + provider: cryptoProvider, + keyID: "test_rsa", + audience: "test-audience", + storage: jwkStorage, + clients: make(map[string]*OAuthClient), + codes: make(map[string]*authCode), + DefaultScopes: opts.DefaultScopes, + } + + for _, c := range opts.PreRegisteredClients { + s.clients[c.ClientID] = c + } + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/jwks.json", s.handleJWKS) + mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleASMetadata) + mux.HandleFunc("/token", s.handleToken) + mux.HandleFunc("/register", s.handleRegister) + mux.HandleFunc("/authorize", s.handleAuthorize) + + s.server = httptest.NewServer(withCORS(mux)) + s.issuer = s.server.URL + s.jwksURL = s.server.URL + "/.well-known/jwks.json" + + t.Logf("OAuth test server started at %s", s.issuer) + return s, nil +} + +// --------------------------------------------------------------------------- +// Endpoints +// --------------------------------------------------------------------------- + +func (s *OAuthTestServer) handleJWKS(w http.ResponseWriter, _ *http.Request) { + rawJWKS, err := s.storage.JSON(context.Background()) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(rawJWKS) +} + +// handleASMetadata serves RFC 8414 Authorization Server Metadata. +func (s *OAuthTestServer) handleASMetadata(w http.ResponseWriter, _ *http.Request) { + meta := map[string]any{ + "issuer": s.issuer, + "token_endpoint": s.issuer + "/token", + "authorization_endpoint": s.issuer + "/authorize", + "registration_endpoint": s.issuer + "/register", + "jwks_uri": s.jwksURL, + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"client_credentials", "authorization_code"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meta) +} + +// handleToken handles client_credentials and authorization_code grants. +func (s *OAuthTestServer) handleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + s.tokenError(w, "invalid_request", "POST required", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + s.tokenError(w, "invalid_request", "bad form body", http.StatusBadRequest) + return + } + + switch r.FormValue("grant_type") { + case "client_credentials": + s.handleClientCredentials(w, r) + case "authorization_code": + s.handleAuthorizationCodeExchange(w, r) + default: + s.tokenError(w, "unsupported_grant_type", + fmt.Sprintf("unsupported grant_type %q", r.FormValue("grant_type")), + http.StatusBadRequest) + } +} + +func (s *OAuthTestServer) handleClientCredentials(w http.ResponseWriter, r *http.Request) { + clientID, clientSecret, ok := s.authenticateClient(r) + if !ok { + s.tokenError(w, "invalid_client", "client authentication failed", http.StatusUnauthorized) + return + } + + s.mu.RLock() + client, exists := s.clients[clientID] + s.mu.RUnlock() + + if !exists || client.ClientSecret != clientSecret { + s.tokenError(w, "invalid_client", "unknown client or bad secret", http.StatusUnauthorized) + return + } + + scope := r.FormValue("scope") + if scope == "" { + scope = client.Scope + } + if scope == "" { + scope = s.DefaultScopes + } + + s.issueTokenResponse(w, clientID, scope) +} + +func (s *OAuthTestServer) handleAuthorizationCodeExchange(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") + if code == "" { + s.tokenError(w, "invalid_request", "missing code", http.StatusBadRequest) + return + } + + s.mu.Lock() + pending, exists := s.codes[code] + if exists { + delete(s.codes, code) // one-time use + } + s.mu.Unlock() + + if !exists { + s.tokenError(w, "invalid_grant", "unknown or expired code", http.StatusBadRequest) + return + } + + // Codes expire after 60 seconds + if time.Since(pending.createdAt) > 60*time.Second { + s.tokenError(w, "invalid_grant", "code expired", http.StatusBadRequest) + return + } + + // Authenticate the client + clientID, clientSecret, ok := s.authenticateClient(r) + if !ok { + s.tokenError(w, "invalid_client", "client authentication failed", http.StatusUnauthorized) + return + } + + s.mu.RLock() + client, clientExists := s.clients[clientID] + s.mu.RUnlock() + + if !clientExists || client.ClientSecret != clientSecret { + s.tokenError(w, "invalid_client", "unknown client or bad secret", http.StatusUnauthorized) + return + } + + if pending.clientID != clientID { + s.tokenError(w, "invalid_grant", "code was issued to a different client", http.StatusBadRequest) + return + } + + s.issueTokenResponse(w, clientID, pending.scope) +} + +// handleAuthorize is a simplified authorization endpoint that auto-approves. +// For interactive testing it returns a minimal HTML page; for automated tests +// it immediately redirects with a code. +func (s *OAuthTestServer) handleAuthorize(w http.ResponseWriter, r *http.Request) { + clientID := r.URL.Query().Get("client_id") + redirectURI := r.URL.Query().Get("redirect_uri") + scope := r.URL.Query().Get("scope") + + if clientID == "" || redirectURI == "" { + http.Error(w, "missing client_id or redirect_uri", http.StatusBadRequest) + return + } + + // Validate redirect_uri against registered URIs to prevent open redirects + s.mu.RLock() + c, ok := s.clients[clientID] + s.mu.RUnlock() + if !ok { + http.Error(w, "unknown client_id", http.StatusBadRequest) + return + } + if len(c.RedirectURIs) > 0 { + redirectAllowed := false + for _, allowed := range c.RedirectURIs { + if allowed == redirectURI { + redirectAllowed = true + break + } + } + if !redirectAllowed { + http.Error(w, "unregistered redirect_uri", http.StatusBadRequest) + return + } + } + + // Generate authorization code + code := randomString(32) + + s.mu.Lock() + s.codes[code] = &authCode{ + clientID: clientID, + scope: scope, + redirectURI: redirectURI, + createdAt: time.Now(), + } + s.mu.Unlock() + + // Preserve the state parameter for PKCE / CSRF + state := r.URL.Query().Get("state") + location := fmt.Sprintf("%s?code=%s", redirectURI, code) + if state != "" { + location += "&state=" + state + } + + http.Redirect(w, r, location, http.StatusFound) +} + +// handleRegister implements RFC 7591 Dynamic Client Registration. +func (s *OAuthTestServer) handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + var req struct { + ClientName string `json:"client_name"` + GrantTypes []string `json:"grant_types"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad JSON", http.StatusBadRequest) + return + } + + clientID := "dyn-" + randomString(16) + clientSecret := "secret-" + randomString(24) + + client := &OAuthClient{ + ClientID: clientID, + ClientSecret: clientSecret, + GrantTypes: req.GrantTypes, + RedirectURIs: req.RedirectURIs, + Scope: req.Scope, + } + + s.mu.Lock() + s.clients[clientID] = client + s.mu.Unlock() + + resp := map[string]any{ + "client_id": clientID, + "client_secret": clientSecret, + "client_name": req.ClientName, + "grant_types": req.GrantTypes, + "redirect_uris": req.RedirectURIs, + "token_endpoint_auth_method": req.TokenEndpointAuthMethod, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// authenticateClient extracts client credentials via Basic auth or POST body. +func (s *OAuthTestServer) authenticateClient(r *http.Request) (clientID, clientSecret string, ok bool) { + // client_secret_basic + if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Basic ") { + decoded, err := base64.StdEncoding.DecodeString(authHeader[6:]) + if err == nil { + if parts := strings.SplitN(string(decoded), ":", 2); len(parts) == 2 { + return parts[0], parts[1], true + } + } + } + + // client_secret_post + id, secret := r.FormValue("client_id"), r.FormValue("client_secret") + if id != "" && secret != "" { + return id, secret, true + } + + return "", "", false +} + +func (s *OAuthTestServer) issueTokenResponse(w http.ResponseWriter, sub, scope string) { + now := time.Now() + claims := jwt.MapClaims{ + "iss": s.issuer, + "aud": s.audience, + "sub": sub, + "iat": now.Unix(), + "exp": now.Add(1 * time.Hour).Unix(), + "client_id": sub, + } + if scope != "" { + claims["scope"] = scope + } + + token := jwt.NewWithClaims(s.provider.SigningMethod(), claims) + token.Header[jwkset.HeaderKID] = s.keyID + + accessToken, err := token.SignedString(s.provider.PrivateKey()) + if err != nil { + s.t.Logf("Failed to sign token: %v", err) + s.tokenError(w, "server_error", "token signing failed", http.StatusInternalServerError) + return + } + + resp := map[string]any{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": 3600, + } + if scope != "" { + resp["scope"] = scope + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +func (s *OAuthTestServer) tokenError(w http.ResponseWriter, errCode, description string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": errCode, + "error_description": description, + }) +} + +// --------------------------------------------------------------------------- +// Public API for tests +// --------------------------------------------------------------------------- + +// CreateToken creates a signed JWT with the given claims (for direct test use). +func (s *OAuthTestServer) CreateToken(claims map[string]any) (string, error) { + s.t.Helper() + + now := time.Now() + tokenClaims := jwt.MapClaims{ + "iss": s.issuer, + "aud": s.audience, + "iat": now.Unix(), + "exp": now.Add(1 * time.Hour).Unix(), + } + for k, v := range claims { + tokenClaims[k] = v + } + + token := jwt.NewWithClaims(s.provider.SigningMethod(), tokenClaims) + token.Header[jwkset.HeaderKID] = s.keyID + + return token.SignedString(s.provider.PrivateKey()) +} + +// CreateTokenWithScopes creates a signed JWT with specific OAuth scopes. +func (s *OAuthTestServer) CreateTokenWithScopes(sub string, scopes []string) (string, error) { + s.t.Helper() + return s.CreateToken(map[string]any{ + "sub": sub, + "scope": strings.Join(scopes, " "), + }) +} + +// RegisterClient pre-registers a client (bypass dynamic registration). +func (s *OAuthTestServer) RegisterClient(clientID, clientSecret, scope string) *OAuthClient { + client := &OAuthClient{ + ClientID: clientID, + ClientSecret: clientSecret, + GrantTypes: []string{"client_credentials"}, + Scope: scope, + } + s.mu.Lock() + s.clients[clientID] = client + s.mu.Unlock() + return client +} + +// JWKSURL returns the JWKS endpoint URL. +func (s *OAuthTestServer) JWKSURL() string { return s.jwksURL } + +// Issuer returns the base URL / issuer of the OAuth server. +func (s *OAuthTestServer) Issuer() string { return s.issuer } + +// TokenEndpoint returns the token endpoint URL. +func (s *OAuthTestServer) TokenEndpoint() string { return s.issuer + "/token" } + +// Close stops the server. +func (s *OAuthTestServer) Close() error { + s.server.Close() + return nil +} + +func randomString(nBytes int) string { + b := make([]byte, nBytes) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// withCORS wraps an http.Handler with permissive CORS headers for browser-based +// MCP clients (e.g. MCP Inspector). This is required because the TypeScript SDK +// fetches /.well-known/oauth-authorization-server cross-origin from the browser. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, MCP-Protocol-Version") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/router-tests/testutil/oauth_server_test.go b/router-tests/testutil/oauth_server_test.go new file mode 100644 index 0000000000..5fb555f037 --- /dev/null +++ b/router-tests/testutil/oauth_server_test.go @@ -0,0 +1,234 @@ +package testutil + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOAuthTestServer_ASMetadata(t *testing.T) { + srv, err := NewOAuthTestServer(t, nil) + require.NoError(t, err) + defer srv.Close() + + resp, err := http.Get(srv.Issuer() + "/.well-known/oauth-authorization-server") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var meta map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&meta)) + + assert.Equal(t, srv.Issuer(), meta["issuer"]) + assert.Equal(t, srv.Issuer()+"/token", meta["token_endpoint"]) + assert.Equal(t, srv.Issuer()+"/register", meta["registration_endpoint"]) + assert.Equal(t, srv.Issuer()+"/authorize", meta["authorization_endpoint"]) + assert.Equal(t, srv.JWKSURL(), meta["jwks_uri"]) +} + +func TestOAuthTestServer_ClientCredentials(t *testing.T) { + srv, err := NewOAuthTestServer(t, &OAuthTestServerOptions{ + PreRegisteredClients: []*OAuthClient{ + { + ClientID: "test-client", + ClientSecret: "test-secret", + GrantTypes: []string{"client_credentials"}, + Scope: "mcp:tools:read mcp:tools:write", + }, + }, + }) + require.NoError(t, err) + defer srv.Close() + + t.Run("valid credentials with Basic auth", func(t *testing.T) { + form := url.Values{"grant_type": {"client_credentials"}} + req, err := http.NewRequest(http.MethodPost, srv.TokenEndpoint(), strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("test-client", "test-secret") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var tokenResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokenResp)) + + assert.Equal(t, "Bearer", tokenResp["token_type"]) + assert.NotEmpty(t, tokenResp["access_token"]) + assert.Equal(t, "mcp:tools:read mcp:tools:write", tokenResp["scope"]) + }) + + t.Run("valid credentials with POST body", func(t *testing.T) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"test-secret"}, + } + resp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("scope override", func(t *testing.T) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"test-secret"}, + "scope": {"mcp:admin"}, + } + resp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + + var tokenResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokenResp)) + assert.Equal(t, "mcp:admin", tokenResp["scope"]) + }) + + t.Run("bad secret rejected", func(t *testing.T) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"wrong"}, + } + resp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("unknown client rejected", func(t *testing.T) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"ghost"}, + "client_secret": {"nope"}, + } + resp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestOAuthTestServer_DynamicRegistration(t *testing.T) { + srv, err := NewOAuthTestServer(t, nil) + require.NoError(t, err) + defer srv.Close() + + // Register a client dynamically + body := `{"client_name":"my-test","grant_types":["client_credentials"],"token_endpoint_auth_method":"client_secret_basic"}` + resp, err := http.Post(srv.Issuer()+"/register", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var regResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(®Resp)) + + clientID, _ := regResp["client_id"].(string) + clientSecret, _ := regResp["client_secret"].(string) + require.NotEmpty(t, clientID) + require.NotEmpty(t, clientSecret) + + // Use the dynamically registered client to get a token + form := url.Values{"grant_type": {"client_credentials"}} + req, err := http.NewRequest(http.MethodPost, srv.TokenEndpoint(), strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + tokenResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer tokenResp.Body.Close() + + assert.Equal(t, http.StatusOK, tokenResp.StatusCode) +} + +func TestOAuthTestServer_AuthorizationCodeFlow(t *testing.T) { + srv, err := NewOAuthTestServer(t, &OAuthTestServerOptions{ + PreRegisteredClients: []*OAuthClient{ + { + ClientID: "authcode-client", + ClientSecret: "authcode-secret", + GrantTypes: []string{"authorization_code"}, + Scope: "openid", + }, + }, + }) + require.NoError(t, err) + defer srv.Close() + + // Step 1: Hit /authorize — should redirect with a code + authURL := fmt.Sprintf("%s/authorize?client_id=authcode-client&redirect_uri=http://localhost:9999/callback&scope=openid&state=xyz123", srv.Issuer()) + + client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse // don't follow redirects + }} + + resp, err := client.Get(authURL) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusFound, resp.StatusCode) + + loc, err := resp.Location() + require.NoError(t, err) + + code := loc.Query().Get("code") + state := loc.Query().Get("state") + require.NotEmpty(t, code) + assert.Equal(t, "xyz123", state) + + // Step 2: Exchange code for token + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {"authcode-client"}, + "client_secret": {"authcode-secret"}, + } + tokenResp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer tokenResp.Body.Close() + + assert.Equal(t, http.StatusOK, tokenResp.StatusCode) + + var tokens map[string]any + require.NoError(t, json.NewDecoder(tokenResp.Body).Decode(&tokens)) + assert.NotEmpty(t, tokens["access_token"]) + + // Step 3: Code cannot be reused + tokenResp2, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer tokenResp2.Body.Close() + + assert.Equal(t, http.StatusBadRequest, tokenResp2.StatusCode) +} + +func TestOAuthTestServer_CreateTokenDirectly(t *testing.T) { + srv, err := NewOAuthTestServer(t, nil) + require.NoError(t, err) + defer srv.Close() + + token, err := srv.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) + require.NoError(t, err) + require.NotEmpty(t, token) + + // Token should be a valid JWT (3 dot-separated parts) + parts := strings.Split(token, ".") + assert.Len(t, parts, 3) +} diff --git a/router/core/graph_server.go b/router/core/graph_server.go index f478e53590..23812b8fe4 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -1393,7 +1393,7 @@ func (s *graphServer) buildGraphMux( // We support the MCP only on the base graph. Feature flags are not supported yet. if opts.IsBaseGraph() && s.mcpServer != nil { - if mErr := s.mcpServer.Reload(executor.ClientSchema); mErr != nil { + if mErr := s.mcpServer.Reload(executor.ClientSchema, opts.EngineConfig.FieldConfigurations); mErr != nil { return nil, fmt.Errorf("failed to reload MCP server: %w", mErr) } } diff --git a/router/core/router.go b/router/core/router.go index 4f3a7dddf1..cb173417d3 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -944,68 +944,8 @@ func (r *Router) bootstrap(ctx context.Context) error { } } - if r.mcp.Enabled { - var operationsDir string - - // If storage provider ID is set, resolve it to a directory path - if r.mcp.Storage.ProviderID != "" { - r.logger.Debug("Resolving storage provider for MCP operations", - zap.String("provider_id", r.mcp.Storage.ProviderID)) - - provider, ok := r.providerRegistry.FileSystem(r.mcp.Storage.ProviderID) - if !ok { - return fmt.Errorf("storage provider with id '%s' for mcp server not found", r.mcp.Storage.ProviderID) - } - r.logger.Debug("Found file_system storage provider for MCP", - zap.String("id", provider.ID), - zap.String("path", provider.Path)) - operationsDir = provider.Path - } - - logFields := []zap.Field{ - zap.String("storage_provider_id", r.mcp.Storage.ProviderID), - } - - // Initialize the MCP server with the resolved operations directory - mcpOpts := []func(*mcpserver.Options){ - mcpserver.WithGraphName(r.mcp.GraphName), - mcpserver.WithOperationsDir(operationsDir), - mcpserver.WithListenAddr(r.mcp.Server.ListenAddr), - mcpserver.WithLogger(r.logger.With(logFields...)), - mcpserver.WithExcludeMutations(r.mcp.ExcludeMutations), - mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations), - mcpserver.WithExposeSchema(r.mcp.ExposeSchema), - mcpserver.WithOmitToolNamePrefix(r.mcp.OmitToolNamePrefix), - mcpserver.WithStateless(r.mcp.Session.Stateless), - } - - if r.corsOptions != nil { - mcpOpts = append(mcpOpts, mcpserver.WithCORS(*r.corsOptions)) - } - - mcpGraphQLEndpoint := r.graphqlEndpointURL - if r.mcp.RouterURL != "" { - mcpGraphQLEndpoint = r.mcp.RouterURL - } - - mcpss, err := mcpserver.NewGraphQLSchemaServer( - mcpGraphQLEndpoint, - mcpOpts..., - ) - if err != nil { - return fmt.Errorf("failed to create mcp server: %w", err) - } - - err = mcpss.Start() - if err != nil { - // Cleanup the server if Start() fails to prevent resource leaks - if stopErr := mcpss.Stop(ctx); stopErr != nil { - r.logger.Warn("Failed to stop MCP server during error cleanup", zap.Error(stopErr)) - } - return fmt.Errorf("failed to start MCP server: %w", err) - } - - r.mcpServer = mcpss + if err := r.startMCPServer(ctx); err != nil { + return err } if r.connectRPC.Enabled { @@ -1127,6 +1067,89 @@ func (r *Router) bootstrap(ctx context.Context) error { return nil } +// startMCPServer initializes and starts the MCP server if enabled. +func (r *Router) startMCPServer(ctx context.Context) error { + if !r.mcp.Enabled { + return nil + } + + var operationsDir string + + // If storage provider ID is set, resolve it to a directory path + if r.mcp.Storage.ProviderID != "" { + r.logger.Debug("Resolving storage provider for MCP operations", + zap.String("provider_id", r.mcp.Storage.ProviderID)) + + provider, ok := r.providerRegistry.FileSystem(r.mcp.Storage.ProviderID) + if !ok { + return fmt.Errorf("storage provider with id '%s' for mcp server not found", r.mcp.Storage.ProviderID) + } + r.logger.Debug("Found file_system storage provider for MCP", + zap.String("id", provider.ID), + zap.String("path", provider.Path)) + operationsDir = provider.Path + } + + logFields := []zap.Field{ + zap.String("storage_provider_id", r.mcp.Storage.ProviderID), + } + + // Initialize the MCP server with the resolved operations directory + mcpOpts := []func(*mcpserver.Options){ + mcpserver.WithGraphName(r.mcp.GraphName), + mcpserver.WithOperationsDir(operationsDir), + mcpserver.WithListenAddr(r.mcp.Server.ListenAddr), + mcpserver.WithLogger(r.logger.With(logFields...)), + mcpserver.WithExcludeMutations(r.mcp.ExcludeMutations), + mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations), + mcpserver.WithExposeSchema(r.mcp.ExposeSchema), + mcpserver.WithOmitToolNamePrefix(r.mcp.OmitToolNamePrefix), + mcpserver.WithStateless(r.mcp.Session.Stateless), + } + + if r.corsOptions != nil { + mcpOpts = append(mcpOpts, mcpserver.WithCORS(*r.corsOptions)) + } + + // Add OAuth configuration if enabled + if r.mcp.OAuth.Enabled { + mcpOpts = append(mcpOpts, mcpserver.WithOAuth(&r.mcp.OAuth)) + + if r.mcp.Server.BaseURL != "" { + mcpOpts = append(mcpOpts, mcpserver.WithServerBaseURL(r.mcp.Server.BaseURL)) + } + } + + if r.mcp.ResourceDocumentation != "" { + mcpOpts = append(mcpOpts, mcpserver.WithResourceDocumentation(r.mcp.ResourceDocumentation)) + } + + mcpGraphQLEndpoint := r.graphqlEndpointURL + if r.mcp.RouterURL != "" { + mcpGraphQLEndpoint = r.mcp.RouterURL + } + + mcpss, err := mcpserver.NewGraphQLSchemaServer( + ctx, + mcpGraphQLEndpoint, + mcpOpts..., + ) + if err != nil { + return fmt.Errorf("failed to create mcp server: %w", err) + } + + if err := mcpss.Start(); err != nil { + // Cleanup the server if Start() fails to prevent resource leaks + if stopErr := mcpss.Stop(ctx); stopErr != nil { + r.logger.Warn("Failed to stop MCP server during error cleanup", zap.Error(stopErr)) + } + return fmt.Errorf("failed to start MCP server: %w", err) + } + + r.mcpServer = mcpss + return nil +} + // buildClients initializes the storage clients for persisted operations and router config. func (r *Router) buildClients(ctx context.Context) error { registry := r.providerRegistry diff --git a/router/go.mod b/router/go.mod index 8659d3b114..1b107c9753 100644 --- a/router/go.mod +++ b/router/go.mod @@ -1,6 +1,6 @@ module github.com/wundergraph/cosmo/router -go 1.25 +go 1.25.0 require ( connectrpc.com/connect v1.16.2 @@ -13,7 +13,7 @@ require ( github.com/go-redis/redis_rate/v10 v10.0.1 github.com/gobwas/ws v1.4.0 github.com/goccy/go-yaml v1.17.1 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/websocket v1.5.1 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.7 @@ -51,7 +51,7 @@ require ( go.uber.org/zap v1.27.0 go.withmatt.com/connect-brotli v0.4.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.39.0 // indirect + golang.org/x/sys v0.40.0 // indirect google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.10 ) @@ -75,8 +75,8 @@ require ( github.com/hashicorp/go-plugin v1.6.3 github.com/iancoleman/strcase v0.3.0 github.com/klauspost/compress v1.18.0 - github.com/mark3labs/mcp-go v0.36.0 github.com/minio/minio-go/v7 v7.0.74 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/posthog/posthog-go v1.5.5 github.com/pquerna/cachecontrol v0.2.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 @@ -93,7 +93,6 @@ require ( require ( github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/clock v1.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -109,6 +108,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/frankban/quicktest v1.14.6 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -117,19 +117,18 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/yamux v0.1.1 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect github.com/jensneuse/byte-template v0.0.0-20231025215717-69252eb3ed56 // indirect github.com/jhump/protoreflect v1.17.0 // indirect github.com/kingledion/go-tools v0.6.0 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect @@ -150,10 +149,11 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect github.com/r3labs/sse/v2 v2.8.1 // indirect github.com/rs/xid v1.5.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/spf13/cast v1.7.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -161,7 +161,6 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/twmb/franz-go/pkg/kmsg v1.7.0 // indirect github.com/vbatts/tar-split v0.12.1 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect @@ -169,6 +168,7 @@ require ( go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.46.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect diff --git a/router/go.sum b/router/go.sum index bcbd5ad3d2..f3e013294a 100644 --- a/router/go.sum +++ b/router/go.sum @@ -19,8 +19,6 @@ github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQg github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -53,6 +51,7 @@ github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRcc github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -111,8 +110,8 @@ github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -124,6 +123,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -154,8 +155,6 @@ github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jensneuse/abstractlogger v0.0.4 h1:sa4EH8fhWk3zlTDbSncaWKfwxYM8tYSlQ054ETLyyQY= github.com/jensneuse/abstractlogger v0.0.4/go.mod h1:6WuamOHuykJk8zED/R0LNiLhWR6C7FIAo43ocUEB3mo= github.com/jensneuse/byte-template v0.0.0-20231025215717-69252eb3ed56 h1:wo26fh6a6Za0cOMZIopD2sfH/kq83SJ89ixUWl7pCWc= @@ -166,7 +165,6 @@ github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5Xum github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kingledion/go-tools v0.6.0 h1:y8C/4mWoHgLkO45dB+Y/j0o4Y4WUB5lDTAcMPMtFpTg= github.com/kingledion/go-tools v0.6.0/go.mod h1:qcDJQxBui/H/hterGb90GMlLs9Yi7QrwaJL8OGdbsms= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -187,10 +185,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= -github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -207,6 +201,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.35.0 h1:XFNqNM7v5B+MQMKqVGAyHwYhyKb48jrenXNxIU20ULk= @@ -229,6 +225,7 @@ github.com/phf/go-queue v0.0.0-20170504031614-9abe38d0371d h1:U+PMnTlV2tu7RuMK5e github.com/phf/go-queue v0.0.0-20170504031614-9abe38d0371d/go.mod h1:lXfE4PvvTW5xOjO6Mba8zDPyw8M93B6AQ7frTGnMlA8= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -255,6 +252,7 @@ github.com/r3labs/sse/v2 v2.8.1/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEm github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= @@ -263,6 +261,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+x github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E= github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= @@ -279,8 +281,6 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -323,8 +323,6 @@ github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnn github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLVL040= github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= github.com/wundergraph/go-arena v1.1.0 h1:9+wSRkJAkA2vbYHp6s8tEGhPViRGQNGXqPHT0QzhdIc= @@ -399,6 +397,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -418,8 +418,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= @@ -429,6 +429,8 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/router/mcp.oauth.config.yaml b/router/mcp.oauth.config.yaml new file mode 100644 index 0000000000..ca74648926 --- /dev/null +++ b/router/mcp.oauth.config.yaml @@ -0,0 +1,67 @@ +# MCP OAuth - Example Config +# yaml-language-server: $schema=./pkg/config/config.schema.json +# +# Example config for running the router's MCP server with OAuth 2.1 and +# per-operation scope enforcement. +# +# Before use, edit the fields marked EDIT ME to point at your own +# execution config and MCP operations directory. +# +# Usage (from the router/ directory): +# 1. Start the test OAuth server: +# go run ../router-tests/cmd/oauth-server +# 2. Start the router with this config: +# go run ./cmd/router -config mcp.oauth.config.yaml +# 3. Point any MCP client at http://localhost:5025/mcp + +authentication: + jwt: + jwks: + - url: 'http://localhost:9099/.well-known/jwks.json' + refresh_interval: 1m + +mcp: + enabled: true + graph_name: 'my-graph' + omit_tool_name_prefix: true + + server: + listen_addr: 'localhost:5025' + # port 5026 is for the published MCP server (proxy) endpoint + base_url: 'http://localhost:5026' + + oauth: + enabled: true + authorization_server_url: 'http://localhost:9099' + jwks: + - url: 'http://localhost:9099/.well-known/jwks.json' + refresh_interval: 1m + algorithms: ['RS256'] + # Include the token's existing scopes in 403 insufficient_scope challenges. + # Workaround for MCP client SDKs that replace rather than accumulate scopes. + scope_challenge_include_token_scopes: true + scopes: + initialize: ['mcp:connect'] + tools_list: ['mcp:tools:read'] + tools_call: ['mcp:tools:call'] + execute_graphql: ['mcp:graphql:execute'] + get_schema: ['mcp:schema:read'] + get_operation_info: ['mcp:ops:read'] + + storage: + provider_id: 'mcp-operations' + + session: + stateless: true + + exclude_mutations: false + enable_arbitrary_operations: true + expose_schema: true + + router_url: 'http://localhost:3002/graphql' + +storage_providers: + file_system: + - id: 'mcp-operations' + # EDIT ME: directory containing your persisted MCP operations. + path: '../connectrpc-tutorial/services' diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 73a9a2d9fe..892267b938 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1138,7 +1138,50 @@ type MCPConfiguration struct { RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"` // OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names. // When enabled, GetUser becomes get_user. When disabled (default), GetUser becomes execute_operation_get_user. - OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` + OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` + OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"` + // ResourceDocumentation is a URL to a human-readable page describing this MCP resource, + // its access policies, and how to get started. Included in RFC 9728 Protected Resource Metadata if set. + ResourceDocumentation string `yaml:"resource_documentation,omitempty" env:"MCP_RESOURCE_DOCUMENTATION"` +} + +type MCPOAuthConfiguration struct { + Enabled bool `yaml:"enabled" envDefault:"false" env:"ENABLED"` + JWKS []JWKSConfiguration `yaml:"jwks"` + AuthorizationServerURL string `yaml:"authorization_server_url,omitempty" env:"AUTHORIZATION_SERVER_URL"` + // Scopes configures which OAuth scopes are required for different MCP operations. + Scopes MCPOAuthScopesConfiguration `yaml:"scopes,omitempty" envPrefix:"SCOPES_"` + // ScopeChallengeIncludeTokenScopes controls whether the server includes the token's existing scopes + // in the scope parameter of 403 insufficient_scope responses. + // When false (default), only the scopes required for the operation are returned (RFC 6750 strict). + // When true, the token's existing scopes are unioned with the required scopes. + // This is a workaround for MCP client SDKs that replace rather than accumulate scopes. + ScopeChallengeIncludeTokenScopes bool `yaml:"scope_challenge_include_token_scopes" envDefault:"false" env:"SCOPE_CHALLENGE_INCLUDE_TOKEN_SCOPES"` + // MaxScopeCombinations sets the upper limit on the number of OR-group combinations + // produced when computing the Cartesian product of @requiresScopes across fields. + // Increase for complex RBAC configurations. + MaxScopeCombinations int `yaml:"max_scope_combinations" envDefault:"2048" env:"MAX_SCOPE_COMBINATIONS"` +} + +// MCPOAuthScopesConfiguration defines which scopes are required for different MCP operations. +// All configured scopes are automatically unioned into scopes_supported for OAuth metadata discovery. +type MCPOAuthScopesConfiguration struct { + // Initialize specifies scopes required for ALL HTTP requests (checked before JSON-RPC parsing). + // This is the baseline scope needed to establish an MCP connection. + Initialize []string `yaml:"initialize,omitempty" env:"INITIALIZE"` + // ToolsList specifies scopes required for the tools/list MCP method. + ToolsList []string `yaml:"tools_list,omitempty" env:"TOOLS_LIST"` + // ToolsCall specifies scopes required for the tools/call MCP method (any tool). + ToolsCall []string `yaml:"tools_call,omitempty" env:"TOOLS_CALL"` + // ExecuteGraphQL specifies scopes required to call the execute_graphql built-in tool. + // Additive to tools_call scopes. Only relevant when enable_arbitrary_operations is true. + ExecuteGraphQL []string `yaml:"execute_graphql,omitempty" env:"EXECUTE_GRAPHQL"` + // GetOperationInfo specifies scopes required to call the get_operation_info built-in tool. + // Additive to tools_call scopes. + GetOperationInfo []string `yaml:"get_operation_info,omitempty" env:"GET_OPERATION_INFO"` + // GetSchema specifies scopes required to call the get_schema built-in tool. + // Additive to tools_call scopes. Only relevant when expose_schema is true. + GetSchema []string `yaml:"get_schema,omitempty" env:"GET_SCHEMA"` } type MCPSessionConfig struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 5e2e00d99f..242112f83f 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2047,7 +2047,7 @@ "max_wait": { "type": "string", "description": "Maximum time to wait for a refresh permit before giving up.", - "default": "10s", + "default": "2m", "duration": { "minimum": "0s" } @@ -2055,7 +2055,7 @@ "interval": { "type": "string", "description": "Token refill interval for the rate limiter.", - "default": "1m", + "default": "30s", "duration": { "minimum": "1s" } @@ -2355,10 +2355,8 @@ "format": "hostname-port" }, "base_url": { - "deprecated": true, - "deprecationMessage": "The base_url is deprecated. This property was related to the SSE protocol that is not supported anymore.", "type": "string", - "description": "The base URL of the MCP server. This is the URL advertised to the LLM clients when SSE is used as primary transport. By default, the base URL is relative to the URL that the router is running on. The URL is specified as a string with the format 'scheme://host:port'.", + "description": "The base URL of the MCP server used for OAuth 2.0 discovery (RFC 9728). This URL is advertised in the Protected Resource Metadata endpoint and used to construct the resource metadata URL. Required when OAuth is enabled. The URL is specified as a string with the format 'scheme://host:port'.", "format": "http-url" } } @@ -2416,7 +2414,247 @@ "type": "boolean", "default": false, "description": "When enabled, MCP tool names generated from GraphQL operations omit the 'execute_operation_' prefix. For example, the GraphQL operation 'GetUser' results in a tool named 'get_user' instead of 'execute_operation_get_user'." + }, + "resource_documentation": { + "type": "string", + "description": "A URL to a human-readable page describing this MCP resource, its access policies, and how to get started. Included in the RFC 9728 Protected Resource Metadata response if set.", + "format": "http-url" + }, + "oauth": { + "type": "object", + "description": "OAuth/JWKS authentication configuration for the MCP server. When enabled, MCP tool calls require valid JWT authentication and the server implements OAuth 2.0 discovery mechanisms (RFC 8414, RFC 9728).", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable OAuth/JWKS authentication for the MCP server. When true, all MCP tool calls must include a valid JWT token." + }, + "authorization_server_url": { + "type": "string", + "description": "The base URL of the OAuth 2.0 authorization server. This URL is advertised to MCP clients via the Protected Resource Metadata endpoint (RFC 9728) to enable automatic discovery of OAuth endpoints. Clients will append '/.well-known/oauth-authorization-server' to this URL to discover token, authorization, and registration endpoints. Example: 'https://auth.example.com'", + "format": "http-url" + }, + "scopes": { + "type": "object", + "description": "Configures which OAuth scopes are required for different MCP operations. All configured scopes are automatically unioned into 'scopes_supported' for OAuth metadata discovery.", + "additionalProperties": false, + "properties": { + "initialize": { + "type": "array", + "description": "Scopes required for ALL HTTP requests (checked before JSON-RPC parsing). This is the baseline scope needed to establish an MCP connection.", + "items": { "type": "string" } + }, + "tools_list": { + "type": "array", + "description": "Scopes required for the tools/list MCP method.", + "items": { "type": "string" } + }, + "tools_call": { + "type": "array", + "description": "Scopes required for the tools/call MCP method (any tool).", + "items": { "type": "string" } + }, + "execute_graphql": { + "type": "array", + "description": "Scopes required to call the execute_graphql built-in tool. Additive to tools_call scopes. Only relevant when enable_arbitrary_operations is true.", + "items": { "type": "string" } + }, + "get_operation_info": { + "type": "array", + "description": "Scopes required to call the get_operation_info built-in tool. Additive to tools_call scopes.", + "items": { "type": "string" } + }, + "get_schema": { + "type": "array", + "description": "Scopes required to call the get_schema built-in tool. Additive to tools_call scopes. Only relevant when expose_schema is true.", + "items": { "type": "string" } + } + } + }, + "scope_challenge_include_token_scopes": { + "type": "boolean", + "default": false, + "description": "When true, includes the token's existing scopes in the scope parameter of 403 insufficient_scope responses (workaround for MCP client SDKs that replace rather than accumulate scopes). When false (default), only the scopes required for the operation are returned (RFC 6750 strict)." + }, + "jwks": { + "type": "array", + "description": "List of JWKS (JSON Web Key Set) configurations for JWT token verification. Multiple JWKS providers can be configured for different authentication sources.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "The URL of the JWKs. The JWKs are used to verify the JWT (JSON Web Token). The URL is specified as a string with the format 'scheme://host:port'.", + "format": "http-url" + }, + "audiences": { + "type": "array", + "description": "The audiences of the JWKs. The audiences are used to verify the JWT (JSON Web Token). The audiences are specified as a list of strings.", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string", + "description": "The secret of the JWKs" + }, + "symmetric_algorithm": { + "type": "string", + "description": "The symmetric algorithm used", + "enum": ["HS256", "HS384", "HS512"] + }, + "header_key_id": { + "type": "string", + "description": "The KID header of the JWK token created using the secret" + }, + "allowed_use": { + "type": "array", + "description": "The allowed value of the use parameter for the JWKs. If not specified, only keys with use set to 'sig' will be used. If your server provides no use, you can add an empty value to allow those keys.", + "default": ["sig"], + "items": { + "type": "string", + "enum": [ + "sig", + "enc", + "" + ] + } + }, + "algorithms": { + "type": "array", + "description": "The allowed algorithms for the keys that are retrieved from the JWKs. An empty list means that all algorithms are allowed.", + "items": { + "type": "string", + "enum": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", + "EdDSA" + ] + } + }, + "refresh_interval": { + "type": "string", + "duration": { + "minimum": "5s" + }, + "description": "The interval at which the JWKs are refreshed. The period is specified as a string with a number and a unit, e.g. 10ms, 1s, 1m, 1h. The supported units are 'ms', 's', 'm', 'h'.", + "default": "1m" + }, + "refresh_unknown_kid": { + "type": "object", + "description": "Controls rate-limited refresh behavior when a JWT KID is unknown.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable refresh attempts on unknown KID.", + "default": false + }, + "max_wait": { + "type": "string", + "description": "Maximum time to wait for a refresh permit before giving up.", + "default": "2m", + "duration": { + "minimum": "0s" + } + }, + "interval": { + "type": "string", + "description": "Token refill interval for the rate limiter.", + "default": "30s", + "duration": { + "minimum": "1s" + } + }, + "burst": { + "type": "integer", + "description": "Burst size for the rate limiter.", + "default": 2, + "minimum": 1 + } + } + } + }, + "oneOf": [ + { + "required": ["url"], + "not": { + "anyOf": [ + { + "required": ["secret"] + }, + { + "required": ["symmetric_algorithm"] + }, + { + "required": ["header_key_id"] + } + ] + } + }, + { + "required": ["secret", "symmetric_algorithm", "header_key_id"], + "not": { + "anyOf": [ + { + "required": ["url"] + }, + { + "required": ["algorithms"] + }, + { + "required": ["refresh_interval"] + }, + { + "required": ["refresh_unknown_kid"] + } + ] + } + } + ] + } + } + } } + }, + "if": { + "properties": { + "oauth": { + "properties": { + "enabled": { "const": true } + }, + "required": ["enabled"] + } + }, + "required": ["oauth"] + }, + "then": { + "properties": { + "server": { + "required": ["base_url"] + }, + "oauth": { + "properties": { + "jwks": { + "minItems": 1 + } + }, + "required": ["jwks"] + } + }, + "required": ["server", "oauth"] } }, "connect_rpc": { @@ -3721,6 +3959,138 @@ } }, "$defs": { + "jwks_configuration": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "The URL of the JWKs. The JWKs are used to verify the JWT (JSON Web Token). The URL is specified as a string with the format 'scheme://host:port'.", + "format": "http-url" + }, + "audiences": { + "type": "array", + "description": "The audiences of the JWKs. The audiences are used to verify the JWT (JSON Web Token). The audiences are specified as a list of strings.", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string", + "description": "The secret of the JWKs" + }, + "symmetric_algorithm": { + "type": "string", + "description": "The symmetric algorithm used", + "enum": ["HS256", "HS384", "HS512"] + }, + "header_key_id": { + "type": "string", + "description": "The KID header of the JWK token created using the secret" + }, + "algorithms": { + "type": "array", + "description": "The allowed algorithms for the keys that are retrieved from the JWKs. An empty list means that all algorithms are allowed.", + "items": { + "type": "string", + "enum": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", + "EdDSA" + ] + } + }, + "refresh_interval": { + "type": "string", + "duration": { + "minimum": "5s" + }, + "description": "The interval at which the JWKs are refreshed. The period is specified as a string with a number and a unit, e.g. 10ms, 1s, 1m, 1h. The supported units are 'ms', 's', 'm', 'h'.", + "default": "1m" + }, + "refresh_unknown_kid": { + "type": "object", + "description": "Controls rate-limited refresh behavior when a JWT KID is unknown.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable refresh attempts on unknown KID.", + "default": false + }, + "max_wait": { + "type": "string", + "description": "Maximum time to wait for a refresh permit before giving up.", + "default": "2m", + "duration": { + "minimum": "0s" + } + }, + "interval": { + "type": "string", + "description": "Token refill interval for the rate limiter.", + "default": "30s", + "duration": { + "minimum": "1s" + } + }, + "burst": { + "type": "integer", + "description": "Burst size for the rate limiter.", + "default": 2, + "minimum": 1 + } + } + } + }, + "oneOf": [ + { + "required": ["url"], + "not": { + "anyOf": [ + { + "required": ["secret"] + }, + { + "required": ["symmetric_algorithm"] + }, + { + "required": ["header_key_id"] + } + ] + } + }, + { + "required": ["secret", "symmetric_algorithm", "header_key_id"], + "not": { + "anyOf": [ + { + "required": ["url"] + }, + { + "required": ["algorithms"] + }, + { + "required": ["refresh_interval"] + }, + { + "required": ["refresh_unknown_kid"] + } + ] + } + } + ] + }, "traffic_shaping_subgraph_request_rule": { "type": "object", "additionalProperties": false, diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index e4237fd1c5..97090e690f 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -164,7 +164,23 @@ "EnableArbitraryOperations": false, "ExposeSchema": false, "RouterURL": "", - "OmitToolNamePrefix": false + "OmitToolNamePrefix": false, + "OAuth": { + "Enabled": false, + "JWKS": null, + "AuthorizationServerURL": "", + "Scopes": { + "Initialize": null, + "ToolsList": null, + "ToolsCall": null, + "ExecuteGraphQL": null, + "GetOperationInfo": null, + "GetSchema": null + }, + "ScopeChallengeIncludeTokenScopes": false, + "MaxScopeCombinations": 2048 + }, + "ResourceDocumentation": "" }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 6e5dc9ae7d..cf2b7dec45 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -209,7 +209,23 @@ "EnableArbitraryOperations": false, "ExposeSchema": false, "RouterURL": "https://cosmo-router.wundergraph.com", - "OmitToolNamePrefix": false + "OmitToolNamePrefix": false, + "OAuth": { + "Enabled": false, + "JWKS": null, + "AuthorizationServerURL": "", + "Scopes": { + "Initialize": null, + "ToolsList": null, + "ToolsCall": null, + "ExecuteGraphQL": null, + "GetOperationInfo": null, + "GetSchema": null + }, + "ScopeChallengeIncludeTokenScopes": false, + "MaxScopeCombinations": 2048 + }, + "ResourceDocumentation": "" }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go new file mode 100644 index 0000000000..6c78a9bfc2 --- /dev/null +++ b/router/pkg/mcpserver/auth_middleware.go @@ -0,0 +1,335 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + + "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" +) + +type contextKey string + +const ( + userClaimsContextKey contextKey = "mcp_user_claims" + // maxBodyBytes prevents memory exhaustion from oversized payloads. + maxBodyBytes int64 = 10 << 20 // 10 MiB +) + +// mcpAuthProvider adapts MCP headers to the authentication.Provider interface +type mcpAuthProvider struct { + headers http.Header +} + +func (p *mcpAuthProvider) AuthenticationHeaders() http.Header { + return p.headers +} + +// MCPAuthMiddleware provides HTTP-level authentication and scope enforcement for MCP. +type MCPAuthMiddleware struct { + authenticator authentication.Authenticator + resourceMetadataURL string + scopes config.MCPOAuthScopesConfiguration + scopeChallengeIncludeTokenScopes bool + toolScopesMu sync.RWMutex + toolScopes map[string][][]string // toolName -> OR-of-AND scope groups + scopeExtractorMu sync.RWMutex + scopeExtractor *ScopeExtractor +} + +// NewMCPAuthMiddleware creates a new authentication middleware. +func NewMCPAuthMiddleware(tokenDecoder authentication.TokenDecoder, resourceMetadataURL string, scopes config.MCPOAuthScopesConfiguration, scopeChallengeIncludeTokenScopes bool) (*MCPAuthMiddleware, error) { + if tokenDecoder == nil { + return nil, fmt.Errorf("token decoder must be provided") + } + + authenticator, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ + Name: "mcp-auth", + TokenDecoder: tokenDecoder, + }) + if err != nil { + return nil, fmt.Errorf("failed to create authenticator: %w", err) + } + + return &MCPAuthMiddleware{ + authenticator: authenticator, + resourceMetadataURL: resourceMetadataURL, + scopes: scopes, + scopeChallengeIncludeTokenScopes: scopeChallengeIncludeTokenScopes, + }, nil +} + +// SetToolScopes atomically replaces the per-tool scope map. +func (m *MCPAuthMiddleware) SetToolScopes(scopes map[string][][]string) { + m.toolScopesMu.Lock() + defer m.toolScopesMu.Unlock() + m.toolScopes = scopes +} + +func (m *MCPAuthMiddleware) getToolScopes(toolName string) [][]string { + m.toolScopesMu.RLock() + defer m.toolScopesMu.RUnlock() + if m.toolScopes == nil { + return nil + } + return m.toolScopes[toolName] +} + +func (m *MCPAuthMiddleware) getBuiltinToolScopes(toolName string) []string { + switch toolName { + case "execute_graphql": + return m.scopes.ExecuteGraphQL + case "get_operation_info": + return m.scopes.GetOperationInfo + case "get_schema": + return m.scopes.GetSchema + default: + return nil + } +} + +// SetScopeExtractor atomically replaces the scope extractor used for +// runtime scope checking of execute_graphql arbitrary operations. +func (m *MCPAuthMiddleware) SetScopeExtractor(extractor *ScopeExtractor) { + m.scopeExtractorMu.Lock() + defer m.scopeExtractorMu.Unlock() + m.scopeExtractor = extractor +} + +func (m *MCPAuthMiddleware) getScopeExtractor() *ScopeExtractor { + m.scopeExtractorMu.RLock() + defer m.scopeExtractorMu.RUnlock() + return m.scopeExtractor +} + +// HTTPMiddleware wraps HTTP handlers with authentication for ALL MCP operations. +// Per MCP spec: "authorization MUST be included in every HTTP request from client to server" +func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + provider := &mcpAuthProvider{headers: r.Header} + + claims, err := m.authenticator.Authenticate(r.Context(), provider) + if err != nil || len(claims) == 0 { + m.sendUnauthorizedResponse(w, "invalid or missing access token") + return + } + + // Extract token scopes once for all checks in this request + tokenScopes := extractScopes(claims) + tokenScopeSet := toSet(tokenScopes) + + if len(m.scopes.Initialize) > 0 { + if missing := findMissing(tokenScopeSet, m.scopes.Initialize); len(missing) > 0 { + m.sendInsufficientScopeResponse(w, m.scopes.Initialize, tokenScopes, missing) + return + } + } + + // Parse JSON-RPC body for method-level scope checks (SSE/GET requests have no body) + var body []byte + if r.Method == http.MethodPost && r.Body != nil { + body, err = io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1)) + if err != nil { + m.sendUnauthorizedResponse(w, "failed to read request body") + return + } + if int64(len(body)) > maxBodyBytes { + m.sendUnauthorizedResponse(w, "request body too large") + return + } + r.Body = io.NopCloser(bytes.NewBuffer(body)) + } + + if len(body) > 0 { + var jsonRPCReq struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } `json:"params"` + } + if err := json.Unmarshal(body, &jsonRPCReq); err == nil && jsonRPCReq.Method != "" { + // Method-level scope check + var methodScopes []string + switch jsonRPCReq.Method { + case "tools/list": + methodScopes = m.scopes.ToolsList + case "tools/call": + methodScopes = m.scopes.ToolsCall + } + if len(methodScopes) > 0 { + if missing := findMissing(tokenScopeSet, methodScopes); len(missing) > 0 { + m.sendInsufficientScopeResponse(w, methodScopes, tokenScopes, missing) + return + } + } + + if jsonRPCReq.Method == "tools/call" && jsonRPCReq.Params.Name != "" { + toolName := jsonRPCReq.Params.Name + + // Built-in tool scope check (additive to tools_call gate) + if builtinScopes := m.getBuiltinToolScopes(toolName); len(builtinScopes) > 0 { + if missing := findMissing(tokenScopeSet, builtinScopes); len(missing) > 0 { + m.sendInsufficientScopeResponse(w, builtinScopes, tokenScopes, missing) + return + } + } + + // Per-tool scope check from @requiresScopes directives + if toolOrScopes := m.getToolScopes(toolName); len(toolOrScopes) > 0 { + if !satisfiesAnyGroup(tokenScopeSet, toolOrScopes) { + challengeScopes := bestScopeChallengeWithExisting(tokenScopes, toolOrScopes, m.scopeChallengeIncludeTokenScopes) + m.sendPerToolInsufficientScopeResponse(w, challengeScopes, toolName) + return + } + } + + // Runtime scope check for execute_graphql: parse the query and + // extract @requiresScopes at the HTTP level (proper 403 + WWW-Authenticate) + if toolName == "execute_graphql" && len(jsonRPCReq.Params.Arguments) > 0 { + if extractor := m.getScopeExtractor(); extractor != nil { + if challengeScopes := m.checkExecuteGraphQLScopes(tokenScopes, tokenScopeSet, jsonRPCReq.Params.Arguments, extractor); len(challengeScopes) > 0 { + m.sendPerToolInsufficientScopeResponse(w, challengeScopes, "execute_graphql") + return + } + } + } + } + } + } + + ctx := context.WithValue(r.Context(), userClaimsContextKey, claims) + ctx = requestHeadersFromRequest(ctx, r) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// sendUnauthorizedResponse sends a 401 with WWW-Authenticate per RFC 6750 and RFC 9728. +func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, errorDescription string) { + authHeader := `Bearer realm="mcp"` + + if len(m.scopes.Initialize) > 0 { + authHeader += fmt.Sprintf(`, scope="%s"`, strings.Join(m.scopes.Initialize, " ")) + } + if m.resourceMetadataURL != "" { + authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) + } + if errorDescription != "" { + desc := strings.ReplaceAll(errorDescription, `"`, `'`) + authHeader += fmt.Sprintf(`, error_description="%s"`, desc) + } + + w.Header().Set("WWW-Authenticate", authHeader) + w.WriteHeader(http.StatusUnauthorized) +} + +// sendInsufficientScopeResponse sends a 403 per RFC 6750 Section 3.1. +// When scopeChallengeIncludeTokenScopes is true, the challenge includes the token's +// existing scopes to work around client SDKs that replace rather than accumulate scopes. +func (m *MCPAuthMiddleware) sendInsufficientScopeResponse(w http.ResponseWriter, operationScopes []string, tokenScopes []string, missingScopes []string) { + challengeScopes := operationScopes + if m.scopeChallengeIncludeTokenScopes { + challengeScopes = mergeAndDedup(tokenScopes, operationScopes) + } + + desc := strings.ReplaceAll(fmt.Sprintf("missing required scopes: %s", strings.Join(missingScopes, ", ")), `"`, `'`) + m.writeScopeChallenge(w, challengeScopes, desc) +} + +// sendPerToolInsufficientScopeResponse sends a 403 for per-tool scope failures. +func (m *MCPAuthMiddleware) sendPerToolInsufficientScopeResponse(w http.ResponseWriter, challengeScopes []string, toolName string) { + sanitizedName := strings.ReplaceAll(toolName, `"`, `'`) + m.writeScopeChallenge(w, challengeScopes, fmt.Sprintf("insufficient scopes for tool %s", sanitizedName)) +} + +// writeScopeChallenge writes a 403 with a WWW-Authenticate Bearer challenge. +func (m *MCPAuthMiddleware) writeScopeChallenge(w http.ResponseWriter, scopes []string, errorDescription string) { + authHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s"`, strings.Join(scopes, " ")) + if m.resourceMetadataURL != "" { + authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) + } + authHeader += fmt.Sprintf(`, error_description="%s"`, errorDescription) + + w.Header().Set("WWW-Authenticate", authHeader) + w.WriteHeader(http.StatusForbidden) +} + +// checkExecuteGraphQLScopes parses the GraphQL query from execute_graphql arguments, +// extracts @requiresScopes requirements, and returns the challenge scopes if insufficient. +func (m *MCPAuthMiddleware) checkExecuteGraphQLScopes(tokenScopes []string, tokenScopeSet map[string]struct{}, arguments json.RawMessage, extractor *ScopeExtractor) []string { + var args struct { + Query string `json:"query"` + } + if err := json.Unmarshal(arguments, &args); err != nil || args.Query == "" { + return nil + } + + opDoc, report := astparser.ParseGraphqlDocumentString(args.Query) + if report.HasErrors() { + return nil // let the tool handler deal with parse errors + } + + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + if err != nil { + // Fail closed: if we cannot determine scope requirements, treat as insufficient. + return []string{"insufficient_scope"} + } + if len(fieldReqs) == 0 { + return nil + } + + combinedScopes, err := extractor.ComputeCombinedScopes(fieldReqs) + if err != nil { + // Scope combination limit exceeded — fail closed. The query touches too many + // @requiresScopes fields, producing a pathological number of combinations. + // Return a sentinel challenge so the caller sends 403. + return []string{"insufficient_scope"} + } + if len(combinedScopes) == 0 { + return nil + } + + if satisfiesAnyGroup(tokenScopeSet, combinedScopes) { + return nil + } + + return bestScopeChallengeWithExisting(tokenScopes, combinedScopes, m.scopeChallengeIncludeTokenScopes) +} + +// findMissing returns scopes from required that are not in tokenSet. +func findMissing(tokenSet map[string]struct{}, required []string) []string { + var missing []string + for _, s := range required { + if _, ok := tokenSet[s]; !ok { + missing = append(missing, s) + } + } + return missing +} + +// extractScopes extracts space-separated scope values from the OAuth 2.0 "scope" claim. +func extractScopes(claims authentication.Claims) []string { + scopeClaim, ok := claims["scope"] + if !ok { + return nil + } + scopeStr, ok := scopeClaim.(string) + if !ok { + return nil + } + return strings.Fields(scopeStr) +} + +// GetClaimsFromContext retrieves authenticated user claims from context. +func GetClaimsFromContext(ctx context.Context) (authentication.Claims, bool) { + claims, ok := ctx.Value(userClaimsContextKey).(authentication.Claims) + return claims, ok +} diff --git a/router/pkg/mcpserver/auth_middleware_test.go b/router/pkg/mcpserver/auth_middleware_test.go new file mode 100644 index 0000000000..b6591418ab --- /dev/null +++ b/router/pkg/mcpserver/auth_middleware_test.go @@ -0,0 +1,700 @@ +package mcpserver + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +// mockTokenDecoder is a mock implementation of authentication.TokenDecoder for testing +type mockTokenDecoder struct { + decodeFunc func(token string) (authentication.Claims, error) +} + +func (m *mockTokenDecoder) Decode(token string) (authentication.Claims, error) { + if m.decodeFunc != nil { + return m.decodeFunc(token) + } + return nil, errors.New("decode not implemented") +} + +func TestNewMCPAuthMiddleware(t *testing.T) { + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return authentication.Claims{"sub": "user123"}, nil + }, + } + + tests := []struct { + name string + decoder authentication.TokenDecoder + wantErr bool + }{ + { + name: "returns middleware when decoder is valid", + decoder: validDecoder, + wantErr: false, + }, + { + name: "returns error when decoder is nil", + decoder: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(tt.decoder, "https://test.example/.well-known/oauth-protected-resource/mcp", config.MCPOAuthScopesConfiguration{}, false) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, middleware) + } else { + assert.NoError(t, err) + assert.NotNil(t, middleware) + } + }) + } +} + +func TestGetClaimsFromContext(t *testing.T) { + expectedClaims := authentication.Claims{"sub": "user123", "email": "user@example.com"} + + tests := []struct { + name string + setupCtx func() context.Context + wantOk bool + wantClaims authentication.Claims + }{ + { + name: "returns claims when present in context", + setupCtx: func() context.Context { + return context.WithValue(context.Background(), userClaimsContextKey, expectedClaims) + }, + wantOk: true, + wantClaims: expectedClaims, + }, + { + name: "returns false when claims are absent from context", + setupCtx: func() context.Context { + return context.Background() + }, + wantOk: false, + wantClaims: nil, + }, + { + name: "returns false when context value has wrong type", + setupCtx: func() context.Context { + return context.WithValue(context.Background(), userClaimsContextKey, "not-claims") + }, + wantOk: false, + wantClaims: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims, ok := GetClaimsFromContext(tt.setupCtx()) + assert.Equal(t, tt.wantOk, ok) + assert.Equal(t, tt.wantClaims, claims) + }) + } +} + +func TestExtractScopes(t *testing.T) { + tests := []struct { + name string + claims authentication.Claims + want []string + }{ + { + name: "splits scope claim into multiple values", + claims: authentication.Claims{ + "scope": "mcp:tools mcp:read mcp:write", + }, + want: []string{"mcp:tools", "mcp:read", "mcp:write"}, + }, + { + name: "returns single value for single-scope claim", + claims: authentication.Claims{ + "scope": "mcp:tools", + }, + want: []string{"mcp:tools"}, + }, + { + name: "returns nil when scope claim is missing", + claims: authentication.Claims{}, + want: nil, + }, + { + name: "returns empty slice when scope claim is empty string", + claims: authentication.Claims{ + "scope": "", + }, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractScopes(tt.claims) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMCPAuthMiddlewareHTTP(t *testing.T) { + t.Parallel() + + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" + + tests := []struct { + name string + scopes config.MCPOAuthScopesConfiguration + setupDecoder func() *mockTokenDecoder + setupRequest func() *http.Request + wantStatusCode int + wantWWWAuthenticatePrefix string + }{ + { + name: "allows request with valid token when no scopes are configured", + scopes: config.MCPOAuthScopesConfiguration{}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + if token == "valid-token" { + return authentication.Claims{"sub": "user123"}, nil + } + return nil, errors.New("invalid token") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + req.Header.Set("Authorization", "Bearer valid-token") + return req + }, + wantStatusCode: 200, + }, + { + name: "returns 401 with init scopes in challenge when auth header is missing", + scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return nil, errors.New("missing authorization header") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + return req + }, + wantStatusCode: 401, + wantWWWAuthenticatePrefix: `Bearer realm="mcp", scope="mcp:connect", resource_metadata="` + testMetadataURL + `"`, + }, + { + name: "returns 401 without scope challenge when no scopes are configured", + scopes: config.MCPOAuthScopesConfiguration{}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return nil, errors.New("missing authorization header") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + return req + }, + wantStatusCode: 401, + wantWWWAuthenticatePrefix: `Bearer realm="mcp", resource_metadata="` + testMetadataURL + `"`, + }, + { + name: "returns 401 with init scopes in challenge when token is invalid", + scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return nil, errors.New("token validation failed") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + req.Header.Set("Authorization", "Bearer invalid-token") + return req + }, + wantStatusCode: 401, + wantWWWAuthenticatePrefix: `Bearer realm="mcp", scope="mcp:connect", resource_metadata="` + testMetadataURL + `"`, + }, + { + name: "returns 403 with token scopes in challenge when init scopes are insufficient", + scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + if token == "valid-token" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:tools:read", + }, nil + } + return nil, errors.New("invalid token") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + req.Header.Set("Authorization", "Bearer valid-token") + return req + }, + wantStatusCode: 403, + wantWWWAuthenticatePrefix: `Bearer error="insufficient_scope", scope="mcp:tools:read mcp:connect"`, + }, + { + name: "allows request when token has all required scopes", + scopes: config.MCPOAuthScopesConfiguration{ + Initialize: []string{"mcp:connect"}, + }, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + if token == "valid-token" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:connect mcp:tools:read mcp:tools:write", + }, nil + } + return nil, errors.New("invalid token") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + req.Header.Set("Authorization", "Bearer valid-token") + return req + }, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decoder := tt.setupDecoder() + middleware, err := NewMCPAuthMiddleware(decoder, testMetadataURL, tt.scopes, true) + assert.NoError(t, err) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req := tt.setupRequest() + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantWWWAuthenticatePrefix != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantWWWAuthenticatePrefix) + } + }) + } +} + +func TestMCPAuthMiddlewarePerToolScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" + + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + switch token { + case "no-scopes": + return authentication.Claims{"sub": "user1", "scope": "mcp:connect mcp:tools:write"}, nil + case "has-read-fact": + return authentication.Claims{"sub": "user2", "scope": "mcp:connect mcp:tools:write read:fact"}, nil + case "has-read-all": + return authentication.Claims{"sub": "user3", "scope": "mcp:connect mcp:tools:write read:all"}, nil + case "has-read-employee": + return authentication.Claims{"sub": "user4", "scope": "mcp:connect mcp:tools:write read:employee"}, nil + case "has-read-employee-private": + return authentication.Claims{"sub": "user5", "scope": "mcp:connect mcp:tools:write read:employee read:private"}, nil + default: + return nil, errors.New("invalid token") + } + }, + } + + scopes := config.MCPOAuthScopesConfiguration{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:write"}, + } + + // Tool scopes simulating @requiresScopes extraction + toolScopes := map[string][][]string{ + "execute_operation_get_top_secret_facts": { + {"read:fact"}, + {"read:all"}, + }, + "execute_operation_get_employee_start_date": { + {"read:employee", "read:private"}, + {"read:all"}, + }, + // execute_operation_list_employees has no scopes (not in map) + } + + tests := []struct { + name string + token string + body string + scopeChallengeIncludeTokenScopes bool + wantStatusCode int + wantScope string + wantContains string // additional WWW-Authenticate check + }{ + { + name: "allows tool call when tool has no per-tool scopes configured", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_list_employees"}}`, + wantStatusCode: 200, + }, + { + name: "allows tool call when token has required per-tool scope", + token: "has-read-fact", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, + wantStatusCode: 200, + }, + { + name: "allows tool call when token has alternative per-tool scope", + token: "has-read-all", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, + wantStatusCode: 200, + }, + { + name: "returns 403 with smallest group as challenge when token lacks per-tool scopes", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, + wantStatusCode: 403, + wantScope: `scope="read:fact"`, + wantContains: `error_description="insufficient scopes for tool execute_operation_get_top_secret_facts"`, + }, + { + name: "includes token scopes in per-tool challenge when configured", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:write read:fact"`, + }, + { + name: "returns 403 with closest group as challenge when token has only one scope from an AND group", + token: "has-read-employee", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, + wantStatusCode: 403, + // Group 1: ["read:employee", "read:private"] missing "read:private" (1 missing) + // Group 2: ["read:all"] missing "read:all" (1 missing) + // Tie → first group wins + wantScope: `scope="read:employee read:private"`, + }, + { + name: "allows tool call when token satisfies full AND group", + token: "has-read-employee-private", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, + wantStatusCode: 200, + }, + { + name: "allows tool call when token has scope from alternative OR group", + token: "has-read-all", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, + wantStatusCode: 200, + }, + { + name: "returns 403 with smallest group as challenge when token has no relevant scopes", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, + wantStatusCode: 403, + // Group 1: 2 missing, Group 2: 1 missing → Group 2 wins + wantScope: `scope="read:all"`, + }, + { + name: "allows tools/list regardless of per-tool scopes", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + wantStatusCode: 200, + }, + { + name: "allows tool call when tool name has no per-tool scopes configured", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"unknown_tool"}}`, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(validDecoder, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + assert.NoError(t, err) + + // Set per-tool scopes + middleware.SetToolScopes(toolScopes) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(tt.body)) + req.Header.Set("Authorization", "Bearer "+tt.token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantScope != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantScope, "WWW-Authenticate header should contain expected scope") + } + if tt.wantContains != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantContains, "WWW-Authenticate header should contain expected string") + } + }) + } +} + +func TestMCPAuthMiddlewareMethodLevelScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" + + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + if token == "connect-only" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:connect", + }, nil + } + if token == "connect-and-read" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:connect mcp:tools:read", + }, nil + } + if token == "all-scopes" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:connect mcp:tools:read mcp:tools:write", + }, nil + } + return nil, errors.New("invalid token") + }, + } + + scopes := config.MCPOAuthScopesConfiguration{ + Initialize: []string{"mcp:connect"}, + ToolsList: []string{"mcp:tools:read"}, + ToolsCall: []string{"mcp:tools:write"}, + } + + tests := []struct { + name string + token string + body string + scopeChallengeIncludeTokenScopes bool + wantStatusCode int + wantScope string // expected scope value in WWW-Authenticate, empty if not checked + }{ + { + name: "returns 403 with only operation scopes when tools/list lacks required scopes", + token: "connect-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 403, + wantScope: `scope="mcp:tools:read"`, + }, + { + name: "returns 403 with token and operation scopes when tools/list lacks required scopes and include token scopes is enabled", + token: "connect-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:read"`, + }, + { + name: "allows tools/list when token has required scopes", + token: "connect-and-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 200, + }, + { + name: "returns 403 with only operation scopes when tools/call lacks required scopes", + token: "connect-and-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 403, + wantScope: `scope="mcp:tools:write"`, + }, + { + name: "returns 403 with token and operation scopes when tools/call lacks required scopes and include token scopes is enabled", + token: "connect-and-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:read mcp:tools:write"`, + }, + { + name: "allows tools/call when token has all required scopes", + token: "all-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 200, + }, + { + name: "allows unknown method when no scope requirements are configured", + token: "connect-only", + body: `{"jsonrpc":"2.0","id":1,"method":"ping"}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(validDecoder, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + assert.NoError(t, err) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(tt.body)) + req.Header.Set("Authorization", "Bearer "+tt.token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantScope != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantScope) + } + }) + } +} + +func TestMCPAuthMiddlewareBuiltinToolScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" + + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + switch token { + case "base-only": + return authentication.Claims{"sub": "user1", "scope": "mcp:connect mcp:tools:call"}, nil + case "has-schema-read": + return authentication.Claims{"sub": "user2", "scope": "mcp:connect mcp:tools:call mcp:schema:read"}, nil + case "has-graphql-execute": + return authentication.Claims{"sub": "user3", "scope": "mcp:connect mcp:tools:call mcp:graphql:execute"}, nil + case "has-ops-read": + return authentication.Claims{"sub": "user4", "scope": "mcp:connect mcp:tools:call mcp:ops:read"}, nil + default: + return nil, errors.New("invalid token") + } + }, + } + + scopes := config.MCPOAuthScopesConfiguration{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:call"}, + ExecuteGraphQL: []string{"mcp:graphql:execute"}, + GetOperationInfo: []string{"mcp:ops:read"}, + GetSchema: []string{"mcp:schema:read"}, + } + + tests := []struct { + name string + token string + body string + wantStatusCode int + wantScope string + }{ + { + name: "returns 403 when execute_graphql lacks required builtin scope", + token: "base-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + wantStatusCode: 403, + wantScope: `scope="mcp:graphql:execute"`, + }, + { + name: "allows execute_graphql when token has required builtin scope", + token: "has-graphql-execute", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + wantStatusCode: 200, + }, + { + name: "returns 403 when get_schema lacks required builtin scope", + token: "base-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema"}}`, + wantStatusCode: 403, + wantScope: `scope="mcp:schema:read"`, + }, + { + name: "allows get_schema when token has required builtin scope", + token: "has-schema-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema"}}`, + wantStatusCode: 200, + }, + { + name: "returns 403 when get_operation_info lacks required builtin scope", + token: "base-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_operation_info"}}`, + wantStatusCode: 403, + wantScope: `scope="mcp:ops:read"`, + }, + { + name: "allows get_operation_info when token has required builtin scope", + token: "has-ops-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_operation_info"}}`, + wantStatusCode: 200, + }, + { + name: "allows non-builtin tool regardless of builtin scopes", + token: "base-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_users"}}`, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(validDecoder, testMetadataURL, scopes, false) + assert.NoError(t, err) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(tt.body)) + req.Header.Set("Authorization", "Bearer "+tt.token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantScope != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantScope, "WWW-Authenticate header should contain expected scope") + } + }) + } +} diff --git a/router/pkg/mcpserver/errors.go b/router/pkg/mcpserver/errors.go new file mode 100644 index 0000000000..e91a64cf88 --- /dev/null +++ b/router/pkg/mcpserver/errors.go @@ -0,0 +1,34 @@ +package mcpserver + +// JSON-RPC 2.0 and MCP error codes +// +// Error code ranges: +// - Standard JSON-RPC 2.0: -32768 to -32000 (reserved by JSON-RPC spec) +// - Server errors (implementation-defined): -32000 to -32099 (within JSON-RPC reserved range) +// - Application errors: Must use codes outside -32768 to -32000 to avoid conflicts with JSON-RPC reserved codes +const ( + // Standard JSON-RPC 2.0 error codes + ErrorCodeParseError = -32700 // Invalid JSON was received by the server + ErrorCodeInvalidRequest = -32600 // The JSON sent is not a valid Request object + ErrorCodeMethodNotFound = -32601 // The method does not exist / is not available + ErrorCodeInvalidParams = -32602 // Invalid method parameter(s) + ErrorCodeInternalError = -32603 // Internal JSON-RPC error + + // MCP-specific error codes (from MCP specification) + // See: https://spec.modelcontextprotocol.io/specification/basic/errors/ + ErrorCodeResourceNotFound = -32002 // Requested resource was not found + + // Custom Cosmo MCP server error codes + // These use the reserved range -32000 to -32099 for implementation-defined server errors + ErrorCodeAuthenticationRequired = -32001 // Authentication required (OAuth/JWT) + ErrorCodeInsufficientScope = -32003 // Token lacks required OAuth scopes (RFC 6750) +) + +// Error messages +const ( + ErrorMessageAuthenticationRequired = "Authentication required" + ErrorMessageInsufficientScope = "Insufficient scope" + ErrorMessageResourceNotFound = "Resource not found" + ErrorMessageInvalidParams = "Invalid params" + ErrorMessageInternalError = "Internal error" +) diff --git a/router/pkg/mcpserver/execute_graphql_scope_test.go b/router/pkg/mcpserver/execute_graphql_scope_test.go new file mode 100644 index 0000000000..3f39e6f216 --- /dev/null +++ b/router/pkg/mcpserver/execute_graphql_scope_test.go @@ -0,0 +1,205 @@ +package mcpserver + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +func TestMCPAuthMiddlewareExecuteGraphQLScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" + + schema := parseTestSchema(t) + fieldConfigs := testFieldConfigs() + extractor := NewScopeExtractor(fieldConfigs, &schema, 0) + + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + switch token { + case "no-extra-scopes": + return authentication.Claims{"sub": "user1", "scope": "mcp:connect mcp:tools:write"}, nil + case "has-read-fact": + return authentication.Claims{"sub": "user2", "scope": "mcp:connect mcp:tools:write read:fact"}, nil + case "has-read-all": + return authentication.Claims{"sub": "user3", "scope": "mcp:connect mcp:tools:write read:all"}, nil + case "has-read-employee": + return authentication.Claims{"sub": "user4", "scope": "mcp:connect mcp:tools:write read:employee"}, nil + case "has-read-employee-private": + return authentication.Claims{"sub": "user5", "scope": "mcp:connect mcp:tools:write read:employee read:private"}, nil + case "has-mcp-connect": + return authentication.Claims{"sub": "user6", "scope": "mcp:connect mcp:tools:write"}, nil + default: + return nil, errors.New("invalid token") + } + }, + } + + scopes := config.MCPOAuthScopesConfiguration{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:write"}, + } + + makeBody := func(query string) string { + // Escape quotes in query for JSON + escaped := strings.ReplaceAll(query, `"`, `\"`) + return `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql","arguments":{"query":"` + escaped + `"}}}` + } + + tests := []struct { + name string + token string + query string + scopeChallengeIncludeTokenScopes bool + wantStatusCode int + wantScope string + wantContains string + }{ + { + name: "allows request when query has no scoped fields", + token: "no-extra-scopes", + query: `query { employees { id tag } }`, + wantStatusCode: 200, + }, + { + name: "allows request when token has required scope", + token: "has-read-fact", + query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, + wantStatusCode: 200, + }, + { + name: "allows request when token has alternative OR scope", + token: "has-read-all", + query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, + wantStatusCode: 200, + }, + { + name: "returns 403 with scope challenge when token is missing required scope", + token: "no-extra-scopes", + query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, + wantStatusCode: 403, + wantScope: `scope="read:fact"`, + wantContains: `error_description="insufficient scopes for tool execute_graphql"`, + }, + { + name: "returns 403 when token has only one scope from an AND group", + token: "has-read-employee", + query: `query { employee(id: 1) { id startDate } }`, + wantStatusCode: 403, + wantScope: `scope="read:employee read:private"`, + }, + { + name: "allows request when token satisfies full AND group", + token: "has-read-employee-private", + query: `query { employee(id: 1) { id startDate } }`, + wantStatusCode: 200, + }, + { + name: "allows request when token has scope from alternative OR group", + token: "has-read-all", + query: `query { employee(id: 1) { id startDate } }`, + wantStatusCode: 200, + }, + { + name: "picks smallest missing group for challenge when token has no relevant scopes", + token: "no-extra-scopes", + query: `query { employee(id: 1) { id startDate } }`, + wantStatusCode: 403, + // Group 1: 2 missing, Group 2: 1 missing → Group 2 wins + wantScope: `scope="read:all"`, + }, + { + name: "includes token scopes in challenge when configured", + token: "has-mcp-connect", + query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:write read:fact"`, + }, + { + name: "allows request through when query fails to parse", + token: "no-extra-scopes", + query: `not a valid query {}`, + wantStatusCode: 200, + }, + { + name: "allows request through when query is empty", + token: "no-extra-scopes", + query: ``, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(validDecoder, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + assert.NoError(t, err) + + // Set scope extractor for execute_graphql runtime checking + middleware.SetScopeExtractor(extractor) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(makeBody(tt.query))) + req.Header.Set("Authorization", "Bearer "+tt.token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantScope != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantScope, "WWW-Authenticate header should contain expected scope") + } + if tt.wantContains != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantContains, "WWW-Authenticate header should contain expected string") + } + }) + } +} + +func TestMCPAuthMiddlewareExecuteGraphQLNoExtractor(t *testing.T) { + t.Parallel() + + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" + + decoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return authentication.Claims{"sub": "user1", "scope": "mcp:connect mcp:tools:write"}, nil + }, + } + + scopes := config.MCPOAuthScopesConfiguration{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:write"}, + } + + middleware, err := NewMCPAuthMiddleware(decoder, testMetadataURL, scopes, false) + assert.NoError(t, err) + // Deliberately NOT setting a scope extractor + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + // Scoped query should pass through when no extractor is set + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql","arguments":{"query":"query { topSecretFederationFacts { ... on DirectiveFact { title } } }"}}}` + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer test") + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, 200, rr.Code, "should pass through when no scope extractor is configured") +} diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index 0bbe2e15d6..85e4b3a1c7 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -3,9 +3,12 @@ package mcpserver import ( "fmt" + "go.uber.org/zap" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/pkg/schemaloader" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "go.uber.org/zap" ) // OperationsManager handles the loading and preparation of GraphQL operations @@ -83,6 +86,26 @@ func (om *OperationsManager) GetOperation(name string) *schemaloader.Operation { return nil } +// ComputeToolScopes runs the scope extractor against all loaded operations, +// populating each operation's RequiredScopes from @requiresScopes directives. +// Returns an error if any operation exceeds the scope combination limit, which +// indicates a pathological @requiresScopes configuration that should be simplified. +func (om *OperationsManager) ComputeToolScopes(fieldConfigs []*nodev1.FieldConfiguration, maxScopeCombinations int) error { + extractor := NewScopeExtractor(fieldConfigs, om.schemaDoc, maxScopeCombinations) + for i := range om.operations { + fieldReqs, err := extractor.ExtractScopesForOperation(&om.operations[i].Document) + if err != nil { + return fmt.Errorf("tool %q: %w", om.operations[i].Name, err) + } + combinedScopes, err := extractor.ComputeCombinedScopes(fieldReqs) + if err != nil { + return fmt.Errorf("tool %q: %w", om.operations[i].Name, err) + } + om.operations[i].RequiredScopes = combinedScopes + } + return nil +} + // GetSchema returns the schema document used by the operations manager func (om *OperationsManager) GetSchema() *ast.Document { return om.schemaDoc diff --git a/router/pkg/mcpserver/schema_compiler.go b/router/pkg/mcpserver/schema_compiler.go index 2bfcf79966..8ec3412447 100644 --- a/router/pkg/mcpserver/schema_compiler.go +++ b/router/pkg/mcpserver/schema_compiler.go @@ -62,7 +62,7 @@ func (sc *SchemaCompiler) ValidateInput(data []byte, compiledSchema *jsonschema. return nil } - var v interface{} + var v any if err := json.Unmarshal(data, &v); err != nil { return fmt.Errorf("failed to parse JSON input: %w", err) } @@ -70,7 +70,10 @@ func (sc *SchemaCompiler) ValidateInput(data []byte, compiledSchema *jsonschema. if err := compiledSchema.Validate(v); err != nil { var validationErr *jsonschema.ValidationError if errors.As(err, &validationErr) { - return fmt.Errorf("validation error: %s", validationErr.Causes[0].Error()) + if len(validationErr.Causes) > 0 { + return fmt.Errorf("validation error: %s", validationErr.Causes[0].Error()) + } + return fmt.Errorf("validation error: %s", validationErr.Error()) } return fmt.Errorf("schema validation failed: %w", err) } diff --git a/router/pkg/mcpserver/scope_challenge.go b/router/pkg/mcpserver/scope_challenge.go new file mode 100644 index 0000000000..6b3bd0b7bc --- /dev/null +++ b/router/pkg/mcpserver/scope_challenge.go @@ -0,0 +1,88 @@ +package mcpserver + +// satisfiesAnyGroup checks whether tokenScopeSet satisfies at least one AND-group +// in the OR-of-AND scope requirements. Returns true if no requirements exist. +func satisfiesAnyGroup(tokenScopeSet map[string]struct{}, orScopes [][]string) bool { + if len(orScopes) == 0 { + return true + } + for _, andGroup := range orScopes { + if satisfiesAll(tokenScopeSet, andGroup) { + return true + } + } + return false +} + +// bestScopeChallenge picks the AND-group closest to the client's current scopes. +// Returns the complete AND-group that the client should request, or nil if any +// group is already satisfied. +// +// Algorithm: +// 1. For each AND-group, count how many scopes the token is missing. +// 2. If any group has 0 missing, return nil (already satisfied). +// 3. Pick the group with the fewest missing scopes (ties: first group wins). +func bestScopeChallenge(tokenScopes []string, combinedOrScopes [][]string) []string { + if len(combinedOrScopes) == 0 { + return nil + } + + tokenSet := toSet(tokenScopes) + + bestIdx := -1 + bestMissing := -1 + + for i, andGroup := range combinedOrScopes { + missing := 0 + for _, scope := range andGroup { + if _, ok := tokenSet[scope]; !ok { + missing++ + } + } + if missing == 0 { + return nil + } + if bestIdx == -1 || missing < bestMissing { + bestIdx = i + bestMissing = missing + } + } + + return combinedOrScopes[bestIdx] +} + +// bestScopeChallengeWithExisting returns the challenge scopes, optionally including +// the token's existing scopes. When includeExisting is true, the result is the union +// of the token's current scopes and the best AND-group, deduplicated. This works +// around MCP client SDKs that replace rather than accumulate scopes on re-authorization. +func bestScopeChallengeWithExisting(tokenScopes []string, combinedOrScopes [][]string, includeExisting bool) []string { + best := bestScopeChallenge(tokenScopes, combinedOrScopes) + if best == nil { + return nil + } + + if !includeExisting { + return best + } + + return mergeAndDedup(tokenScopes, best) +} + +// satisfiesAll returns true if tokenScopeSet contains every scope in required. +func satisfiesAll(tokenScopeSet map[string]struct{}, required []string) bool { + for _, r := range required { + if _, ok := tokenScopeSet[r]; !ok { + return false + } + } + return true +} + +// toSet converts a string slice to a set for O(1) lookups. +func toSet(ss []string) map[string]struct{} { + m := make(map[string]struct{}, len(ss)) + for _, s := range ss { + m[s] = struct{}{} + } + return m +} diff --git a/router/pkg/mcpserver/scope_challenge.md b/router/pkg/mcpserver/scope_challenge.md new file mode 100644 index 0000000000..c14968b78d --- /dev/null +++ b/router/pkg/mcpserver/scope_challenge.md @@ -0,0 +1,94 @@ +# Smart Scope Challenge Algorithm + +## Problem + +When an MCP client calls a tool but lacks the required OAuth scopes, the server must return a `403 Forbidden` with a `WWW-Authenticate` header containing a `scope` parameter. The client uses this to request the right scopes during re-authorization. + +The `@requiresScopes` directive uses **OR-of-AND** semantics — there may be multiple valid scope combinations that grant access. Rather than dumping all possible scopes, the server should guide the client toward the **closest satisfiable path** based on what scopes the token already has. + +## Scope Representation + +Scopes are represented as `[][]string` — a list of AND-groups where satisfying **any one** group grants access: + +```text +[["a", "b"], ["c", "d"]] → (a AND b) OR (c AND d) +``` + +When an operation touches multiple scoped fields, their requirements are combined via Cartesian product (see `scope_extractor.go`), producing a single `[][]string` for the tool. + +## Algorithm: `bestScopeChallenge` + +**Input:** + +- `tokenScopes` — scopes the client's JWT currently has +- `combinedOrScopes` — the tool's OR-of-AND requirements + +**Steps:** + +1. If `combinedOrScopes` is empty, return `nil` (no requirements). +2. Build a set from `tokenScopes` for O(1) lookup. +3. For each AND-group, count how many scopes the token is **missing**. +4. If any group has **0 missing**, return `nil` — the token already satisfies the requirement. +5. Pick the group with the **fewest missing** scopes. On ties, pick the **first** group (stable ordering). +6. Return the complete AND-group as the challenge. + +**Why return the complete group, not just the missing scopes?** + +OAuth authorization requests specify the full set of scopes desired. The client needs the complete group to know what to request — not a diff. + +## Examples + +### Simple OR (single-scope groups) + +```text +Required: [["read:fact"], ["read:all"]] +``` + +| Token scopes | Missing per group | Best group | Challenge | +| --------------- | ----------------- | ----------- | --------------- | +| `["read:fact"]` | 0, 1 | satisfied | `nil` | +| `["read:all"]` | 1, 0 | satisfied | `nil` | +| `[]` | 1, 1 | first (tie) | `["read:fact"]` | +| `["other"]` | 1, 1 | first (tie) | `["read:fact"]` | + +### AND group with shortcut + +```text +Required: [["read:employee", "read:private"], ["read:all"]] +``` + +| Token scopes | Missing per group | Best group | Challenge | +| ----------------------------------- | ----------------- | ----------- | ----------------------------------- | +| `["read:employee", "read:private"]` | 0, 1 | satisfied | `nil` | +| `["read:employee"]` | 1, 1 | first (tie) | `["read:employee", "read:private"]` | +| `[]` | 2, 1 | group 2 | `["read:all"]` | + +### Cross-subgraph aggregation + +```text +Required: [ + ["read:fact", "read:employee", "read:private"], + ["read:fact", "read:all"], + ["read:all", "read:employee", "read:private"], + ["read:all"] +] +``` + +| Token scopes | Missing per group | Best group | Challenge | +| --------------- | ----------------- | ------------------- | --------------------------- | +| `["read:all"]` | 2, 1, 2, 0 | satisfied | `nil` | +| `["read:fact"]` | 2, 1, 2, 1 | group 2 (tie→first) | `["read:fact", "read:all"]` | +| `[]` | 3, 2, 3, 1 | group 4 | `["read:all"]` | + +## `bestScopeChallengeWithExisting` + +Some MCP client SDKs **replace** rather than **accumulate** scopes when re-authorizing. If the challenge only contains the scopes for the failed operation, the client loses its existing scopes. + +When `includeExisting` is `true`, the result is the **union** of the token's current scopes and the best AND-group, deduplicated, preserving order (token scopes first). + +Example: token has `["init", "mcp:tools:write", "a"]`, best group is `["a", "b", "d"]` +→ result: `["init", "mcp:tools:write", "a", "b", "d"]` + +## `satisfiesAnyGroup` + +A simple check: does the token satisfy at least one AND-group? Returns `true` if requirements are empty/nil (no scopes needed). Used as the gate check before computing a challenge. diff --git a/router/pkg/mcpserver/scope_challenge_test.go b/router/pkg/mcpserver/scope_challenge_test.go new file mode 100644 index 0000000000..44df50dbc8 --- /dev/null +++ b/router/pkg/mcpserver/scope_challenge_test.go @@ -0,0 +1,339 @@ +package mcpserver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBestScopeChallenge(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tokenScopes []string + combinedOrScopes [][]string + want []string + }{ + // --- Simple OR scopes (single field, single-scope groups) --- + // e.g. Query.topSecretFederationFacts → [["read:fact"], ["read:all"]] + { + name: "returns nil when token satisfies first OR group", + tokenScopes: []string{"read:fact"}, + combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, + want: nil, + }, + { + name: "returns nil when token satisfies second OR group", + tokenScopes: []string{"read:all"}, + combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, + want: nil, + }, + { + name: "returns first group as challenge when token is empty and all groups tie", + tokenScopes: []string{}, + combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, + want: []string{"read:fact"}, + }, + { + name: "returns first group as challenge when token has only unrelated scopes", + tokenScopes: []string{"read:other"}, + combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, + want: []string{"read:fact"}, + }, + + // --- Mutation with simple OR scopes --- + // e.g. Mutation.addFact → [["write:fact"], ["write:all"]] + { + name: "returns nil when token has matching write scope for first OR group", + tokenScopes: []string{"write:fact"}, + combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, + want: nil, + }, + { + name: "returns nil when token has wildcard write scope for second OR group", + tokenScopes: []string{"write:all"}, + combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, + want: nil, + }, + { + name: "returns first group as challenge when token has scope from wrong category", + tokenScopes: []string{"read:fact"}, + combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, + want: []string{"write:fact"}, + }, + { + name: "returns first group as challenge when token is empty for mutation", + tokenScopes: []string{}, + combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, + want: []string{"write:fact"}, + }, + + // --- AND scopes with OR alternative --- + // e.g. Employee.startDate → [["read:employee", "read:private"], ["read:all"]] + { + name: "returns nil when token satisfies all scopes in an AND group", + tokenScopes: []string{"read:employee", "read:private"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: nil, + }, + { + name: "returns nil when token satisfies alternative single-scope OR group", + tokenScopes: []string{"read:all"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: nil, + }, + { + name: "returns first group when token partially matches on tie", + tokenScopes: []string{"read:employee"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: []string{"read:employee", "read:private"}, + }, + { + name: "returns first group when token has the other partial match on tie", + tokenScopes: []string{"read:private"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: []string{"read:employee", "read:private"}, + }, + { + name: "returns shorter group as challenge when token is empty and groups differ in size", + tokenScopes: []string{}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: []string{"read:all"}, + }, + + // --- Cross-product: multiple scoped fields --- + // 3 scoped fields with cross-product yielding 6 groups (see plan Operation 5) + { + name: "returns group with fewest missing scopes when token has wildcard", + tokenScopes: []string{"read:all"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:scalar", "read:all", "read:miscellaneous"}, + {"read:fact", "read:all", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:all", "read:miscellaneous"}, + {"read:all", "read:scalar", "read:miscellaneous"}, + {"read:all", "read:miscellaneous"}, + }, + want: []string{"read:all", "read:miscellaneous"}, // missing only 1 + }, + { + name: "returns group with fewest missing scopes for partial match", + tokenScopes: []string{"read:fact", "read:scalar"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:scalar", "read:all", "read:miscellaneous"}, + {"read:fact", "read:all", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:all", "read:miscellaneous"}, + {"read:all", "read:scalar", "read:miscellaneous"}, + {"read:all", "read:miscellaneous"}, + }, + want: []string{"read:fact", "read:scalar", "read:miscellaneous"}, // missing only "read:miscellaneous" + }, + { + name: "returns group with fewest total scopes when token is empty", + tokenScopes: []string{}, + combinedOrScopes: [][]string{ + {"read:fact", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:scalar", "read:all", "read:miscellaneous"}, + {"read:fact", "read:all", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:all", "read:miscellaneous"}, + {"read:all", "read:scalar", "read:miscellaneous"}, + {"read:all", "read:miscellaneous"}, + }, + want: []string{"read:all", "read:miscellaneous"}, // fewest total: 2 + }, + + // --- Cross-subgraph aggregation --- + // Products + Employees subgraph scoped fields, cross-product yields 4 groups + { + name: "returns nil when token has wildcard scope satisfying aggregated groups", + tokenScopes: []string{"read:all"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, + }, + want: nil, + }, + { + name: "returns closest group when token partially matches across subgraphs", + tokenScopes: []string{"read:fact"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, + }, + want: []string{"read:fact", "read:all"}, // missing 1, tied with group 4, first tie wins + }, + { + name: "returns smallest group when token has unrelated partial match", + tokenScopes: []string{"read:employee"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, + }, + want: []string{"read:all"}, // missing 1, clear winner + }, + { + name: "returns smallest group when token is empty across subgraphs", + tokenScopes: []string{}, + combinedOrScopes: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, + }, + want: []string{"read:all"}, // fewest missing: 1 + }, + + // --- Edge cases --- + { + name: "returns nil when combined scopes is nil", + tokenScopes: []string{"some:scope"}, + combinedOrScopes: nil, + want: nil, + }, + { + name: "returns nil when combined scopes is empty", + tokenScopes: []string{"some:scope"}, + combinedOrScopes: [][]string{}, + want: nil, + }, + { + name: "returns the only group when single AND group is not satisfied", + tokenScopes: []string{"a"}, + combinedOrScopes: [][]string{{"a", "b", "c"}}, + want: []string{"a", "b", "c"}, + }, + { + name: "returns nil when single AND group is fully satisfied", + tokenScopes: []string{"a", "b", "c"}, + combinedOrScopes: [][]string{{"a", "b", "c"}}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := bestScopeChallenge(tt.tokenScopes, tt.combinedOrScopes) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBestScopeChallengeWithExisting(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tokenScopes []string + combinedOrScopes [][]string + includeExisting bool + want []string + }{ + { + name: "returns union of token scopes and best group when include existing is true", + tokenScopes: []string{"init", "mcp:tools:write", "a"}, + combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, + includeExisting: true, + want: []string{"init", "mcp:tools:write", "a", "b", "d"}, + }, + { + name: "returns only the best group when include existing is false", + tokenScopes: []string{"init", "mcp:tools:write", "a"}, + combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, + includeExisting: false, + want: []string{"a", "b", "d"}, + }, + { + name: "returns nil when token satisfies scopes even with include existing enabled", + tokenScopes: []string{"a", "b", "d"}, + combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, + includeExisting: true, + want: nil, + }, + { + name: "deduplicates overlapping scopes when merging token scopes with best group", + tokenScopes: []string{"read:employee"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + includeExisting: true, + want: []string{"read:employee", "read:private"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := bestScopeChallengeWithExisting(tt.tokenScopes, tt.combinedOrScopes, tt.includeExisting) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSatisfiesAnyGroup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tokenScopes []string + orScopes [][]string + want bool + }{ + { + name: "returns true when token satisfies first AND group", + tokenScopes: []string{"a", "b"}, + orScopes: [][]string{{"a", "b"}, {"c", "d"}}, + want: true, + }, + { + name: "returns true when token satisfies second AND group with extra scopes", + tokenScopes: []string{"c", "d", "e"}, + orScopes: [][]string{{"a", "b"}, {"c", "d"}}, + want: true, + }, + { + name: "returns false when token only partially matches each AND group", + tokenScopes: []string{"a", "c"}, + orScopes: [][]string{{"a", "b"}, {"c", "d"}}, + want: false, + }, + { + name: "returns true when required scopes are empty", + tokenScopes: []string{}, + orScopes: [][]string{}, + want: true, + }, + { + name: "returns true when required scopes are nil", + tokenScopes: []string{}, + orScopes: nil, + want: true, + }, + { + name: "returns false when token is empty but scopes are required", + tokenScopes: []string{}, + orScopes: [][]string{{"a"}}, + want: false, + }, + { + name: "returns true when token is a superset of an AND group", + tokenScopes: []string{"a", "b", "c", "d"}, + orScopes: [][]string{{"a", "b"}}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := satisfiesAnyGroup(toSet(tt.tokenScopes), tt.orScopes) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/router/pkg/mcpserver/scope_extractor.go b/router/pkg/mcpserver/scope_extractor.go new file mode 100644 index 0000000000..a855abbea9 --- /dev/null +++ b/router/pkg/mcpserver/scope_extractor.go @@ -0,0 +1,181 @@ +package mcpserver + +import ( + "fmt" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +// FieldScopeRequirement represents the scope requirement for a single field. +// OrScopes is a list of AND-groups — satisfy any one group to access the field. +// e.g., [["a", "b"], ["c"]] means (a AND b) OR (c) +type FieldScopeRequirement struct { + TypeName string + FieldName string + OrScopes [][]string +} + +// ScopeExtractor walks operations and extracts per-field scope requirements +// from FieldConfigurations. +type ScopeExtractor struct { + // scopeIndex maps "TypeName.FieldName" to OR-of-AND scope groups for O(1) lookup. + scopeIndex map[string][][]string + schemaDoc *ast.Document + maxScopeCombinations int +} + +// NewScopeExtractor creates a new ScopeExtractor. +func NewScopeExtractor(fieldConfigs []*nodev1.FieldConfiguration, schemaDoc *ast.Document, maxScopeCombinations int) *ScopeExtractor { + index := make(map[string][][]string) + for _, fc := range fieldConfigs { + authConfig := fc.GetAuthorizationConfiguration() + if authConfig == nil { + continue + } + orScopes := authConfig.GetRequiredOrScopes() + if len(orScopes) == 0 { + continue + } + groups := make([][]string, len(orScopes)) + for i, s := range orScopes { + groups[i] = s.GetRequiredAndScopes() + } + index[fc.GetTypeName()+"."+fc.GetFieldName()] = groups + } + return &ScopeExtractor{ + scopeIndex: index, + schemaDoc: schemaDoc, + maxScopeCombinations: maxScopeCombinations, + } +} + +// ExtractScopesForOperation walks the operation's selection set and returns +// per-field scope requirements for fields that have @requiresScopes. +// Returns an error if the walker fails, so callers can fail closed rather than +// acting on a partially-populated result. +func (e *ScopeExtractor) ExtractScopesForOperation(operation *ast.Document) ([]FieldScopeRequirement, error) { + walker := astvisitor.NewWalker(48) + + v := &scopeFieldVisitor{ + walker: &walker, + operation: operation, + definition: e.schemaDoc, + scopeIndex: e.scopeIndex, + } + + walker.RegisterEnterFieldVisitor(v) + + report := &operationreport.Report{} + walker.Walk(operation, e.schemaDoc, report) + if report.HasErrors() { + return nil, fmt.Errorf("scope extraction walker failed: %w", report) + } + + return v.results, nil +} + +// ComputeCombinedScopes computes the Cartesian product of OR-groups across fields, +// deduplicating scopes within each combined AND-group. +// Returns an error if the number of combinations exceeds MaxScopeCombinations, +// which prevents pathological scope configurations from consuming unbounded resources. +func (e *ScopeExtractor) ComputeCombinedScopes(fieldReqs []FieldScopeRequirement) ([][]string, error) { + if len(fieldReqs) == 0 { + return nil, nil + } + + // Start with the first field's OR-groups + result := fieldReqs[0].OrScopes + + // Iteratively cross-product with each subsequent field's OR-groups + for i := 1; i < len(fieldReqs); i++ { + product, err := crossProduct(result, fieldReqs[i].OrScopes, e.maxScopeCombinations) + if err != nil { + return nil, fmt.Errorf("scope combination limit (%d) exceeded at field %s.%s: %w", + e.maxScopeCombinations, fieldReqs[i].TypeName, fieldReqs[i].FieldName, err) + } + result = product + } + + return result, nil +} + +// scopeFieldVisitor collects scoped field coordinates during AST walking. +type scopeFieldVisitor struct { + walker *astvisitor.Walker + operation *ast.Document + definition *ast.Document + scopeIndex map[string][][]string + results []FieldScopeRequirement + seen map[string]struct{} // dedup "TypeName.FieldName" +} + +func (v *scopeFieldVisitor) EnterField(ref int) { + typeName := v.walker.EnclosingTypeDefinition.NameString(v.definition) + fieldName := v.operation.FieldNameString(ref) + + coordinate := typeName + "." + fieldName + + // Deduplicate — a field can appear multiple times in a selection set + if v.seen == nil { + v.seen = make(map[string]struct{}) + } + if _, ok := v.seen[coordinate]; ok { + return + } + + orScopes, ok := v.scopeIndex[coordinate] + if !ok { + return + } + + v.seen[coordinate] = struct{}{} + v.results = append(v.results, FieldScopeRequirement{ + TypeName: typeName, + FieldName: fieldName, + OrScopes: orScopes, + }) +} + +// crossProduct computes the Cartesian product of two sets of OR-groups, +// merging AND-scopes within each combination and deduplicating. +// Returns an error if the resulting number of combinations would exceed the limit. +// +// Callers pass non-empty inputs in practice. If `a` is empty (no OR branches), +// the result is empty, meaning nothing grants access. +func crossProduct(a, b [][]string, maxCombinations int) ([][]string, error) { + total := len(a) * len(b) + if total > maxCombinations { + return nil, fmt.Errorf("cross product would produce %d combinations", total) + } + result := make([][]string, 0, total) + for _, groupA := range a { + for _, groupB := range b { + merged := mergeAndDedup(groupA, groupB) + result = append(result, merged) + } + } + return result, nil +} + +// mergeAndDedup merges two AND-groups into one, preserving order and removing duplicates. +func mergeAndDedup(a, b []string) []string { + seen := make(map[string]struct{}, len(a)+len(b)) + result := make([]string, 0, len(a)+len(b)) + for _, s := range a { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + for _, s := range b { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + return result +} diff --git a/router/pkg/mcpserver/scope_extractor_test.go b/router/pkg/mcpserver/scope_extractor_test.go new file mode 100644 index 0000000000..62869cfbea --- /dev/null +++ b/router/pkg/mcpserver/scope_extractor_test.go @@ -0,0 +1,504 @@ +package mcpserver + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" +) + +// testFieldConfigs returns field configurations matching the demo subgraphs' +// @requiresScopes directives after composition (from config.json). +func testFieldConfigs() []*nodev1.FieldConfiguration { + return []*nodev1.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "topSecretFederationFacts", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:fact"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "Mutation", + FieldName: "addFact", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"write:fact"}}, + {RequiredAndScopes: []string{"write:all"}}, + }, + }, + }, + { + TypeName: "Employee", + FieldName: "startDate", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:employee", "read:private"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "TopSecretFact", + FieldName: "description", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:scalar"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "DirectiveFact", + FieldName: "description", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:scalar"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "EntityFact", + FieldName: "description", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:scalar"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "MiscellaneousFact", + FieldName: "description", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:scalar", "read:miscellaneous"}}, + {RequiredAndScopes: []string{"read:all", "read:miscellaneous"}}, + }, + }, + }, + // Fields with no scope requirements (included to verify they're ignored) + { + TypeName: "Query", + FieldName: "employees", + }, + { + TypeName: "Query", + FieldName: "employee", + }, + { + TypeName: "Employee", + FieldName: "id", + }, + { + TypeName: "Employee", + FieldName: "tag", + }, + } +} + +// testSchemaSDL is a minimal schema covering the demo subgraph types +// needed for selection set walking in scope extraction tests. +const testSchemaSDL = ` +type Query { + employees: [Employee!]! + employee(id: Int!): Employee + topSecretFederationFacts: [TopSecretFact!]! +} + +type Mutation { + addFact(fact: TopSecretFactInput!): TopSecretFact! +} + +input TopSecretFactInput { + title: String! + description: String +} + +type Employee { + id: Int! + details: Details! + tag: String! + updatedAt: String! + startDate: String! +} + +type Details { + forename: String! + surname: String! +} + +interface TopSecretFact { + title: String! + description: String +} + +type DirectiveFact implements TopSecretFact { + title: String! + description: String +} + +type EntityFact implements TopSecretFact { + title: String! + description: String +} + +type MiscellaneousFact implements TopSecretFact { + title: String! + description: String +} +` + +// parseTestSchema parses the test schema SDL and merges it with the base schema +// (required by the AST walker to resolve operation types like Query/Mutation). +func parseTestSchema(t *testing.T) ast.Document { + t.Helper() + doc, report := astparser.ParseGraphqlDocumentString(testSchemaSDL) + require.False(t, report.HasErrors(), "schema parse error: %s", report.Error()) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&doc)) + return doc +} + +func TestExtractScopesForOperation(t *testing.T) { + t.Parallel() + + fieldConfigs := testFieldConfigs() + + tests := []struct { + name string + operation string + wantFields int // expected number of scoped FieldScopeRequirements + wantNoScopes bool // expect nil/empty RequiredScopes + }{ + { + name: "returns no scoped fields for query with only public fields", + operation: ` + query ListEmployees { + employees { + id + details { + forename + surname + } + tag + } + }`, + wantFields: 0, + wantNoScopes: true, + }, + { + name: "returns one scoped field for scoped root query field", + operation: ` + query GetTopSecretFacts { + topSecretFederationFacts { + ... on DirectiveFact { title } + ... on EntityFact { title } + ... on MiscellaneousFact { title } + } + }`, + wantFields: 1, // Query.topSecretFederationFacts + }, + { + name: "returns one scoped field for scoped mutation", + operation: ` + mutation AddFact($fact: TopSecretFactInput!) { + addFact(fact: $fact) { + ... on DirectiveFact { title } + ... on EntityFact { title } + ... on MiscellaneousFact { title } + } + }`, + wantFields: 1, // Mutation.addFact + }, + { + name: "returns one scoped field for entity field with AND group", + operation: ` + query GetEmployeeStartDate($id: Int!) { + employee(id: $id) { + id + details { forename surname } + startDate + } + }`, + wantFields: 1, // Employee.startDate + }, + { + name: "returns multiple scoped fields for inline fragments on different types", + operation: ` + query GetTopSecretFactsWithDescriptions { + topSecretFederationFacts { + ... on DirectiveFact { + title + description + } + ... on MiscellaneousFact { + title + description + } + } + }`, + wantFields: 3, // Query.topSecretFederationFacts, DirectiveFact.description, MiscellaneousFact.description + }, + { + name: "returns scoped fields aggregated from multiple subgraphs", + operation: ` + query GetFactsAndEmployeeStartDate($id: Int!) { + topSecretFederationFacts { + ... on DirectiveFact { title } + } + employee(id: $id) { + id + startDate + } + }`, + wantFields: 2, // Query.topSecretFederationFacts, Employee.startDate + }, + { + name: "returns no scoped fields when only unscoped fields are selected on a scoped type", + operation: ` + query GetEmployeeBasicInfo($id: Int!) { + employee(id: $id) { + id + tag + updatedAt + } + }`, + wantFields: 0, + wantNoScopes: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + schemaDoc := parseTestSchema(t) + + opDoc, opReport := astparser.ParseGraphqlDocumentString(tt.operation) + require.False(t, opReport.HasErrors(), "operation parse error: %s", opReport.Error()) + + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + require.NoError(t, err) + + if tt.wantNoScopes { + assert.Empty(t, fieldReqs, "expected no scoped fields") + } else { + assert.Len(t, fieldReqs, tt.wantFields, "unexpected number of scoped fields") + } + }) + } + + t.Run("returns correct OR-of-AND scopes for root query field", func(t *testing.T) { + t.Parallel() + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) + + opDoc, report := astparser.ParseGraphqlDocumentString(` + query GetTopSecretFacts { + topSecretFederationFacts { + ... on DirectiveFact { title } + } + }`) + require.False(t, report.HasErrors()) + + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + require.NoError(t, err) + require.Len(t, fieldReqs, 1) + assert.Equal(t, "Query", fieldReqs[0].TypeName) + assert.Equal(t, "topSecretFederationFacts", fieldReqs[0].FieldName) + assert.Equal(t, [][]string{{"read:fact"}, {"read:all"}}, fieldReqs[0].OrScopes) + }) + + t.Run("returns AND scopes with OR alternative for entity field", func(t *testing.T) { + t.Parallel() + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) + + opDoc, report := astparser.ParseGraphqlDocumentString(` + query GetEmployeeStartDate($id: Int!) { + employee(id: $id) { + startDate + } + }`) + require.False(t, report.HasErrors()) + + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + require.NoError(t, err) + require.Len(t, fieldReqs, 1) + assert.Equal(t, "Employee", fieldReqs[0].TypeName) + assert.Equal(t, "startDate", fieldReqs[0].FieldName) + assert.Equal(t, [][]string{{"read:employee", "read:private"}, {"read:all"}}, fieldReqs[0].OrScopes) + }) + + t.Run("returns correct scopes for mutation field", func(t *testing.T) { + t.Parallel() + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) + + opDoc, report := astparser.ParseGraphqlDocumentString(` + mutation AddFact($fact: TopSecretFactInput!) { + addFact(fact: $fact) { + ... on DirectiveFact { title } + } + }`) + require.False(t, report.HasErrors()) + + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + require.NoError(t, err) + require.Len(t, fieldReqs, 1) + assert.Equal(t, "Mutation", fieldReqs[0].TypeName) + assert.Equal(t, "addFact", fieldReqs[0].FieldName) + assert.Equal(t, [][]string{{"write:fact"}, {"write:all"}}, fieldReqs[0].OrScopes) + }) +} + +func TestComputeCombinedScopes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fieldReqs []FieldScopeRequirement + want [][]string + }{ + { + name: "returns nil when there are no field requirements", + fieldReqs: nil, + want: nil, + }, + { + name: "passes through a single field's scopes directly", + fieldReqs: []FieldScopeRequirement{ + { + TypeName: "Query", + FieldName: "topSecretFederationFacts", + OrScopes: [][]string{{"read:fact"}, {"read:all"}}, + }, + }, + want: [][]string{{"read:fact"}, {"read:all"}}, + }, + { + name: "computes cross-product with dedup for two fields", + fieldReqs: []FieldScopeRequirement{ + { + TypeName: "Query", + FieldName: "topSecretFederationFacts", + OrScopes: [][]string{{"read:fact"}, {"read:all"}}, + }, + { + TypeName: "Employee", + FieldName: "startDate", + OrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + }, + }, + want: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, // dedup: "read:all" + "read:all" → "read:all" + }, + }, + { + name: "computes full cross-product with dedup for three fields", + fieldReqs: []FieldScopeRequirement{ + { + TypeName: "Query", + FieldName: "topSecretFederationFacts", + OrScopes: [][]string{{"read:fact"}, {"read:all"}}, + }, + { + TypeName: "DirectiveFact", + FieldName: "description", + OrScopes: [][]string{{"read:scalar"}, {"read:all"}}, + }, + { + TypeName: "MiscellaneousFact", + FieldName: "description", + OrScopes: [][]string{{"read:scalar", "read:miscellaneous"}, {"read:all", "read:miscellaneous"}}, + }, + }, + want: [][]string{ + // read:fact × read:scalar × (read:scalar, read:miscellaneous) → dedup read:scalar + {"read:fact", "read:scalar", "read:miscellaneous"}, + // read:fact × read:scalar × (read:all, read:miscellaneous) + {"read:fact", "read:scalar", "read:all", "read:miscellaneous"}, + // read:fact × read:all × (read:scalar, read:miscellaneous) + {"read:fact", "read:all", "read:scalar", "read:miscellaneous"}, + // read:fact × read:all × (read:all, read:miscellaneous) → dedup read:all + {"read:fact", "read:all", "read:miscellaneous"}, + // read:all × read:scalar × (read:scalar, read:miscellaneous) → dedup read:scalar + {"read:all", "read:scalar", "read:miscellaneous"}, + // read:all × read:scalar × (read:all, read:miscellaneous) → dedup read:all + {"read:all", "read:scalar", "read:miscellaneous"}, + // read:all × read:all × (read:scalar, read:miscellaneous) → dedup read:all + {"read:all", "read:scalar", "read:miscellaneous"}, + // read:all × read:all × (read:all, read:miscellaneous) → dedup all + {"read:all", "read:miscellaneous"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + schemaDoc := parseTestSchema(t) + + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 2048) + got, err := extractor.ComputeCombinedScopes(tt.fieldReqs) + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("returns error when combinations exceed configured limit", func(t *testing.T) { + t.Parallel() + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 2048) + + // Build field requirements that will exceed MaxScopeCombinations (2048). + // 12 fields × 2 OR-groups each = 2^12 = 4096 combinations > 2048. + fieldReqs := make([]FieldScopeRequirement, 12) + for i := range fieldReqs { + fieldReqs[i] = FieldScopeRequirement{ + TypeName: "Query", + FieldName: fmt.Sprintf("field_%d", i), + OrScopes: [][]string{{"scope_a"}, {"scope_b"}}, + } + } + + got, err := extractor.ComputeCombinedScopes(fieldReqs) + assert.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "scope combination limit") + }) +} + +func TestCrossProduct(t *testing.T) { + t.Parallel() + + t.Run("returns empty result when OR list is empty", func(t *testing.T) { + t.Parallel() + got, err := crossProduct([][]string{}, [][]string{{"x"}}, 100) + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("returns the other side unchanged when AND group is empty", func(t *testing.T) { + t.Parallel() + got, err := crossProduct([][]string{{}}, [][]string{{"x"}}, 100) + require.NoError(t, err) + assert.Equal(t, [][]string{{"x"}}, got) + }) +} diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 414b19683d..a3c5c36858 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -14,15 +14,19 @@ import ( "github.com/hashicorp/go-retryablehttp" "github.com/iancoleman/strcase" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/santhosh-tekuri/jsonschema/v6" + "go.uber.org/zap" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/internal/headers" + "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/cors" "github.com/wundergraph/cosmo/router/pkg/schemaloader" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" - "go.uber.org/zap" ) // reservedToolNames contains tool names that are internally registered by the MCP server @@ -83,11 +87,17 @@ type Options struct { Stateless bool // CorsConfig is the CORS configuration for the MCP server CorsConfig cors.Config + // OAuthConfig is the OAuth/JWKS configuration for authentication + OAuthConfig *config.MCPOAuthConfiguration + // ServerBaseURL is the base URL of this MCP server (for resource metadata) + ServerBaseURL string + // ResourceDocumentation is a URL to a human-readable page describing this resource + ResourceDocumentation string } // GraphQLSchemaServer represents an MCP server that works with GraphQL schemas and operations type GraphQLSchemaServer struct { - server *server.MCPServer + server *mcp.Server graphName string operationsDir string listenAddr string @@ -95,7 +105,7 @@ type GraphQLSchemaServer struct { httpClient *http.Client requestTimeout time.Duration routerGraphQLEndpoint string - httpServer *server.StreamableHTTPServer + httpServer *http.Server excludeMutations bool enableArbitraryOperations bool exposeSchema bool @@ -105,6 +115,11 @@ type GraphQLSchemaServer struct { schemaCompiler *SchemaCompiler registeredTools []string corsConfig cors.Config + cancel context.CancelFunc + oauthConfig *config.MCPOAuthConfiguration + serverBaseURL string + resourceDocumentation string + authMiddleware *MCPAuthMiddleware } type graphqlRequest struct { @@ -150,6 +165,7 @@ type GraphQLOperationInfoResponse struct { Query string `json:"query"` LLMGuidance LLMGuidance `json:"llmGuidance"` Endpoint string `json:"endpoint"` + RequiredScopes [][]string `json:"requiredScopes,omitempty"` } // GraphQLOperationInfoInput defines the input structure for the graphql_operation_info tool. @@ -176,8 +192,7 @@ type GraphQLResponse struct { } // NewGraphQLSchemaServer creates a new GraphQL schema server -func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options)) (*GraphQLSchemaServer, error) { - +func NewGraphQLSchemaServer(ctx context.Context, routerGraphQLEndpoint string, opts ...func(*Options)) (*GraphQLSchemaServer, error) { if routerGraphQLEndpoint == "" { return nil, fmt.Errorf("routerGraphQLEndpoint cannot be empty") } @@ -203,14 +218,80 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) opt(options) } - // Create the MCP server - mcpServer := server.NewMCPServer( - "wundergraph-cosmo-"+strcase.ToKebab(options.GraphName), - "0.0.1", - // Prompt, Resources aren't supported yet in any of the popular platforms - server.WithToolCapabilities(true), - server.WithPaginationLimit(100), - server.WithRecovery(), + ctx, cancel := context.WithCancel(ctx) + + var authMiddleware *MCPAuthMiddleware + if options.OAuthConfig != nil && options.OAuthConfig.Enabled { + if len(options.OAuthConfig.JWKS) == 0 { + cancel() + return nil, fmt.Errorf("MCP OAuth is enabled but no JWKS providers are configured; this would start an unprotected endpoint") + } + if options.ServerBaseURL == "" { + cancel() + return nil, fmt.Errorf("MCP OAuth is enabled but server base_url is not configured; it is required for OAuth 2.0 Protected Resource Metadata discovery (RFC 9728)") + } + // Convert config.JWKSConfiguration to authentication.JWKSConfig + authConfigs := make([]authentication.JWKSConfig, 0, len(options.OAuthConfig.JWKS)) + for _, jwks := range options.OAuthConfig.JWKS { + authConfigs = append(authConfigs, authentication.JWKSConfig{ + URL: jwks.URL, + RefreshInterval: jwks.RefreshInterval, + AllowedAlgorithms: jwks.Algorithms, + Secret: jwks.Secret, + Algorithm: jwks.Algorithm, + KeyId: jwks.KeyId, + Audiences: jwks.Audiences, + RefreshUnknownKID: authentication.RefreshUnknownKIDConfig{ + Enabled: jwks.RefreshUnknownKID.Enabled, + MaxWait: jwks.RefreshUnknownKID.MaxWait, + Interval: jwks.RefreshUnknownKID.Interval, + Burst: jwks.RefreshUnknownKID.Burst, + }, + }) + } + + // Create token decoder using the managed context for proper lifecycle management + tokenDecoder, err := authentication.NewJwksTokenDecoder( + ctx, + options.Logger, + authConfigs, + ) + if err != nil { + cancel() // Clean up the context if initialization fails + return nil, fmt.Errorf("failed to create token decoder: %w", err) + } + + // Build resource metadata URL for WWW-Authenticate header + resourceMetadataURL := "" + if options.ServerBaseURL != "" { + resourceMetadataURL = fmt.Sprintf("%s/.well-known/oauth-protected-resource/mcp", options.ServerBaseURL) + } + + authMiddleware, err = NewMCPAuthMiddleware(tokenDecoder, resourceMetadataURL, options.OAuthConfig.Scopes, options.OAuthConfig.ScopeChallengeIncludeTokenScopes) + if err != nil { + cancel() // Clean up the context if initialization fails + return nil, fmt.Errorf("failed to create auth middleware: %w", err) + } + + options.Logger.Info("MCP OAuth authentication enabled", + zap.Int("jwks_providers", len(options.OAuthConfig.JWKS)), + zap.String("authorization_server", options.OAuthConfig.AuthorizationServerURL)) + } + + // Create the MCP server with all options + mcpServer := mcp.NewServer( + &mcp.Implementation{ + Name: "wundergraph-cosmo-" + strcase.ToKebab(options.GraphName), + Version: "0.0.1", + }, + &mcp.ServerOptions{ + PageSize: 100, + // Override default capabilities to disable the "logging" capability + // that the SDK advertises by default (for historical reasons). + // We don't implement logging/setLevel, so advertising it causes + // clients like MCP Inspector to call it and fail. + Capabilities: &mcp.ServerCapabilities{}, + }, ) retryClient := retryablehttp.NewClient() @@ -233,6 +314,11 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) omitToolNamePrefix: options.OmitToolNamePrefix, stateless: options.Stateless, corsConfig: options.CorsConfig, + cancel: cancel, + oauthConfig: options.OAuthConfig, + serverBaseURL: options.ServerBaseURL, + resourceDocumentation: options.ResourceDocumentation, + authMiddleware: authMiddleware, } return gs, nil @@ -311,6 +397,7 @@ func WithCORS(corsCfg cors.Config) func(*Options) { corsCfg.AllowOrigins = []string{"*"} corsCfg.AllowMethods = []string{"GET", "PUT", "POST", "DELETE", "OPTIONS"} corsCfg.AllowHeaders = append(corsCfg.AllowHeaders, "Content-Type", "Accept", "Authorization", "Last-Event-ID", "Mcp-Protocol-Version", "Mcp-Session-Id") + corsCfg.ExposeHeaders = append(corsCfg.ExposeHeaders, "Mcp-Session-Id", "WWW-Authenticate") if corsCfg.MaxAge <= 0 { corsCfg.MaxAge = 24 * time.Hour } @@ -318,8 +405,29 @@ func WithCORS(corsCfg cors.Config) func(*Options) { } } -// Serve starts the server with the configured options and returns a streamable HTTP server. -func (s *GraphQLSchemaServer) Serve() (*server.StreamableHTTPServer, error) { +// WithOAuth sets the OAuth configuration +func WithOAuth(oauthCfg *config.MCPOAuthConfiguration) func(*Options) { + return func(o *Options) { + o.OAuthConfig = oauthCfg + } +} + +// WithServerBaseURL sets the server base URL for OAuth discovery +func WithServerBaseURL(baseURL string) func(*Options) { + return func(o *Options) { + o.ServerBaseURL = baseURL + } +} + +// WithResourceDocumentation sets the human-readable documentation URL for RFC 9728 metadata +func WithResourceDocumentation(url string) func(*Options) { + return func(o *Options) { + o.ResourceDocumentation = url + } +} + +// Serve starts the server with the configured options and returns the HTTP server. +func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { // Create custom HTTP server httpServer := &http.Server{ Addr: s.listenAddr, @@ -328,24 +436,44 @@ func (s *GraphQLSchemaServer) Serve() (*server.StreamableHTTPServer, error) { IdleTimeout: 60 * time.Second, } - streamableHTTPServer := server.NewStreamableHTTPServer(s.server, - server.WithStreamableHTTPServer(httpServer), - server.WithLogger(NewZapAdapter(s.logger.With(zap.String("component", "mcp-server")))), - server.WithStateLess(s.stateless), - server.WithHTTPContextFunc(requestHeadersFromRequest), - server.WithHeartbeatInterval(10*time.Second), + // Create MCP streamable HTTP handler + // The getServer function returns our MCP server instance for each request + // Disable the SDK's built-in cross-origin protection (Sec-Fetch-Site check) + // because the router already applies its own CORS middleware around the handler. + cop := http.NewCrossOriginProtection() + cop.AddInsecureBypassPattern("/{path...}") + + streamableHTTPHandler := mcp.NewStreamableHTTPHandler( + func(req *http.Request) *mcp.Server { + return s.server + }, + &mcp.StreamableHTTPOptions{ + Stateless: s.stateless, + CrossOriginProtection: cop, + }, ) middleware := cors.New(s.corsConfig) mux := http.NewServeMux() - // No OAuth protection - original behavior - mux.Handle("/mcp", middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - streamableHTTPServer.ServeHTTP(w, r) - }))) + // OAuth 2.0 Protected Resource Metadata (RFC 9728) — public discovery endpoint + if s.oauthConfig != nil && s.oauthConfig.Enabled && s.oauthConfig.AuthorizationServerURL != "" { + mux.Handle("/.well-known/oauth-protected-resource/mcp", middleware(http.HandlerFunc(s.handleProtectedResourceMetadata))) + } + + // Inject request headers into context so tool handlers can forward them + // to the GraphQL engine via headersFromContext. + mcpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(requestHeadersFromRequest(r.Context(), r)) + streamableHTTPHandler.ServeHTTP(w, r) + }) + if s.authMiddleware != nil { + mux.Handle("/mcp", middleware(s.authMiddleware.HTTPMiddleware(mcpHandler))) + } else { + mux.Handle("/mcp", middleware(mcpHandler)) + } - // Set the handler for the custom HTTP server httpServer.Handler = mux logger := []zap.Field{ @@ -369,12 +497,11 @@ func (s *GraphQLSchemaServer) Serve() (*server.StreamableHTTPServer, error) { } }() - return streamableHTTPServer, nil + return httpServer, nil } // Start loads operations and starts the server func (s *GraphQLSchemaServer) Start() error { - ss, err := s.Serve() if err != nil { return fmt.Errorf("failed to create HTTP server: %w", err) @@ -385,9 +512,9 @@ func (s *GraphQLSchemaServer) Start() error { return nil } -// Reload reloads the operations and schema -func (s *GraphQLSchemaServer) Reload(schema *ast.Document) error { - +// Reload reloads the operations and schema, and computes per-tool scope +// requirements from @requiresScopes directives in the field configurations. +func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev1.FieldConfiguration) error { if s.server == nil { return fmt.Errorf("server is not started") } @@ -401,7 +528,18 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document) error { } } - s.server.DeleteTools(s.registeredTools...) + // Compute per-tool scope requirements from @requiresScopes directives. + // Only meaningful when OAuth is enabled; the scope extractor feeds the + // auth middleware, which is only constructed alongside oauthConfig. + if s.oauthConfig != nil && len(fieldConfigs) > 0 { + maxScopeCombinations := s.oauthConfig.MaxScopeCombinations + if err := s.operationsManager.ComputeToolScopes(fieldConfigs, maxScopeCombinations); err != nil { + return fmt.Errorf("failed to compute tool scopes: %w", err) + } + s.authMiddleware.SetScopeExtractor(NewScopeExtractor(fieldConfigs, schema, maxScopeCombinations)) + } + + s.server.RemoveTools(s.registeredTools...) s.registeredTools = nil if err := s.registerTools(); err != nil { @@ -419,6 +557,11 @@ func (s *GraphQLSchemaServer) Stop(ctx context.Context) error { s.logger.Debug("shutting down MCP server") + // Cancel the server's context to stop background operations (e.g., JWKS key refresh) + if s.cancel != nil { + s.cancel() + } + // Create a shutdown context with timeout shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -432,70 +575,65 @@ func (s *GraphQLSchemaServer) Stop(ctx context.Context) error { // registerTools registers all tools for the MCP server func (s *GraphQLSchemaServer) registerTools() error { - // Only register the schema tool if exposeSchema is enabled if s.exposeSchema { - s.server.AddTool( - mcp.NewTool( - "get_schema", - mcp.WithDescription("Provides the full GraphQL schema of the API."), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: "Get GraphQL Schema", - ReadOnlyHint: mcp.ToBoolPtr(true), - }), - ), - s.handleGetGraphQLSchema(), - ) + // Create a schema with empty properties since get_schema takes no input + getSchemaInputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + + tool := &mcp.Tool{ + Name: "get_schema", + Description: "Provides the full GraphQL schema of the API.", + InputSchema: getSchemaInputSchema, + Annotations: &mcp.ToolAnnotations{ + Title: "Get GraphQL Schema", + ReadOnlyHint: true, + }, + } + s.server.AddTool(tool, s.handleGetGraphQLSchema()) s.registeredTools = append(s.registeredTools, "get_schema") } // Only register the execute_graphql tool if enableArbitraryOperations is enabled if s.enableArbitraryOperations { // Add a tool to execute arbitrary GraphQL queries - executeGraphQLSchema := []byte(`{ - "type": "object", + executeGraphQLSchema := map[string]any{ + "type": "object", "description": "The query and variables to execute.", - "properties": { - "query": { - "type": "string", - "description": "The GraphQL query or mutation string to execute." + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "The GraphQL query or mutation string to execute.", }, - "variables": { - "type": "object", + "variables": map[string]any{ + "type": "object", "additionalProperties": true, - "description": "The variables to pass to the GraphQL query as a JSON object." - } + "description": "The variables to pass to the GraphQL query as a JSON object.", + }, }, "additionalProperties": false, - "required": ["query"] - }`) - - // Validate the schema before using it - if err := s.schemaCompiler.ValidateJSONSchema(executeGraphQLSchema); err != nil { - return fmt.Errorf("invalid schema for execute_graphql tool: %w", err) + "required": []string{"query"}, } - tool := mcp.NewToolWithRawSchema( - "execute_graphql", - "Executes a GraphQL query or mutation.", - executeGraphQLSchema, - ) - - tool.Annotations = mcp.ToolAnnotation{ - Title: "Execute GraphQL Query", - DestructiveHint: mcp.ToBoolPtr(true), - IdempotentHint: mcp.ToBoolPtr(false), - OpenWorldHint: mcp.ToBoolPtr(true), + destructiveHint := true + openWorldHint := true + tool := &mcp.Tool{ + Name: "execute_graphql", + Description: "Executes a GraphQL query or mutation.", + InputSchema: executeGraphQLSchema, + Annotations: &mcp.ToolAnnotations{ + Title: "Execute GraphQL Query", + DestructiveHint: &destructiveHint, + IdempotentHint: false, + OpenWorldHint: &openWorldHint, + }, } - s.server.AddTool( - tool, - s.handleExecuteGraphQL(), - ) - + s.server.AddTool(tool, s.handleExecuteGraphQL()) s.registeredTools = append(s.registeredTools, "execute_graphql") - } // Get operations filtered by the excludeMutations setting @@ -503,6 +641,9 @@ func (s *GraphQLSchemaServer) registerTools() error { graphqlOperationNames := make([]string, 0, len(operations)) + // Build per-tool scope map for the auth middleware + toolScopes := make(map[string][][]string) + for _, op := range operations { var compiledSchema *jsonschema.Schema var err error @@ -556,43 +697,68 @@ func (s *GraphQLSchemaServer) registerTools() error { ) continue } - tool := mcp.NewToolWithRawSchema( - toolName, - toolDescription, - op.JSONSchema, - ) + // Parse JSON schema into map for the official SDK + var inputSchema any + if len(op.JSONSchema) > 0 { + if err := json.Unmarshal(op.JSONSchema, &inputSchema); err != nil { + s.logger.Error("failed to parse JSON schema for operation", + zap.String("operation", op.Name), + zap.Error(err)) + continue + } + } else { + inputSchema = map[string]any{"type": "object", "properties": map[string]any{}} + } - tool.Annotations = mcp.ToolAnnotation{ - IdempotentHint: mcp.ToBoolPtr(op.OperationType != "mutation"), - Title: fmt.Sprintf("Execute operation %s", op.Name), - ReadOnlyHint: mcp.ToBoolPtr(op.OperationType == "query"), - OpenWorldHint: mcp.ToBoolPtr(true), + openWorld := true + tool := &mcp.Tool{ + Name: toolName, + Description: toolDescription, + InputSchema: inputSchema, + Annotations: &mcp.ToolAnnotations{ + IdempotentHint: op.OperationType != "mutation", + Title: fmt.Sprintf("Execute operation %s", op.Name), + ReadOnlyHint: op.OperationType == "query", + OpenWorldHint: &openWorld, + }, } - s.server.AddTool( - tool, - s.handleOperation(handler), - ) + s.server.AddTool(tool, s.handleOperation(handler)) s.registeredTools = append(s.registeredTools, toolName) + + // Record per-tool scope requirements for auth middleware enforcement + if len(op.RequiredScopes) > 0 { + toolScopes[toolName] = op.RequiredScopes + } } - s.server.AddTool( - mcp.NewTool( - "get_operation_info", - mcp.WithDescription("Provides instructions on how to execute the GraphQL operation via HTTP and how to integrate it into your application."), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: "Get GraphQL Operation Info", - ReadOnlyHint: mcp.ToBoolPtr(true), - }), - mcp.WithString("operationName", - mcp.Required(), - mcp.Description("The exact name of the GraphQL operation to retrieve information for."), - mcp.Enum(graphqlOperationNames...), - ), - ), - s.handleGraphQLOperationInfo(), - ) + // Update auth middleware with per-tool scopes (thread-safe) + if s.authMiddleware != nil { + s.authMiddleware.SetToolScopes(toolScopes) + } + + getOperationInfoTool := &mcp.Tool{ + Name: "get_operation_info", + Description: "Provides instructions on how to execute the GraphQL operation via HTTP and how to integrate it into your application.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "operationName": map[string]any{ + "type": "string", + "description": "The exact name of the GraphQL operation to retrieve information for.", + "enum": graphqlOperationNames, + }, + }, + "required": []string{"operationName"}, + }, + Annotations: &mcp.ToolAnnotations{ + Title: "Get GraphQL Operation Info", + ReadOnlyHint: true, + }, + } + + s.server.AddTool(getOperationInfoTool, s.handleGraphQLOperationInfo()) s.registeredTools = append(s.registeredTools, "get_operation_info") @@ -600,18 +766,25 @@ func (s *GraphQLSchemaServer) registerTools() error { } // handleOperation handles a specific operation -func (s *GraphQLSchemaServer) handleOperation(handler *operationHandler) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - - jsonBytes, err := json.Marshal(request.GetArguments()) - if err != nil { - return nil, fmt.Errorf("failed to marshal arguments: %w", err) +func (s *GraphQLSchemaServer) handleOperation(handler *operationHandler) func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Log authenticated user if OAuth is enabled + if claims, ok := GetClaimsFromContext(ctx); ok { + s.logger.Debug("operation called by authenticated user", + zap.String("sub", getClaimString(claims, "sub")), + zap.String("email", getClaimString(claims, "email")), + zap.String("operation", handler.operation.Name)) } + jsonBytes := request.Params.Arguments + // Validate the JSON input against the pre-compiled schema derived from the operation input type if handler.compiledSchema != nil { if err := s.schemaCompiler.ValidateInput(jsonBytes, handler.compiledSchema); err != nil { - return mcp.NewToolResultErrorFromErr("Input validation Error", err), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Input validation error: %v", err)}}, + IsError: true, + }, nil } } @@ -621,15 +794,12 @@ func (s *GraphQLSchemaServer) handleOperation(handler *operationHandler) func(ct } // handleGraphQLOperationInfo returns a handler function that provides detailed info for a specific operation. -func (s *GraphQLSchemaServer) handleGraphQLOperationInfo() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (s *GraphQLSchemaServer) handleGraphQLOperationInfo() func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var input GraphQLOperationInfoInput - inputBytes, err := json.Marshal(request.GetArguments()) - if err != nil { - return nil, fmt.Errorf("failed to marshal input arguments: %w", err) - } + inputBytes := request.Params.Arguments if err := json.Unmarshal(inputBytes, &input); err != nil { - return nil, fmt.Errorf("failed to unmarshal input arguments: %w. Ensure you provide {\"operationName\": \"\"}", err) + return nil, fmt.Errorf(`failed to unmarshal input arguments: %w. Ensure you provide {"operationName": ""}`, err) } if input.OperationName == "" { @@ -683,17 +853,30 @@ Usage Instructions: } requestFormat += "```" + // Scope requirements section + var scopeInfo string + if len(targetOp.RequiredScopes) > 0 { + scopeInfo = "\nRequired Scopes (OR-of-AND):\n" + for i, andGroup := range targetOp.RequiredScopes { + if i > 0 { + scopeInfo += " OR\n" + } + scopeInfo += fmt.Sprintf(" - %s\n", strings.Join(andGroup, " AND ")) + } + } + // Important notes section importantNotes := ` - Important Notes: 1. Use the query string exactly as provided above 2. Do not modify or reformat the query string` // Combine all sections - response := overview + schemaInfo + queryInfo + usageInstructions + requestFormat + importantNotes + response := overview + schemaInfo + scopeInfo + queryInfo + usageInstructions + requestFormat + importantNotes - return mcp.NewToolResultText(response), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: response}}, + }, nil } } @@ -764,27 +947,39 @@ func (s *GraphQLSchemaServer) executeGraphQLQuery(ctx context.Context, query str // If there are errors but no data, return only the errors if len(graphqlResponse.Data) == 0 || string(graphqlResponse.Data) == "null" { - return mcp.NewToolResultErrorFromErr("Response Error", err), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Response error: %s", errorMessage)}}, + IsError: true, + }, nil } // If we have both errors and data, include data in the error message dataString := string(graphqlResponse.Data) combinedErrorMsg := fmt.Sprintf("Response error with partial success, Error: %s, Data: %s)", errorMessage, dataString) - return mcp.NewToolResultErrorFromErr(combinedErrorMsg, err), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: combinedErrorMsg}}, + IsError: true, + }, nil } - return mcp.NewToolResultText(string(body)), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(body)}}, + }, nil } // handleExecuteGraphQL returns a handler function that executes arbitrary GraphQL queries -func (s *GraphQLSchemaServer) handleExecuteGraphQL() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Parse the JSON input - jsonBytes, err := json.Marshal(request.GetArguments()) - if err != nil { - return nil, fmt.Errorf("failed to marshal arguments: %w", err) +func (s *GraphQLSchemaServer) handleExecuteGraphQL() func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Log authenticated user if OAuth is enabled + if claims, ok := GetClaimsFromContext(ctx); ok { + s.logger.Debug("arbitrary GraphQL query called by authenticated user", + zap.String("sub", getClaimString(claims, "sub")), + zap.String("email", getClaimString(claims, "email"))) } + // Parse the JSON input + jsonBytes := request.Params.Arguments + var input ExecuteGraphQLInput if err := json.Unmarshal(jsonBytes, &input); err != nil { return nil, fmt.Errorf("failed to unmarshal input arguments: %w", err) @@ -799,8 +994,8 @@ func (s *GraphQLSchemaServer) handleExecuteGraphQL() func(ctx context.Context, r } // handleGetGraphQLSchema returns a handler function that returns the full GraphQL schema -func (s *GraphQLSchemaServer) handleGetGraphQLSchema() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (s *GraphQLSchemaServer) handleGetGraphQLSchema() func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get the schema from the operations manager schema := s.operationsManager.GetSchema() if schema == nil { @@ -813,6 +1008,123 @@ func (s *GraphQLSchemaServer) handleGetGraphQLSchema() func(ctx context.Context, return nil, fmt.Errorf("failed to convert schema to string: %w", err) } - return mcp.NewToolResultText(schemaStr), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: schemaStr}}, + }, nil + } +} + +// getClaimString safely extracts a string value from claims +func getClaimString(claims authentication.Claims, key string) string { + if val, ok := claims[key]; ok { + if str, ok := val.(string); ok { + return str + } + } + return "" +} + +// ProtectedResourceMetadata represents the OAuth 2.0 Protected Resource Metadata (RFC 9728) +type ProtectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + ResourceDocumentation string `json:"resource_documentation,omitempty"` + ScopesSupported []string `json:"scopes_supported"` +} + +// handleProtectedResourceMetadata handles the OAuth 2.0 Protected Resource Metadata endpoint +// as specified in RFC 9728. This endpoint allows MCP clients to discover the authorization +// server(s) associated with this resource server. +func (s *GraphQLSchemaServer) handleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Determine the resource URL (this MCP server's base URL) + resourceURL := s.serverBaseURL + if resourceURL == "" { + // Fallback: construct from request if not configured + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + resourceURL = fmt.Sprintf("%s://%s", scheme, r.Host) + } + + // Build scopes_supported from all configured scopes (union across all levels) + // plus all scopes extracted from @requiresScopes directives on operations + scopesSet := make(map[string]bool) + // Collect all static scope lists, conditionally including built-in tool scopes + // based on whether the corresponding feature is enabled + scopeLists := [][]string{ + s.oauthConfig.Scopes.Initialize, + s.oauthConfig.Scopes.ToolsList, + s.oauthConfig.Scopes.ToolsCall, + s.oauthConfig.Scopes.GetOperationInfo, // get_operation_info is always available + } + if s.enableArbitraryOperations { + scopeLists = append(scopeLists, s.oauthConfig.Scopes.ExecuteGraphQL) + } + if s.exposeSchema { + scopeLists = append(scopeLists, s.oauthConfig.Scopes.GetSchema) + } + + for _, scopeList := range scopeLists { + for _, scope := range scopeList { + scopesSet[scope] = true + } + } + + // Include all scopes from per-tool @requiresScopes extraction + if s.operationsManager != nil { + for _, op := range s.operationsManager.GetOperations() { + for _, andGroup := range op.RequiredScopes { + for _, scope := range andGroup { + scopesSet[scope] = true + } + } + } + } + + // Convert set to sorted slice for consistent output + scopes := make([]string, 0, len(scopesSet)) + for scope := range scopesSet { + scopes = append(scopes, scope) + } + slices.Sort(scopes) + if len(scopes) == 0 { + scopes = []string{} // Ensure non-nil for JSON encoding + } + + mcpResourceURL := strings.TrimRight(resourceURL, "/") + "/mcp" + + metadata := ProtectedResourceMetadata{ + Resource: mcpResourceURL, + AuthorizationServers: []string{s.oauthConfig.AuthorizationServerURL}, + BearerMethodsSupported: []string{"header"}, + ResourceDocumentation: s.resourceDocumentation, + ScopesSupported: scopes, + } + + // Encode to buffer first so we can handle errors before writing headers + data, err := json.Marshal(metadata) + if err != nil { + s.logger.Error("failed to encode protected resource metadata", zap.Error(err)) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +// GetResourceMetadataURL returns the URL for the OAuth 2.0 Protected Resource Metadata endpoint +func (s *GraphQLSchemaServer) GetResourceMetadataURL() string { + if s.serverBaseURL != "" { + return fmt.Sprintf("%s/.well-known/oauth-protected-resource/mcp", s.serverBaseURL) } + return "" } diff --git a/router/pkg/mcpserver/server_test.go b/router/pkg/mcpserver/server_test.go index 3c18725402..3a3c044609 100644 --- a/router/pkg/mcpserver/server_test.go +++ b/router/pkg/mcpserver/server_test.go @@ -81,6 +81,7 @@ func TestReload_NoToolDuplication(t *testing.T) { require.NoError(t, err) srv, err := NewGraphQLSchemaServer( + t.Context(), "http://localhost:4000/graphql", WithLogger(logger), WithOperationsDir(tempDir), @@ -89,14 +90,14 @@ func TestReload_NoToolDuplication(t *testing.T) { require.NoError(t, err) // First load - err = srv.Reload(&schemaDoc) + err = srv.Reload(&schemaDoc, nil) require.NoError(t, err) firstLoadTools := make([]string, len(srv.registeredTools)) copy(firstLoadTools, srv.registeredTools) // Second load (simulates config reload) - err = srv.Reload(&schemaDoc) + err = srv.Reload(&schemaDoc, nil) require.NoError(t, err) // registeredTools should be identical after reload — no duplicates @@ -127,6 +128,7 @@ func TestReload_ReservedToolNameCollision(t *testing.T) { require.NoError(t, err) srv, err := NewGraphQLSchemaServer( + t.Context(), "http://localhost:4000/graphql", WithLogger(logger), WithOperationsDir(tempDir), @@ -134,7 +136,7 @@ func TestReload_ReservedToolNameCollision(t *testing.T) { ) require.NoError(t, err) - err = srv.Reload(&schemaDoc) + err = srv.Reload(&schemaDoc, nil) require.NoError(t, err) // The operation "GetOperationInfo" (snake: "get_operation_info") should be skipped @@ -170,6 +172,7 @@ func TestReload_PrefixModeAvoidsReservedNameCollision(t *testing.T) { require.NoError(t, err) srv, err := NewGraphQLSchemaServer( + t.Context(), "http://localhost:4000/graphql", WithLogger(logger), WithOperationsDir(tempDir), @@ -177,7 +180,7 @@ func TestReload_PrefixModeAvoidsReservedNameCollision(t *testing.T) { ) require.NoError(t, err) - err = srv.Reload(&schemaDoc) + err = srv.Reload(&schemaDoc, nil) require.NoError(t, err) // No collisions because the prefix disambiguates from the reserved name diff --git a/router/pkg/schemaloader/loader.go b/router/pkg/schemaloader/loader.go index fbb0f7dcae..e82f584357 100644 --- a/router/pkg/schemaloader/loader.go +++ b/router/pkg/schemaloader/loader.go @@ -8,11 +8,12 @@ import ( "path/filepath" "strings" + "go.uber.org/zap" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" - "go.uber.org/zap" ) // Operation represents a GraphQL operation with its AST document and schema information @@ -23,7 +24,8 @@ type Operation struct { OperationString string Description string JSONSchema json.RawMessage - OperationType string // "query", "mutation", or "subscription" + OperationType string // "query", "mutation", or "subscription" + RequiredScopes [][]string // OR-of-AND scope groups from @requiresScopes (nil = no scope check) } // OperationLoader loads GraphQL operations from files in a directory diff --git a/router/pkg/schemaloader/loader_test.go b/router/pkg/schemaloader/loader_test.go index b4573d89a5..fde3147491 100644 --- a/router/pkg/schemaloader/loader_test.go +++ b/router/pkg/schemaloader/loader_test.go @@ -7,9 +7,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" - "go.uber.org/zap" ) // TestLoadOperationsWithDescriptions tests that the OperationLoader properly loads