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:
Rui Valim committed 2026-10-20 14:23:37 -03:00
1 parent 0931af55ea
commit 8567f195c0
13 files changed
+1600 -16

No files matched your search

+697 -5
View File
@@ -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"
}
}
}
}
+1
View File
@@ -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;
+3
View File
@@ -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.
+3
View File
@@ -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);
+66
View File
@@ -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;
}
+360
View File
@@ -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);
+30 -5
View File
@@ -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" });
}),
);
+55
View File
@@ -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;
+34 -5
View File
@@ -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();
+1
View File
@@ -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"
+4 -1
View File
@@ -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 () => {
+336
View File
@@ -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);
});
});