diff --git a/client-tests/Makefile b/client-tests/Makefile index c88686cae8..b9c16cb91c 100644 --- a/client-tests/Makefile +++ b/client-tests/Makefile @@ -1,4 +1,4 @@ -test-all: test-apollo-js test-apollo-swift test-apollo-kotlin +test-all: test-apollo-js test-apollo-swift test-apollo-kotlin test-mcp-ts test-apollo-kotlin: cd apollo-kotlin && ./gradlew test @@ -7,4 +7,7 @@ test-apollo-js: cd apollo-js && pnpm test test-apollo-swift: - cd apollo-swift && swift test \ No newline at end of file + cd apollo-swift && swift test + +test-mcp-ts: + cd mcp-ts && pnpm test \ No newline at end of file diff --git a/client-tests/mcp-ts/package.json b/client-tests/mcp-ts/package.json new file mode 100644 index 0000000000..d957660f29 --- /dev/null +++ b/client-tests/mcp-ts/package.json @@ -0,0 +1,17 @@ +{ + "name": "mcp-ts-client-tests", + "version": "1.0.0", + "description": "MCP client e2e tests using the official TypeScript SDK", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest watch" + }, + "packageManager": "pnpm@9.12.3", + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.27.0", + "@types/node": "^22.17.0", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} \ No newline at end of file diff --git a/client-tests/mcp-ts/pnpm-lock.yaml b/client-tests/mcp-ts/pnpm-lock.yaml new file mode 100644 index 0000000000..a0bcdf61dc --- /dev/null +++ b/client-tests/mcp-ts/pnpm-lock.yaml @@ -0,0 +1,1753 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.0 + version: 1.27.0(zod@4.3.6) + '@types/node': + specifier: ^22.17.0 + version: 22.19.11 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.11) + +packages: + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@modelcontextprotocol/sdk@1.27.0': + resolution: {integrity: sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.11': + resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.2: + resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + 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 + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + 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 + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@hono/node-server@1.19.9(hono@4.12.2)': + dependencies: + hono: 4.12.2 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@modelcontextprotocol/sdk@1.27.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.2) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.2 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.11': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.11))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.11) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + assertion-error@2.0.1: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + expect-type@1.3.0: {} + + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.1.3: {} + + js-tokens@9.0.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.3.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pkce-challenge@5.0.1: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + toidentifier@1.0.1: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + vite-node@3.2.4(@types/node@22.19.11): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.11) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@22.19.11): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + fsevents: 2.3.3 + + vitest@3.2.4(@types/node@22.19.11): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.11)) + '@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.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.11) + vite-node: 3.2.4(@types/node@22.19.11) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.11 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/client-tests/mcp-ts/src/mcp-client-credentials.test.ts b/client-tests/mcp-ts/src/mcp-client-credentials.test.ts new file mode 100644 index 0000000000..ca18b8d31e --- /dev/null +++ b/client-tests/mcp-ts/src/mcp-client-credentials.test.ts @@ -0,0 +1,110 @@ +/** + * MCP e2e tests using the official TypeScript SDK with ClientCredentialsProvider. + * + * These tests exercise the full OAuth 2.1 flow: + * 1. SDK discovers /.well-known/oauth-protected-resource from the MCP server + * 2. SDK discovers /.well-known/oauth-authorization-server from the OAuth AS + * 3. SDK dynamically registers (or uses pre-registered) client + * 4. SDK exchanges client_credentials for a signed JWT at /token + * 5. SDK attaches Bearer token to all MCP requests + * + * Environment variables (set by the Go test harness or manually): + * MCP_SERVER_URL – e.g. http://localhost:5025/mcp + * MCP_CLIENT_ID – pre-registered OAuth client ID + * MCP_CLIENT_SECRET – pre-registered OAuth client secret + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +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'; + +// Read configuration from environment +const MCP_SERVER_URL = process.env.MCP_SERVER_URL ?? 'http://localhost:5025/mcp'; +const MCP_CLIENT_ID = process.env.MCP_CLIENT_ID ?? 'test-mcp-client'; +const MCP_CLIENT_SECRET = process.env.MCP_CLIENT_SECRET ?? 'test-mcp-secret'; + +describe('MCP client_credentials flow', () => { + let client: Client; + let transport: StreamableHTTPClientTransport; + + beforeAll(async () => { + const provider = new ClientCredentialsProvider({ + clientId: MCP_CLIENT_ID, + clientSecret: MCP_CLIENT_SECRET, + }); + + transport = new StreamableHTTPClientTransport( + new URL(MCP_SERVER_URL), + { authProvider: provider }, + ); + + client = new Client({ name: 'mcp-ts-test', version: '1.0.0' }); + await client.connect(transport); + }); + + afterAll(async () => { + await transport?.close(); + }); + + it('should list tools', async () => { + const result = await client.listTools(); + expect(result.tools).toBeDefined(); + expect(result.tools.length).toBeGreaterThan(0); + + const toolNames = result.tools.map((t) => t.name); + console.log('Available tools:', toolNames); + }); + + it('should call a read-only tool', async () => { + // Find a tool that looks like a query (employee list, etc.) + const { tools } = await client.listTools(); + const queryTool = tools.find( + (t) => t.name.includes('employees') || t.name.includes('employee'), + ); + + if (!queryTool) { + // If no employee tool exists, try get_schema if available + const schemaTool = tools.find((t) => t.name === 'get_schema'); + if (schemaTool) { + const result = await client.callTool({ name: 'get_schema', arguments: {} }); + expect(result).toBeDefined(); + return; + } + // Fall back to calling the first available tool + console.log('No employee/schema tool found, calling first tool:', tools[0]?.name); + return; + } + + const result = await client.callTool({ + name: queryTool.name, + arguments: { criteria: {} }, + }); + expect(result).toBeDefined(); + console.log('Tool call result:', JSON.stringify(result).slice(0, 200)); + }); +}); + +describe('MCP client_credentials with scopes', () => { + it('should work with scoped client', async () => { + const provider = new ClientCredentialsProvider({ + clientId: MCP_CLIENT_ID, + clientSecret: MCP_CLIENT_SECRET, + }); + + const transport = new StreamableHTTPClientTransport( + new URL(MCP_SERVER_URL), + { authProvider: provider }, + ); + + const client = new Client({ name: 'mcp-ts-scoped-test', version: '1.0.0' }); + + try { + await client.connect(transport); + const result = await client.listTools(); + expect(result.tools.length).toBeGreaterThan(0); + } finally { + await transport.close(); + } + }); +}); \ No newline at end of file diff --git a/client-tests/mcp-ts/tsconfig.json b/client-tests/mcp-ts/tsconfig.json new file mode 100644 index 0000000000..6c9a4059b6 --- /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"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/client-tests/mcp-ts/vitest.config.ts b/client-tests/mcp-ts/vitest.config.ts new file mode 100644 index 0000000000..a5f207112a --- /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, + }, +}); \ No newline at end of file diff --git a/demo/go.mod b/demo/go.mod index 8a21708ffa..a0f6e12a92 100644 --- a/demo/go.mod +++ b/demo/go.mod @@ -94,7 +94,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 4fc68a7941..ab48248f77 100644 --- a/demo/go.sum +++ b/demo/go.sum @@ -220,8 +220,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/oauth-server/main.go b/router-tests/cmd/oauth-server/main.go new file mode 100644 index 0000000000..38f5a5be70 --- /dev/null +++ b/router-tests/cmd/oauth-server/main.go @@ -0,0 +1,438 @@ +/* +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" + "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 +} + +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: 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 + } + + 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} + 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) + }) +} \ No newline at end of file diff --git a/router-tests/go.mod b/router-tests/go.mod index 28d13da9e5..84079da643 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -13,7 +13,8 @@ require ( 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.2.0 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 @@ -89,6 +90,7 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-containerregistry v0.20.3 // indirect + github.com/google/jsonschema-go v0.3.0 // 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 @@ -174,6 +176,7 @@ require ( golang.org/x/crypto v0.43.0 // indirect golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/mod v0.29.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.9.0 // indirect diff --git a/router-tests/go.sum b/router-tests/go.sum index 8740bb0404..286f9724f9 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -134,10 +134,13 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/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= @@ -205,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= @@ -227,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.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= +github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= 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= @@ -430,6 +435,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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= diff --git a/router-tests/mcp_auth_e2e_test.go b/router-tests/mcp_auth_e2e_test.go new file mode 100644 index 0000000000..896c2e4afb --- /dev/null +++ b/router-tests/mcp_auth_e2e_test.go @@ -0,0 +1,370 @@ +package integration + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "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" +) + +// previewToken returns a truncated preview of a token for logging purposes. +// Returns the full token if shorter than n characters, otherwise returns first n characters with "...". +func previewToken(token string, n int) string { + if len(token) <= n { + return token + } + return token[:n] + "..." +} + +// 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 response for error analysis + 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 { + // Create a custom round tripper that adds Authorization headers + roundTripper := &authRoundTripper{ + base: http.DefaultTransport, + token: initialToken, + } + + // Create HTTP client with custom round tripper + httpClient := &http.Client{ + Transport: roundTripper, + } + + // Create streamable transport + transport := &mcp.StreamableClientTransport{ + Endpoint: endpoint, + HTTPClient: httpClient, + } + + // Create MCP client + 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 +func (c *MCPAuthClient) Connect(ctx context.Context) error { + session, err := c.client.Connect(ctx, c.transport, nil) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + c.session = session + return nil +} + +// SetToken updates the authorization token +// This is the KEY method - it allows changing tokens 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, + } + + result, err := c.session.CallTool(ctx, params) + if err != nil { + // Check if this was an HTTP auth error + 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 + + // Check for 401 Unauthorized or 403 Forbidden + if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden { + return nil + } + + // Parse WWW-Authenticate header + 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"], + } + + // Parse required scopes (space-separated) + 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 +} + +// TestMCPAuthorizationWithOfficialSDK demonstrates authorization testing with the official MCP Go SDK +func TestMCPAuthorizationWithOfficialSDK(t *testing.T) { + t.Run("Basic connection with token", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + // Create MCP client with initial token + token := "test-token-with-read-scopes" + mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), token) + + // Connect and initialize + err := mcpClient.Connect(ctx) + require.NoError(t, err) + defer mcpClient.Close() //nolint:errcheck + + t.Logf("✓ Connected to MCP server with token: %s", previewToken(token, 20)) + + // Call a tool + result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ + "criteria": map[string]any{}, + }) + + // Without authorization configured, this should work + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("✓ Successfully called tool") + }) + }) + + t.Run("Scope upgrade on persistent session", func(t *testing.T) { + // This test demonstrates the KEY concept: + // - Establish session with token1 + // - Get "insufficient scopes" error + // - Update token (SetToken) + // - Retry on SAME session with new token + + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + // TODO: Add authorization configuration when implemented + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + // Step 1: Connect with limited token + readToken := "token-with-scope-mcp:tools:read" + mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + + err := mcpClient.Connect(ctx) + require.NoError(t, err) + defer mcpClient.Close() //nolint:errcheck + + t.Logf("✓ Step 1: Connected with read-only token") + t.Logf(" Token: %s", readToken[:30]+"...") + + // Step 2: Call read operation (should succeed) + result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ + "criteria": map[string]any{}, + }) + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("✓ Step 2: Read operation succeeded") + + // Step 3: Try write operation (should fail with insufficient scopes) + // NOTE: This would fail if authorization is configured + _, err = mcpClient.CallTool(ctx, "execute_operation_update_mood", map[string]any{ + "employeeID": 1, + "mood": "HAPPY", + }) + + // Without authorization, this succeeds. With authorization, check for scope error + if err != nil { + t.Logf("✓ Step 3: Write operation failed (expected with auth): %v", err) + + // In a real scenario with authorization: + // 1. Parse error to get required scopes + // 2. User goes through OAuth flow + // 3. Get new token with required scopes + + // Step 4: Update token on SAME session + writeToken := "token-with-scope-mcp:tools:read,mcp:tools:write" + mcpClient.SetToken(writeToken) + t.Logf("✓ Step 4: Updated token (same session)") + t.Logf(" New Token: %s", writeToken[:30]+"...") + + // Step 5: Retry write operation with upgraded token + result, err := mcpClient.CallTool(ctx, "execute_operation_update_mood", map[string]any{ + "employeeID": 1, + "mood": "HAPPY", + }) + + assert.NoError(t, err) + assert.NotNil(t, result) + t.Logf("✓ Step 5: Write operation succeeded with upgraded token") + } else { + t.Logf("✓ Step 3: Write operation succeeded (no authorization configured)") + } + }) + }) + + t.Run("Multiple token changes on same session", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "initial-token") + err := mcpClient.Connect(ctx) + require.NoError(t, err) + defer mcpClient.Close() //nolint:errcheck + + t.Logf("✓ Connected with initial token") + + // Simulate multiple scope upgrades + tokens := []string{ + "token-with-basic-scopes", + "token-with-read-scopes", + "token-with-write-scopes", + "token-with-admin-scopes", + } + + for i, token := range tokens { + mcpClient.SetToken(token) + + // Make a call with the new token + result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ + "criteria": map[string]any{}, + }) + + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("✓ Request %d succeeded with token: %s", i+1, previewToken(token, 25)) + } + + t.Logf("✓ All token changes worked on same session") + }) + }) +} + +// Example_mcpAuthorizationFlow shows how to use the auth client +func Example_mcpAuthorizationFlow() { + ctx := context.Background() + + // Create client with initial token + client := NewMCPAuthClient("http://localhost:3000/mcp", "initial-token") + defer client.Close() //nolint:errcheck + + // Connect + if err := client.Connect(ctx); err != nil { + panic(err) + } + + // Try to call a tool + _, err := client.CallTool(ctx, "some_tool", map[string]any{}) + + // If we get insufficient scopes error + if err != nil { + // 1. User goes through OAuth flow (not shown) + // 2. Get new token with more scopes + newToken := "token-with-more-scopes" + + // 3. Update token on SAME session + client.SetToken(newToken) + + // 4. Retry the tool call + _, err = client.CallTool(ctx, "some_tool", map[string]any{}) + if err != nil { + panic(err) + } + } + + fmt.Println("Success!") +} diff --git a/router-tests/mcp_auth_harness_example.go b/router-tests/mcp_auth_harness_example.go new file mode 100644 index 0000000000..42d9e1550b --- /dev/null +++ b/router-tests/mcp_auth_harness_example.go @@ -0,0 +1,242 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Example demonstrating the actual HTTP-level MCP authorization flow +// This shows how tokens are sent in HTTP headers, not JSON-RPC + +type MCPClient struct { + serverURL string + httpClient *http.Client + sessionID string // Persistent across requests +} + +// Step 1: Initialize - First HTTP POST with initial token +func (c *MCPClient) Initialize(ctx context.Context, token string) error { + // Create JSON-RPC initialize request + jsonRPCRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]interface{}{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{ + "name": "test-client", + "version": "1.0.0", + }, + }, + } + + // HTTP POST #1 + req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // ← Token in HTTP header + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + // Extract session ID from HTTP response headers + c.sessionID = resp.Header.Get("Mcp-Session-Id") // ← Session ID from HTTP header + + fmt.Printf("✓ HTTP POST #1 - Initialize\n") + fmt.Printf(" Request Header: Authorization: Bearer %s\n", token[:20]+"...") + fmt.Printf(" Response Header: Mcp-Session-Id: %s\n", c.sessionID) + + return nil +} + +// Step 2: Call tool with initial token (limited scopes) +func (c *MCPClient) CallToolWithLimitedScopes(ctx context.Context, token string) error { + jsonRPCRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": map[string]interface{}{ + "name": "execute_operation_update_mood", + "arguments": map[string]interface{}{ + "employeeID": 1, + "mood": "HAPPY", + }, + }, + } + + // HTTP POST #2 - Same session, same token + req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // ← Same token + req.Header.Set("Mcp-Session-Id", c.sessionID) // ← Same session ID + + fmt.Printf("\n✓ HTTP POST #2 - Call tool (limited scopes)\n") + fmt.Printf(" Request Header: Authorization: Bearer %s\n", token[:20]+"...") + fmt.Printf(" Request Header: Mcp-Session-Id: %s\n", c.sessionID) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + // Parse JSON-RPC response + var jsonRPCResp struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + RequiredScopes []string `json:"required_scopes"` // ← Scopes in JSON-RPC error data + } `json:"data"` + } `json:"error"` + } + json.NewDecoder(resp.Body).Decode(&jsonRPCResp) //nolint:errcheck + + if jsonRPCResp.Error != nil { + fmt.Printf(" Response Body: JSON-RPC Error\n") + fmt.Printf(" {\n") + fmt.Printf(" \"error\": {\n") + fmt.Printf(" \"code\": %d,\n", jsonRPCResp.Error.Code) + fmt.Printf(" \"message\": \"%s\",\n", jsonRPCResp.Error.Message) + fmt.Printf(" \"data\": {\n") + fmt.Printf(" \"required_scopes\": %v\n", jsonRPCResp.Error.Data.RequiredScopes) + fmt.Printf(" }\n") + fmt.Printf(" }\n") + fmt.Printf(" }\n") + return fmt.Errorf("insufficient scopes: %v", jsonRPCResp.Error.Data.RequiredScopes) + } + + return nil +} + +// Step 3: Obtain new token (simulated OAuth flow) +func (c *MCPClient) ObtainNewToken(requiredScopes []string) string { + // In reality, this would: + // 1. Open browser to authorization server + // 2. User consents to new scopes + // 3. Exchange auth code for new access token + // 4. Return new access token + + newToken := fmt.Sprintf("new-token-with-scopes-%v", requiredScopes) + fmt.Printf("\n✓ OAuth Flow - Obtained new token\n") + fmt.Printf(" Scopes: %v\n", requiredScopes) + fmt.Printf(" New Token: %s\n", newToken[:30]+"...") + return newToken +} + +// Step 4: Retry tool call with upgraded token +func (c *MCPClient) CallToolWithUpgradedToken(ctx context.Context, newToken string) error { + jsonRPCRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": map[string]interface{}{ + "name": "execute_operation_update_mood", + "arguments": map[string]interface{}{ + "employeeID": 1, + "mood": "HAPPY", + }, + }, + } + + // HTTP POST #3 - SAME session, DIFFERENT token + req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", newToken)) // ← NEW token (different Authorization header) + req.Header.Set("Mcp-Session-Id", c.sessionID) // ← SAME session ID + + fmt.Printf("\n✓ HTTP POST #3 - Call tool (upgraded scopes)\n") + fmt.Printf(" Request Header: Authorization: Bearer %s ← DIFFERENT TOKEN\n", newToken[:30]+"...") + fmt.Printf(" Request Header: Mcp-Session-Id: %s ← SAME SESSION\n", c.sessionID) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + fmt.Printf(" Response: %d OK\n", resp.StatusCode) + fmt.Printf(" Response Body: JSON-RPC Success\n") + + return nil +} + +func toReader(v interface{}) io.Reader { + b, _ := json.Marshal(v) + return bytes.NewReader(b) +} + +// ExampleAuthorizationFlow demonstrates the complete flow +func ExampleAuthorizationFlow() { + client := &MCPClient{ + serverURL: "http://localhost:3000/mcp", + httpClient: &http.Client{}, + } + + ctx := context.Background() + + // Step 1: Initialize with limited scopes + initialToken := "token-with-scopes-mcp:tools:read" + client.Initialize(ctx, initialToken) //nolint:errcheck + + // Step 2: Try to call write operation (will fail) + err := client.CallToolWithLimitedScopes(ctx, initialToken) + + // Step 3: Get new token with required scopes + if err != nil { + newToken := client.ObtainNewToken([]string{"mcp:tools:write"}) + + // Step 4: Retry with upgraded token (same session!) + _ = client.CallToolWithUpgradedToken(ctx, newToken) + } + + fmt.Printf("\n=== Summary ===\n") + fmt.Printf("• Session persists via Mcp-Session-Id HTTP header\n") + fmt.Printf("• Authorization changes via Authorization HTTP header\n") + fmt.Printf("• Each JSON-RPC request is a separate HTTP POST\n") + fmt.Printf("• HTTP headers carry auth/session, not JSON-RPC payload\n") +} + +/* +Expected Output: + +✓ HTTP POST #1 - Initialize + Request Header: Authorization: Bearer token-with-scopes-mc... + Response Header: Mcp-Session-Id: abc-123-def-456 + +✓ HTTP POST #2 - Call tool (limited scopes) + Request Header: Authorization: Bearer token-with-scopes-mc... + Request Header: Mcp-Session-Id: abc-123-def-456 + Response Body: JSON-RPC Error + { + "error": { + "code": -32001, + "message": "Insufficient permissions", + "data": { + "required_scopes": [mcp:tools:write] + } + } + } + +✓ OAuth Flow - Obtained new token + Scopes: [mcp:tools:write] + New Token: new-token-with-scopes-[mcp:too... + +✓ HTTP POST #3 - Call tool (upgraded scopes) + Request Header: Authorization: Bearer new-token-with-scopes-[mcp:too... ← DIFFERENT TOKEN + Request Header: Mcp-Session-Id: abc-123-def-456 ← SAME SESSION + Response: 200 OK + Response Body: JSON-RPC Success + +=== Summary === +• Session persists via Mcp-Session-Id HTTP header +• Authorization changes via Authorization HTTP header +• Each JSON-RPC request is a separate HTTP POST +• HTTP headers carry auth/session, not JSON-RPC payload +*/ diff --git a/router-tests/mcp_oauth_e2e_test.go b/router-tests/mcp_oauth_e2e_test.go new file mode 100644 index 0000000000..950f1a0c01 --- /dev/null +++ b/router-tests/mcp_oauth_e2e_test.go @@ -0,0 +1,283 @@ +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" +) + +// TestMCPOAuthScopeUpgrade tests the complete OAuth scope upgrade flow with real JWT validation. +// Uses OAuthTestServer which provides a full OAuth 2.1 AS (JWKS + token endpoint + registration) +// so the same server can be used by both Go tests and the official MCP TypeScript SDK. +func TestMCPOAuthScopeUpgrade(t *testing.T) { + oauthServer, err := testutil.NewOAuthTestServer(t, nil) + require.NoError(t, err, "failed to start OAuth server") + defer oauthServer.Close() //nolint:errcheck + + readOnlyToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) + require.NoError(t, err, "failed to create read-only 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{}, + }, + }, + MCPAuthToken: readOnlyToken, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readOnlyToken) + err = client.Connect(ctx) + require.NoError(t, err, "should connect with valid token") + defer client.Close() //nolint:errcheck + + t.Log("Connected with read-only token") + + result, err := client.CallTool(ctx, "get_schema", nil) + require.NoError(t, err, "get_schema should succeed with valid token") + require.NotNil(t, result) + t.Log("Tool call succeeded with initial token") + + newToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read", "mcp:tools:write"}) + require.NoError(t, err, "failed to create new token") + + client.SetToken(newToken) + t.Log("Updated to new token (same session)") + + result, err = client.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { employees { id } }", + }) + require.NoError(t, err, "tool call should succeed after token change") + require.NotNil(t, result) + t.Log("Tool call succeeded with new token") + + anotherToken, err := oauthServer.CreateTokenWithScopes("different-user", []string{"mcp:admin"}) + require.NoError(t, err, "failed to create another token") + + client.SetToken(anotherToken) + _, err = client.CallTool(ctx, "get_schema", nil) + require.NoError(t, err, "should succeed after second token change") + t.Log("Multiple token changes work on same session") + }) +} + +// TestMCPOAuthInvalidToken tests that invalid JWT tokens are rejected with HTTP 401. +func TestMCPOAuthInvalidToken(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, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + 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) + if ok { + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + t.Logf("Invalid token rejected with HTTP 401: %v", authErr) + } + }) +} + +// TestMCPOAuthMissingToken tests that missing Authorization header is rejected. +func TestMCPOAuthMissingToken(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, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "") + + err := client.Connect(ctx) + require.Error(t, err, "should fail to connect without token") + + authErr, ok := err.(*AuthError) + if ok { + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + t.Logf("Request without token rejected with HTTP 401: %v", authErr) + } + }) +} + +// TestMCPOAuthPerToolScopes tests per-tool scope requirements. +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"}, + ToolsCall: []string{"mcp:tools:write"}, + }, + ScopeChallengeMode: "required_and_existing", + }, + }, + MCPAuthToken: initToken, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + t.Run("HTTP-level scopes are enforced on all requests", 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) + if ok { + assert.True(t, authErr.StatusCode == http.StatusUnauthorized || authErr.StatusCode == http.StatusForbidden) + t.Logf("HTTP-level scope enforcement: %v", authErr) + } + }) + + t.Run("Per-tool scopes are enforced on tool calls", 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") + 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("Token with correct per-tool scopes succeeds", 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("Different tools require different scopes", 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("Scope upgrade on same session works", 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, "execute_graphql", map[string]any{ + "query": "query { __typename }", + }) + require.Error(t, err, "should fail without write scopes") + + writeToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read", "mcp:tools:write"}) + require.NoError(t, err) + + client.SetToken(writeToken) + + result, err := client.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { __typename }", + }) + require.NoError(t, err, "should succeed after scope upgrade") + require.NotNil(t, result) + }) + }) +} \ No newline at end of file diff --git a/router-tests/mcp_test.go b/router-tests/mcp_test.go index 39de7ffbe7..6db7b8099e 100644 --- a/router-tests/mcp_test.go +++ b/router-tests/mcp_test.go @@ -473,7 +473,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 +530,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 +564,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 +602,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 +643,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")) @@ -947,7 +947,7 @@ input UserInput { // 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) @@ -1053,7 +1053,7 @@ 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) diff --git a/router-tests/oauth-server b/router-tests/oauth-server new file mode 100755 index 0000000000..98157410fc Binary files /dev/null and b/router-tests/oauth-server differ diff --git a/router-tests/testenv/testenv.go b/router-tests/testenv/testenv.go index 6d1d884ed3..526a539b09 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" @@ -344,6 +345,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 @@ -825,7 +827,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) } @@ -1253,7 +1265,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..391bf0c52a --- /dev/null +++ b/router-tests/testutil/auth_helpers.go @@ -0,0 +1,63 @@ +package testutil + +import ( + "strings" +) + +// ParseWWWAuthenticateParams parses the WWW-Authenticate header from HTTP responses. +// This is a simple parser for test validation only, not production use. +// +// NOTE: LLM-generated - there are no well-established Go libraries for parsing +// WWW-Authenticate response headers (as of 2026). This parser handles the +// common case of Bearer authentication with quoted parameter values. +// +// Example input: `Bearer error="insufficient_scope", scope="read write", resource_metadata="https://example.com"` +// Example output: map[string]string{"error": "insufficient_scope", "scope": "read write", "resource_metadata": "https://example.com"} +func ParseWWWAuthenticateParams(header string) map[string]string { + params := make(map[string]string) + + // Remove "Bearer " prefix (case-insensitive) + if len(header) >= 7 && strings.EqualFold(header[:7], "Bearer ") { + header = header[7:] + } + header = strings.TrimSpace(header) + + // Simple state machine to parse key="value" pairs + var key, value strings.Builder + inKey := true + inQuote := false + + for i := 0; i < len(header); i++ { + ch := header[i] + + switch { + case ch == '=' && inKey: + inKey = false + case ch == '"' && !inKey: + // Track quote state but don't add quotes to value + inQuote = !inQuote + case ch == ',' && !inQuote: + if key.Len() > 0 { + params[strings.TrimSpace(key.String())] = strings.TrimSpace(value.String()) + } + key.Reset() + value.Reset() + inKey = true + case inKey: + key.WriteByte(ch) + default: + // We're in a value (!inKey) and ch is not a quote (already handled above) + // Include everything (including spaces) when inside quotes + if inQuote || ch != ' ' || value.Len() > 0 { + value.WriteByte(ch) + } + } + } + + // Add final pair + if key.Len() > 0 { + params[strings.TrimSpace(key.String())] = strings.TrimSpace(value.String()) + } + + return params +} diff --git a/router-tests/testutil/jwt_helper.go b/router-tests/testutil/jwt_helper.go new file mode 100644 index 0000000000..99b1929d62 --- /dev/null +++ b/router-tests/testutil/jwt_helper.go @@ -0,0 +1,195 @@ +package testutil + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router-tests/freeport" + "github.com/wundergraph/cosmo/router-tests/jwks" +) + +// JWKSTestServer provides JWT token generation for testing +type JWKSTestServer struct { + t *testing.T + provider jwks.Crypto + keyID string + issuer string + audience string + jwksURL string + server *http.Server + storage jwkset.Storage +} + +// NewJWKSTestServer creates a new JWKS test server with RSA keys +// The server will automatically allocate a free port and return it when the test ends +func NewJWKSTestServer(t *testing.T) (*JWKSTestServer, error) { + t.Helper() + + // Get a free port using the freeport package + port := freeport.GetOne(t) + portStr := fmt.Sprintf("%d", port) + + keyID := "test_rsa" + provider, err := jwks.NewRSACrypto(keyID, jwkset.AlgRS256, 2048) + if err != nil { + return nil, fmt.Errorf("failed to create RSA crypto: %w", err) + } + + storage := jwkset.NewMemoryStorage() + ctx := context.Background() + + jwk, err := provider.MarshalJWK() + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK: %w", err) + } + + if err := storage.KeyWrite(ctx, jwk); err != nil { + return nil, fmt.Errorf("failed to write key to storage: %w", err) + } + + server := &JWKSTestServer{ + t: t, + provider: provider, + keyID: keyID, + issuer: fmt.Sprintf("http://localhost:%s", portStr), + audience: "test-audience", + jwksURL: fmt.Sprintf("http://localhost:%s/.well-known/jwks.json", portStr), + storage: storage, + } + + // Start HTTP server + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/jwks.json", server.handleJWKS) + + httpServer := &http.Server{ + Addr: ":" + portStr, + Handler: mux, + } + + server.server = httpServer + + go func() { + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("JWKS server error: %v", err) + } + }() + + // Wait for server to start + if err := server.waitForReady(5 * time.Second); err != nil { + return nil, fmt.Errorf("JWKS server failed to start: %w", err) + } + + t.Logf("JWKS test server started at %s", server.issuer) + + return server, nil +} + +// waitForReady waits for the server to be ready +func (s *JWKSTestServer) waitForReady(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for JWKS server") + case <-ticker.C: + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.jwksURL, nil) + if err != nil { + continue + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +// handleJWKS serves the JWKS JSON +func (s *JWKSTestServer) handleJWKS(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + rawJWKS, err := s.storage.JSON(ctx) + if err != nil { + s.t.Logf("Failed to get JWKS: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(rawJWKS) +} + +// CreateToken creates a JWT token with the specified claims +// Default claims (iss, aud, iat, exp) are added automatically +func (s *JWKSTestServer) 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(), + } + + // Merge custom claims + for k, v := range claims { + tokenClaims[k] = v + } + + token := jwt.NewWithClaims(s.provider.SigningMethod(), tokenClaims) + token.Header[jwkset.HeaderKID] = s.keyID + + signed, err := token.SignedString(s.provider.PrivateKey()) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return signed, nil +} + +// CreateTokenWithScopes creates a token with specific OAuth scopes +func (s *JWKSTestServer) CreateTokenWithScopes(sub string, scopes []string) (string, error) { + s.t.Helper() + + scopeStr := "" + if len(scopes) > 0 { + scopeStr = scopes[0] + for i := 1; i < len(scopes); i++ { + scopeStr += " " + scopes[i] + } + } + + return s.CreateToken(map[string]any{ + "sub": sub, + "scope": scopeStr, + }) +} + +// JWKSURL returns the URL of the JWKS endpoint +func (s *JWKSTestServer) JWKSURL() string { + return s.jwksURL +} + +// Issuer returns the issuer URL +func (s *JWKSTestServer) Issuer() string { + return s.issuer +} + +// Close stops the JWKS server +func (s *JWKSTestServer) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.server.Shutdown(ctx) +} diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go new file mode 100644 index 0000000000..ea8353553c --- /dev/null +++ b/router-tests/testutil/oauth_server.go @@ -0,0 +1,544 @@ +package testutil + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router-tests/freeport" + "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"` + 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. +// +// Supported endpoints: +// - GET /.well-known/jwks.json — JWKS for token verification +// - GET /.well-known/oauth-authorization-server — AS metadata (RFC 8414) +// - POST /token — Token endpoint (client_credentials + authorization_code) +// - POST /register — Dynamic client registration (RFC 7591) +// - GET /authorize — Authorization endpoint (auto-approves for testing) +// +// The server 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 *http.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{} + } + + port := freeport.GetOne(t) + portStr := fmt.Sprintf("%d", port) + + 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) + } + + baseURL := fmt.Sprintf("http://localhost:%s", portStr) + + s := &OAuthTestServer{ + t: t, + provider: cryptoProvider, + keyID: "test_rsa", + issuer: baseURL, + audience: "test-audience", + jwksURL: baseURL + "/.well-known/jwks.json", + 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) + + httpServer := &http.Server{ + Addr: ":" + portStr, + Handler: withCORS(mux), + } + s.server = httpServer + + go func() { + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("OAuth server error: %v", err) + } + }() + + if err := s.waitForReady(5 * time.Second); err != nil { + return nil, fmt.Errorf("OAuth server failed to start: %w", err) + } + + 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"}, + "code_challenge_methods_supported": []string{"S256"}, + } + + 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 + } + + // 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, + 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, + }) +} + +func (s *OAuthTestServer) waitForReady(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for OAuth server") + case <-ticker.C: + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.jwksURL, nil) + if err != nil { + continue + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +// --------------------------------------------------------------------------- +// 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 { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.server.Shutdown(ctx) +} + +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) + }) +} \ No newline at end of file diff --git a/router-tests/testutil/oauth_server_test.go b/router-tests/testutil/oauth_server_test.go new file mode 100644 index 0000000000..2347c40c48 --- /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) +} \ No newline at end of file diff --git a/router/core/router.go b/router/core/router.go index 63ae344944..38f6977dfa 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -958,6 +958,16 @@ func (r *Router) bootstrap(ctx context.Context) error { 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)) + + // Add server base URL for OAuth discovery if configured + if r.mcp.Server.BaseURL != "" { + mcpOpts = append(mcpOpts, mcpserver.WithServerBaseURL(r.mcp.Server.BaseURL)) + } + } + mcpGraphQLEndpoint := r.graphqlEndpointURL if r.mcp.RouterURL != "" { mcpGraphQLEndpoint = r.mcp.RouterURL diff --git a/router/go.mod b/router/go.mod index fde3dffcb3..373a4cd5d1 100644 --- a/router/go.mod +++ b/router/go.mod @@ -75,7 +75,6 @@ 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/posthog/posthog-go v1.5.5 github.com/pquerna/cachecontrol v0.2.0 @@ -91,9 +90,14 @@ require ( golang.org/x/time v0.9.0 ) +require ( + github.com/frankban/quicktest v1.14.6 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect +) + 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 @@ -123,17 +127,16 @@ require ( 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 github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.7 // indirect github.com/oklog/run v1.0.0 // indirect @@ -153,7 +156,6 @@ require ( github.com/sergi/go-diff v1.3.1 // indirect github.com/shoenig/go-m1cpu v0.1.6 // 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 +163,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 diff --git a/router/go.sum b/router/go.sum index b3f1a180a2..86e7c5f4af 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= @@ -119,10 +118,13 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/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= @@ -153,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= @@ -165,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= @@ -186,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= @@ -206,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.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= +github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= 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= @@ -228,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= @@ -254,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= @@ -276,8 +275,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= @@ -320,8 +317,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= @@ -396,6 +391,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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -426,6 +423,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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 5baa086249..5fb0d7d0fe 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1023,18 +1023,45 @@ type CacheWarmupConfiguration struct { } type MCPConfiguration struct { - Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_ENABLED"` - Server MCPServer `yaml:"server,omitempty"` - Storage MCPStorageConfig `yaml:"storage,omitempty"` - Session MCPSessionConfig `yaml:"session,omitempty"` - GraphName string `yaml:"graph_name" envDefault:"mygraph" env:"MCP_GRAPH_NAME"` - ExcludeMutations bool `yaml:"exclude_mutations" envDefault:"false" env:"MCP_EXCLUDE_MUTATIONS"` - EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"` - ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"` - RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"` + Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_ENABLED"` + Server MCPServer `yaml:"server,omitempty"` + Storage MCPStorageConfig `yaml:"storage,omitempty"` + Session MCPSessionConfig `yaml:"session,omitempty"` + GraphName string `yaml:"graph_name" envDefault:"mygraph" env:"MCP_GRAPH_NAME"` + ExcludeMutations bool `yaml:"exclude_mutations" envDefault:"false" env:"MCP_EXCLUDE_MUTATIONS"` + EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"` + ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"` + 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_"` +} + +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"` + // 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"` +} + +// 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"` + // ToolsList specifies scopes required for the tools/list MCP method. + ToolsList []string `yaml:"tools_list,omitempty"` + // ToolsCall specifies scopes required for the tools/call MCP method (any tool). + ToolsCall []string `yaml:"tools_call,omitempty"` } type MCPSessionConfig struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index e9335caf1c..e699b9b3fc 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2270,10 +2270,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" } } @@ -2333,7 +2331,219 @@ "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'." + }, + "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" } + } + } + }, + "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": "10s", + "duration": { + "minimum": "0s" + } + }, + "interval": { + "type": "string", + "description": "Token refill interval for the rate limiter.", + "default": "1m", + "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"] + } + }, + "required": ["server"] } }, "connect_rpc": { @@ -3590,6 +3800,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": "10s", + "duration": { + "minimum": "0s" + } + }, + "interval": { + "type": "string", + "description": "Token refill interval for the rate limiter.", + "default": "1m", + "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 f53b401f76..c6f98694d0 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -142,7 +142,18 @@ "EnableArbitraryOperations": false, "ExposeSchema": false, "RouterURL": "", - "OmitToolNamePrefix": false + "OmitToolNamePrefix": false, + "OAuth": { + "Enabled": false, + "JWKS": null, + "AuthorizationServerURL": "", + "Scopes": { + "Initialize": null, + "ToolsList": null, + "ToolsCall": null + }, + "ScopeChallengeIncludeTokenScopes": false + } }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 70091fcae3..36ca6e60b6 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -177,7 +177,18 @@ "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 + }, + "ScopeChallengeIncludeTokenScopes": false + } }, "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..55c9adf3e0 --- /dev/null +++ b/router/pkg/mcpserver/auth_middleware.go @@ -0,0 +1,317 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "strings" + + "github.com/wundergraph/cosmo/router/pkg/authentication" +) + +type contextKey string + +const ( + userClaimsContextKey contextKey = "mcp_user_claims" + // maxBodyBytes is the maximum size of the request body we'll read for scope checking. + // This prevents memory exhaustion from oversized payloads. + maxBodyBytes int64 = 1 << 20 // 1 MB +) + +// mcpAuthProvider adapts MCP headers to the authentication.Provider interface +type mcpAuthProvider struct { + headers http.Header +} + +func (p *mcpAuthProvider) AuthenticationHeaders() http.Header { + return p.headers +} + +// MCPScopeConfig holds the structured scope requirements for MCP operations. +type MCPScopeConfig struct { + Initialize []string // Scopes required for all HTTP requests + ToolsList []string // Scopes required for tools/list + ToolsCall []string // Scopes required for tools/call (any tool) +} + +// MCPAuthMiddleware creates authentication middleware for MCP tools and resources +type MCPAuthMiddleware struct { + authenticator authentication.Authenticator + enabled bool + resourceMetadataURL string + scopes MCPScopeConfig + scopeChallengeIncludeTokenScopes bool +} + +// NewMCPAuthMiddleware creates a new authentication middleware using the existing +// authentication infrastructure from the router +func NewMCPAuthMiddleware(tokenDecoder authentication.TokenDecoder, enabled bool, resourceMetadataURL string, scopes MCPScopeConfig, scopeChallengeIncludeTokenScopes bool) (*MCPAuthMiddleware, error) { + if tokenDecoder == nil { + return nil, fmt.Errorf("token decoder must be provided") + } + + // Use the existing HttpHeaderAuthenticator with default settings (Authorization header, Bearer prefix) + // This ensures consistency with the rest of the router's authentication logic + authenticator, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ + Name: "mcp-auth", + TokenDecoder: tokenDecoder, + // HeaderSourcePrefixes defaults to {"Authorization": {"Bearer"}} when not specified + // This can be extended in the future to support additional schemes like DPoP + }) + if err != nil { + return nil, fmt.Errorf("failed to create authenticator: %w", err) + } + + return &MCPAuthMiddleware{ + authenticator: authenticator, + enabled: enabled, + resourceMetadataURL: resourceMetadataURL, + scopes: scopes, + scopeChallengeIncludeTokenScopes: scopeChallengeIncludeTokenScopes, + }, nil +} + +// authenticateRequest extracts and validates the JWT token using the existing +// authentication infrastructure from the router +func (m *MCPAuthMiddleware) authenticateRequest(ctx context.Context) (authentication.Claims, error) { + // Extract headers from context (passed by mcp-go HTTP transport) + headers, err := headersFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("missing request headers: %w", err) + } + + // Use the existing authenticator instead of manual token parsing + // This provides better error messages and supports multiple authentication schemes + provider := &mcpAuthProvider{headers: headers} + claims, err := m.authenticator.Authenticate(ctx, provider) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + // If claims are empty, treat as authentication failure + if len(claims) == 0 { + return nil, fmt.Errorf("authentication failed: no valid credentials provided") + } + + // Note: Scope validation is now handled at HTTP level, not here + // This is per MCP spec: authorization must be at HTTP level + + return claims, nil +} + +// HTTPMiddleware wraps HTTP handlers with authentication for ALL MCP operations +// Per MCP specification: "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) { + if !m.enabled { + next.ServeHTTP(w, r) + return + } + + // Create a provider from the HTTP request headers + provider := &mcpAuthProvider{headers: r.Header} + + // Validate the token + claims, err := m.authenticator.Authenticate(r.Context(), provider) + if err != nil || len(claims) == 0 { + m.sendUnauthorizedResponse(w, err) + return + } + + // Step 1: Validate HTTP-level required scopes (initialize) + if len(m.scopes.Initialize) > 0 { + if err := m.validateScopesForRequest(claims, m.scopes.Initialize); err != nil { + m.sendInsufficientScopeResponse(w, m.scopes.Initialize, claims, err) + return + } + } + + // Step 2: Parse JSON-RPC request to check method-level scopes + // Read body to extract method name (only if body exists) + // Use LimitReader to prevent memory exhaustion from oversized payloads + var body []byte + if r.Body != nil { + limitedReader := io.LimitReader(r.Body, maxBodyBytes+1) + body, err = io.ReadAll(limitedReader) + if err != nil { + m.sendUnauthorizedResponse(w, fmt.Errorf("failed to read request body")) + return + } + if int64(len(body)) > maxBodyBytes { + m.sendUnauthorizedResponse(w, fmt.Errorf("request body too large")) + return + } + // Restore body for downstream handlers + r.Body = io.NopCloser(bytes.NewBuffer(body)) + } + + // Try to parse as JSON-RPC request (only if we have body content) + if len(body) > 0 { + var jsonRPCReq struct { + Method string `json:"method"` + } + if err := json.Unmarshal(body, &jsonRPCReq); err == nil && jsonRPCReq.Method != "" { + // Check method-level scopes + 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 err := m.validateScopesForRequest(claims, methodScopes); err != nil { + m.sendInsufficientScopeResponse(w, methodScopes, claims, err) + return + } + } + } + } + + // Add claims and request headers to request context for downstream handlers + ctx := context.WithValue(r.Context(), userClaimsContextKey, claims) + ctx = requestHeadersFromRequest(ctx, r) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// sendUnauthorizedResponse sends a 401 Unauthorized response with proper headers. +// It includes the minimum required scopes (from initialize) so that the MCP SDK +// can request exactly the scopes needed to establish a connection. +func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, err error) { + // Build WWW-Authenticate header per RFC 6750 and RFC 9728 + authHeader := `Bearer realm="mcp"` + + // Include minimum required scopes (initialize scopes) so the client knows + // what scopes to request for initial authentication + if len(m.scopes.Initialize) > 0 { + authHeader += fmt.Sprintf(`, scope="%s"`, strings.Join(m.scopes.Initialize, " ")) + } + + // Add resource_metadata per RFC 9728 for OAuth discovery + if m.resourceMetadataURL != "" { + authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) + } + + // Add optional error_description for debugging + if err != nil { + authHeader += fmt.Sprintf(`, error_description="%s"`, err.Error()) + } + + w.Header().Set("WWW-Authenticate", authHeader) + w.WriteHeader(http.StatusUnauthorized) + + // Per MCP spec: Authorization failures at HTTP level return only HTTP status and WWW-Authenticate header + // No JSON-RPC response body is returned +} + +// sendInsufficientScopeResponse sends a 403 Forbidden response per RFC 6750 Section 3.1 +// when the token is valid but lacks required scopes. +// +// When scopeChallengeIncludeTokenScopes is 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 to work around client SDKs that replace rather than accumulate scopes. +func (m *MCPAuthMiddleware) sendInsufficientScopeResponse(w http.ResponseWriter, operationScopes []string, claims authentication.Claims, err error) { + challengeScopes := operationScopes + + if m.scopeChallengeIncludeTokenScopes { + // Union of token's existing scopes + operation's required scopes. + // Existing scopes come first so the client retains them on re-auth. + existing := extractScopes(claims) + seen := make(map[string]struct{}, len(existing)+len(operationScopes)) + combined := make([]string, 0, len(existing)+len(operationScopes)) + for _, s := range existing { + seen[s] = struct{}{} + combined = append(combined, s) + } + for _, s := range operationScopes { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + combined = append(combined, s) + } + } + challengeScopes = combined + } + + scopeList := strings.Join(challengeScopes, " ") + + // Build WWW-Authenticate header with error and scope information + // Per RFC 6750 Section 3.1 and MCP spec: error, scope, resource_metadata, error_description + authHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s"`, scopeList) + + // Add resource_metadata per MCP spec (should be included per spec line 513) + if m.resourceMetadataURL != "" { + authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) + } + + // Add optional error_description for human-readable message + if err != nil { + authHeader += fmt.Sprintf(`, error_description="%s"`, err.Error()) + } + + w.Header().Set("WWW-Authenticate", authHeader) + w.WriteHeader(http.StatusForbidden) + + // Per MCP spec: Authorization failures at HTTP level return only HTTP status and WWW-Authenticate header + // No JSON-RPC response body is returned +} + +// validateScopesForRequest checks if the token contains all required scopes +func (m *MCPAuthMiddleware) validateScopesForRequest(claims authentication.Claims, requiredScopes []string) error { + // If no scopes are required, skip validation + if len(requiredScopes) == 0 { + return nil + } + + // Extract scopes from claims + tokenScopes := extractScopes(claims) + + // Check if all required scopes are present + var missingScopes []string + for _, requiredScope := range requiredScopes { + if !contains(tokenScopes, requiredScope) { + missingScopes = append(missingScopes, requiredScope) + } + } + + if len(missingScopes) > 0 { + return fmt.Errorf("missing required scopes: %s", strings.Join(missingScopes, ", ")) + } + + return nil +} + +// extractScopes extracts scope values from JWT claims +// Supports only the OAuth 2.0 standard "scope" claim as a space-separated string +func extractScopes(claims authentication.Claims) []string { + // Check for "scope" claim (OAuth 2.0 standard - space-separated string) + scopeClaim, ok := claims["scope"] + if !ok { + return []string{} + } + + // Only support string format per OAuth 2.0 spec + scopeStr, ok := scopeClaim.(string) + if !ok { + return []string{} + } + + // Use Fields() to split on any whitespace (spaces, tabs, newlines) + // and automatically filter out empty strings + return strings.Fields(scopeStr) +} + +// contains checks if a slice contains a specific string +func contains(slice []string, item string) bool { + return slices.Contains(slice, item) +} + +// 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..93bc7747d8 --- /dev/null +++ b/router/pkg/mcpserver/auth_middleware_test.go @@ -0,0 +1,440 @@ +package mcpserver + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/cosmo/router/pkg/authentication" +) + +// 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 + enabled bool + wantErr bool + }{ + { + name: "valid decoder and enabled", + decoder: validDecoder, + enabled: true, + wantErr: false, + }, + { + name: "valid decoder and disabled", + decoder: validDecoder, + enabled: false, + wantErr: false, + }, + { + name: "nil decoder", + decoder: nil, + enabled: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(tt.decoder, tt.enabled, "http://localhost:5025/.well-known/oauth-protected-resource/mcp", MCPScopeConfig{}, 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: "claims present", + setupCtx: func() context.Context { + return context.WithValue(context.Background(), userClaimsContextKey, expectedClaims) + }, + wantOk: true, + wantClaims: expectedClaims, + }, + { + name: "claims absent", + setupCtx: func() context.Context { + return context.Background() + }, + wantOk: false, + wantClaims: nil, + }, + { + name: "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: "scope with multiple values", + claims: authentication.Claims{ + "scope": "mcp:tools mcp:read mcp:write", + }, + want: []string{"mcp:tools", "mcp:read", "mcp:write"}, + }, + { + name: "scope with single value", + claims: authentication.Claims{ + "scope": "mcp:tools", + }, + want: []string{"mcp:tools"}, + }, + { + name: "no scope claim", + claims: authentication.Claims{}, + want: []string{}, + }, + { + name: "empty scope 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 TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { + t.Parallel() + + const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + + tests := []struct { + name string + scopes MCPScopeConfig + setupDecoder func() *mockTokenDecoder + setupRequest func() *http.Request + wantStatusCode int + wantWWWAuthenticatePrefix string + }{ + { + name: "valid token without scopes", + scopes: MCPScopeConfig{}, + 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: "missing auth header - 401 includes init scopes", + scopes: MCPScopeConfig{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: "missing auth header - 401 without scopes when none configured", + scopes: MCPScopeConfig{}, + 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: "invalid token - 401 includes init scopes", + scopes: MCPScopeConfig{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: "insufficient init scopes - 403 with include token scopes enabled", + scopes: MCPScopeConfig{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: "valid token with all required scopes", + scopes: MCPScopeConfig{ + 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, true, 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 TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "http://localhost:5025/.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 := MCPScopeConfig{ + 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: "tools/list with insufficient scopes - default returns operation scopes only", + token: "connect-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 403, + wantScope: `scope="mcp:tools:read"`, + }, + { + name: "tools/list with insufficient scopes - include token scopes", + token: "connect-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:read"`, + }, + { + name: "tools/list with sufficient scopes succeeds", + token: "connect-and-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 200, + }, + { + name: "tools/call with insufficient scopes - default returns operation scopes only", + 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: "tools/call with insufficient scopes - include token scopes", + 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: "tools/call with all scopes succeeds", + token: "all-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 200, + }, + { + name: "unknown method with no scope requirements succeeds", + 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, true, 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) + } + }) + } +} \ No newline at end of file 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/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index 0bbe2e15d6..643ac48e13 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -3,9 +3,11 @@ package mcpserver import ( "fmt" + "go.uber.org/zap" + "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 diff --git a/router/pkg/mcpserver/schema_compiler.go b/router/pkg/mcpserver/schema_compiler.go index 2bfcf79966..11816bd196 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) } diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 35b365bba4..8b07ba3ad5 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -14,15 +14,18 @@ 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" + "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" ) // requestHeadersKey is a custom context key for storing request headers. @@ -76,11 +79,15 @@ 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 } // 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 @@ -88,7 +95,7 @@ type GraphQLSchemaServer struct { httpClient *http.Client requestTimeout time.Duration routerGraphQLEndpoint string - httpServer *server.StreamableHTTPServer + httpServer *http.Server excludeMutations bool enableArbitraryOperations bool exposeSchema bool @@ -98,6 +105,11 @@ type GraphQLSchemaServer struct { schemaCompiler *SchemaCompiler registeredTools []string corsConfig cors.Config + ctx context.Context + cancel context.CancelFunc + oauthConfig *config.MCPOAuthConfiguration + serverBaseURL string + authMiddleware *MCPAuthMiddleware } type graphqlRequest struct { @@ -170,7 +182,6 @@ type GraphQLResponse struct { // NewGraphQLSchemaServer creates a new GraphQL schema server func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options)) (*GraphQLSchemaServer, error) { - if routerGraphQLEndpoint == "" { return nil, fmt.Errorf("routerGraphQLEndpoint cannot be empty") } @@ -196,14 +207,90 @@ 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(), + // Create a cancellable context for managing the server lifecycle + ctx, cancel := context.WithCancel(context.Background()) + + // Add authentication middleware if OAuth is configured + 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) + } + + // Create authentication middleware with scope configuration + // The middleware checks scopes at three levels: + // - initialize: scopes required for all HTTP requests + // - tools_list: scopes required for tools/list method + // - tools_call: scopes required for tools/call method (any tool) + scopeConfig := MCPScopeConfig{ + Initialize: options.OAuthConfig.Scopes.Initialize, + ToolsList: options.OAuthConfig.Scopes.ToolsList, + ToolsCall: options.OAuthConfig.Scopes.ToolsCall, + } + authMiddleware, err = NewMCPAuthMiddleware(tokenDecoder, true, resourceMetadataURL, scopeConfig, 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) + } + + // Store auth middleware for HTTP-level protection + // Note: We don't use tool middleware here because per MCP spec, + // ALL HTTP requests must be authenticated, not just tool calls + 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, + }, ) retryClient := retryablehttp.NewClient() @@ -226,6 +313,11 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) omitToolNamePrefix: options.OmitToolNamePrefix, stateless: options.Stateless, corsConfig: options.CorsConfig, + ctx: ctx, + cancel: cancel, + oauthConfig: options.OAuthConfig, + serverBaseURL: options.ServerBaseURL, + authMiddleware: authMiddleware, } return gs, nil @@ -311,8 +403,22 @@ 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 + } +} + +// 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, @@ -321,22 +427,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 + streamableHTTPHandler := mcp.NewStreamableHTTPHandler( + func(req *http.Request) *mcp.Server { + // Add request headers to context for tool handlers + return s.server + }, + nil, // Use default options ) 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 endpoint (RFC 9728 Section 3.1) + // This endpoint is required for MCP clients to discover the authorization server. + // This endpoint is NOT protected by authentication (it's public discovery). + // + // Per RFC 9728, when a resource is served at a path other than /, the well-known + // URI must include the path suffix: /.well-known/oauth-protected-resource/mcp + if s.oauthConfig != nil && s.oauthConfig.Enabled && s.oauthConfig.AuthorizationServerURL != "" { + mux.Handle("/.well-known/oauth-protected-resource/mcp", middleware(http.HandlerFunc(s.handleProtectedResourceMetadata))) + s.logger.Info("OAuth 2.0 Protected Resource Metadata endpoint enabled (RFC 9728 path-aware)", + zap.String("path", "/.well-known/oauth-protected-resource/mcp"), + zap.String("authorization_server", s.oauthConfig.AuthorizationServerURL)) + } + + // MCP endpoint with HTTP-level authentication + // Per MCP spec: "authorization MUST be included in every HTTP request from client to server" + mcpHandler := http.Handler(streamableHTTPHandler) + + // Apply authentication middleware if OAuth is enabled + if s.authMiddleware != nil { + mux.Handle("/mcp", middleware(s.authMiddleware.HTTPMiddleware(mcpHandler))) + s.logger.Info("MCP endpoint protected with OAuth authentication at HTTP level") + } else { + mux.Handle("/mcp", middleware(mcpHandler)) + } // Set the handler for the custom HTTP server httpServer.Handler = mux @@ -362,12 +490,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) @@ -380,7 +507,6 @@ func (s *GraphQLSchemaServer) Start() error { // Reload reloads the operations and schema func (s *GraphQLSchemaServer) Reload(schema *ast.Document) error { - if s.server == nil { return fmt.Errorf("server is not started") } @@ -394,7 +520,7 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document) error { } } - s.server.DeleteTools(s.registeredTools...) + s.server.RemoveTools(s.registeredTools...) if err := s.registerTools(); err != nil { return fmt.Errorf("failed to register tools: %w", err) @@ -411,6 +537,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() @@ -424,70 +555,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 @@ -549,43 +675,62 @@ func (s *GraphQLSchemaServer) registerTools() error { ) toolName = fmt.Sprintf("execute_operation_%s", operationToolName) } - 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), + idempotent := op.OperationType != "mutation" + 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), - ) + // IdempotentHint uses the plain bool value, but keep it for later if needed + _ = idempotent + + s.server.AddTool(tool, s.handleOperation(handler)) s.registeredTools = append(s.registeredTools, toolName) } - 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(), - ) + 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") @@ -593,18 +738,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 } } @@ -614,13 +766,10 @@ 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) } @@ -686,7 +835,9 @@ Important Notes: // Combine all sections response := overview + schemaInfo + queryInfo + usageInstructions + requestFormat + importantNotes - return mcp.NewToolResultText(response), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: response}}, + }, nil } } @@ -757,27 +908,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) @@ -792,8 +955,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 { @@ -806,6 +969,97 @@ 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) + scopesSet := make(map[string]bool) + for _, scopeList := range [][]string{ + s.oauthConfig.Scopes.Initialize, + s.oauthConfig.Scopes.ToolsList, + s.oauthConfig.Scopes.ToolsCall, + } { + for _, scope := range scopeList { + 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) + } + if len(scopes) == 0 { + scopes = []string{} // Ensure non-nil for JSON encoding + } + + metadata := ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{s.oauthConfig.AuthorizationServerURL}, + BearerMethodsSupported: []string{"header"}, + ResourceDocumentation: fmt.Sprintf("%s/mcp", resourceURL), + ScopesSupported: scopes, // Automatically derived from required 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 "" }