diff --git a/package-lock.json b/package-lock.json index a80bfd8..e3a5fac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.14", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.29.0", "backlog-js": "^0.16.0", "cosmiconfig": "^9.0.0", "dotenv": "^16.5.0", @@ -19,6 +19,7 @@ "hono": "^4.12.14", "pino": "^9.9.0", "pino-pretty": "^13.1.1", + "sharp": "^0.34.5", "yargs": "^18.0.0", "zod": "^3.24.3" }, @@ -132,6 +133,16 @@ } } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", @@ -780,6 +791,471 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/checkbox": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.5.tgz", @@ -1164,9 +1640,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -3291,6 +3767,15 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "dev": true }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -3878,12 +4363,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "ip-address": "^10.2.0" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -3895,6 +4380,15 @@ "express": ">= 4.11" } }, + "node_modules/express-rate-limit/node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/exsolve": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz", @@ -4510,14 +5004,24 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, "engines": { "node": ">= 12" } }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4758,6 +5262,12 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6246,7 +6756,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6322,6 +6831,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6427,13 +6980,12 @@ } }, "node_modules/socks": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", - "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "dev": true, - "license": "MIT", "dependencies": { - "ip-address": "^10.1.1", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { @@ -6743,7 +7295,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true + "devOptional": true }, "node_modules/tsx": { "version": "4.20.6", diff --git a/package.json b/package.json index 0f4a7f3..0d3bdaa 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ ], "dependencies": { "@hono/node-server": "^1.19.14", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.29.0", "backlog-js": "^0.16.0", "cosmiconfig": "^9.0.0", "dotenv": "^16.5.0", @@ -37,6 +37,7 @@ "hono": "^4.12.14", "pino": "^9.9.0", "pino-pretty": "^13.1.1", + "sharp": "^0.34.5", "yargs": "^18.0.0", "zod": "^3.24.3" }, diff --git a/src/registerTools.test.ts b/src/registerTools.test.ts index afe0e19..334371f 100644 --- a/src/registerTools.test.ts +++ b/src/registerTools.test.ts @@ -7,6 +7,8 @@ import { allTools } from './tools/tools'; import { buildToolsetGroup } from './utils/toolsetUtils.js'; import { wrapServerWithToolRegistry } from './utils/wrapServerWithToolRegistry.js'; import type { Toolset } from './types/toolsets.js'; +import { z } from 'zod'; +import type { DynamicToolDefinition } from './types/tool.js'; vi.mock('./handlers/builders/composeToolHandler'); @@ -77,8 +79,57 @@ describe('registerTools', () => { prefix: '', }); - expect(mockServer.tool).toHaveBeenCalledTimes( - toolsetGroup.toolsets.flatMap((a) => a.tools).length + const totalTools = + toolsetGroup.toolsets.flatMap((a) => a.tools).length + + toolsetGroup.toolsets.flatMap((a) => a.dynamicTools ?? []).length; + expect(mockServer.tool).toHaveBeenCalledTimes(totalTools); + }); + + it('wraps dynamic tool errors with backlog error handling', async () => { + const mockServer = wrapServerWithToolRegistry({ + tool: vi.fn(), + } as unknown as McpServer); + const throwingDynamicTool: DynamicToolDefinition<{ + attachmentId: z.ZodNumber; + }> = { + name: 'broken_dynamic_tool', + description: 'Broken dynamic tool', + schema: z.object({ + attachmentId: z.number(), + }), + handler: async () => { + throw new Error('boom'); + }, + }; + + registerTools( + mockServer, + { + toolsets: [ + { + name: 'broken', + description: 'Broken toolset', + enabled: true, + tools: [], + dynamicTools: [throwingDynamicTool], + }, + ], + }, + { + useFields: false, + prefix: '', + maxTokens: 5000, + } ); + + const handler = (mockServer.tool as Mock).mock.calls.find( + (call) => call[0] === 'broken_dynamic_tool' + )?.[3]; + + expect(handler).toBeTypeOf('function'); + await expect(handler({ attachmentId: 1 }, {})).resolves.toEqual({ + isError: true, + content: [{ type: 'text', text: 'boom' }], + }); }); }); diff --git a/src/registerTools.ts b/src/registerTools.ts index 4fbea6c..e22abdb 100644 --- a/src/registerTools.ts +++ b/src/registerTools.ts @@ -4,20 +4,25 @@ import { MCPOptions } from './types/mcp.js'; import { DynamicToolDefinition, ToolDefinition } from './types/tool.js'; import { DynamicToolsetGroup, ToolsetGroup } from './types/toolsets.js'; import { BacklogMCPServer } from './utils/wrapServerWithToolRegistry.js'; +import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { + CallToolResult, + ServerNotification, + ServerRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; -type ToolsetSource = ToolsetGroup | DynamicToolsetGroup; - -type RegisterOptions = { - server: BacklogMCPServer; - toolsetGroup: ToolsetSource; - prefix: string; - onlyEnabled?: boolean; - handlerStrategy: ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tool: ToolDefinition | DynamicToolDefinition - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => (...args: any[]) => any; -}; +type RegisterOptions = + { + server: BacklogMCPServer; + toolsetGroup: TToolsetGroup; + prefix: string; + onlyEnabled?: boolean; + handlerStrategy: ( + tool: TToolsetGroup['toolsets'][number]['tools'][number] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => (...args: any[]) => any; + }; export function registerTools( server: BacklogMCPServer, @@ -38,6 +43,23 @@ export function registerTools( maxTokens, }), }); + + // Register dynamic tools within toolsets (e.g., attachment downloads) + for (const toolset of toolsetGroup.toolsets) { + if (!toolset.enabled || !toolset.dynamicTools) { + continue; + } + + for (const tool of toolset.dynamicTools) { + const toolNameWithPrefix = `${prefix}${tool.name}`; + server.registerOnce( + toolNameWithPrefix, + tool.description, + tool.schema.shape, + wrapDynamicToolHandler(tool) + ); + } + } } export function registerDynamicTools( @@ -49,16 +71,18 @@ export function registerDynamicTools( server, toolsetGroup: dynamicToolsetGroup, prefix, - handlerStrategy: (tool) => tool.handler, + handlerStrategy: (tool) => wrapDynamicToolHandler(tool), }); } -function registerToolsets({ +function registerToolsets< + TToolsetGroup extends ToolsetGroup | DynamicToolsetGroup, +>({ server, toolsetGroup, prefix, handlerStrategy, -}: RegisterOptions) { +}: RegisterOptions) { for (const toolset of toolsetGroup.toolsets) { if (!toolset.enabled) { continue; @@ -77,3 +101,27 @@ function registerToolsets({ } } } + +function wrapDynamicToolHandler( + tool: DynamicToolDefinition +): ( + input: z.infer>, + extra: RequestHandlerExtra +) => Promise { + return async (input, _extra) => { + try { + return await tool.handler(input); + } catch (error) { + const parsedError = backlogErrorHandler(error); + return { + isError: true, + content: [ + { + type: 'text', + text: parsedError.message, + }, + ], + }; + } + }; +} diff --git a/src/tools/dynamicTools/toolsets.test.ts b/src/tools/dynamicTools/toolsets.test.ts index e4d0ff3..fa58c92 100644 --- a/src/tools/dynamicTools/toolsets.test.ts +++ b/src/tools/dynamicTools/toolsets.test.ts @@ -1,6 +1,10 @@ import { describe, expect, vi, it } from 'vitest'; import { z } from 'zod'; -import { ToolDefinition, ToolRegistrar } from '../../types/tool.js'; +import { + DynamicToolDefinition, + ToolDefinition, + ToolRegistrar, +} from '../../types/tool.js'; import { ToolsetGroup } from '../../types/toolsets.js'; import { enableToolsetTool, @@ -30,6 +34,14 @@ describe('dynamicTools', () => { content: [{ type: 'text', text: 'dummy' }], }), }; + const dummyDynamicTool: DynamicToolDefinition = { + name: 'get_issue_attachment', + description: 'Downloads an issue attachment', + schema: z.object({}), + handler: async () => ({ + content: [{ type: 'text', text: 'dynamic dummy' }], + }), + }; const mockToolsetGroup: ToolsetGroup = { toolsets: [ @@ -38,6 +50,7 @@ describe('dynamicTools', () => { description: 'Project management tools', enabled: false, tools: [dummyTool], + dynamicTools: [dummyDynamicTool], }, ], }; @@ -99,6 +112,14 @@ describe('dynamicTools', () => { description: 'Returns a list of projects', toolset: 'project', canEnable: true, + dynamic: false, + }); + expect(json[1]).toEqual({ + name: 'get_issue_attachment', + description: 'Downloads an issue attachment', + toolset: 'project', + canEnable: true, + dynamic: true, }); } }); diff --git a/src/tools/dynamicTools/toolsets.ts b/src/tools/dynamicTools/toolsets.ts index 09f8acd..081600a 100644 --- a/src/tools/dynamicTools/toolsets.ts +++ b/src/tools/dynamicTools/toolsets.ts @@ -121,12 +121,22 @@ export const getToolsetTools = ( }; } - const tools = found.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - toolset: found.name, - canEnable: true, - })); + const tools = [ + ...found.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + toolset: found.name, + canEnable: true, + dynamic: false, + })), + ...(found.dynamicTools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description, + toolset: found.name, + canEnable: true, + dynamic: true, + })), + ]; return { content: [ diff --git a/src/tools/getIssueAttachment.test.ts b/src/tools/getIssueAttachment.test.ts new file mode 100644 index 0000000..facbbc0 --- /dev/null +++ b/src/tools/getIssueAttachment.test.ts @@ -0,0 +1,334 @@ +import { getIssueAttachmentTool } from './getIssueAttachment.js'; +import { vi, describe, it, expect } from 'vitest'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; +import { PassThrough } from 'stream'; +import { Buffer } from 'node:buffer'; +import sharp from 'sharp'; + +function createMockStream(content: string): PassThrough { + const stream = new PassThrough(); + stream.end(content); + return stream; +} + +async function createLargePngBuffer(): Promise { + const width = 1200; + const height = 1200; + const raw = Buffer.alloc(width * height * 3); + + for (let index = 0; index < raw.length; index++) { + raw[index] = (index * 37) % 256; + } + + return sharp(raw, { raw: { width, height, channels: 3 } }) + .png() + .toBuffer(); +} + +describe('getIssueAttachmentTool', () => { + const mockTranslationHelper = createTranslationHelper(); + + it('returns image content for image attachments', async () => { + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'screenshot.png', + url: 'https://example.backlog.com/file/screenshot.png', + body: createMockStream('fake-png-data'), + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 100, + }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'image'); + expect(result.content[0]).toHaveProperty('mimeType', 'image/png'); + expect(result.content[0]).toHaveProperty( + 'data', + Buffer.from('fake-png-data').toString('base64') + ); + }); + + it('returns resource content for non-image attachments', async () => { + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'document.pdf', + url: 'https://example.backlog.com/file/document.pdf', + body: createMockStream('fake-pdf-data'), + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 101, + }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'resource'); + }); + + it('returns metadata only when responseMode is metadata', async () => { + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'document.pdf', + url: 'https://example.backlog.com/file/document.pdf', + body: createMockStream('fake-pdf-data'), + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 101, + responseMode: 'metadata', + }); + + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'text'); + if (content.type === 'text') { + expect(JSON.parse(content.text)).toMatchObject({ + mode: 'metadata', + filename: 'document.pdf', + mimeType: 'application/pdf', + inlineStatus: 'skipped', + reason: 'metadata_requested', + }); + } + }); + + it('falls back to metadata in auto mode when attachment exceeds maxInlineBytes', async () => { + const imageBuffer = await createLargePngBuffer(); + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'screenshot.png', + url: 'https://example.backlog.com/file/screenshot.png', + body: imageBuffer, + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 100, + responseMode: 'auto', + maxInlineBytes: 4, + }); + + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'text'); + if (content.type === 'text') { + expect(JSON.parse(content.text)).toMatchObject({ + mode: 'metadata', + filename: 'screenshot.png', + mimeType: 'image/png', + inlineStatus: 'too_large', + reason: 'attachment_exceeds_max_inline_bytes', + maxInlineBytes: 4, + }); + } + }); + + it('returns an error in inline mode when attachment exceeds maxInlineBytes and fallback is disabled', async () => { + const imageBuffer = await createLargePngBuffer(); + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'screenshot.png', + url: 'https://example.backlog.com/file/screenshot.png', + body: imageBuffer, + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 100, + responseMode: 'inline', + maxInlineBytes: 4, + fallbackToMetadata: false, + }); + + expect(result.isError).toBe(true); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'text'); + if (content.type === 'text') { + expect(content.text).toContain('maxInlineBytes'); + } + }); + + it('optimizes large images for inline delivery in auto mode', async () => { + const imageBuffer = await createLargePngBuffer(); + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'screenshot.png', + url: 'https://example.backlog.com/file/screenshot.png', + body: imageBuffer, + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 100, + responseMode: 'auto', + maxInlineBytes: 120, + maxImageWidth: 160, + imageQuality: 55, + }); + + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'image'); + expect(content).toHaveProperty('mimeType', 'image/webp'); + if (content.type === 'image') { + expect(Buffer.from(content.data, 'base64').length).toBeLessThanOrEqual( + 120 + ); + } + }); + + it('uses maxVideoInlineBytes for inline video attachments', async () => { + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'demo.mp4', + url: 'https://example.backlog.com/file/demo.mp4', + body: Buffer.from('video-data'), + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 100, + responseMode: 'inline', + maxInlineBytes: 4, + maxVideoInlineBytes: 32, + fallbackToMetadata: false, + }); + + expect(result.isError).not.toBe(true); + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'resource'); + if (content.type === 'resource') { + expect(content.resource.mimeType).toBe('video/mp4'); + if ('blob' in content.resource) { + expect(content.resource.blob).toBe( + Buffer.from('video-data').toString('base64') + ); + } + } + }); + + it('calls backlog.getIssueAttachment with correct params', async () => { + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'test.png', + url: 'https://example.backlog.com/file/test.png', + body: createMockStream('data'), + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + await tool.handler({ issueId: 42, attachmentId: 100 }); + expect(mockBacklog.getIssueAttachment).toHaveBeenCalledWith(42, 100); + }); + + it('decodes URL-encoded filename (dot encoded) when determining MIME type', async () => { + // backlog-js extracts filename from Content-Disposition header via RFC 5987. + // In rare cases the dot may be percent-encoded: "screenshot%2Epng" + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'screenshot%2Epng', + url: 'https://example.backlog.com/file/screenshot.png', + body: createMockStream('fake-png-data'), + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 100, + }); + expect(result.content[0]).toHaveProperty('type', 'image'); + expect(result.content[0]).toHaveProperty('mimeType', 'image/png'); + }); + + it('does not throw when filename contains malformed percent-encoding', async () => { + const mockBacklog: Partial = { + getIssueAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'file%GGname.png', + url: 'https://example.backlog.com/file/filename.png', + body: createMockStream('fake-png-data'), + }), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + // Should not throw — falls back to raw filename, still detects .png + const result = await tool.handler({ + issueKey: 'TEST-1', + attachmentId: 100, + }); + expect(result.content[0]).toHaveProperty('type', 'image'); + }); + + it('returns error if neither issueId nor issueKey is provided', async () => { + const mockBacklog: Partial = { + getIssueAttachment: vi.fn(), + }; + + const tool = getIssueAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ attachmentId: 100 }); + expect(result.isError).toBe(true); + }); +}); diff --git a/src/tools/getIssueAttachment.ts b/src/tools/getIssueAttachment.ts new file mode 100644 index 0000000..0d8d029 --- /dev/null +++ b/src/tools/getIssueAttachment.ts @@ -0,0 +1,158 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, DynamicToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; +import { buildAttachmentResult } from '../utils/buildAttachmentResult.js'; + +const getIssueAttachmentSchema = buildToolSchema((t) => ({ + issueId: z + .number() + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_ISSUE_ID', + 'The numeric ID of the issue (e.g., 12345)' + ) + ), + issueKey: z + .string() + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_ISSUE_KEY', + "The key of the issue (e.g., 'PROJ-123')" + ) + ), + attachmentId: z + .number() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_ATTACHMENT_ID', + 'The numeric ID of the attachment' + ) + ), + responseMode: z + .enum(['metadata', 'auto', 'inline']) + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_RESPONSE_MODE', + "Response mode: 'metadata' returns only attachment details, 'auto' inlines only images within size limits, 'inline' always attempts inline content first." + ) + ), + maxInlineBytes: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_MAX_INLINE_BYTES', + 'Maximum attachment size in bytes to inline when using auto or inline mode.' + ) + ), + maxVideoInlineBytes: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_MAX_VIDEO_INLINE_BYTES', + 'Maximum video size in bytes to inline when using inline mode.' + ) + ), + maxImageWidth: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_MAX_IMAGE_WIDTH', + 'Maximum image width used when preparing inline-ready images.' + ) + ), + imageQuality: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_IMAGE_QUALITY', + 'Preferred image quality used when compressing inline-ready images.' + ) + ), + fallbackToMetadata: z + .boolean() + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_FALLBACK_TO_METADATA', + 'When true, returns metadata instead of an error if inline mode exceeds the byte limit.' + ) + ), + outputFormat: z + .enum(['default', 'raw_base64']) + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENT_OUTPUT_FORMAT', + "Output format: 'default' renders images inline or as resource blobs, 'raw_base64' returns the raw base64 string as text so it can be passed to other tools." + ) + ), +})); + +export const getIssueAttachmentTool = ( + backlog: Backlog, + { t }: TranslationHelper +): DynamicToolDefinition> => { + return { + name: 'get_issue_attachment', + description: t( + 'TOOL_GET_ISSUE_ATTACHMENT_DESCRIPTION', + 'Downloads an attachment file from an issue. Returns the file content as base64-encoded data with its MIME type.' + ), + schema: z.object(getIssueAttachmentSchema(t)), + handler: async ({ + issueId, + issueKey, + attachmentId, + responseMode, + maxInlineBytes, + maxVideoInlineBytes, + maxImageWidth, + imageQuality, + fallbackToMetadata, + outputFormat, + }) => { + const result = resolveIdOrKey('issue', { id: issueId, key: issueKey }, t); + if (!result.ok) { + return { + isError: true, + content: [{ type: 'text' as const, text: result.error.message }], + }; + } + + const fileData = await backlog.getIssueAttachment( + result.value, + attachmentId + ); + return buildAttachmentResult({ + body: fileData.body, + filename: 'filename' in fileData ? (fileData.filename as string) : '', + responseMode, + outputFormat, + maxInlineBytes, + maxVideoInlineBytes, + maxImageWidth, + imageQuality, + fallbackToMetadata, + url: fileData.url, + }); + }, + }; +}; diff --git a/src/tools/getIssueAttachments.test.ts b/src/tools/getIssueAttachments.test.ts new file mode 100644 index 0000000..b60ee55 --- /dev/null +++ b/src/tools/getIssueAttachments.test.ts @@ -0,0 +1,73 @@ +import { getIssueAttachmentsTool } from './getIssueAttachments.js'; +import { vi, describe, it, expect } from 'vitest'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('getIssueAttachmentsTool', () => { + const mockBacklog: Partial = { + getIssueAttachments: vi.fn<() => Promise>().mockResolvedValue([ + { + id: 100, + name: 'screenshot.png', + size: 12345, + createdUser: { + id: 1, + userId: 'admin', + name: 'Admin User', + roleType: 1, + lang: 'en', + mailAddress: 'admin@example.com', + lastLoginTime: '2023-01-01T00:00:00Z', + }, + created: '2023-01-01T00:00:00Z', + }, + { + id: 101, + name: 'document.pdf', + size: 54321, + createdUser: { + id: 2, + userId: 'user', + name: 'Test User', + roleType: 2, + lang: 'en', + mailAddress: 'test@example.com', + lastLoginTime: '2023-01-01T00:00:00Z', + }, + created: '2023-01-02T00:00:00Z', + }, + ]), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = getIssueAttachmentsTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('returns issue attachments', async () => { + const result = await tool.handler({ issueKey: 'TEST-1' }); + + if (!Array.isArray(result)) { + throw new Error('Unexpected non array result'); + } + + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('name', 'screenshot.png'); + expect(result[1]).toHaveProperty('name', 'document.pdf'); + }); + + it('calls backlog.getIssueAttachments with issue key', async () => { + await tool.handler({ issueKey: 'TEST-1' }); + expect(mockBacklog.getIssueAttachments).toHaveBeenCalledWith('TEST-1'); + }); + + it('calls backlog.getIssueAttachments with issue ID', async () => { + await tool.handler({ issueId: 42 }); + expect(mockBacklog.getIssueAttachments).toHaveBeenCalledWith(42); + }); + + it('throws an error if neither issueId nor issueKey is provided', async () => { + await expect(tool.handler({})).rejects.toThrow(Error); + }); +}); diff --git a/src/tools/getIssueAttachments.ts b/src/tools/getIssueAttachments.ts new file mode 100644 index 0000000..bce3bf7 --- /dev/null +++ b/src/tools/getIssueAttachments.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { IssueFileInfoSchema } from '../types/zod/backlogOutputDefinition.js'; +import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; + +const getIssueAttachmentsSchema = buildToolSchema((t) => ({ + issueId: z + .number() + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENTS_ISSUE_ID', + 'The numeric ID of the issue (e.g., 12345)' + ) + ), + issueKey: z + .string() + .optional() + .describe( + t( + 'TOOL_GET_ISSUE_ATTACHMENTS_ISSUE_KEY', + "The key of the issue (e.g., 'PROJ-123')" + ) + ), +})); + +export const getIssueAttachmentsTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof IssueFileInfoSchema)['shape'] +> => { + return { + name: 'get_issue_attachments', + description: t( + 'TOOL_GET_ISSUE_ATTACHMENTS_DESCRIPTION', + 'Returns list of attachments for an issue' + ), + schema: z.object(getIssueAttachmentsSchema(t)), + outputSchema: IssueFileInfoSchema, + handler: async ({ issueId, issueKey }) => { + const result = resolveIdOrKey('issue', { id: issueId, key: issueKey }, t); + if (!result.ok) { + throw result.error; + } + return backlog.getIssueAttachments(result.value); + }, + }; +}; diff --git a/src/tools/getPullRequestAttachment.test.ts b/src/tools/getPullRequestAttachment.test.ts new file mode 100644 index 0000000..c8c87d7 --- /dev/null +++ b/src/tools/getPullRequestAttachment.test.ts @@ -0,0 +1,303 @@ +import { getPullRequestAttachmentTool } from './getPullRequestAttachment.js'; +import { vi, describe, it, expect } from 'vitest'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; +import { PassThrough } from 'stream'; +import { Buffer } from 'node:buffer'; +import sharp from 'sharp'; + +function createMockStream(content: string): PassThrough { + const stream = new PassThrough(); + stream.end(content); + return stream; +} + +async function createLargePngBuffer(): Promise { + const width = 1200; + const height = 1200; + const raw = Buffer.alloc(width * height * 3); + + for (let index = 0; index < raw.length; index++) { + raw[index] = (index * 37) % 256; + } + + return sharp(raw, { raw: { width, height, channels: 3 } }) + .png() + .toBuffer(); +} + +describe('getPullRequestAttachmentTool', () => { + const mockTranslationHelper = createTranslationHelper(); + + it('returns image content for image attachments', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'screenshot.jpg', + url: 'https://example.backlog.com/file/screenshot.jpg', + body: createMockStream('fake-jpg-data'), + }), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + projectKey: 'PROJ', + repoName: 'my-repo', + number: 1, + attachmentId: 300, + }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'image'); + expect(result.content[0]).toHaveProperty('mimeType', 'image/jpeg'); + }); + + it('returns resource content for non-image attachments', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'report.csv', + url: 'https://example.backlog.com/file/report.csv', + body: createMockStream('csv-data'), + }), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + projectKey: 'PROJ', + repoName: 'my-repo', + number: 1, + attachmentId: 301, + }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'resource'); + }); + + it('falls back to metadata in auto mode when pull request attachment exceeds maxInlineBytes', async () => { + const imageBuffer = await createLargePngBuffer(); + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'screenshot.jpg', + url: 'https://example.backlog.com/file/screenshot.jpg', + body: imageBuffer, + }), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + projectKey: 'PROJ', + repoName: 'my-repo', + number: 1, + attachmentId: 300, + responseMode: 'auto', + maxInlineBytes: 4, + }); + + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'text'); + if (content.type === 'text') { + expect(JSON.parse(content.text)).toMatchObject({ + mode: 'metadata', + filename: 'screenshot.jpg', + mimeType: 'image/jpeg', + inlineStatus: 'too_large', + reason: 'attachment_exceeds_max_inline_bytes', + maxInlineBytes: 4, + }); + } + }); + + it('uses maxVideoInlineBytes for inline pull request video attachments', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'demo.mp4', + url: 'https://example.backlog.com/file/demo.mp4', + body: Buffer.from('video-data'), + }), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + projectKey: 'PROJ', + repoName: 'my-repo', + number: 1, + attachmentId: 300, + responseMode: 'inline', + maxInlineBytes: 4, + maxVideoInlineBytes: 32, + fallbackToMetadata: false, + }); + + expect(result.isError).not.toBe(true); + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'resource'); + if (content.type === 'resource') { + expect(content.resource.mimeType).toBe('video/mp4'); + if ('blob' in content.resource) { + expect(content.resource.blob).toBe( + Buffer.from('video-data').toString('base64') + ); + } + } + }); + + it('calls backlog.getPullRequestAttachment with correct params', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'test.png', + url: 'https://example.backlog.com/file/test.png', + body: createMockStream('data'), + }), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + await tool.handler({ + projectId: 5, + repoName: 'my-repo', + number: 1, + attachmentId: 300, + }); + + expect(mockBacklog.getPullRequestAttachment).toHaveBeenCalledWith( + 5, + 'my-repo', + 1, + 300 + ); + }); + + it('decodes URL-encoded filename (dot encoded) when determining MIME type', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'screenshot%2Ejpg', + url: 'https://example.backlog.com/file/screenshot.jpg', + body: createMockStream('fake-jpg-data'), + }), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + projectKey: 'PROJ', + repoName: 'my-repo', + number: 1, + attachmentId: 300, + }); + expect(result.content[0]).toHaveProperty('type', 'image'); + expect(result.content[0]).toHaveProperty('mimeType', 'image/jpeg'); + }); + + it('does not throw when filename contains malformed percent-encoding', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'file%GGname.png', + url: 'https://example.backlog.com/file/filename.png', + body: createMockStream('fake-png-data'), + }), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + projectKey: 'PROJ', + repoName: 'my-repo', + number: 1, + attachmentId: 300, + }); + expect(result.content[0]).toHaveProperty('type', 'image'); + }); + + it('returns error if neither projectId nor projectKey is provided', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn(), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + repoName: 'my-repo', + number: 1, + attachmentId: 300, + }); + + expect(result.isError).toBe(true); + }); + + it('accepts repoId as an alternative to repoName', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'test.png', + url: 'https://example.backlog.com/file/test.png', + body: createMockStream('data'), + }), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + await tool.handler({ + projectId: 5, + repoId: 99, + number: 1, + attachmentId: 300, + }); + + expect(mockBacklog.getPullRequestAttachment).toHaveBeenCalledWith( + 5, + '99', + 1, + 300 + ); + }); + + it('returns error if neither repoId nor repoName is provided', async () => { + const mockBacklog: Partial = { + getPullRequestAttachment: vi.fn(), + }; + + const tool = getPullRequestAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + projectKey: 'PROJ', + number: 1, + attachmentId: 300, + }); + + expect(result.isError).toBe(true); + }); +}); diff --git a/src/tools/getPullRequestAttachment.ts b/src/tools/getPullRequestAttachment.ts new file mode 100644 index 0000000..2777b5f --- /dev/null +++ b/src/tools/getPullRequestAttachment.ts @@ -0,0 +1,187 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, DynamicToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; +import { buildAttachmentResult } from '../utils/buildAttachmentResult.js'; + +const getPullRequestAttachmentSchema = buildToolSchema((t) => ({ + projectId: z + .number() + .optional() + .describe( + t('TOOL_GET_PR_ATTACHMENT_PROJECT_ID', 'The numeric ID of the project') + ), + projectKey: z + .string() + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_PROJECT_KEY', + "The key of the project (e.g., 'PROJ')" + ) + ), + repoId: z + .number() + .optional() + .describe(t('TOOL_GET_PR_ATTACHMENT_REPO_ID', 'Repository ID')), + repoName: z + .string() + .optional() + .describe(t('TOOL_GET_PR_ATTACHMENT_REPO_NAME', 'Repository name')), + number: z + .number() + .describe(t('TOOL_GET_PR_ATTACHMENT_NUMBER', 'The pull request number')), + attachmentId: z + .number() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_ATTACHMENT_ID', + 'The numeric ID of the attachment' + ) + ), + responseMode: z + .enum(['metadata', 'auto', 'inline']) + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_RESPONSE_MODE', + "Response mode: 'metadata' returns only attachment details, 'auto' inlines only images within size limits, 'inline' always attempts inline content first." + ) + ), + maxInlineBytes: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_MAX_INLINE_BYTES', + 'Maximum attachment size in bytes to inline when using auto or inline mode.' + ) + ), + maxVideoInlineBytes: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_MAX_VIDEO_INLINE_BYTES', + 'Maximum video size in bytes to inline when using inline mode.' + ) + ), + maxImageWidth: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_MAX_IMAGE_WIDTH', + 'Maximum image width used when preparing inline-ready images.' + ) + ), + imageQuality: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_IMAGE_QUALITY', + 'Preferred image quality used when compressing inline-ready images.' + ) + ), + fallbackToMetadata: z + .boolean() + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_FALLBACK_TO_METADATA', + 'When true, returns metadata instead of an error if inline mode exceeds the byte limit.' + ) + ), + outputFormat: z + .enum(['default', 'raw_base64']) + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENT_OUTPUT_FORMAT', + "Output format: 'default' renders images inline or as resource blobs, 'raw_base64' returns the raw base64 string as text so it can be passed to other tools." + ) + ), +})); + +export const getPullRequestAttachmentTool = ( + backlog: Backlog, + { t }: TranslationHelper +): DynamicToolDefinition> => { + return { + name: 'get_pull_request_attachment', + description: t( + 'TOOL_GET_PR_ATTACHMENT_DESCRIPTION', + 'Downloads an attachment file from a pull request. Returns the file content as base64-encoded data with its MIME type.' + ), + schema: z.object(getPullRequestAttachmentSchema(t)), + handler: async ({ + projectId, + projectKey, + repoId, + repoName, + number, + attachmentId, + responseMode, + maxInlineBytes, + maxVideoInlineBytes, + maxImageWidth, + imageQuality, + fallbackToMetadata, + outputFormat, + }) => { + const result = resolveIdOrKey( + 'project', + { id: projectId, key: projectKey }, + t + ); + if (!result.ok) { + return { + isError: true, + content: [{ type: 'text' as const, text: result.error.message }], + }; + } + + const repoResult = resolveIdOrName( + 'repository', + { id: repoId, name: repoName }, + t + ); + if (!repoResult.ok) { + return { + isError: true, + content: [{ type: 'text' as const, text: repoResult.error.message }], + }; + } + + const fileData = await backlog.getPullRequestAttachment( + result.value, + String(repoResult.value), + number, + attachmentId + ); + return buildAttachmentResult({ + body: fileData.body, + filename: 'filename' in fileData ? (fileData.filename as string) : '', + responseMode, + outputFormat, + maxInlineBytes, + maxVideoInlineBytes, + maxImageWidth, + imageQuality, + fallbackToMetadata, + url: fileData.url, + }); + }, + }; +}; diff --git a/src/tools/getPullRequestAttachments.test.ts b/src/tools/getPullRequestAttachments.test.ts new file mode 100644 index 0000000..6b90bc4 --- /dev/null +++ b/src/tools/getPullRequestAttachments.test.ts @@ -0,0 +1,87 @@ +import { getPullRequestAttachmentsTool } from './getPullRequestAttachments.js'; +import { vi, describe, it, expect } from 'vitest'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('getPullRequestAttachmentsTool', () => { + const mockBacklog: Partial = { + getPullRequestAttachments: vi.fn<() => Promise>().mockResolvedValue([ + { + id: 300, + name: 'diff.patch', + size: 2048, + createdUser: { + id: 1, + userId: 'admin', + name: 'Admin User', + roleType: 1, + lang: 'en', + mailAddress: 'admin@example.com', + lastLoginTime: '2023-01-01T00:00:00Z', + }, + created: '2023-01-01T00:00:00Z', + }, + ]), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = getPullRequestAttachmentsTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('returns pull request attachments', async () => { + const result = await tool.handler({ + projectKey: 'PROJ', + repoName: 'my-repo', + number: 1, + }); + + if (!Array.isArray(result)) { + throw new Error('Unexpected non array result'); + } + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('name', 'diff.patch'); + }); + + it('calls backlog.getPullRequestAttachments with correct params', async () => { + await tool.handler({ + projectId: 5, + repoName: 'my-repo', + number: 1, + }); + + expect(mockBacklog.getPullRequestAttachments).toHaveBeenCalledWith( + 5, + 'my-repo', + 1 + ); + }); + + it('throws an error if neither projectId nor projectKey is provided', async () => { + await expect( + tool.handler({ repoName: 'my-repo', number: 1 }) + ).rejects.toThrow(Error); + }); + + it('accepts repoId as an alternative to repoName', async () => { + await tool.handler({ + projectId: 5, + repoId: 99, + number: 1, + }); + + expect(mockBacklog.getPullRequestAttachments).toHaveBeenCalledWith( + 5, + '99', + 1 + ); + }); + + it('throws an error if neither repoId nor repoName is provided', async () => { + await expect( + tool.handler({ projectKey: 'PROJ', number: 1 }) + ).rejects.toThrow(Error); + }); +}); diff --git a/src/tools/getPullRequestAttachments.ts b/src/tools/getPullRequestAttachments.ts new file mode 100644 index 0000000..38a3779 --- /dev/null +++ b/src/tools/getPullRequestAttachments.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { PullRequestFileInfoSchema } from '../types/zod/backlogOutputDefinition.js'; +import { resolveIdOrKey, resolveIdOrName } from '../utils/resolveIdOrKey.js'; + +const getPullRequestAttachmentsSchema = buildToolSchema((t) => ({ + projectId: z + .number() + .optional() + .describe( + t('TOOL_GET_PR_ATTACHMENTS_PROJECT_ID', 'The numeric ID of the project') + ), + projectKey: z + .string() + .optional() + .describe( + t( + 'TOOL_GET_PR_ATTACHMENTS_PROJECT_KEY', + "The key of the project (e.g., 'PROJ')" + ) + ), + repoId: z + .number() + .optional() + .describe(t('TOOL_GET_PR_ATTACHMENTS_REPO_ID', 'Repository ID')), + repoName: z + .string() + .optional() + .describe(t('TOOL_GET_PR_ATTACHMENTS_REPO_NAME', 'Repository name')), + number: z + .number() + .describe(t('TOOL_GET_PR_ATTACHMENTS_NUMBER', 'The pull request number')), +})); + +export const getPullRequestAttachmentsTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof PullRequestFileInfoSchema)['shape'] +> => { + return { + name: 'get_pull_request_attachments', + description: t( + 'TOOL_GET_PR_ATTACHMENTS_DESCRIPTION', + 'Returns list of attachments for a pull request' + ), + schema: z.object(getPullRequestAttachmentsSchema(t)), + outputSchema: PullRequestFileInfoSchema, + handler: async ({ projectId, projectKey, repoId, repoName, number }) => { + const result = resolveIdOrKey( + 'project', + { id: projectId, key: projectKey }, + t + ); + if (!result.ok) { + throw result.error; + } + + const repoResult = resolveIdOrName( + 'repository', + { id: repoId, name: repoName }, + t + ); + if (!repoResult.ok) { + throw repoResult.error; + } + + return backlog.getPullRequestAttachments( + result.value, + String(repoResult.value), + number + ); + }, + }; +}; diff --git a/src/tools/getWikiAttachment.test.ts b/src/tools/getWikiAttachment.test.ts new file mode 100644 index 0000000..bc6c2c5 --- /dev/null +++ b/src/tools/getWikiAttachment.test.ts @@ -0,0 +1,183 @@ +import { getWikiAttachmentTool } from './getWikiAttachment.js'; +import { vi, describe, it, expect } from 'vitest'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; +import { PassThrough } from 'stream'; +import { Buffer } from 'node:buffer'; + +function createMockStream(content: string): PassThrough { + const stream = new PassThrough(); + stream.end(content); + return stream; +} + +describe('getWikiAttachmentTool', () => { + const mockTranslationHelper = createTranslationHelper(); + + it('returns image content for image attachments', async () => { + const mockBacklog: Partial = { + getWikiAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'diagram.png', + url: 'https://example.backlog.com/file/diagram.png', + body: createMockStream('fake-image-data'), + }), + }; + + const tool = getWikiAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ wikiId: 10, attachmentId: 200 }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'image'); + expect(result.content[0]).toHaveProperty('mimeType', 'image/png'); + }); + + it('returns resource content for non-image attachments', async () => { + const mockBacklog: Partial = { + getWikiAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'notes.txt', + url: 'https://example.backlog.com/file/notes.txt', + body: createMockStream('text content'), + }), + }; + + const tool = getWikiAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ wikiId: 10, attachmentId: 201 }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'resource'); + }); + + it('returns metadata for non-image attachments in auto mode', async () => { + const mockBacklog: Partial = { + getWikiAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'notes.txt', + url: 'https://example.backlog.com/file/notes.txt', + body: createMockStream('text content'), + }), + }; + + const tool = getWikiAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + wikiId: 10, + attachmentId: 201, + responseMode: 'auto', + }); + + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'text'); + if (content.type === 'text') { + expect(JSON.parse(content.text)).toMatchObject({ + mode: 'metadata', + filename: 'notes.txt', + mimeType: 'text/plain', + inlineStatus: 'skipped', + reason: 'non_image_attachment', + }); + } + }); + + it('uses maxVideoInlineBytes for inline wiki video attachments', async () => { + const mockBacklog: Partial = { + getWikiAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'demo.mp4', + url: 'https://example.backlog.com/file/demo.mp4', + body: Buffer.from('video-data'), + }), + }; + + const tool = getWikiAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ + wikiId: 10, + attachmentId: 201, + responseMode: 'inline', + maxInlineBytes: 4, + maxVideoInlineBytes: 32, + fallbackToMetadata: false, + }); + + expect(result.isError).not.toBe(true); + expect(result.content).toHaveLength(1); + const content = result.content[0]; + expect(content).toHaveProperty('type', 'resource'); + if (content.type === 'resource') { + expect(content.resource.mimeType).toBe('video/mp4'); + if ('blob' in content.resource) { + expect(content.resource.blob).toBe( + Buffer.from('video-data').toString('base64') + ); + } + } + }); + + it('decodes URL-encoded filename (dot encoded) when determining MIME type', async () => { + const mockBacklog: Partial = { + getWikiAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'diagram%2Epng', + url: 'https://example.backlog.com/file/diagram.png', + body: createMockStream('fake-image-data'), + }), + }; + + const tool = getWikiAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ wikiId: 10, attachmentId: 200 }); + expect(result.content[0]).toHaveProperty('type', 'image'); + expect(result.content[0]).toHaveProperty('mimeType', 'image/png'); + }); + + it('does not throw when filename contains malformed percent-encoding', async () => { + const mockBacklog: Partial = { + getWikiAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'file%GGname.png', + url: 'https://example.backlog.com/file/filename.png', + body: createMockStream('fake-image-data'), + }), + }; + + const tool = getWikiAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + const result = await tool.handler({ wikiId: 10, attachmentId: 200 }); + expect(result.content[0]).toHaveProperty('type', 'image'); + }); + + it('calls backlog.getWikiAttachment with correct params', async () => { + const mockBacklog: Partial = { + getWikiAttachment: vi.fn<() => Promise>().mockResolvedValue({ + filename: 'test.png', + url: 'https://example.backlog.com/file/test.png', + body: createMockStream('data'), + }), + }; + + const tool = getWikiAttachmentTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + await tool.handler({ wikiId: 10, attachmentId: 200 }); + expect(mockBacklog.getWikiAttachment).toHaveBeenCalledWith(10, 200); + }); +}); diff --git a/src/tools/getWikiAttachment.ts b/src/tools/getWikiAttachment.ts new file mode 100644 index 0000000..b011be1 --- /dev/null +++ b/src/tools/getWikiAttachment.ts @@ -0,0 +1,132 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, DynamicToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { buildAttachmentResult } from '../utils/buildAttachmentResult.js'; + +const getWikiAttachmentSchema = buildToolSchema((t) => ({ + wikiId: z + .number() + .describe( + t('TOOL_GET_WIKI_ATTACHMENT_WIKI_ID', 'The numeric ID of the wiki page') + ), + attachmentId: z + .number() + .describe( + t( + 'TOOL_GET_WIKI_ATTACHMENT_ATTACHMENT_ID', + 'The numeric ID of the attachment' + ) + ), + responseMode: z + .enum(['metadata', 'auto', 'inline']) + .optional() + .describe( + t( + 'TOOL_GET_WIKI_ATTACHMENT_RESPONSE_MODE', + "Response mode: 'metadata' returns only attachment details, 'auto' inlines only images within size limits, 'inline' always attempts inline content first." + ) + ), + maxInlineBytes: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_WIKI_ATTACHMENT_MAX_INLINE_BYTES', + 'Maximum attachment size in bytes to inline when using auto or inline mode.' + ) + ), + maxVideoInlineBytes: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_WIKI_ATTACHMENT_MAX_VIDEO_INLINE_BYTES', + 'Maximum video size in bytes to inline when using inline mode.' + ) + ), + maxImageWidth: z + .number() + .int() + .positive() + .optional() + .describe( + t( + 'TOOL_GET_WIKI_ATTACHMENT_MAX_IMAGE_WIDTH', + 'Maximum image width used when preparing inline-ready images.' + ) + ), + imageQuality: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + t( + 'TOOL_GET_WIKI_ATTACHMENT_IMAGE_QUALITY', + 'Preferred image quality used when compressing inline-ready images.' + ) + ), + fallbackToMetadata: z + .boolean() + .optional() + .describe( + t( + 'TOOL_GET_WIKI_ATTACHMENT_FALLBACK_TO_METADATA', + 'When true, returns metadata instead of an error if inline mode exceeds the byte limit.' + ) + ), + outputFormat: z + .enum(['default', 'raw_base64']) + .optional() + .describe( + t( + 'TOOL_GET_WIKI_ATTACHMENT_OUTPUT_FORMAT', + "Output format: 'default' renders images inline or as resource blobs, 'raw_base64' returns the raw base64 string as text so it can be passed to other tools." + ) + ), +})); + +export const getWikiAttachmentTool = ( + backlog: Backlog, + { t }: TranslationHelper +): DynamicToolDefinition> => { + return { + name: 'get_wiki_attachment', + description: t( + 'TOOL_GET_WIKI_ATTACHMENT_DESCRIPTION', + 'Downloads an attachment file from a wiki page. Returns the file content as base64-encoded data with its MIME type.' + ), + schema: z.object(getWikiAttachmentSchema(t)), + handler: async ({ + wikiId, + attachmentId, + responseMode, + maxInlineBytes, + maxVideoInlineBytes, + maxImageWidth, + imageQuality, + fallbackToMetadata, + outputFormat, + }) => { + const fileData = await backlog.getWikiAttachment(wikiId, attachmentId); + return buildAttachmentResult({ + body: fileData.body, + filename: 'filename' in fileData ? (fileData.filename as string) : '', + responseMode, + outputFormat, + maxInlineBytes, + maxVideoInlineBytes, + maxImageWidth, + imageQuality, + fallbackToMetadata, + url: fileData.url, + }); + }, + }; +}; diff --git a/src/tools/getWikiAttachments.test.ts b/src/tools/getWikiAttachments.test.ts new file mode 100644 index 0000000..98a8d44 --- /dev/null +++ b/src/tools/getWikiAttachments.test.ts @@ -0,0 +1,48 @@ +import { getWikiAttachmentsTool } from './getWikiAttachments.js'; +import { vi, describe, it, expect } from 'vitest'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('getWikiAttachmentsTool', () => { + const mockBacklog: Partial = { + getWikisAttachments: vi.fn<() => Promise>().mockResolvedValue([ + { + id: 200, + name: 'diagram.png', + size: 8000, + createdUser: { + id: 1, + userId: 'admin', + name: 'Admin User', + roleType: 1, + lang: 'en', + mailAddress: 'admin@example.com', + lastLoginTime: '2023-01-01T00:00:00Z', + }, + created: '2023-01-01T00:00:00Z', + }, + ]), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = getWikiAttachmentsTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('returns wiki attachments', async () => { + const result = await tool.handler({ wikiId: 10 }); + + if (!Array.isArray(result)) { + throw new Error('Unexpected non array result'); + } + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('name', 'diagram.png'); + }); + + it('calls backlog.getWikisAttachments with wiki ID', async () => { + await tool.handler({ wikiId: 10 }); + expect(mockBacklog.getWikisAttachments).toHaveBeenCalledWith(10); + }); +}); diff --git a/src/tools/getWikiAttachments.ts b/src/tools/getWikiAttachments.ts new file mode 100644 index 0000000..566fa6b --- /dev/null +++ b/src/tools/getWikiAttachments.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { WikiFileInfoSchema } from '../types/zod/backlogOutputDefinition.js'; + +const getWikiAttachmentsSchema = buildToolSchema((t) => ({ + wikiId: z + .number() + .describe( + t('TOOL_GET_WIKI_ATTACHMENTS_WIKI_ID', 'The numeric ID of the wiki page') + ), +})); + +export const getWikiAttachmentsTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof WikiFileInfoSchema)['shape'] +> => { + return { + name: 'get_wiki_attachments', + description: t( + 'TOOL_GET_WIKI_ATTACHMENTS_DESCRIPTION', + 'Returns list of attachments for a wiki page' + ), + schema: z.object(getWikiAttachmentsSchema(t)), + outputSchema: WikiFileInfoSchema, + handler: async ({ wikiId }) => { + return backlog.getWikisAttachments(wikiId); + }, + }; +}; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 34ae499..18ec273 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -58,6 +58,12 @@ import { addVersionMilestoneTool } from './addVersionMilestone.js'; import { updateVersionMilestoneTool } from './updateVersionMilestone.js'; import { deleteVersionTool } from './deleteVersion.js'; import { addDocumentTool } from './addDocument.js'; +import { getIssueAttachmentsTool } from './getIssueAttachments.js'; +import { getIssueAttachmentTool } from './getIssueAttachment.js'; +import { getWikiAttachmentsTool } from './getWikiAttachments.js'; +import { getWikiAttachmentTool } from './getWikiAttachment.js'; +import { getPullRequestAttachmentsTool } from './getPullRequestAttachments.js'; +import { getPullRequestAttachmentTool } from './getPullRequestAttachment.js'; export const allTools = ( backlog: Backlog, @@ -120,7 +126,9 @@ export const allTools = ( addVersionMilestoneTool(backlog, helper), updateVersionMilestoneTool(backlog, helper), deleteVersionTool(backlog, helper), + getIssueAttachmentsTool(backlog, helper), ], + dynamicTools: [getIssueAttachmentTool(backlog, helper)], }, { name: 'wiki', @@ -132,7 +140,9 @@ export const allTools = ( getWikiTool(backlog, helper), addWikiTool(backlog, helper), updateWikiTool(backlog, helper), + getWikiAttachmentsTool(backlog, helper), ], + dynamicTools: [getWikiAttachmentTool(backlog, helper)], }, { name: 'git', @@ -149,7 +159,9 @@ export const allTools = ( getPullRequestCommentsTool(backlog, helper), addPullRequestCommentTool(backlog, helper), updatePullRequestCommentTool(backlog, helper), + getPullRequestAttachmentsTool(backlog, helper), ], + dynamicTools: [getPullRequestAttachmentTool(backlog, helper)], }, { name: 'document', diff --git a/src/types/toolsets.ts b/src/types/toolsets.ts index c09fed6..b90c57c 100644 --- a/src/types/toolsets.ts +++ b/src/types/toolsets.ts @@ -8,7 +8,10 @@ type BaseToolset = { }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type Toolset = BaseToolset>; +export type Toolset = BaseToolset> & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dynamicTools?: DynamicToolDefinition[]; +}; export type ToolsetGroup = { toolsets: Toolset[] }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/utils/buildAttachmentResult.test.ts b/src/utils/buildAttachmentResult.test.ts new file mode 100644 index 0000000..1507bf8 --- /dev/null +++ b/src/utils/buildAttachmentResult.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { Buffer } from 'node:buffer'; +import { buildAttachmentResult } from './buildAttachmentResult.js'; + +describe('buildAttachmentResult', () => { + it('returns base64 as text when outputFormat is raw_base64 for images', async () => { + const imageBuffer = Buffer.from('fake-image-data'); + const result = await buildAttachmentResult({ + body: imageBuffer, + filename: 'photo.png', + responseMode: 'inline', + outputFormat: 'raw_base64', + url: 'https://example.backlog.com/file/photo.png', + }); + + expect(result.isError).not.toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'text'); + if (result.content[0].type === 'text') { + const parsed = JSON.parse(result.content[0].text); + expect(parsed.base64).toBe(imageBuffer.toString('base64')); + expect(parsed.mimeType).toBe('image/png'); + expect(parsed.filename).toBe('photo.png'); + } + }); + + it('returns base64 as text when outputFormat is raw_base64 for non-image files', async () => { + const fileBuffer = Buffer.from('pdf-content'); + const result = await buildAttachmentResult({ + body: fileBuffer, + filename: 'doc.pdf', + responseMode: 'inline', + outputFormat: 'raw_base64', + url: 'https://example.backlog.com/file/doc.pdf', + }); + + expect(result.isError).not.toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'text'); + if (result.content[0].type === 'text') { + const parsed = JSON.parse(result.content[0].text); + expect(parsed.base64).toBe(fileBuffer.toString('base64')); + expect(parsed.mimeType).toBe('application/pdf'); + expect(parsed.filename).toBe('doc.pdf'); + } + }); + + it('uses maxVideoInlineBytes for inline video attachments', async () => { + const result = await buildAttachmentResult({ + body: Buffer.from('video-data'), + filename: 'clip.mp4', + responseMode: 'inline', + maxInlineBytes: 4, + maxVideoInlineBytes: 32, + fallbackToMetadata: false, + url: 'https://example.backlog.com/file/clip.mp4', + }); + + expect(result.isError).not.toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toHaveProperty('type', 'resource'); + if (result.content[0].type === 'resource') { + expect(result.content[0].resource.mimeType).toBe('video/mp4'); + if ('blob' in result.content[0].resource) { + expect(result.content[0].resource.blob).toBe( + Buffer.from('video-data').toString('base64') + ); + } + } + }); +}); diff --git a/src/utils/buildAttachmentResult.ts b/src/utils/buildAttachmentResult.ts new file mode 100644 index 0000000..0dcfc70 --- /dev/null +++ b/src/utils/buildAttachmentResult.ts @@ -0,0 +1,267 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { PassThrough } from 'stream'; +import { Buffer } from 'node:buffer'; +import { ReadableStream } from 'node:stream/web'; +import { buildFileContent, tryDecodeFilename } from './buildFileContent.js'; +import { + MaxInlineBytesExceededError, + streamToBase64, +} from './streamToBase64.js'; +import { getMimeType } from './getMimeType.js'; +import { optimizeImageForInline } from './optimizeImageForInline.js'; + +const DEFAULT_MAX_INLINE_BYTES = 1024 * 1024; +const DEFAULT_MAX_VIDEO_INLINE_BYTES = 10 * 1024 * 1024; +const DEFAULT_MAX_IMAGE_WIDTH = 1600; +const DEFAULT_IMAGE_QUALITY = 72; + +export type AttachmentResponseMode = 'legacy' | 'metadata' | 'auto' | 'inline'; + +type AttachmentResultReason = + | 'metadata_requested' + | 'non_image_attachment' + | 'attachment_exceeds_max_inline_bytes'; + +type AttachmentMetadata = { + mode: 'metadata'; + requestedMode: Exclude; + filename: string; + mimeType: string; + url: string; + inlineStatus: 'skipped' | 'too_large'; + reason: AttachmentResultReason; + maxInlineBytes?: number; +}; + +type BuildAttachmentResultParams = { + body: PassThrough | ReadableStream | unknown; + filename?: string; + responseMode?: Exclude; + outputFormat?: 'default' | 'raw_base64'; + fallbackToMetadata?: boolean; + maxInlineBytes?: number; + maxVideoInlineBytes?: number; + maxImageWidth?: number; + imageQuality?: number; + url: string; +}; + +function isVideoMimeType(mimeType: string): boolean { + return mimeType.startsWith('video/'); +} + +function buildMetadataResult(metadata: AttachmentMetadata): CallToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(metadata, null, 2), + }, + ], + }; +} + +function buildTooLargeError( + filename: string, + maxInlineBytes: number, + actualBytes: number +): CallToolResult { + return { + isError: true, + content: [ + { + type: 'text', + text: `Attachment '${filename}' is ${actualBytes} bytes and exceeds maxInlineBytes ${maxInlineBytes}.`, + }, + ], + }; +} + +function shouldInlineAttachment( + responseMode: AttachmentResponseMode, + mimeType: string +): boolean { + switch (responseMode) { + case 'legacy': + case 'inline': + return true; + case 'metadata': + return false; + case 'auto': + return mimeType.startsWith('image/'); + } +} + +function buildImageContent( + filename: string, + mimeType: string, + buffer: Buffer, + url: string +): CallToolResult { + return buildFileContent(filename, mimeType, buffer.toString('base64'), url); +} + +function buildRawBase64Result( + filename: string, + mimeType: string, + base64: string +): CallToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ base64, mimeType, filename }), + }, + ], + }; +} + +export async function buildAttachmentResult({ + body, + filename: rawFilename, + responseMode, + outputFormat = 'default', + fallbackToMetadata = true, + maxInlineBytes = DEFAULT_MAX_INLINE_BYTES, + maxVideoInlineBytes = DEFAULT_MAX_VIDEO_INLINE_BYTES, + maxImageWidth = DEFAULT_MAX_IMAGE_WIDTH, + imageQuality = DEFAULT_IMAGE_QUALITY, + url, +}: BuildAttachmentResultParams): Promise { + const effectiveResponseMode: AttachmentResponseMode = + responseMode ?? 'legacy'; + const filename = tryDecodeFilename(rawFilename ?? ''); + const mimeType = getMimeType(filename); + const effectiveInlineLimit = isVideoMimeType(mimeType) + ? maxVideoInlineBytes + : maxInlineBytes; + + if (outputFormat === 'raw_base64') { + const base64 = await streamToBase64(body); + return buildRawBase64Result(filename, mimeType, base64); + } + + if (effectiveResponseMode === 'metadata') { + return buildMetadataResult({ + mode: 'metadata', + requestedMode: 'metadata', + filename, + mimeType, + url, + inlineStatus: 'skipped', + reason: 'metadata_requested', + }); + } + + if (!shouldInlineAttachment(effectiveResponseMode, mimeType)) { + return buildMetadataResult({ + mode: 'metadata', + requestedMode: 'auto', + filename, + mimeType, + url, + inlineStatus: 'skipped', + reason: 'non_image_attachment', + maxInlineBytes: effectiveInlineLimit, + }); + } + + if ( + effectiveResponseMode !== 'legacy' && + mimeType.startsWith('image/') && + mimeType !== 'image/svg+xml' + ) { + try { + const optimizedImage = await optimizeImageForInline({ + body, + filename, + maxBytes: maxInlineBytes, + maxImageWidth, + imageQuality, + }); + + if (optimizedImage) { + return buildImageContent( + optimizedImage.filename, + optimizedImage.mimeType, + optimizedImage.buffer, + url + ); + } + + if (effectiveResponseMode === 'inline' && !fallbackToMetadata) { + return buildTooLargeError(filename, maxInlineBytes, maxInlineBytes + 1); + } + + return buildMetadataResult({ + mode: 'metadata', + requestedMode: effectiveResponseMode, + filename, + mimeType, + url, + inlineStatus: 'too_large', + reason: 'attachment_exceeds_max_inline_bytes', + maxInlineBytes, + }); + } catch (error) { + if (!(error instanceof MaxInlineBytesExceededError)) { + // Some image-like attachments may have an image extension but not contain + // decodable pixels. Fall back to the regular path so size guards still apply. + } else { + if (effectiveResponseMode === 'inline' && !fallbackToMetadata) { + return buildTooLargeError( + filename, + error.maxInlineBytes, + error.actualBytes + ); + } + + return buildMetadataResult({ + mode: 'metadata', + requestedMode: effectiveResponseMode, + filename, + mimeType, + url, + inlineStatus: 'too_large', + reason: 'attachment_exceeds_max_inline_bytes', + maxInlineBytes: error.maxInlineBytes, + }); + } + } + } + + try { + const base64 = await streamToBase64( + body, + effectiveResponseMode === 'legacy' + ? undefined + : { maxBytes: effectiveInlineLimit } + ); + + return buildFileContent(filename, mimeType, base64, url); + } catch (error) { + if (!(error instanceof MaxInlineBytesExceededError)) { + throw error; + } + + if (effectiveResponseMode === 'inline' && !fallbackToMetadata) { + return buildTooLargeError( + filename, + effectiveInlineLimit, + error.actualBytes + ); + } + + return buildMetadataResult({ + mode: 'metadata', + requestedMode: + effectiveResponseMode === 'legacy' ? 'inline' : effectiveResponseMode, + filename, + mimeType, + url, + inlineStatus: 'too_large', + reason: 'attachment_exceeds_max_inline_bytes', + maxInlineBytes: effectiveInlineLimit, + }); + } +} diff --git a/src/utils/buildFileContent.ts b/src/utils/buildFileContent.ts new file mode 100644 index 0000000..1d5e354 --- /dev/null +++ b/src/utils/buildFileContent.ts @@ -0,0 +1,46 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Decodes a URL-encoded filename from a Content-Disposition header. + * Falls back to the raw value if it contains malformed percent-encoding. + */ +export function tryDecodeFilename(raw: string): string { + if (!raw) return 'attachment'; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + +export function buildFileContent( + _filename: string, + mimeType: string, + base64: string, + url: string +): CallToolResult { + if (mimeType.startsWith('image/')) { + return { + content: [ + { + type: 'image', + data: base64, + mimeType, + }, + ], + }; + } + + return { + content: [ + { + type: 'resource', + resource: { + uri: url, + mimeType, + blob: base64, + }, + }, + ], + }; +} diff --git a/src/utils/getMimeType.test.ts b/src/utils/getMimeType.test.ts new file mode 100644 index 0000000..a0d2a46 --- /dev/null +++ b/src/utils/getMimeType.test.ts @@ -0,0 +1,37 @@ +import { getMimeType } from './getMimeType.js'; +import { describe, it, expect } from 'vitest'; + +describe('getMimeType', () => { + it('returns correct MIME type for common image formats', () => { + expect(getMimeType('photo.png')).toBe('image/png'); + expect(getMimeType('photo.jpg')).toBe('image/jpeg'); + expect(getMimeType('photo.jpeg')).toBe('image/jpeg'); + expect(getMimeType('animation.gif')).toBe('image/gif'); + expect(getMimeType('photo.webp')).toBe('image/webp'); + }); + + it('returns correct MIME type for video formats', () => { + expect(getMimeType('video.mp4')).toBe('video/mp4'); + expect(getMimeType('video.webm')).toBe('video/webm'); + expect(getMimeType('video.mov')).toBe('video/quicktime'); + }); + + it('returns correct MIME type for document formats', () => { + expect(getMimeType('doc.pdf')).toBe('application/pdf'); + expect(getMimeType('data.csv')).toBe('text/csv'); + expect(getMimeType('data.json')).toBe('application/json'); + }); + + it('returns application/octet-stream for unknown extensions', () => { + expect(getMimeType('file.xyz')).toBe('application/octet-stream'); + }); + + it('returns application/octet-stream for files without extension', () => { + expect(getMimeType('Makefile')).toBe('application/octet-stream'); + }); + + it('is case insensitive for extensions', () => { + expect(getMimeType('PHOTO.PNG')).toBe('image/png'); + expect(getMimeType('VIDEO.MP4')).toBe('video/mp4'); + }); +}); diff --git a/src/utils/getMimeType.ts b/src/utils/getMimeType.ts new file mode 100644 index 0000000..e7888b0 --- /dev/null +++ b/src/utils/getMimeType.ts @@ -0,0 +1,53 @@ +const MIME_TYPES: Record = { + // Images + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + + // Videos + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', + '.avi': 'video/x-msvideo', + + // Documents + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.csv': 'text/csv', + + // Archives + '.zip': 'application/zip', + '.gz': 'application/gzip', + '.tar': 'application/x-tar', + + // Text + '.txt': 'text/plain', + '.json': 'application/json', + '.xml': 'application/xml', + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + + // Other + '.log': 'text/plain', +}; + +export function getMimeType(filename: string): string { + const ext = + filename.lastIndexOf('.') >= 0 + ? filename.slice(filename.lastIndexOf('.')).toLowerCase() + : ''; + return MIME_TYPES[ext] ?? 'application/octet-stream'; +} diff --git a/src/utils/optimizeImageForInline.ts b/src/utils/optimizeImageForInline.ts new file mode 100644 index 0000000..d7f17f4 --- /dev/null +++ b/src/utils/optimizeImageForInline.ts @@ -0,0 +1,113 @@ +import { PassThrough } from 'stream'; +import { Buffer } from 'node:buffer'; +import path from 'node:path'; +import { ReadableStream } from 'node:stream/web'; +import sharp from 'sharp'; +import { streamToBuffer } from './streamToBuffer.js'; + +const DEFAULT_MAX_IMAGE_WIDTH = 1600; +const DEFAULT_IMAGE_QUALITY = 72; +const DEFAULT_MAX_SOURCE_BYTES = 25 * 1024 * 1024; + +type OptimizeImageForInlineParams = { + body: PassThrough | ReadableStream | unknown; + filename: string; + maxBytes: number; + maxImageWidth?: number; + imageQuality?: number; + maxSourceBytes?: number; +}; + +type OptimizedImageResult = { + buffer: Buffer; + filename: string; + mimeType: 'image/webp'; +}; + +function clampQuality(value: number): number { + return Math.max(30, Math.min(90, Math.round(value))); +} + +function buildWebpFilename(filename: string): string { + const ext = path.extname(filename); + if (!ext) return `${filename}.webp`; + return `${filename.slice(0, -ext.length)}.webp`; +} + +function qualityCandidates(initialQuality: number): number[] { + const values = [ + initialQuality, + initialQuality - 10, + initialQuality - 20, + 55, + 45, + ]; + return Array.from( + new Set(values.map(clampQuality).filter((quality) => quality > 0)) + ); +} + +function widthCandidates(initialWidth: number): number[] { + const values = [ + initialWidth, + Math.round(initialWidth * 0.8), + Math.round(initialWidth * 0.65), + Math.round(initialWidth * 0.5), + ]; + return Array.from( + new Set(values.map((value) => Math.max(64, value)).filter(Boolean)) + ); +} + +export async function optimizeImageForInline({ + body, + filename, + maxBytes, + maxImageWidth = DEFAULT_MAX_IMAGE_WIDTH, + imageQuality = DEFAULT_IMAGE_QUALITY, + maxSourceBytes = DEFAULT_MAX_SOURCE_BYTES, +}: OptimizeImageForInlineParams): Promise { + const source = await streamToBuffer(body, { maxBytes: maxSourceBytes }); + const widths = widthCandidates(maxImageWidth); + const qualities = qualityCandidates(imageQuality); + const smallestWidth = widths[widths.length - 1]; + const lowestQuality = qualities[qualities.length - 1]; + + const smallestBuffer = await sharp(source) + .rotate() + .resize({ + width: smallestWidth, + fit: 'inside', + withoutEnlargement: true, + }) + .webp({ quality: lowestQuality }) + .toBuffer(); + + if (smallestBuffer.length > maxBytes) { + return null; + } + + for (const width of widths) { + for (const quality of qualities) { + const buffer = await sharp(source) + .rotate() + .resize({ + width, + fit: 'inside', + withoutEnlargement: true, + }) + .webp({ quality }) + .toBuffer(); + + if (buffer.length <= maxBytes) { + return { + buffer, + filename: buildWebpFilename(filename), + mimeType: 'image/webp', + }; + } + } + } + + return null; +} diff --git a/src/utils/streamToBase64.test.ts b/src/utils/streamToBase64.test.ts new file mode 100644 index 0000000..b131094 --- /dev/null +++ b/src/utils/streamToBase64.test.ts @@ -0,0 +1,56 @@ +import { streamToBase64 } from './streamToBase64.js'; +import { describe, it, expect } from 'vitest'; +import { PassThrough } from 'stream'; +import { Buffer } from 'node:buffer'; +import { ReadableStream } from 'node:stream/web'; +import { TextEncoder } from 'node:util'; + +describe('streamToBase64', () => { + it('converts a PassThrough stream to base64', async () => { + const stream = new PassThrough(); + const content = 'hello world'; + stream.end(content); + + const result = await streamToBase64(stream); + expect(result).toBe(Buffer.from(content).toString('base64')); + }); + + it('converts a string to base64', async () => { + const result = await streamToBase64('plain text'); + expect(result).toBe(Buffer.from('plain text').toString('base64')); + }); + + it('converts a ReadableStream to base64', async () => { + const content = 'hello from readable stream'; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(content)); + controller.close(); + }, + }); + + const result = await streamToBase64(stream); + expect(result).toBe(Buffer.from(content).toString('base64')); + }); + + it('converts a ReadableStream with multiple chunks to base64', async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('chunk1')); + controller.enqueue(encoder.encode('chunk2')); + controller.close(); + }, + }); + + const result = await streamToBase64(stream); + expect(result).toBe(Buffer.from('chunk1chunk2').toString('base64')); + }); + + it('throws for unsupported types', async () => { + await expect(streamToBase64(12345)).rejects.toThrow( + 'Unsupported body type' + ); + }); +}); diff --git a/src/utils/streamToBase64.ts b/src/utils/streamToBase64.ts new file mode 100644 index 0000000..7b0af44 --- /dev/null +++ b/src/utils/streamToBase64.ts @@ -0,0 +1,100 @@ +import { PassThrough } from 'stream'; +import { ReadableStream } from 'node:stream/web'; +import { Buffer } from 'node:buffer'; + +export class MaxInlineBytesExceededError extends Error { + constructor( + public readonly maxInlineBytes: number, + public readonly actualBytes: number + ) { + super( + `Attachment size ${actualBytes} bytes exceeds maxInlineBytes ${maxInlineBytes}` + ); + this.name = 'MaxInlineBytesExceededError'; + } +} + +type StreamToBase64Options = { + maxBytes?: number; +}; + +function ensureWithinLimit( + totalBytes: number, + maxBytes?: number +): asserts totalBytes is number { + if (maxBytes !== undefined && totalBytes > maxBytes) { + throw new MaxInlineBytesExceededError(maxBytes, totalBytes); + } +} + +export async function streamToBase64( + stream: PassThrough | ReadableStream | unknown, + options?: StreamToBase64Options +): Promise { + const { maxBytes } = options ?? {}; + + if ( + typeof ReadableStream !== 'undefined' && + stream instanceof ReadableStream + ) { + const reader = (stream as ReadableStream).getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + totalBytes += value.length; + ensureWithinLimit(totalBytes, maxBytes); + } + const merged = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + return Buffer.from(merged).toString('base64'); + } + + if ( + stream instanceof PassThrough || + (stream !== null && + typeof stream === 'object' && + 'on' in stream && + typeof (stream as { on: unknown }).on === 'function') + ) { + const chunks: Buffer[] = []; + const readable = stream as PassThrough; + let totalBytes = 0; + + return new Promise((resolve, reject) => { + readable.on('data', (chunk: Buffer) => { + const buffer = Buffer.from(chunk); + totalBytes += buffer.length; + try { + ensureWithinLimit(totalBytes, maxBytes); + } catch (error) { + readable.destroy(error as Error); + return; + } + chunks.push(buffer); + }); + readable.on('end', () => + resolve(Buffer.concat(chunks).toString('base64')) + ); + readable.on('error', reject); + }); + } + + if (typeof stream === 'string') { + ensureWithinLimit(Buffer.byteLength(stream), maxBytes); + return Buffer.from(stream).toString('base64'); + } + + if (Buffer.isBuffer(stream)) { + ensureWithinLimit(stream.length, maxBytes); + return stream.toString('base64'); + } + + throw new Error('Unsupported body type for base64 conversion'); +} diff --git a/src/utils/streamToBuffer.ts b/src/utils/streamToBuffer.ts new file mode 100644 index 0000000..db4601d --- /dev/null +++ b/src/utils/streamToBuffer.ts @@ -0,0 +1,80 @@ +import { PassThrough } from 'stream'; +import { Buffer } from 'node:buffer'; +import { ReadableStream } from 'node:stream/web'; +import { MaxInlineBytesExceededError } from './streamToBase64.js'; + +type StreamToBufferOptions = { + maxBytes?: number; +}; + +function ensureWithinLimit(totalBytes: number, maxBytes?: number): void { + if (maxBytes !== undefined && totalBytes > maxBytes) { + throw new MaxInlineBytesExceededError(maxBytes, totalBytes); + } +} + +export async function streamToBuffer( + stream: PassThrough | ReadableStream | unknown, + options?: StreamToBufferOptions +): Promise { + const { maxBytes } = options ?? {}; + + if ( + typeof ReadableStream !== 'undefined' && + stream instanceof ReadableStream + ) { + const reader = (stream as ReadableStream).getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + totalBytes += value.length; + ensureWithinLimit(totalBytes, maxBytes); + } + + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); + } + + if ( + stream instanceof PassThrough || + (stream !== null && + typeof stream === 'object' && + 'on' in stream && + typeof (stream as { on: unknown }).on === 'function') + ) { + const chunks: Buffer[] = []; + const readable = stream as PassThrough; + let totalBytes = 0; + + return new Promise((resolve, reject) => { + readable.on('data', (chunk: Buffer) => { + const buffer = Buffer.from(chunk); + totalBytes += buffer.length; + try { + ensureWithinLimit(totalBytes, maxBytes); + } catch (error) { + readable.destroy(error as Error); + return; + } + chunks.push(buffer); + }); + readable.on('end', () => resolve(Buffer.concat(chunks))); + readable.on('error', reject); + }); + } + + if (typeof stream === 'string') { + const buffer = Buffer.from(stream); + ensureWithinLimit(buffer.length, maxBytes); + return buffer; + } + + if (Buffer.isBuffer(stream)) { + ensureWithinLimit(stream.length, maxBytes); + return stream; + } + + throw new Error('Unsupported body type for buffer conversion'); +}