mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat(backend): MCP endpoint at /api/mcp, with tools chosen per API token
An AI assistant connects over Streamable HTTP (stateless) with the user's API token and gets read-only tools: search_models, list_models, get_model, list_collections, get_collection, list_tags, list_categories, get_author and library_stats. Each runs as the token's user through the same services as the REST API. Every MCP message is a POST, which requireAuth refuses to an API token, so /api/mcp checks the token itself and is mounted before the routers that apply requireAuth to every path. The token stays read-only over REST; over MCP it only reaches the tools its user ticked, stored per token in User.apiTokenMcpTools. Existing tokens get the read-only tools; a tool that writes would be off for every token until its user turns it on. get_model reads through printOutById rather than GET /print/:id, so an assistant looking a model up doesn't count as a view or land in Browsing History.
This commit is contained in:
1 parent
0931af55ea
commit
8567f195c0
13 files changed
+1600
-16
No files matched your search
Generated
+697
-5
@@ -9,6 +9,7 @@
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cheerio": "^1.2.0",
|
||||
@@ -513,6 +514,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz",
|
||||
"integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4 || ^5.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
@@ -1026,6 +1039,388 @@
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.32.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz",
|
||||
"integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"hono": "^4.11.4",
|
||||
"jose": "^6.1.3",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
|
||||
"integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.3.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"content-type": "^2.0.0",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.8",
|
||||
"qs": "^6.16.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.1.0",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/express/node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"parseurl": "^1.3.3",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
|
||||
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
|
||||
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator/node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.7.0",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -2157,6 +2552,39 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
@@ -2447,6 +2875,20 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
@@ -2786,6 +3228,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
||||
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
@@ -2842,6 +3305,54 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.7.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
|
||||
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
@@ -2849,6 +3360,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -3080,6 +3607,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.13",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
|
||||
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
@@ -3302,6 +3838,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.4",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
|
||||
"integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -3311,12 +3856,24 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.12",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||
@@ -3326,6 +3883,18 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -3951,7 +4520,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
@@ -4095,6 +4663,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
@@ -4134,6 +4711,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.30",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
||||
@@ -4189,9 +4775,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
@@ -4199,6 +4785,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
@@ -4253,6 +4843,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
|
||||
@@ -4287,6 +4886,55 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"is-promise": "^4.0.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"path-to-regexp": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/router/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/router/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/router/node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -4431,6 +5079,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
@@ -5044,6 +5713,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -5065,7 +5749,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
@@ -5088,6 +5771,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"lint": "oxlint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cheerio": "^1.2.0",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Which MCP tools the user's API token may call at /api/mcp. Tokens made before MCP existed get the
|
||||
-- read-only tools: they read nothing the token couldn't already read over the REST API.
|
||||
ALTER TABLE "User" ADD COLUMN "apiTokenMcpTools" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
UPDATE "User"
|
||||
SET "apiTokenMcpTools" = ARRAY[
|
||||
'search_models', 'list_models', 'get_model', 'list_collections', 'get_collection',
|
||||
'list_tags', 'list_categories', 'get_author', 'library_stats'
|
||||
]
|
||||
WHERE "apiTokenHash" IS NOT NULL;
|
||||
@@ -52,6 +52,9 @@ model User {
|
||||
apiTokenHint String?
|
||||
apiTokenCreatedAt DateTime?
|
||||
apiTokenLastUsedAt DateTime?
|
||||
// The MCP tools the token may call at /api/mcp (see mcp/tools.ts). Chosen per token, so a tool
|
||||
// that writes is only ever reachable if the user ticked it.
|
||||
apiTokenMcpTools String[] @default([])
|
||||
// Per-user, unlike the instance-wide Thingiverse token.
|
||||
makerworldCookie String?
|
||||
// When MakerWorld last turned the cookie down. Scheduled MakerWorld syncs wait until it changes.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HttpError } from "./utils/fileUtils";
|
||||
import { trackClients } from "./services/clientActivityService";
|
||||
|
||||
import healthRoutes from "./routes/health";
|
||||
import mcpRoutes from "./routes/mcp";
|
||||
import captchaRoutes from "./routes/captcha";
|
||||
import authRoutes from "./routes/auth";
|
||||
import oidcRoutes from "./routes/oidc";
|
||||
@@ -47,6 +48,8 @@ export function createApp(): Express {
|
||||
|
||||
// The frontend's nginx proxies /api/* here unmodified.
|
||||
app.use("/api", healthRoutes);
|
||||
// Before the routers that apply requireAuth to every path: see routes/mcp.ts.
|
||||
app.use("/api", mcpRoutes);
|
||||
app.use("/api", captchaRoutes);
|
||||
app.use("/api", authRoutes);
|
||||
app.use("/api", oidcRoutes);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
type CallToolResult,
|
||||
type ToolAnnotations,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { ZodRawShape } from "zod";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { getBackendVersion } from "../services/versionService";
|
||||
import { MCP_TOOLS, type McpToolContext } from "./tools";
|
||||
|
||||
/** A server offering only the tools this token was given. One per request: /api/mcp is stateless. */
|
||||
export function buildMcpServer(ctx: McpToolContext, allowedTools: ReadonlySet<string>): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: "thingport", title: "Thingport", version: getBackendVersion() ?? "unknown" },
|
||||
{
|
||||
instructions:
|
||||
"Thingport is the user's self-hosted library of 3D-printable models. Find models with search_models or " +
|
||||
"list_models, then read one with get_model. Ids from one tool are what the others take.",
|
||||
},
|
||||
);
|
||||
// registerTool's generics recurse past TypeScript's limit (TS2589) on a shape typed as any zod shape.
|
||||
// The SDK still validates each call's arguments against inputSchema before the callback runs.
|
||||
const registerTool = server.registerTool.bind(server) as unknown as (
|
||||
name: string,
|
||||
config: { title: string; description: string; inputSchema: ZodRawShape; annotations: ToolAnnotations },
|
||||
callback: (args: Record<string, unknown>) => Promise<CallToolResult>,
|
||||
) => void;
|
||||
for (const tool of MCP_TOOLS) {
|
||||
if (!allowedTools.has(tool.name)) continue;
|
||||
registerTool(
|
||||
tool.name,
|
||||
{
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: { readOnlyHint: tool.readOnly, openWorldHint: false },
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
try {
|
||||
const result = await tool.run(ctx, args);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
||||
} catch (err) {
|
||||
// An HttpError is meant for the caller ("Print not found"); anything else stays in the logs.
|
||||
if (err instanceof HttpError) {
|
||||
return { isError: true, content: [{ type: "text" as const, text: err.message }] };
|
||||
}
|
||||
console.error(`[mcp] ${tool.name} failed`, err);
|
||||
return { isError: true, content: [{ type: "text" as const, text: "Something went wrong on the server." }] };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
// The SDK only answers tools/list once a tool is registered. A token with every tool turned off
|
||||
// should see an empty list, not a "method not found" an assistant would report as a broken server.
|
||||
if (![...allowedTools].some((name) => MCP_TOOLS.some((tool) => tool.name === name))) {
|
||||
server.server.registerCapabilities({ tools: {} });
|
||||
server.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
|
||||
server.server.setRequestHandler(CallToolRequestSchema, (request) => ({
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: `Tool ${request.params.name} isn't turned on for this token.` }],
|
||||
}));
|
||||
}
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../db";
|
||||
import { PUBLIC_URL } from "../config";
|
||||
import { toAuthorOut, type PrintOut } from "../dto";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { SYSTEM_COLLECTIONS, systemCollectionKeyForId } from "../services/collectionService";
|
||||
import { getDashboardSummary } from "../services/dashboardService";
|
||||
import { printOutById } from "../services/printLoader";
|
||||
import { listPrints, listTagsOf, PRINT_SORT_MODES, SELF_AUTHOR_ID } from "../services/printQuery";
|
||||
import { search } from "../services/searchService";
|
||||
|
||||
// The tools /api/mcp offers. Each runs as the token's user, through the same services as the REST
|
||||
// API, so it sees exactly that user's library. Outputs are trimmed to what an assistant can use:
|
||||
// no image or file URLs, which need a session to open anyway.
|
||||
//
|
||||
// `readOnly` is what lets an API token reach a tool at all over POST (see routes/mcp.ts). A tool that
|
||||
// writes must say so, and is off for every token until its user ticks it.
|
||||
|
||||
export type McpToolContext = { userId: string };
|
||||
|
||||
export type McpToolDefinition = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
readOnly: boolean;
|
||||
inputSchema: z.ZodRawShape;
|
||||
run: (ctx: McpToolContext, args: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const MAX_PAGE = 50;
|
||||
const DEFAULT_PAGE = 20;
|
||||
|
||||
const pageArgs = {
|
||||
limit: z.number().int().min(1).max(MAX_PAGE).optional().describe(`How many to return, up to ${MAX_PAGE}.`),
|
||||
offset: z.number().int().min(0).optional().describe("How many to skip, for the next page."),
|
||||
};
|
||||
|
||||
/** The model's page in the Thingport web app, when the instance knows its own address. */
|
||||
function modelUrl(id: string): string | null {
|
||||
return PUBLIC_URL ? `${PUBLIC_URL}/models/${id}` : null;
|
||||
}
|
||||
|
||||
function modelSummary(print: PrintOut) {
|
||||
return {
|
||||
id: print.id,
|
||||
title: print.title || print.name,
|
||||
creator: print.author?.name ?? print.creator,
|
||||
tags: print.tags,
|
||||
category: print.category_name,
|
||||
source: print.source_provider ? { provider: print.source_provider, url: print.source_url } : null,
|
||||
file_count: print.plates.length,
|
||||
view_count: print.view_count,
|
||||
print_count: print.print_count,
|
||||
favorite: print.is_favorite,
|
||||
added_at: print.created_at,
|
||||
url: modelUrl(print.id),
|
||||
};
|
||||
}
|
||||
|
||||
function page(prints: PrintOut[], total: number, hasMore: boolean, nextOffset: number) {
|
||||
return { models: prints.map(modelSummary), total, next_offset: hasMore ? nextOffset : null };
|
||||
}
|
||||
|
||||
const searchModels: McpToolDefinition = {
|
||||
name: "search_models",
|
||||
title: "Search models",
|
||||
description:
|
||||
"Search the library by words in a model's title, tags, creator or description. Also returns matching " +
|
||||
"collections and tags. Start here to find a model by what it is.",
|
||||
readOnly: true,
|
||||
inputSchema: {
|
||||
query: z.string().min(1).describe("Words to look for, e.g. 'benchy' or 'cable clip'."),
|
||||
limit: z.number().int().min(1).max(25).optional().describe("How many models to return, up to 25."),
|
||||
},
|
||||
async run({ userId }, args) {
|
||||
const result = await search(userId, args.query as string, args.limit as number | undefined);
|
||||
return {
|
||||
models: result.models.map(modelSummary),
|
||||
more_models: result.has_more_models,
|
||||
collections: result.collections.map((c) => ({ id: c.id, name: c.name, model_count: c.item_count })),
|
||||
tags: result.tags.map((t) => ({ tag: t.tag, model_count: t.count })),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const listModels: McpToolDefinition = {
|
||||
name: "list_models",
|
||||
title: "List models",
|
||||
description:
|
||||
"List models, optionally filtered by tags, category, collection or author, newest first unless sorted " +
|
||||
"otherwise. Filters combine: a model must match every one given.",
|
||||
readOnly: true,
|
||||
inputSchema: {
|
||||
query: z.string().optional().describe("Only models matching these words, as in search_models."),
|
||||
tags: z.array(z.string()).optional().describe("Only models that have every one of these tags."),
|
||||
category_id: z.string().optional().describe("Only models in this category (an id from list_categories)."),
|
||||
collection_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Only models in this collection (an id from list_collections, or 'favorites' or 'history')."),
|
||||
author_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(`Only models by this author (an id from a model's author), or '${SELF_AUTHOR_ID}' for the user's own.`),
|
||||
sort: z
|
||||
.enum(PRINT_SORT_MODES)
|
||||
.optional()
|
||||
.describe("newest (default), popular (most viewed), downloads (most printed), or relevance (needs query)."),
|
||||
...pageArgs,
|
||||
},
|
||||
async run({ userId }, args) {
|
||||
const result = await listPrints(
|
||||
userId,
|
||||
{
|
||||
q: args.query as string | undefined,
|
||||
tags: args.tags as string[] | undefined,
|
||||
categoryIds: args.category_id ? [args.category_id as string] : [],
|
||||
collectionId: args.collection_id as string | undefined,
|
||||
authorId: args.author_id as string | undefined,
|
||||
},
|
||||
{
|
||||
sort: args.sort as (typeof PRINT_SORT_MODES)[number] | undefined,
|
||||
limit: (args.limit as number | undefined) ?? DEFAULT_PAGE,
|
||||
offset: args.offset as number | undefined,
|
||||
},
|
||||
);
|
||||
return page(result.prints, result.total, result.hasMore, result.nextOffset);
|
||||
},
|
||||
};
|
||||
|
||||
const getModel: McpToolDefinition = {
|
||||
name: "get_model",
|
||||
title: "Get a model",
|
||||
description:
|
||||
"Everything about one model: its description, files, tags, category, author, collections, where it was " +
|
||||
"imported from, and how often it was viewed and printed.",
|
||||
readOnly: true,
|
||||
inputSchema: { id: z.string().min(1).describe("The model's id, from search_models or list_models.") },
|
||||
async run({ userId }, args) {
|
||||
// printOutById, not GET /print/:id: an assistant looking at a model isn't the user viewing it, so
|
||||
// its view count and Browsing History stay as they are.
|
||||
const print = await printOutById(userId, args.id as string);
|
||||
const memberships = await prisma.collectionItem.findMany({
|
||||
where: { printId: print.id, collection: { userId } },
|
||||
select: { collection: { select: { id: true, name: true } } },
|
||||
});
|
||||
return {
|
||||
...modelSummary(print),
|
||||
description: print.notes,
|
||||
author: print.author
|
||||
? {
|
||||
id: print.author.id,
|
||||
name: print.author.name,
|
||||
handle: print.author.handle,
|
||||
provider: print.author.provider,
|
||||
links: print.author.links,
|
||||
}
|
||||
: null,
|
||||
files: print.plates.map((plate) => ({ name: plate.filename, size_bytes: plate.size })),
|
||||
supporting_file_count: print.supporting_file_count,
|
||||
total_size_bytes: print.total_size,
|
||||
collections: memberships.map((m) => m.collection),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const listCollections: McpToolDefinition = {
|
||||
name: "list_collections",
|
||||
title: "List collections",
|
||||
description: "The user's collections, with how many models each holds, plus Favourites and Browsing History.",
|
||||
readOnly: true,
|
||||
inputSchema: {},
|
||||
async run({ userId }) {
|
||||
const [collections, favoriteCount, historyCount] = await Promise.all([
|
||||
prisma.collection.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { _count: { select: { items: true } }, sync: { select: { provider: true, sourceUrl: true } } },
|
||||
}),
|
||||
prisma.print.count({ where: { userId, favoritedAt: { not: null } } }),
|
||||
prisma.print.count({ where: { userId, lastViewedAt: { not: null } } }),
|
||||
]);
|
||||
return {
|
||||
collections: [
|
||||
{ id: SYSTEM_COLLECTIONS.favorites.id, name: SYSTEM_COLLECTIONS.favorites.name, model_count: favoriteCount },
|
||||
{ id: SYSTEM_COLLECTIONS.history.id, name: SYSTEM_COLLECTIONS.history.name, model_count: historyCount },
|
||||
...collections.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
tags: c.tags,
|
||||
model_count: c._count.items,
|
||||
synced_with: c.sync ? { provider: c.sync.provider, url: c.sync.sourceUrl } : null,
|
||||
})),
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getCollection: McpToolDefinition = {
|
||||
name: "get_collection",
|
||||
title: "Get a collection",
|
||||
description: "One collection and the models in it, a page at a time.",
|
||||
readOnly: true,
|
||||
inputSchema: {
|
||||
id: z.string().min(1).describe("The collection's id, from list_collections ('favorites' and 'history' work too)."),
|
||||
...pageArgs,
|
||||
},
|
||||
async run({ userId }, args) {
|
||||
const id = args.id as string;
|
||||
const systemKey = systemCollectionKeyForId(id);
|
||||
const collection = systemKey
|
||||
? { id, name: SYSTEM_COLLECTIONS[systemKey].name, description: null, tags: [] as string[] }
|
||||
: await prisma.collection.findFirst({
|
||||
where: { id, userId },
|
||||
select: { id: true, name: true, description: true, tags: true },
|
||||
});
|
||||
if (!collection) throw new HttpError(404, "Collection not found");
|
||||
const result = await listPrints(
|
||||
userId,
|
||||
{ collectionId: id },
|
||||
{ limit: (args.limit as number | undefined) ?? DEFAULT_PAGE, offset: args.offset as number | undefined },
|
||||
);
|
||||
return { ...collection, ...page(result.prints, result.total, result.hasMore, result.nextOffset) };
|
||||
},
|
||||
};
|
||||
|
||||
const listTags: McpToolDefinition = {
|
||||
name: "list_tags",
|
||||
title: "List tags",
|
||||
description: "Every tag in the library, so models can then be listed by the exact tag names it uses.",
|
||||
readOnly: true,
|
||||
inputSchema: {},
|
||||
async run({ userId }) {
|
||||
return { tags: await listTagsOf(userId, {}) };
|
||||
},
|
||||
};
|
||||
|
||||
const listCategories: McpToolDefinition = {
|
||||
name: "list_categories",
|
||||
title: "List categories",
|
||||
description:
|
||||
"The user's category and folder tree, with how many models each holds directly. Use an id with " +
|
||||
"list_models' category_id.",
|
||||
readOnly: true,
|
||||
inputSchema: {},
|
||||
async run({ userId }) {
|
||||
const [categories, counts] = await Promise.all([
|
||||
prisma.category.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ position: "asc" }, { name: "asc" }],
|
||||
select: { id: true, name: true, parentId: true, kind: true },
|
||||
}),
|
||||
prisma.print.groupBy({ by: ["categoryId"], where: { userId, categoryId: { not: null } }, _count: true }),
|
||||
]);
|
||||
const countById = new Map(counts.map((c) => [c.categoryId, c._count]));
|
||||
return {
|
||||
categories: categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
parent_id: c.parentId,
|
||||
kind: c.kind,
|
||||
model_count: countById.get(c.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getAuthor: McpToolDefinition = {
|
||||
name: "get_author",
|
||||
title: "Get an author",
|
||||
description: "A designer the library has models from: who they are, where to find them, and their models here.",
|
||||
readOnly: true,
|
||||
inputSchema: {
|
||||
id: z.string().min(1).describe("The author's id, from a model's author."),
|
||||
...pageArgs,
|
||||
},
|
||||
async run({ userId }, args) {
|
||||
// Authors are shared across users; only this user's models of theirs are listed.
|
||||
const author = await prisma.author.findUnique({ where: { id: args.id as string } });
|
||||
if (!author) throw new HttpError(404, "Author not found");
|
||||
const out = toAuthorOut(author);
|
||||
const result = await listPrints(
|
||||
userId,
|
||||
{ authorId: author.id },
|
||||
{ limit: (args.limit as number | undefined) ?? DEFAULT_PAGE, offset: args.offset as number | undefined },
|
||||
);
|
||||
return {
|
||||
id: out.id,
|
||||
name: out.name,
|
||||
handle: out.handle,
|
||||
provider: out.provider,
|
||||
bio: out.bio_translated ?? out.bio,
|
||||
links: out.links,
|
||||
...page(result.prints, result.total, result.hasMore, result.nextOffset),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const libraryStats: McpToolDefinition = {
|
||||
name: "library_stats",
|
||||
title: "Library stats",
|
||||
description:
|
||||
"How big the library is (models, collections, authors, categories) and its most viewed, most printed and " +
|
||||
"most recently added models, top authors and the sites models came from.",
|
||||
readOnly: true,
|
||||
inputSchema: {},
|
||||
async run({ userId }) {
|
||||
const summary = await getDashboardSummary(userId);
|
||||
const model = (m: (typeof summary.topViewed)[number]) => ({
|
||||
id: m.id,
|
||||
title: m.name,
|
||||
view_count: m.viewCount,
|
||||
print_count: m.printCount,
|
||||
added_at: m.createdAt.toISOString(),
|
||||
url: modelUrl(m.id),
|
||||
});
|
||||
return {
|
||||
model_count: summary.modelCount,
|
||||
collection_count: summary.collectionCount,
|
||||
author_count: summary.authorCount,
|
||||
category_count: summary.categoryCount,
|
||||
most_viewed: summary.topViewed.map(model),
|
||||
most_printed: summary.topPrinted.map(model),
|
||||
recently_added: summary.recentlyAdded.map(model),
|
||||
top_authors: summary.topAuthors.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
handle: a.handle,
|
||||
model_count: a.modelCount,
|
||||
})),
|
||||
sources: summary.topProviders.map((p) => ({ provider: p.provider, model_count: p.modelCount })),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const MCP_TOOLS: readonly McpToolDefinition[] = [
|
||||
searchModels,
|
||||
listModels,
|
||||
getModel,
|
||||
listCollections,
|
||||
getCollection,
|
||||
listTags,
|
||||
listCategories,
|
||||
getAuthor,
|
||||
libraryStats,
|
||||
];
|
||||
|
||||
export const MCP_TOOL_NAMES = MCP_TOOLS.map((tool) => tool.name);
|
||||
|
||||
/** What the profile page offers to tick. */
|
||||
export const MCP_TOOL_CATALOG = MCP_TOOLS.map((tool) => ({
|
||||
name: tool.name,
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
read_only: tool.readOnly,
|
||||
}));
|
||||
|
||||
/** What a new token gets when its user doesn't choose: every tool that only reads. */
|
||||
export const DEFAULT_MCP_TOOL_NAMES = MCP_TOOLS.filter((tool) => tool.readOnly).map((tool) => tool.name);
|
||||
@@ -13,7 +13,8 @@ import { findValidInvitation } from "../services/invitationService";
|
||||
import { checkCaptcha } from "../services/captchaService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import { seedDefaultCategories } from "../services/categoryService";
|
||||
import { generateApiToken, getApiToken, revokeApiToken } from "../services/apiTokenService";
|
||||
import { generateApiToken, getApiToken, revokeApiToken, setApiTokenMcpTools } from "../services/apiTokenService";
|
||||
import { DEFAULT_MCP_TOOL_NAMES, MCP_TOOL_CATALOG, MCP_TOOL_NAMES } from "../mcp/tools";
|
||||
import { toUserOut } from "../dto";
|
||||
import type { Prisma, Role } from "@prisma/client";
|
||||
|
||||
@@ -397,16 +398,40 @@ router.get(
|
||||
"/profile/api-token",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json(await getApiToken(req.userId!));
|
||||
res.json({ ...(await getApiToken(req.userId!)), available_mcp_tools: MCP_TOOL_CATALOG });
|
||||
}),
|
||||
);
|
||||
|
||||
// Unknown names are refused rather than dropped, so a typo can't quietly leave a tool off.
|
||||
const mcpToolsSchema = z
|
||||
.array(z.enum(MCP_TOOL_NAMES as [string, ...string[]]))
|
||||
.transform((names) => [...new Set(names)]);
|
||||
// No body (older clients) gives the tools that only read.
|
||||
const generateApiTokenSchema = z.object({ mcp_tools: mcpToolsSchema.optional() }).default({});
|
||||
|
||||
router.post(
|
||||
"/profile/api-token",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json(await generateApiToken(req.userId!));
|
||||
void createLog({ userId: req.userId!, action: "api_token_generated" });
|
||||
const body = parseBody(generateApiTokenSchema, req.body ?? {});
|
||||
const token = await generateApiToken(req.userId!, body.mcp_tools ?? DEFAULT_MCP_TOOL_NAMES);
|
||||
res.json({ ...token, available_mcp_tools: MCP_TOOL_CATALOG });
|
||||
void createLog({ userId: req.userId!, action: "api_token_generated", details: { mcp_tools: token.mcp_tools } });
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/profile/api-token/mcp-tools",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(z.object({ mcp_tools: mcpToolsSchema }), req.body);
|
||||
const token = await setApiTokenMcpTools(req.userId!, body.mcp_tools);
|
||||
res.json({ ...token, available_mcp_tools: MCP_TOOL_CATALOG });
|
||||
void createLog({
|
||||
userId: req.userId!,
|
||||
action: "api_token_mcp_tools_changed",
|
||||
details: { mcp_tools: token.mcp_tools },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -415,7 +440,7 @@ router.delete(
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
await revokeApiToken(req.userId!);
|
||||
res.json(await getApiToken(req.userId!));
|
||||
res.json({ ...(await getApiToken(req.userId!)), available_mcp_tools: MCP_TOOL_CATALOG });
|
||||
void createLog({ userId: req.userId!, action: "api_token_revoked" });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { findApiTokenUser, isApiToken } from "../services/apiTokenService";
|
||||
import { buildMcpServer } from "../mcp/server";
|
||||
import { MCP_TOOL_NAMES } from "../mcp/tools";
|
||||
|
||||
// The MCP endpoint (Streamable HTTP, stateless): an assistant connects with the user's API token.
|
||||
//
|
||||
// Not behind requireAuth, which refuses an API token anything but GET: every MCP message is a POST.
|
||||
// The read-only promise is kept by the tools instead. A token only ever reaches the ones its user
|
||||
// ticked, and a tool that writes is off until they do. Mounted before every router that applies
|
||||
// requireAuth to all its paths, or those would turn the POST away first.
|
||||
|
||||
const router = Router();
|
||||
|
||||
const MCP_PATH = "/mcp";
|
||||
|
||||
function bearerToken(req: Request): string | null {
|
||||
const header = req.header("authorization");
|
||||
return header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : null;
|
||||
}
|
||||
|
||||
function jsonRpcError(res: Response, status: number, message: string): void {
|
||||
res.status(status).json({ jsonrpc: "2.0", error: { code: -32000, message }, id: null });
|
||||
}
|
||||
|
||||
router.post(
|
||||
MCP_PATH,
|
||||
asyncHandler(async (req, res) => {
|
||||
const token = bearerToken(req);
|
||||
// API tokens only: a session token lasts hours and belongs to the web app, not to a tool's config.
|
||||
const user = token && isApiToken(token) ? await findApiTokenUser(token) : null;
|
||||
if (!user) {
|
||||
res.setHeader("WWW-Authenticate", 'Bearer realm="thingport"');
|
||||
jsonRpcError(res, 401, "Send your Thingport API token as 'Authorization: Bearer tp_...'");
|
||||
return;
|
||||
}
|
||||
const allowed = new Set(user.apiTokenMcpTools.filter((name) => MCP_TOOL_NAMES.includes(name)));
|
||||
const server = buildMcpServer({ userId: user.id }, allowed);
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
||||
res.on("close", () => {
|
||||
void transport.close();
|
||||
void server.close();
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
}),
|
||||
);
|
||||
|
||||
// Stateless: there's no session to stream events on (GET) or to end (DELETE).
|
||||
router.get(MCP_PATH, (_req, res) => jsonRpcError(res, 405, "Method not allowed"));
|
||||
router.delete(MCP_PATH, (_req, res) => jsonRpcError(res, 405, "Method not allowed"));
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
|
||||
// Session JWTs start "eyJ", so the prefix alone says which kind of token a request carries. It also
|
||||
// makes a leaked token recognisable to secret scanners.
|
||||
@@ -12,6 +13,8 @@ export type ApiTokenOut = {
|
||||
hint: string | null;
|
||||
created_at: Date | null;
|
||||
last_used_at: Date | null;
|
||||
/** The MCP tools the token may call; empty without a token. */
|
||||
mcp_tools: string[];
|
||||
};
|
||||
|
||||
const hashApiToken = (token: string) => crypto.createHash("sha256").update(token).digest("hex");
|
||||
@@ -21,18 +24,26 @@ export const isApiToken = (token: string) => token.startsWith(API_TOKEN_PREFIX);
|
||||
export async function getApiToken(userId: string): Promise<ApiTokenOut> {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { apiTokenHash: true, apiTokenHint: true, apiTokenCreatedAt: true, apiTokenLastUsedAt: true },
|
||||
select: {
|
||||
apiTokenHash: true,
|
||||
apiTokenHint: true,
|
||||
apiTokenCreatedAt: true,
|
||||
apiTokenLastUsedAt: true,
|
||||
apiTokenMcpTools: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
configured: Boolean(user.apiTokenHash),
|
||||
hint: user.apiTokenHint,
|
||||
created_at: user.apiTokenCreatedAt,
|
||||
last_used_at: user.apiTokenLastUsedAt,
|
||||
mcp_tools: user.apiTokenHash ? user.apiTokenMcpTools : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces any existing token, which stops working at once. The token is only ever returned here. */
|
||||
export async function generateApiToken(userId: string): Promise<ApiTokenOut & { token: string }> {
|
||||
/** Replaces any existing token, which stops working at once. The token is only ever returned here.
|
||||
* `mcpTools` must already be known tool names. */
|
||||
export async function generateApiToken(userId: string, mcpTools: string[]): Promise<ApiTokenOut & { token: string }> {
|
||||
const token = API_TOKEN_PREFIX + crypto.randomBytes(32).toString("base64url");
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
@@ -41,6 +52,7 @@ export async function generateApiToken(userId: string): Promise<ApiTokenOut & {
|
||||
apiTokenHint: token.slice(-4),
|
||||
apiTokenCreatedAt: new Date(),
|
||||
apiTokenLastUsedAt: null,
|
||||
apiTokenMcpTools: mcpTools,
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -49,13 +61,30 @@ export async function generateApiToken(userId: string): Promise<ApiTokenOut & {
|
||||
hint: user.apiTokenHint,
|
||||
created_at: user.apiTokenCreatedAt,
|
||||
last_used_at: null,
|
||||
mcp_tools: user.apiTokenMcpTools,
|
||||
};
|
||||
}
|
||||
|
||||
/** Changes what the current token may call over MCP, without replacing the token. */
|
||||
export async function setApiTokenMcpTools(userId: string, mcpTools: string[]): Promise<ApiTokenOut> {
|
||||
const { count } = await prisma.user.updateMany({
|
||||
where: { id: userId, apiTokenHash: { not: null } },
|
||||
data: { apiTokenMcpTools: mcpTools },
|
||||
});
|
||||
if (!count) throw new HttpError(409, "Generate an API token first");
|
||||
return getApiToken(userId);
|
||||
}
|
||||
|
||||
export async function revokeApiToken(userId: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { apiTokenHash: null, apiTokenHint: null, apiTokenCreatedAt: null, apiTokenLastUsedAt: null },
|
||||
data: {
|
||||
apiTokenHash: null,
|
||||
apiTokenHint: null,
|
||||
apiTokenCreatedAt: null,
|
||||
apiTokenLastUsedAt: null,
|
||||
apiTokenMcpTools: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +93,7 @@ export async function findApiTokenUser(token: string) {
|
||||
const apiTokenHash = hashApiToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { apiTokenHash },
|
||||
select: { id: true, role: true, apiTokenLastUsedAt: true },
|
||||
select: { id: true, role: true, apiTokenLastUsedAt: true, apiTokenMcpTools: true },
|
||||
});
|
||||
if (!user) return null;
|
||||
const now = Date.now();
|
||||
|
||||
@@ -8,6 +8,7 @@ export type LogAction =
|
||||
| "password_reset"
|
||||
| "api_token_generated"
|
||||
| "api_token_revoked"
|
||||
| "api_token_mcp_tools_changed"
|
||||
| "user_invited"
|
||||
| "authors_linked"
|
||||
| "descriptions_refetched"
|
||||
|
||||
@@ -28,7 +28,10 @@ describe("API token", () => {
|
||||
it("reports no token for a new account", async () => {
|
||||
const res = await request(app).get("/api/profile/api-token").set(auth(session));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ configured: false, hint: null, created_at: null, last_used_at: null });
|
||||
const { available_mcp_tools, ...token } = res.body;
|
||||
expect(token).toEqual({ configured: false, hint: null, created_at: null, last_used_at: null, mcp_tools: [] });
|
||||
// What the profile page offers to tick, token or not.
|
||||
expect(available_mcp_tools.map((tool: { name: string }) => tool.name)).toContain("search_models");
|
||||
});
|
||||
|
||||
it("generates a token that is shown once and stored only as a hash", async () => {
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
import { DEFAULT_MCP_TOOL_NAMES, MCP_TOOLS } from "../src/mcp/tools";
|
||||
|
||||
// /api/mcp end to end: who gets in, which tools each token sees, and that every tool only ever
|
||||
// reads the token owner's own library.
|
||||
|
||||
const app = createApp();
|
||||
|
||||
type Account = { session: string; userId: string; token: string };
|
||||
|
||||
function auth(token: string) {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
async function register(label: string): Promise<{ session: string; userId: string }> {
|
||||
const email = `mcp-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: `MCP ${label}`, email, password: "password123" });
|
||||
if (res.status !== 200) throw new Error(`register ${label}: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
return { session: res.body.token, userId: res.body.user.id };
|
||||
}
|
||||
|
||||
async function apiToken(session: string, body?: { mcp_tools: string[] }): Promise<string> {
|
||||
const res = await request(app)
|
||||
.post("/api/profile/api-token")
|
||||
.set(auth(session))
|
||||
.send(body ?? {});
|
||||
if (res.status !== 200) throw new Error(`api token: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
return res.body.token;
|
||||
}
|
||||
|
||||
async function upload(session: string, filename: string): Promise<string> {
|
||||
// supertest uses the basename as the upload filename, so each file gets its own directory.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "thingport-mcp-test-"));
|
||||
const file = path.join(dir, filename);
|
||||
fs.writeFileSync(file, `solid ${filename}\nendsolid ${filename}\n`);
|
||||
const res = await request(app).post("/api/upload").set(auth(session)).attach("files", file);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (res.status !== 200) throw new Error(`upload: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
return res.body.prints[0].id;
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
function mcp(token: string | null, method: string, params: Record<string, unknown> = {}) {
|
||||
const req = request(app)
|
||||
.post("/api/mcp")
|
||||
.set("Accept", "application/json, text/event-stream")
|
||||
.set("Content-Type", "application/json");
|
||||
if (token) req.set(auth(token));
|
||||
return req.send({ jsonrpc: "2.0", id: nextId++, method, params });
|
||||
}
|
||||
|
||||
async function callTool(token: string, name: string, args: Record<string, unknown> = {}) {
|
||||
const res = await mcp(token, "tools/call", { name, arguments: args });
|
||||
expect(res.status).toBe(200);
|
||||
const result = res.body.result as { isError?: boolean; content: { type: string; text: string }[] };
|
||||
const text = result.content[0]?.text ?? "";
|
||||
return { isError: Boolean(result.isError), text, data: result.isError ? null : JSON.parse(text) };
|
||||
}
|
||||
|
||||
async function toolNames(token: string): Promise<string[]> {
|
||||
const res = await mcp(token, "tools/list");
|
||||
expect(res.status).toBe(200);
|
||||
return (res.body.result.tools as { name: string }[]).map((tool) => tool.name).toSorted();
|
||||
}
|
||||
|
||||
let alice: Account;
|
||||
let bob: Account;
|
||||
let dragonId: string;
|
||||
let benchyId: string;
|
||||
let bobSecretId: string;
|
||||
let collectionId: string;
|
||||
let bobCollectionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const a = await register("alice");
|
||||
const b = await register("bob");
|
||||
alice = { ...a, token: await apiToken(a.session) };
|
||||
bob = { ...b, token: await apiToken(b.session) };
|
||||
|
||||
dragonId = await upload(alice.session, "dragon.stl");
|
||||
benchyId = await upload(alice.session, "benchy.stl");
|
||||
await prisma.print.update({
|
||||
where: { id: dragonId },
|
||||
data: { title: "Articulated Dragon", tags: ["dragon", "flexi"], notes: "A flexible dragon that prints in place." },
|
||||
});
|
||||
await prisma.print.update({ where: { id: benchyId }, data: { title: "Speed Benchy", tags: ["calibration"] } });
|
||||
|
||||
// Bob's model also says "dragon": search must still never return it to Alice.
|
||||
bobSecretId = await upload(bob.session, "secret.stl");
|
||||
await prisma.print.update({
|
||||
where: { id: bobSecretId },
|
||||
data: { title: "Bob's Secret Dragon", tags: ["dragon"] },
|
||||
});
|
||||
|
||||
const created = await request(app).post("/api/collections").set(auth(alice.session)).send({ name: "MCP Dragons" });
|
||||
collectionId = created.body.id;
|
||||
await prisma.collectionItem.create({ data: { collectionId, printId: dragonId } });
|
||||
const bobs = await request(app).post("/api/collections").set(auth(bob.session)).send({ name: "Bob Private" });
|
||||
bobCollectionId = bobs.body.id;
|
||||
});
|
||||
|
||||
describe("/api/mcp authentication", () => {
|
||||
it("asks for a token without one", async () => {
|
||||
const res = await mcp(null, "tools/list");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers["www-authenticate"]).toMatch(/Bearer/);
|
||||
});
|
||||
|
||||
it("turns away a session token: MCP takes API tokens only", async () => {
|
||||
const res = await mcp(alice.session, "tools/list");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("turns away an unknown API token", async () => {
|
||||
const res = await mcp(`tp_${"x".repeat(43)}`, "tools/list");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("doesn't take the token from the address", async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/mcp?token=${alice.token}`)
|
||||
.set("Accept", "application/json, text/event-stream")
|
||||
.send({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("answers initialize as Thingport", async () => {
|
||||
const res = await mcp(alice.token, "initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "vitest", version: "1" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result.serverInfo.name).toBe("thingport");
|
||||
});
|
||||
|
||||
it("has no session to stream or end", async () => {
|
||||
expect((await request(app).get("/api/mcp").set(auth(alice.token))).status).toBe(405);
|
||||
expect((await request(app).delete("/api/mcp").set(auth(alice.token))).status).toBe(405);
|
||||
});
|
||||
|
||||
it("stops working the moment the token is revoked", async () => {
|
||||
const user = await register("revoked");
|
||||
const token = await apiToken(user.session);
|
||||
expect((await mcp(token, "tools/list")).status).toBe(200);
|
||||
await request(app).delete("/api/profile/api-token").set(auth(user.session));
|
||||
expect((await mcp(token, "tools/list")).status).toBe(401);
|
||||
});
|
||||
|
||||
it("leaves the REST API read-only for the same token", async () => {
|
||||
const res = await request(app).post("/api/collections").set(auth(alice.token)).send({ name: "Sneaky" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("choosing a token's tools", () => {
|
||||
it("gives a new token every read-only tool, all marked read-only", async () => {
|
||||
expect(await toolNames(alice.token)).toEqual([...DEFAULT_MCP_TOOL_NAMES].toSorted());
|
||||
const res = await mcp(alice.token, "tools/list");
|
||||
for (const tool of res.body.result.tools) expect(tool.annotations.readOnlyHint).toBe(true);
|
||||
});
|
||||
|
||||
it("offers only the tools the token was given, and refuses a call to any other", async () => {
|
||||
const user = await register("picky");
|
||||
const token = await apiToken(user.session, { mcp_tools: ["search_models"] });
|
||||
expect(await toolNames(token)).toEqual(["search_models"]);
|
||||
|
||||
const res = await mcp(token, "tools/call", { name: "library_stats", arguments: {} });
|
||||
// Unknown to this server: a protocol error or an error result, never the stats.
|
||||
const refused = res.body.error ?? (res.body.result?.isError ? res.body.result : null);
|
||||
expect(refused).toBeTruthy();
|
||||
expect(JSON.stringify(res.body)).not.toContain("model_count");
|
||||
});
|
||||
|
||||
it("can turn every tool off", async () => {
|
||||
const user = await register("none");
|
||||
const token = await apiToken(user.session, { mcp_tools: [] });
|
||||
expect(await toolNames(token)).toEqual([]);
|
||||
});
|
||||
|
||||
it("changes the tools without replacing the token", async () => {
|
||||
const user = await register("changes");
|
||||
const token = await apiToken(user.session);
|
||||
const res = await request(app)
|
||||
.put("/api/profile/api-token/mcp-tools")
|
||||
.set(auth(user.session))
|
||||
.send({ mcp_tools: ["get_model", "list_tags", "get_model"] });
|
||||
expect(res.status).toBe(200);
|
||||
// Duplicates collapse.
|
||||
expect(res.body.mcp_tools).toEqual(["get_model", "list_tags"]);
|
||||
expect(await toolNames(token)).toEqual(["get_model", "list_tags"]);
|
||||
const log = await prisma.log.findFirst({
|
||||
where: { userId: user.userId, action: "api_token_mcp_tools_changed" },
|
||||
});
|
||||
expect(log).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses a tool name it doesn't know instead of dropping it", async () => {
|
||||
const res = await request(app)
|
||||
.put("/api/profile/api-token/mcp-tools")
|
||||
.set(auth(alice.session))
|
||||
.send({ mcp_tools: ["search_models", "delete_everything"] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(await toolNames(alice.token)).toEqual([...DEFAULT_MCP_TOOL_NAMES].toSorted());
|
||||
});
|
||||
|
||||
it("can't be changed with the token itself", async () => {
|
||||
const res = await request(app)
|
||||
.put("/api/profile/api-token/mcp-tools")
|
||||
.set(auth(alice.token))
|
||||
.send({ mcp_tools: DEFAULT_MCP_TOOL_NAMES });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("needs a token to set tools on", async () => {
|
||||
const user = await register("tokenless");
|
||||
const res = await request(app)
|
||||
.put("/api/profile/api-token/mcp-tools")
|
||||
.set(auth(user.session))
|
||||
.send({ mcp_tools: ["search_models"] });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("forgets the tools when the token is revoked", async () => {
|
||||
const user = await register("forget");
|
||||
await apiToken(user.session);
|
||||
await request(app).delete("/api/profile/api-token").set(auth(user.session));
|
||||
const db = await prisma.user.findUniqueOrThrow({ where: { id: user.userId } });
|
||||
expect(db.apiTokenMcpTools).toEqual([]);
|
||||
});
|
||||
|
||||
it("only ever ships tools that say whether they write", () => {
|
||||
// A write tool must not slip into the default set.
|
||||
for (const tool of MCP_TOOLS) expect(typeof tool.readOnly).toBe("boolean");
|
||||
expect(DEFAULT_MCP_TOOL_NAMES.every((name) => MCP_TOOLS.find((t) => t.name === name)?.readOnly)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tools", () => {
|
||||
it("search_models finds the user's model and never another user's", async () => {
|
||||
const { data } = await callTool(alice.token, "search_models", { query: "dragon" });
|
||||
const ids = data.models.map((m: { id: string }) => m.id);
|
||||
expect(ids).toContain(dragonId);
|
||||
expect(ids).not.toContain(bobSecretId);
|
||||
expect(data.collections.map((c: { id: string }) => c.id)).toContain(collectionId);
|
||||
});
|
||||
|
||||
it("get_model reads everything without counting as a view", async () => {
|
||||
const before = await prisma.print.findUniqueOrThrow({ where: { id: dragonId } });
|
||||
const { data } = await callTool(alice.token, "get_model", { id: dragonId });
|
||||
expect(data.title).toBe("Articulated Dragon");
|
||||
expect(data.description).toBe("A flexible dragon that prints in place.");
|
||||
expect(data.tags.toSorted()).toEqual(["Dragon", "Flexi"].map((t) => t.toLowerCase()).toSorted());
|
||||
expect(data.files).toEqual([{ name: "dragon.stl", size_bytes: expect.any(Number) }]);
|
||||
expect(data.collections).toEqual([{ id: collectionId, name: "MCP Dragons" }]);
|
||||
// No file or image URLs: they need a session to open.
|
||||
expect(JSON.stringify(data)).not.toMatch(/\/api\/(plate|preview|print-file)/);
|
||||
|
||||
const after = await prisma.print.findUniqueOrThrow({ where: { id: dragonId } });
|
||||
expect(after.viewCount).toBe(before.viewCount);
|
||||
expect(after.lastViewedAt).toEqual(before.lastViewedAt);
|
||||
});
|
||||
|
||||
it("get_model says not found for another user's model", async () => {
|
||||
const result = await callTool(alice.token, "get_model", { id: bobSecretId });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.text).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it("rejects arguments that don't fit the tool", async () => {
|
||||
const res = await mcp(alice.token, "tools/call", { name: "get_model", arguments: {} });
|
||||
const failed = res.body.error ?? (res.body.result?.isError ? res.body.result : null);
|
||||
expect(failed).toBeTruthy();
|
||||
const tooBig = await mcp(alice.token, "tools/call", { name: "list_models", arguments: { limit: 5000 } });
|
||||
expect(tooBig.body.error ?? (tooBig.body.result?.isError ? tooBig.body.result : null)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("list_models filters by tag and pages through results", async () => {
|
||||
const byTag = await callTool(alice.token, "list_models", { tags: ["FLEXI"] });
|
||||
expect(byTag.data.models.map((m: { id: string }) => m.id)).toEqual([dragonId]);
|
||||
|
||||
const first = await callTool(alice.token, "list_models", { limit: 1 });
|
||||
expect(first.data.total).toBe(2);
|
||||
expect(first.data.models).toHaveLength(1);
|
||||
expect(first.data.next_offset).toBe(1);
|
||||
const second = await callTool(alice.token, "list_models", { limit: 1, offset: 1 });
|
||||
expect(second.data.next_offset).toBeNull();
|
||||
expect(second.data.models[0].id).not.toBe(first.data.models[0].id);
|
||||
});
|
||||
|
||||
it("list_collections lists the user's collections and the built-in ones", async () => {
|
||||
const { data } = await callTool(alice.token, "list_collections");
|
||||
const ids = data.collections.map((c: { id: string }) => c.id);
|
||||
expect(ids).toEqual(expect.arrayContaining(["favorites", "history", collectionId]));
|
||||
expect(ids).not.toContain(bobCollectionId);
|
||||
expect(data.collections.find((c: { id: string }) => c.id === collectionId).model_count).toBe(1);
|
||||
});
|
||||
|
||||
it("get_collection lists its models, and won't open another user's", async () => {
|
||||
const { data } = await callTool(alice.token, "get_collection", { id: collectionId });
|
||||
expect(data.name).toBe("MCP Dragons");
|
||||
expect(data.models.map((m: { id: string }) => m.id)).toEqual([dragonId]);
|
||||
|
||||
const other = await callTool(alice.token, "get_collection", { id: bobCollectionId });
|
||||
expect(other.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("list_tags lists only the user's tags", async () => {
|
||||
const { data } = await callTool(alice.token, "list_tags");
|
||||
expect(data.tags.map((t: string) => t.toLowerCase())).toEqual(
|
||||
expect.arrayContaining(["dragon", "flexi", "calibration"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("list_categories and library_stats answer for the user", async () => {
|
||||
const categories = await callTool(alice.token, "list_categories");
|
||||
expect(Array.isArray(categories.data.categories)).toBe(true);
|
||||
const stats = await callTool(alice.token, "library_stats");
|
||||
expect(stats.data.model_count).toBe(2);
|
||||
expect(stats.data.collection_count).toBe(1);
|
||||
});
|
||||
|
||||
it("get_author says not found for an unknown author", async () => {
|
||||
const result = await callTool(alice.token, "get_author", { id: "no-such-author" });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user