diff --git a/package.json b/package.json index 371daac99..4f3f99b85 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@modelcontextprotocol/sdk": "1.29.0", "chardet": "^2.1.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c086eeb46..45ab9e014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.7) + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@4.4.3) chardet: specifier: ^2.1.1 version: 2.1.1 @@ -647,6 +650,12 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@hono/node-server@1.19.15': + resolution: {integrity: sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -806,6 +815,16 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@module-federation/error-codes@0.22.0': resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==} @@ -2149,6 +2168,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2171,6 +2194,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: @@ -2319,6 +2350,10 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bonjour-service@1.3.0: resolution: {integrity: sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==} @@ -2485,16 +2520,28 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + 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'} @@ -2506,6 +2553,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -2861,6 +2912,14 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exit-hook@4.0.0: resolution: {integrity: sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==} engines: {node: '>=18'} @@ -2869,10 +2928,20 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.6.1: + resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2924,6 +2993,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2959,6 +3032,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -3113,6 +3190,10 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hono@4.12.32: + resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} + engines: {node: '>=16.9.0'} + hot-patcher@2.0.1: resolution: {integrity: sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==} @@ -3176,6 +3257,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3213,6 +3298,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ip-address@10.3.1: + resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3325,6 +3414,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3397,6 +3489,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -3424,6 +3519,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3650,6 +3748,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + memfs@4.56.10: resolution: {integrity: sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w==} peerDependencies: @@ -3658,6 +3760,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -3826,6 +3932,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + nested-property@4.0.0: resolution: {integrity: sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==} @@ -3962,6 +4072,9 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3980,6 +4093,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkijs@3.4.0: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} @@ -4083,6 +4200,10 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -4235,6 +4356,10 @@ packages: 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'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -4295,6 +4420,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serve-index@1.9.2: resolution: {integrity: sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==} engines: {node: '>= 0.8.0'} @@ -4303,6 +4432,10 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} @@ -4357,7 +4490,7 @@ packages: engines: {node: '>= 10'} sockjs@https://codeload.github.com/sockjs/sockjs-node/tar.gz/c239289d8cd85647aff064337d81073f0e4e080a: - resolution: {gitHosted: true, integrity: sha512-ouaKPfY8XDmKQXrYbvNmqPYIr8KyYkqUznY0/Lwb09qND5cIgKQej3n931To1TpVC5sTX9jVkwh3c635yyo/8w==, tarball: https://codeload.github.com/sockjs/sockjs-node/tar.gz/c239289d8cd85647aff064337d81073f0e4e080a} + resolution: {tarball: https://codeload.github.com/sockjs/sockjs-node/tar.gz/c239289d8cd85647aff064337d81073f0e4e080a} version: 0.4.0-rc.1 engines: {node: '>=18.0.0'} @@ -4559,6 +4692,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4922,6 +5059,11 @@ packages: resolution: {integrity: sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==} engines: {node: '>= 6'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -4967,7 +5109,7 @@ snapshots: '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5047,7 +5189,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5273,6 +5415,10 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@hono/node-server@1.19.15(hono@4.12.32)': + dependencies: + hono: 4.12.32 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -5434,6 +5580,28 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.15(hono@4.12.32) + 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.1.0 + express: 5.2.1 + express-rate-limit: 8.6.1(express@5.2.1) + hono: 4.12.32 + jose: 6.2.4 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@module-federation/error-codes@0.22.0': {} '@module-federation/runtime-core@0.22.0': @@ -6892,6 +7060,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -6906,6 +7079,10 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv-keywords@5.1.0(ajv@8.18.0): dependencies: ajv: 8.18.0 @@ -7101,6 +7278,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bonjour-service@1.3.0: dependencies: fast-deep-equal: 3.1.3 @@ -7279,18 +7470,29 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} cookie@1.0.2: {} core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cosmiconfig@9.0.0(typescript@6.0.3): dependencies: env-paths: 2.2.1 @@ -7739,10 +7941,24 @@ snapshots: eventemitter3@5.0.1: {} + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + exit-hook@4.0.0: {} expect-type@1.3.0: {} + express-rate-limit@8.6.1(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.3.1 + transitivePeerDependencies: + - supports-color + express@4.22.1: dependencies: accepts: 1.3.8 @@ -7779,6 +7995,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + 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.2 + 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.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -7836,6 +8085,17 @@ snapshots: transitivePeerDependencies: - supports-color + 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 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7862,6 +8122,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs.realpath@1.0.0: {} @@ -8048,6 +8310,8 @@ snapshots: highlight.js@11.11.1: {} + hono@4.12.32: {} + hot-patcher@2.0.1: {} hpack.js@2.1.6: @@ -8117,6 +8381,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -8147,6 +8415,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ip-address@10.3.1: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -8241,6 +8511,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.3 @@ -8316,6 +8588,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.4: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -8334,6 +8608,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -8637,6 +8913,8 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.1: {} + memfs@4.56.10(tslib@2.8.1): dependencies: '@jsonjoy.com/fs-core': 4.56.10(tslib@2.8.1) @@ -8656,6 +8934,8 @@ snapshots: merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + methods@1.1.2: {} micromark-core-commonmark@2.0.3: @@ -8830,7 +9110,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.1 + debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -8907,6 +9187,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + nested-property@4.0.0: {} node-domexception@1.0.0: {} @@ -9050,6 +9332,8 @@ snapshots: path-to-regexp@0.1.13: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pbf@3.3.0: @@ -9063,6 +9347,8 @@ snapshots: picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + pkijs@3.4.0: dependencies: '@noble/hashes': 1.4.0 @@ -9212,6 +9498,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -9429,6 +9722,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 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.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} rxjs@7.8.2: @@ -9500,6 +9803,22 @@ snapshots: transitivePeerDependencies: - supports-color + 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-index@1.9.2: dependencies: accepts: 1.3.8 @@ -9521,6 +9840,15 @@ snapshots: 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 + set-cookie-parser@2.7.1: {} set-function-length@1.2.2: @@ -9562,16 +9890,16 @@ snapshots: side-channel-map@1.0.1: dependencies: - call-bound: 1.0.3 + call-bound: 1.0.4 es-errors: 1.3.0 - get-intrinsic: 1.2.7 + get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-weakmap@1.0.2: dependencies: - call-bound: 1.0.3 + call-bound: 1.0.4 es-errors: 1.3.0 - get-intrinsic: 1.2.7 + get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-map: 1.0.1 @@ -9620,7 +9948,7 @@ snapshots: spdy@4.0.2: dependencies: - debug: 4.4.1 + debug: 4.4.3 handle-thing: 2.0.1 http-deceiver: 1.2.7 select-hose: 2.0.0 @@ -9805,6 +10133,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.3 @@ -10197,6 +10531,10 @@ snapshots: compress-commons: 2.1.1 readable-stream: 3.6.2 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/src/app/service/agent/core/mcp_client.test.ts b/src/app/service/agent/core/mcp_client.test.ts index 7f888258d..e6a4e5965 100644 --- a/src/app/service/agent/core/mcp_client.test.ts +++ b/src/app/service/agent/core/mcp_client.test.ts @@ -1,8 +1,16 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +// SDK 的 SSE 管线需要原生 Web Streams;happy-dom 下会超过 fast 项目的 340ms 预算。 +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MCPClient } from "./mcp_client"; import type { MCPServerConfig } from "./types"; -// Mock fetch +type JsonRpcRequest = { + id?: string | number; + method: string; + params?: Record; +}; + const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -18,90 +26,49 @@ function createConfig(overrides?: Partial): MCPServerConfig { }; } -function jsonResponse(result: unknown, headers?: Record): Response { - const h = new Headers({ "Content-Type": "application/json", ...headers }); - return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result }), { +function jsonResponse(id: JsonRpcRequest["id"], result: unknown, headers?: HeadersInit): Response { + return new Response(JSON.stringify({ jsonrpc: "2.0", id, result }), { status: 200, - headers: h, + headers: { "Content-Type": "application/json", ...Object.fromEntries(new Headers(headers)) }, }); } -function jsonErrorResponse(code: number, message: string): Response { - return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code, message } }), { +function sseResponse(id: JsonRpcRequest["id"], result: unknown): Response { + return new Response(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id, result })}\n\n`, { status: 200, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "text/event-stream" }, }); } -function httpErrorResponse(status: number, body = ""): Response { - return new Response(body, { status, headers: { "Content-Type": "text/plain" } }); -} - -describe("MCPClient", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - describe("initialize", () => { - it("应正确发送 initialize 和 initialized 请求", async () => { - const client = new MCPClient(createConfig()); - - // initialize response - mockFetch.mockResolvedValueOnce( - jsonResponse({ - protocolVersion: "2025-03-26", - capabilities: {}, - serverInfo: { name: "TestServer", version: "1.0" }, - }) - ); - // initialized notification response - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - - await client.initialize(); - - expect(mockFetch).toHaveBeenCalledTimes(2); - - // 第一个请求是 initialize - const firstCall = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(firstCall.method).toBe("initialize"); - expect(firstCall.id).toBeDefined(); - expect(firstCall.params.protocolVersion).toBe("2025-03-26"); - - // 第二个请求是 initialized 通知(无 id) - const secondCall = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(secondCall.method).toBe("notifications/initialized"); - expect(secondCall.id).toBeUndefined(); - - expect(client.isInitialized()).toBe(true); - }); - - it("initialize 响应缺少 protocolVersion 时应抛出错误", async () => { - const client = new MCPClient(createConfig()); - mockFetch.mockResolvedValueOnce(jsonResponse({})); - - await expect(client.initialize()).rejects.toThrow("missing protocolVersion"); - }); - - it("HTTP 错误时应抛出", async () => { - const client = new MCPClient(createConfig()); - mockFetch.mockResolvedValueOnce(httpErrorResponse(500, "Internal Server Error")); - - await expect(client.initialize()).rejects.toThrow("500"); - }); - }); +function installServer(overrides: Partial Response>> = {}): void { + mockFetch.mockImplementation(async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "GET") { + return new Response(null, { status: 405 }); + } - describe("listTools", () => { - it("应正确解析 tools/list 响应", async () => { - const client = new MCPClient(createConfig()); + const request = JSON.parse(String(init?.body)) as JsonRpcRequest; + if (request.method === "notifications/initialized") { + return new Response(null, { status: 202 }); + } - // initialize - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); + const override = overrides[request.method]; + if (override) { + return override(request); + } - // listTools - mockFetch.mockResolvedValueOnce( - jsonResponse({ + switch (request.method) { + case "initialize": + return jsonResponse( + request.id, + { + protocolVersion: "2025-03-26", + capabilities: {}, + serverInfo: { name: "TestServer", version: "1.0.0" }, + }, + { "Mcp-Session-Id": "session-123" } + ); + case "tools/list": + return jsonResponse(request.id, { tools: [ { name: "search", @@ -109,337 +76,154 @@ describe("MCPClient", () => { inputSchema: { type: "object", properties: { query: { type: "string" } } }, }, ], - }) - ); - - const tools = await client.listTools(); - expect(tools).toHaveLength(1); - expect(tools[0].name).toBe("search"); - expect(tools[0].serverId).toBe("test-server"); - expect(tools[0].description).toBe("Search the web"); - }); - - it("未初始化时应抛出错误", async () => { - const client = new MCPClient(createConfig()); - await expect(client.listTools()).rejects.toThrow("not initialized"); - }); - }); - - describe("callTool", () => { - async function initClient(): Promise { - const client = new MCPClient(createConfig()); - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - return client; - } - - it("应正确发送 tools/call 并返回文本结果", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - content: [{ type: "text", text: "Result from tool" }], - }) - ); - - const result = await client.callTool("search", { query: "hello" }); - expect(result).toBe("Result from tool"); - - const lastCall = JSON.parse(mockFetch.mock.lastCall![1].body); - expect(lastCall.method).toBe("tools/call"); - expect(lastCall.params.name).toBe("search"); - expect(lastCall.params.arguments).toEqual({ query: "hello" }); - }); - - it("isError 时应抛出错误", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - content: [{ type: "text", text: "Something went wrong" }], - isError: true, - }) - ); - - await expect(client.callTool("search", { query: "fail" })).rejects.toThrow("Something went wrong"); - }); - - it("多内容时应返回完整 content 数组", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - content: [ - { type: "text", text: "Part 1" }, - { type: "image", data: "base64data", mimeType: "image/png" }, - ], - }) - ); - - const result = await client.callTool("multi", {}); - expect(Array.isArray(result)).toBe(true); - expect((result as any[]).length).toBe(2); - }); - }); - - describe("listResources / readResource", () => { - async function initClient(): Promise { - const client = new MCPClient(createConfig()); - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - return client; + }); + case "tools/call": + return jsonResponse(request.id, { content: [{ type: "text", text: "Result from tool" }] }); + case "resources/list": + return jsonResponse(request.id, { + resources: [{ uri: "file:///readme.md", name: "README", mimeType: "text/markdown" }], + }); + case "resources/read": + return jsonResponse(request.id, { + contents: [{ uri: "file:///readme.md", text: "# Hello", mimeType: "text/markdown" }], + }); + case "prompts/list": + return jsonResponse(request.id, { + prompts: [{ name: "summarize", description: "Summarize text", arguments: [{ name: "text" }] }], + }); + case "prompts/get": + return jsonResponse(request.id, { + messages: [{ role: "user", content: { type: "text", text: "Summarize: hello" } }], + }); + default: + throw new Error(`Unexpected MCP method: ${request.method}`); } - - it("应正确解析 resources/list", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - resources: [{ uri: "file:///docs/readme.md", name: "README", mimeType: "text/markdown" }], - }) - ); - - const resources = await client.listResources(); - expect(resources).toHaveLength(1); - expect(resources[0].uri).toBe("file:///docs/readme.md"); - expect(resources[0].serverId).toBe("test-server"); - }); - - it("应正确读取资源", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - contents: [{ uri: "file:///docs/readme.md", text: "# Hello", mimeType: "text/markdown" }], - }) - ); - - const result = await client.readResource("file:///docs/readme.md"); - expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toBe("# Hello"); - }); }); +} - describe("listPrompts / getPrompt", () => { - async function initClient(): Promise { - const client = new MCPClient(createConfig()); - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - return client; - } - - it("应正确解析 prompts/list", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - prompts: [ - { - name: "summarize", - description: "Summarize text", - arguments: [{ name: "text", required: true }], - }, - ], - }) - ); - - const prompts = await client.listPrompts(); - expect(prompts).toHaveLength(1); - expect(prompts[0].name).toBe("summarize"); - expect(prompts[0].arguments).toHaveLength(1); - }); - - it("应正确获取 prompt 消息", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - messages: [{ role: "user", content: { type: "text", text: "Summarize: hello world" } }], - }) - ); +function requests(): JsonRpcRequest[] { + return mockFetch.mock.calls + .filter(([, init]) => init?.body) + .map(([, init]) => JSON.parse(String(init.body)) as JsonRpcRequest); +} - const messages = await client.getPrompt("summarize", { text: "hello world" }); - expect(messages).toHaveLength(1); - expect(messages[0].role).toBe("user"); - }); +describe("MCPClient", () => { + beforeEach(() => { + mockFetch.mockReset(); + installServer(); }); - describe("Session ID 管理", () => { - it("应存储并回传 Mcp-Session-Id", async () => { - const client = new MCPClient(createConfig()); - - // initialize 返回 session id - mockFetch.mockResolvedValueOnce( - jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} }, { "Mcp-Session-Id": "session-123" }) - ); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - - // 后续请求应包含 session id - mockFetch.mockResolvedValueOnce(jsonResponse({ tools: [] })); - await client.listTools(); - - const lastCallHeaders = mockFetch.mock.lastCall![1].headers; - expect(lastCallHeaders["Mcp-Session-Id"]).toBe("session-123"); - }); + it("初始化后可发现工具并保留 ScriptCat 服务器标识", async () => { + const client = new MCPClient(createConfig()); + await client.initialize(); + + await expect(client.listTools()).resolves.toEqual([ + { + serverId: "test-server", + name: "search", + description: "Search the web", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ]); + expect(client.isInitialized()).toBe(true); }); - describe("JSON-RPC 错误处理", () => { - it("应正确处理 JSON-RPC 错误", async () => { - const client = new MCPClient(createConfig()); - - // initialize 返回 JSON-RPC 错误 - mockFetch.mockResolvedValueOnce(jsonErrorResponse(-32600, "Invalid Request")); + it("调用工具时透传参数并将单个文本内容归一化为字符串", async () => { + const client = new MCPClient(createConfig()); + await client.initialize(); - await expect(client.initialize()).rejects.toThrow("MCP error -32600: Invalid Request"); + await expect(client.callTool("search", { query: "hello" })).resolves.toBe("Result from tool"); + expect(requests().find((request) => request.method === "tools/call")?.params).toEqual({ + name: "search", + arguments: { query: "hello" }, }); }); - describe("认证头", () => { - it("应设置 Bearer token", async () => { - const client = new MCPClient(createConfig({ apiKey: "sk-test-key" })); - - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers["Authorization"]).toBe("Bearer sk-test-key"); + it("服务器以 SSE 返回工具结果时仍应完成调用", async () => { + installServer({ + "tools/call": (request) => sseResponse(request.id, { content: [{ type: "text", text: "Result from SSE" }] }), }); + const client = new MCPClient(createConfig()); + await client.initialize(); - it("应设置自定义 headers", async () => { - const client = new MCPClient(createConfig({ headers: { "X-Custom": "custom-value" } })); - - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers["X-Custom"]).toBe("custom-value"); - }); + await expect(client.callTool("search", { query: "hello" })).resolves.toBe("Result from SSE"); }); - describe("close", () => { - it("close 后应标记为未初始化", async () => { - const client = new MCPClient(createConfig()); - - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - - expect(client.isInitialized()).toBe(true); - client.close(); - expect(client.isInitialized()).toBe(false); + it("工具返回 isError 时向调用方抛出文本错误", async () => { + installServer({ + "tools/call": (request) => + jsonResponse(request.id, { content: [{ type: "text", text: "Tool failed" }], isError: true }), }); + const client = new MCPClient(createConfig()); + await client.initialize(); - it("close 后调用 listTools 应抛出 not initialized", async () => { - const client = new MCPClient(createConfig()); - - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); + await expect(client.callTool("search")).rejects.toThrow("Tool failed"); + }); - client.close(); - await expect(client.listTools()).rejects.toThrow("not initialized"); + it("可列出和读取资源", async () => { + const client = new MCPClient(createConfig()); + await client.initialize(); + + await expect(client.listResources()).resolves.toEqual([ + { + serverId: "test-server", + uri: "file:///readme.md", + name: "README", + description: undefined, + mimeType: "text/markdown", + }, + ]); + await expect(client.readResource("file:///readme.md")).resolves.toEqual({ + contents: [{ uri: "file:///readme.md", text: "# Hello", mimeType: "text/markdown" }], }); + }); - it("close 后调用 callTool 应抛出 not initialized", async () => { - const client = new MCPClient(createConfig()); - - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - - client.close(); - await expect(client.callTool("search", { q: "test" })).rejects.toThrow("not initialized"); - }); + it("可列出提示词并获取提示词消息", async () => { + const client = new MCPClient(createConfig()); + await client.initialize(); + + await expect(client.listPrompts()).resolves.toEqual([ + { + serverId: "test-server", + name: "summarize", + description: "Summarize text", + arguments: [{ name: "text" }], + }, + ]); + await expect(client.getPrompt("summarize", { text: "hello" })).resolves.toEqual([ + { role: "user", content: { type: "text", text: "Summarize: hello" } }, + ]); }); - describe("callTool 边界场景", () => { - async function initClient(): Promise { - const client = new MCPClient(createConfig()); - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); - return client; + it("所有请求都携带认证、自定义 header 和服务器 session", async () => { + const client = new MCPClient(createConfig({ apiKey: "secret", headers: { "X-Custom": "custom-value" } })); + await client.initialize(); + await client.listTools(); + + const calls = mockFetch.mock.calls.filter(([, init]) => init?.method === "POST"); + expect(calls).not.toHaveLength(0); + for (const [, init] of calls) { + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBe("Bearer secret"); + expect(headers.get("X-Custom")).toBe("custom-value"); } - - it("callTool 无参数调用:不传 args → 发送 arguments: {}", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - content: [{ type: "text", text: "no args result" }], - }) - ); - - const result = await client.callTool("ping"); - expect(result).toBe("no args result"); - - const lastCall = JSON.parse(mockFetch.mock.lastCall![1].body); - expect(lastCall.method).toBe("tools/call"); - expect(lastCall.params.name).toBe("ping"); - expect(lastCall.params.arguments).toEqual({}); - }); - - it("callTool JSON-RPC 错误:返回 JSON-RPC error → 抛出 MCP error", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce(jsonErrorResponse(-32601, "Method not found")); - - await expect(client.callTool("unknown_tool", {})).rejects.toThrow("MCP error -32601: Method not found"); - }); - - it("callTool 空 content:返回空 content 数组", async () => { - const client = await initClient(); - - mockFetch.mockResolvedValueOnce( - jsonResponse({ - content: [], - }) - ); - - const result = await client.callTool("empty", {}); - // 空 content,不是单个 text,返回整个 content 数组 - expect(Array.isArray(result)).toBe(true); - expect((result as any[]).length).toBe(0); - }); + expect(new Headers(calls.at(-1)?.[1].headers).get("Mcp-Session-Id")).toBe("session-123"); }); - describe("请求超时", () => { - it("sendRequest 应传递 AbortSignal.timeout(60s)", async () => { - const client = new MCPClient(createConfig()); - - mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); - await client.initialize(); + it("关闭后拒绝后续调用", async () => { + const client = new MCPClient(createConfig()); + await client.initialize(); + await client.close(); - // 检查 initialize 的 fetch 调用带了 signal - expect(mockFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); - // 检查 notification 的 fetch 调用也带了 signal - expect(mockFetch.mock.calls[1][1].signal).toBeInstanceOf(AbortSignal); - }); + expect(client.isInitialized()).toBe(false); + await expect(client.listTools()).rejects.toThrow("not initialized"); }); - describe("sendNotification 失败", () => { - it("initialize 过程中通知失败应抛出", async () => { - const client = new MCPClient(createConfig()); - - // initialize 请求成功 - mockFetch.mockResolvedValueOnce( - jsonResponse({ - protocolVersion: "2025-03-26", - capabilities: {}, - serverInfo: { name: "TestServer", version: "1.0" }, - }) - ); - // initialized 通知失败(HTTP error) - mockFetch.mockResolvedValueOnce(httpErrorResponse(503, "Service Unavailable")); + it("初始化失败时保持未初始化", async () => { + installServer({ initialize: () => new Response("unavailable", { status: 503 }) }); + const client = new MCPClient(createConfig()); - await expect(client.initialize()).rejects.toThrow("503"); - }); + await expect(client.initialize()).rejects.toThrow("Error POSTing to endpoint"); + expect(client.isInitialized()).toBe(false); }); }); diff --git a/src/app/service/agent/core/mcp_client.ts b/src/app/service/agent/core/mcp_client.ts index dcb15ad5d..c67e398bc 100644 --- a/src/app/service/agent/core/mcp_client.ts +++ b/src/app/service/agent/core/mcp_client.ts @@ -1,103 +1,68 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { MCPServerConfig, MCPTool, MCPResource, MCPPrompt, MCPPromptMessage } from "./types"; -// JSON-RPC 2.0 请求 -type JsonRpcRequest = { - jsonrpc: "2.0"; - id?: number; - method: string; - params?: Record; -}; - -// JSON-RPC 2.0 响应 -type JsonRpcResponse = { - jsonrpc: "2.0"; - id?: number; - result?: unknown; - error?: { code: number; message: string; data?: unknown }; -}; - -// MCP 协议版本 -const MCP_PROTOCOL_VERSION = "2025-03-26"; - -// MCP Client — JSON-RPC 2.0 over Streamable HTTP (POST only) export class MCPClient { - private nextId = 1; - private sessionId?: string; + private readonly client: Client; + private readonly transport: StreamableHTTPClientTransport; private initialized = false; - constructor(private config: MCPServerConfig) {} - - // 初始化:交换协议版本和能力 - async initialize(): Promise { - const result = (await this.sendRequest("initialize", { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "ScriptCat", version: "1.0.0" }, - })) as { - protocolVersion: string; - capabilities: Record; - serverInfo?: { name: string; version?: string }; - }; - - if (!result || !result.protocolVersion) { - throw new Error("Invalid initialize response: missing protocolVersion"); + constructor(private readonly config: MCPServerConfig) { + const headers = new Headers(config.headers); + if (config.apiKey) { + headers.set("Authorization", `Bearer ${config.apiKey}`); } - // 发送 initialized 通知(无 id = 通知) - await this.sendNotification("notifications/initialized", {}); - this.initialized = true; + this.client = new Client({ name: "ScriptCat", version: chrome.runtime.getManifest().version }); + this.transport = new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: { headers }, + }); } - // ---- Tools ---- + async initialize(): Promise { + await this.client.connect(this.transport); + this.initialized = true; + } async listTools(): Promise { this.ensureInitialized(); - const result = (await this.sendRequest("tools/list", {})) as { - tools: Array<{ name: string; description?: string; inputSchema: Record }>; - }; - return (result.tools || []).map((t) => ({ + const { tools } = await this.client.listTools(); + return tools.map((tool) => ({ serverId: this.config.id, - name: t.name, - description: t.description, - inputSchema: t.inputSchema, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, })); } async callTool(name: string, args?: Record): Promise { this.ensureInitialized(); - const result = (await this.sendRequest("tools/call", { - name, - arguments: args || {}, - })) as { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + const result = (await this.client.callTool({ name, arguments: args ?? {} })) as { + content: Array<{ type: string; text?: string; [key: string]: unknown }>; isError?: boolean; }; + const content = result.content; if (result.isError) { - const errorText = result.content?.map((c) => c.text || "").join("\n") || "Tool call failed"; - throw new Error(errorText); + const errorText = content.map((item) => (item.type === "text" ? item.text : "")).join("\n"); + throw new Error(errorText || "Tool call failed"); } - // 返回文本内容或完整 content - if (result.content?.length === 1 && result.content[0].type === "text") { - return result.content[0].text; + if (content.length === 1 && content[0].type === "text") { + return content[0].text; } - return result.content; + return content; } - // ---- Resources ---- - async listResources(): Promise { this.ensureInitialized(); - const result = (await this.sendRequest("resources/list", {})) as { - resources: Array<{ uri: string; name: string; description?: string; mimeType?: string }>; - }; - return (result.resources || []).map((r) => ({ + const { resources } = await this.client.listResources(); + return resources.map((resource) => ({ serverId: this.config.id, - uri: r.uri, - name: r.name, - description: r.description, - mimeType: r.mimeType, + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, })); } @@ -105,144 +70,38 @@ export class MCPClient { uri: string ): Promise<{ contents: Array<{ uri: string; text?: string; blob?: string; mimeType?: string }> }> { this.ensureInitialized(); - return (await this.sendRequest("resources/read", { uri })) as { - contents: Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>; - }; + return this.client.readResource({ uri }); } - // ---- Prompts ---- - async listPrompts(): Promise { this.ensureInitialized(); - const result = (await this.sendRequest("prompts/list", {})) as { - prompts: Array<{ - name: string; - description?: string; - arguments?: Array<{ name: string; description?: string; required?: boolean }>; - }>; - }; - return (result.prompts || []).map((p) => ({ + const { prompts } = await this.client.listPrompts(); + return prompts.map((prompt) => ({ serverId: this.config.id, - name: p.name, - description: p.description, - arguments: p.arguments, + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments, })); } async getPrompt(name: string, args?: Record): Promise { this.ensureInitialized(); - const result = (await this.sendRequest("prompts/get", { - name, - arguments: args || {}, - })) as { - messages: MCPPromptMessage[]; - }; - return result.messages || []; + const { messages } = await this.client.getPrompt({ name, arguments: args ?? {} }); + return messages as MCPPromptMessage[]; } - // ---- Lifecycle ---- - - close(): void { + async close(): Promise { this.initialized = false; - this.sessionId = undefined; + await this.client.close(); } isInitialized(): boolean { return this.initialized; } - // ---- Internal ---- - private ensureInitialized(): void { if (!this.initialized) { throw new Error("MCPClient not initialized. Call initialize() first."); } } - - private buildHeaders(): Record { - const headers: Record = { - "Content-Type": "application/json", - Accept: "application/json", - }; - - // 认证 - if (this.config.apiKey) { - headers["Authorization"] = `Bearer ${this.config.apiKey}`; - } - - // 自定义 headers - if (this.config.headers) { - Object.assign(headers, this.config.headers); - } - - // Session ID - if (this.sessionId) { - headers["Mcp-Session-Id"] = this.sessionId; - } - - return headers; - } - - async sendRequest(method: string, params?: Record): Promise { - const id = this.nextId++; - const body: JsonRpcRequest = { - jsonrpc: "2.0", - id, - method, - params, - }; - - const response = await fetch(this.config.url, { - method: "POST", - headers: this.buildHeaders(), - body: JSON.stringify(body), - signal: AbortSignal.timeout(60_000), - }); - - // 存储 session ID - const sessionId = response.headers.get("Mcp-Session-Id"); - if (sessionId) { - this.sessionId = sessionId; - } - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new Error(`MCP request failed: ${response.status} ${errorText}`); - } - - const json = (await response.json()) as JsonRpcResponse; - - if (json.error) { - throw new Error(`MCP error ${json.error.code}: ${json.error.message}`); - } - - return json.result; - } - - private async sendNotification(method: string, params?: Record): Promise { - const body: JsonRpcRequest = { - jsonrpc: "2.0", - method, - params, - }; - - const response = await fetch(this.config.url, { - method: "POST", - headers: this.buildHeaders(), - body: JSON.stringify(body), - signal: AbortSignal.timeout(60_000), - }); - - // 存储 session ID - const sessionId = response.headers.get("Mcp-Session-Id"); - if (sessionId) { - this.sessionId = sessionId; - } - - // 通知不需要响应体,但检查状态码 - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new Error(`MCP notification failed: ${response.status} ${errorText}`); - } - } } diff --git a/src/app/service/agent/service_worker/mcp.ts b/src/app/service/agent/service_worker/mcp.ts index 79185995d..de783228b 100644 --- a/src/app/service/agent/service_worker/mcp.ts +++ b/src/app/service/agent/service_worker/mcp.ts @@ -140,7 +140,7 @@ export class MCPService { prompts: prompts.length, }; } finally { - client.close(); + await client.close(); } }