diff --git a/Dockerfile b/Dockerfile index 32fd769..cfc1209 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ RUN npm run build # Expose HTTP port EXPOSE 8080 +# entrypoint to run the MCP server +ENTRYPOINT ["node", "build/app.js"] # Default command to run the MCP server with HTTP transport -# Use ENTRYPOINT + CMD for flexibility -ENTRYPOINT ["node", "build/index.js"] -CMD ["--transport", "stdio", "--port", "8080"] \ No newline at end of file +CMD ["--transport", "http", "--port", "8080"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d38ee5d --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: help +.DEFAULT_GOAL := help + +# initialise .env file for all targets if it exists +-include .env + +help: ## Show this help. + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +# Document each target that should appear in help using a comment placed after the target's colon, starting with '##' (as shown above). +# Targets without such a comment will not appear in the help output. + +run-inspector: ## Run the MCP inspector. + @npx @modelcontextprotocol/inspector -e DISCORD_TOKEN=$(DISCORD_TOKEN) diff --git a/README.md b/README.md index f25072f..d6ada56 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,9 @@ You can use Docker containers with both Claude and Cursor: - `discord_create_text_channel`: Create a text channel - `discord_delete_channel`: Delete a channel +- `discord_create_category`: Create a channel category +- `discord_edit_category`: Edit a channel category +- `discord_delete_category`: Delete a channel category ### Forum Functions diff --git a/package-lock.json b/package-lock.json index 3dc3105..63de826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,15 +42,15 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.2.tgz", - "integrity": "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.12.2.tgz", + "integrity": "sha512-AugKmrgRJOHXEyMkABH/hXpAmIBKbYokCEl9VAM4Kh6FvkdobQ+Zhv7AR6K+y5hS7+VQ7gKXPYCe1JQmV07H1g==", "license": "Apache-2.0", "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.1", + "discord-api-types": "^0.38.26", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -87,9 +87,9 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.5.0.tgz", - "integrity": "sha512-PWhchxTzpn9EV3vvPRpwS0EE2rNYB9pvzDU/eLLW3mByJl0ZHZjHI2/wA8EbH2gRMQV7nu+0FoDF84oiPl8VAQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", @@ -97,10 +97,10 @@ "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.1", + "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.21.3" }, "engines": { "node": ">=18" @@ -134,13 +134,13 @@ } }, "node_modules/@discordjs/ws": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.2.tgz", - "integrity": "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.0", + "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", @@ -179,9 +179,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -197,15 +197,17 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", + "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==", "license": "MIT", "dependencies": { + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", @@ -279,9 +281,9 @@ "license": "MIT" }, "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, "license": "MIT", "dependencies": { @@ -300,9 +302,9 @@ } }, "node_modules/@types/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", - "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", "dependencies": { @@ -312,9 +314,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", - "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", "dev": true, "license": "MIT", "dependencies": { @@ -325,9 +327,9 @@ } }, "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "dev": true, "license": "MIT" }, @@ -339,18 +341,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.50", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.50.tgz", - "integrity": "sha512-Mxiq0ULv/zo1OzOhwPqOA13I81CV/W3nvd3ChtQZRT5Cwz3cr0FKo/wMSsbTqL3EXpaBAEQhva2B8ByRkOIh9A==", + "version": "20.19.22", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.22.tgz", + "integrity": "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==", "license": "MIT", + "peer": true, "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true, "license": "MIT" }, @@ -362,26 +365,36 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", + "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", + "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", "@types/node": "*", - "@types/send": "*" + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" } }, "node_modules/@types/ws": { @@ -394,9 +407,9 @@ } }, "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", - "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -417,9 +430,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -442,6 +455,22 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -529,9 +558,9 @@ } }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -581,9 +610,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -617,33 +646,33 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.8", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.8.tgz", - "integrity": "sha512-xuRXPD44FcbKHrQK15FS1HFlMRNJtsaZou/SVws18vQ7zHqmlxyDktMkZpyvD6gE2ctGOVYC/jUyoMMAyBWfcw==", + "version": "0.38.30", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.30.tgz", + "integrity": "sha512-KhAqlBrg+rVK+Ob7INMF5o63yW4/GUzRatG/AjyVsIO8lgcLyR8qCl2HokIVzWwmzkJYG0CEPXsKMOqau3E8NA==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, "node_modules/discord.js": { - "version": "14.19.3", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.19.3.tgz", - "integrity": "sha512-lncTRk0k+8Q5D3nThnODBR8fR8x2fM798o8Vsr40Krx0DjPwpZCuxxTcFMrXMQVOqM1QB9wqWgaXPg3TbmlHqA==", + "version": "14.23.2", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.23.2.tgz", + "integrity": "sha512-tU2NFr823X3TXEc8KyR/4m296KLxPai4nirN3q9kHCpY4TKj96n9lHZnyLzRNMui8EbL07jg9hgH2PWWfKMGIg==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.11.2", + "@discordjs/builders": "^1.12.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", - "@discordjs/rest": "^2.5.0", + "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.1.1", - "@discordjs/ws": "^1.2.2", + "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.1", + "discord-api-types": "^0.38.29", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.21.3" }, "engines": { "node": ">=18" @@ -653,9 +682,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -739,21 +768,21 @@ } }, "node_modules/eventsource": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.5.tgz", - "integrity": "sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", "dependencies": { - "eventsource-parser": "^3.0.0" + "eventsource-parser": "^3.0.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/eventsource-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", - "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -764,6 +793,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -802,9 +832,9 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", "engines": { "node": ">= 16" @@ -813,7 +843,7 @@ "url": "https://github.com/sponsors/express-rate-limit" }, "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" + "express": ">= 4.11" } }, "node_modules/fast-deep-equal": { @@ -822,6 +852,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -955,6 +991,15 @@ "node": ">= 0.8" } }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -994,6 +1039,12 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -1146,12 +1197,13 @@ } }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", - "engines": { - "node": ">=16" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pkce-challenge": { @@ -1176,6 +1228,15 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1201,18 +1262,34 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.6.3", + "iconv-lite": "0.7.0", "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/router": { @@ -1394,9 +1471,9 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -1482,11 +1559,12 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1496,18 +1574,18 @@ } }, "node_modules/undici": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", - "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/unpipe": { @@ -1519,6 +1597,15 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -1557,9 +1644,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -1588,18 +1675,19 @@ } }, "node_modules/zod": { - "version": "3.25.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.13.tgz", - "integrity": "sha512-Q8mvk2iWi7rTDfpQBsu4ziE7A6AxgzJ5hzRyRYQkoV3A3niYsXVwDaP1Kbz3nWav6S+VZ6k2OznFn8ZyDHvIrg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", "license": "ISC", "peerDependencies": { "zod": "^3.24.1" diff --git a/package.json b/package.json index d82d2b6..183399c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "test": "echo \"Error: no test specified\" && exit 1", "build": "tsc", "start": "node build/index.js", + "start-app": "node build/app.js", "dev": "node --loader ts-node/esm src/index.ts", + "dev-app": "node --loader ts-node/esm src/app.ts", "test-api": "node test-api.js", "prepublishOnly": "npm run build" }, diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..b2012a5 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,344 @@ +import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { randomUUID } from 'crypto'; +import { Client, GatewayIntentBits } from 'discord.js'; +import { config as dotenvConfig } from 'dotenv'; +import express, { Request, Response } from 'express'; +import { info, Level, setLevel, warning } from './notifications.js'; +import * as schemas from './schemas.js'; +import * as handlers from './tools/tools.js'; +import { createToolContext } from './tools/tools.js'; + +// Load environment variables from .env file if exists +dotenvConfig(); + +/** + * Create and return a configured MCP server wired with the Discord client. + * The server registers tools programmatically using the handlers and schemas + * exported from the local modules to avoid a large switch statement. + */ +function createMcpServer(client: Client) { + const server = new McpServer( + { name: 'discord-mcp-server', version: '1.0.0' }, + { capabilities: { tools: {}, logging: {} } } + ); + // attach the Discord client to the tool context for use in handlers + const toolContext = createToolContext(client); + // attach the Discord client to the server for logging purposes + // The underlying Server type from the SDK doesn't declare a `client` property, + // so assert to `any` to attach a runtime-only property used by handlers. + (client as any).server = server.server; + (server as any).discord = client; + + // Map of tool name to handler + schema. Keep this small and explicit so it's + // easy to see which handler is wired to which tool. Use the exported + // Zod schemas from `schemas.ts` where available. + const toolMap: Array<{ name: string; schema?: any; handler: any }> = [ + { name: 'discord_login', schema: schemas.DiscordLoginSchema, handler: handlers.loginHandler }, + { name: 'discord_list_servers', schema: schemas.ListServersSchema, handler: handlers.listServersHandler }, + { name: 'discord_send', schema: schemas.SendMessageSchema, handler: handlers.sendMessageHandler }, + { name: 'discord_get_server_info', schema: schemas.GetServerInfoSchema, handler: handlers.getServerInfoHandler }, + { name: 'discord_create_text_channel', schema: schemas.CreateTextChannelSchema, handler: handlers.createTextChannelHandler }, + { name: 'discord_delete_channel', schema: schemas.DeleteChannelSchema, handler: handlers.deleteChannelHandler }, + { name: 'discord_get_forum_channels', schema: schemas.GetForumChannelsSchema, handler: handlers.getForumChannelsHandler }, + { name: 'discord_create_forum_post', schema: schemas.CreateForumPostSchema, handler: handlers.createForumPostHandler }, + { name: 'discord_get_forum_post', schema: schemas.GetForumPostSchema, handler: handlers.getForumPostHandler }, + { name: 'discord_reply_to_forum', schema: schemas.ReplyToForumSchema, handler: handlers.replyToForumHandler }, + { name: 'discord_delete_forum_post', schema: schemas.DeleteForumPostSchema, handler: handlers.deleteForumPostHandler }, + { name: 'discord_search_messages', schema: schemas.SearchMessagesSchema, handler: handlers.searchMessagesHandler }, + { name: 'discord_read_messages', schema: schemas.ReadMessagesSchema, handler: handlers.readMessagesHandler }, + { name: 'discord_add_reaction', schema: schemas.AddReactionSchema, handler: handlers.addReactionHandler }, + { name: 'discord_add_multiple_reactions', schema: schemas.AddMultipleReactionsSchema, handler: handlers.addMultipleReactionsHandler }, + { name: 'discord_remove_reaction', schema: schemas.RemoveReactionSchema, handler: handlers.removeReactionHandler }, + { name: 'discord_delete_message', schema: schemas.DeleteMessageSchema, handler: handlers.deleteMessageHandler }, + { name: 'discord_create_webhook', schema: schemas.CreateWebhookSchema, handler: handlers.createWebhookHandler }, + { name: 'discord_send_webhook_message', schema: schemas.SendWebhookMessageSchema, handler: handlers.sendWebhookMessageHandler }, + { name: 'discord_edit_webhook', schema: schemas.EditWebhookSchema, handler: handlers.editWebhookHandler }, + { name: 'discord_delete_webhook', schema: schemas.DeleteWebhookSchema, handler: handlers.deleteWebhookHandler }, + { name: 'discord_create_category', schema: schemas.CreateCategorySchema, handler: handlers.createCategoryHandler }, + { name: 'discord_edit_category', schema: schemas.EditCategorySchema, handler: handlers.editCategoryHandler }, + { name: 'discord_delete_category', schema: schemas.DeleteCategorySchema, handler: handlers.deleteCategoryHandler }, + ]; + + // Register each tool on the MCP server + for (const t of toolMap) { + try { + server.tool( + t.name, + t.schema ? t.schema.description ?? '' : '', + t.schema ? t.schema.shape ?? t.schema : undefined, async (args: any) => { + // Handlers follow the signature: (args, context) => Promise + return await t.handler(args, toolContext); + }); + } catch (err) { + // If a particular tool registration fails, log and continue (keeps boot resilient) + // eslint-disable-next-line no-console + warning(server.server, `Failed to register tool ${t.name}: ${String(err)}`); + } + } + + // Handle logging method separately + server.server.setRequestHandler(SetLevelRequestSchema, async (request) => { + const levelParam = request.params.level as Level | undefined; + const ok = setLevel(levelParam || 'info'); + // Empty result on success, error on failure + if (!ok) { + throw { code: -32602, message: 'Invalid log level' }; + } + return {}; + }); + + return server; +} + +// Helper to create a Discord client with sensible defaults used by the CLI +function createDiscordClient(token?: string) { + const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] }); + if (token) { + client.token = token; + // Auto-login on startup if token is available + (async () => { + try { + await client.login(token); + info((client as any).server, 'Successfully logged in to Discord'); + } catch (err: any) { + if (typeof err.message === 'string' && err.message.includes('Privileged intent provided is not enabled or whitelisted')) { + warning((client as any).server, 'Discord login failed due to missing privileged intents. Please enable the required intents in the Discord Developer Portal.'); + } else { + warning((client as any).server, 'Discord login failed: ' + String(err)); + } + } + })(); + } + let clientStatusInterval: NodeJS.Timeout | null = null; + + function logClientState(context: string) { + // only log if server is attached and connected + if (!(client as any).server || !(client as any).server.transport) { + return; + } + info((client as any).server, `Discord client state [${context}]: ${JSON.stringify({ + isReady: client.isReady(), + hasToken: !!client.token, + user: client.user ? { + id: client.user.id, + tag: client.user.tag, + } : null + })}`); + } + clientStatusInterval = setInterval(() => { + logClientState("periodic check"); + }, 10000); + client.on('ready', () => { + logClientState("on ready event"); + }); + client.on('disconnect', () => { + logClientState("on disconnect event"); + if (clientStatusInterval) { + clearInterval(clientStatusInterval); + clientStatusInterval = null; + } + }); + return client; +} + +// Configuration from command-line arguments or environment variables +const config = { + DISCORD_TOKEN: (() => { + try { + // First check for command-line argument + const tokenIndex = process.argv.indexOf('--config'); + if (tokenIndex !== -1 && tokenIndex + 1 < process.argv.length) { + const configArg = process.argv[tokenIndex + 1]; + if (typeof configArg === 'string') { + try { + const parsedConfig = JSON.parse(configArg); + return parsedConfig.DISCORD_TOKEN; + } catch (err) { + // If not valid JSON, try using the string directly + return configArg; + } + } + } + // Then try environment variable + return process.env.DISCORD_TOKEN; + } catch (err) { + process.stderr.write('Error parsing configuration: ' + String(err) + '\n'); + return null; + } + })(), + TRANSPORT: (() => { + // Check for transport type argument + const transportIndex = process.argv.indexOf('--transport'); + if (transportIndex !== -1 && transportIndex + 1 < process.argv.length) { + return process.argv[transportIndex + 1]; + } + // Default to stdio + return 'stdio'; + })(), + HTTP_PORT: (() => { + // Check for port argument + const portIndex = process.argv.indexOf('--port'); + if (portIndex !== -1 && portIndex + 1 < process.argv.length) { + return parseInt(process.argv[portIndex + 1]); + } + // Default port for MCP + return 8080; + })() +}; + +const discord = createDiscordClient(config.DISCORD_TOKEN); +const server = createMcpServer(discord); + +const app = express(); +app.use(express.json()); + +// region MCP Streamable HTTP Transport Handlers +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string; + if (sessionId) { + process.stderr.write(`Received MCP request for session: ${sessionId}\n`); + } else { + process.stderr.write('Request body:' + JSON.stringify(req.body) + '\n'); + } + + try { + let transport: StreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New session initialization + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, + enableJsonResponse: true, // Enable JSON response mode + onsessioninitialized: sessionId => { + // Store the transport by session ID for future requests + transports[sessionId] = transport; + process.stderr.write(`Initialized new MCP session: ${sessionId}\n`); + } + }); + + // Set up onclose handler to clean up transport + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + process.stderr.write(`Closed and removed MCP session: ${sid}\n`); + } + }; + + // Connect the transport to the MCP server before handling the request + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + return; + } else { + // Invalid request without session ID + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Invalid or missing MCP session ID." }, + id: req.body.id || null + }); + return; + } + + // Handle the request with the existing transport - no need to reconnect + await transport.handleRequest(req, res, req.body); + } catch (err) { + process.stderr.write('Error handling MCP request: ' + String(err) + '\n'); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Internal server error." }, + id: req.body.id || null + }); + } + } +}; +// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) +const mcpGetHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + process.stderr.write(`Client reconnecting with Last-Event-ID: ${lastEventId}\n`); + } else { + process.stderr.write(`Establishing new SSE stream for session ${sessionId}\n`); + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}; +// Handle DELETE requests for session termination (according to MCP spec) +const mcpDeleteHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + process.stderr.write(`Received session termination request for session ${sessionId}\n`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + process.stderr.write('Error handling session termination:' + String(error) + '\n'); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}; +// endregion + +// Handle both Stdio and HTTP transports based on configuration +if (config.TRANSPORT.toLowerCase() === 'http') { + // HTTP transport endpoints + app.post('/mcp', mcpPostHandler); + app.get('/mcp', mcpGetHandler); + app.delete('/mcp', mcpDeleteHandler); + + const port = config.HTTP_PORT || 8080; + app.listen(port, error => { + if (error) { + process.stderr.write('Error starting MCP HTTP server: ' + String(error) + '\n'); + process.exit(1); + } + process.stderr.write(`MCP Streamable HTTP Server listening on port ${port}\n`); + }); + + // Handle server shutdown + process.on('SIGINT', async () => { + process.stderr.write('Shutting down server...\n'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + process.stderr.write(`Closing transport for session ${sessionId}\n`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + process.stderr.write(`Error closing transport for session ${sessionId}:` + String(error) + '\n'); + } + } + process.stderr.write('Server shutdown complete\n'); + process.exit(0); + }); +} else { + // Stdio transport + const transport = new StdioServerTransport(); + await server.connect(transport); + process.stderr.write('MCP Stdio Server started. Awaiting messages...\n'); + +} diff --git a/src/notifications.ts b/src/notifications.ts new file mode 100644 index 0000000..a4d5d16 --- /dev/null +++ b/src/notifications.ts @@ -0,0 +1,45 @@ +import { Server } from '@modelcontextprotocol/sdk/server' + +export type Level = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency'; + +const levelPriority: Record = { + debug: 0, + info: 1, + notice: 2, + warning: 3, + error: 4, + critical: 5, + alert: 6, + emergency: 7, +}; + +export let currentLevel: Level = 'info'; + +export function setLevel(level: Level) { + if (!levelPriority.hasOwnProperty(level)) return false; + currentLevel = level; + return true; +} + +export function log(server: Server, message: string, level: Level = 'info') { + if (levelPriority[level] < levelPriority[currentLevel]) return; + server.sendLoggingMessage( + { + level: level, + logger: 'mcp-discord', + data: { text: message }, + } + ); +} + +export function info(server: Server, message: string) { + log(server, message, 'info'); +} + +export function warning(server: Server, message: string) { + log(server, message, 'warning'); +} + +export function error(server: Server, message: string) { + log(server, message, 'error'); +} diff --git a/src/schemas.ts b/src/schemas.ts index 08f6a52..454c1f2 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,28 +1,38 @@ import { z } from "zod"; export const DiscordLoginSchema = z.object({ - token: z.string().optional() + token: z.string({ description: "The bot token to use for login." }).optional() +}, { + description: "Login to Discord using a bot token. If no token is provided, the bot will attempt to use the token from the environment variable DISCORD_TOKEN." }); export const SendMessageSchema = z.object({ - channelId: z.string(), - message: z.string(), - replyToMessageId: z.string().optional() + channelId: z.string({ description: "The ID of the channel to send the message to." }), + message: z.string({ description: "The content of the message to send." }), + replyToMessageId: z.string({ description: "The ID of the message to reply to, if any." }).optional() +}, { + description: "Send a message to a specified channel, optionally as a reply to another message." }); export const GetForumChannelsSchema = z.object({ - guildId: z.string() + guildId: z.string({ description: "The ID of the server (guild) to get forum channels from." }) +}, { + description: "Get all forum channels in a specified server (guild)." }); export const CreateForumPostSchema = z.object({ - forumChannelId: z.string(), - title: z.string(), - content: z.string(), - tags: z.array(z.string()).optional() + forumChannelId: z.string({ description: "The ID of the forum channel where the thread will be created." }), + title: z.string({ description: "The title of the forum post (thread)." }), + content: z.string({ description: "The body content of the forum post." }), + tags: z.array(z.string({ description: "A tag to attach to the forum post." })).optional() +}, { + description: "Create a new forum post (thread) in a specified forum channel." }); export const GetForumPostSchema = z.object({ - threadId: z.string() + threadId: z.string({ description: "The ID of the forum thread to retrieve." }) +}, { + description: "Get details of a specific forum post (thread) by its ID." }); export const ListForumThreadsSchema = z.object({ @@ -32,128 +42,166 @@ export const ListForumThreadsSchema = z.object({ }); export const ReplyToForumSchema = z.object({ - threadId: z.string(), - message: z.string() + threadId: z.string({ description: "The ID of the forum thread to reply to." }), + message: z.string({ description: "The content of the reply message." }) +}, { + description: "Reply to a specific forum post (thread) by its ID." }); export const CreateTextChannelSchema = z.object({ - guildId: z.string(), - channelName: z.string(), - topic: z.string().optional(), - reason: z.string().optional() + guildId: z.string({ description: "The ID of the server (guild) where the text channel will be created." }), + channelName: z.string({ description: "The name for the new text channel." }), + topic: z.string({ description: "The (optional) topic/description for the channel." }).optional(), + reason: z.string({ description: "Optional reason for audit logs when creating the channel." }).optional() +}, { + description: "Create a new text channel in a specified server (guild)." }); // Category schemas export const CreateCategorySchema = z.object({ - guildId: z.string(), - name: z.string(), - position: z.number().optional(), - reason: z.string().optional() + guildId: z.string({ description: "The ID of the server (guild) where the category will be created." }), + name: z.string({ description: "The name of the category to create." }), + position: z.number({ description: "Optional sorting position index for the category." }).optional(), + reason: z.string({ description: "Optional reason for audit logs when creating the category." }).optional() +}, { + description: "Create a new category in a specified server (guild)." }); export const EditCategorySchema = z.object({ - categoryId: z.string(), - name: z.string().optional(), - position: z.number().optional(), - reason: z.string().optional() + categoryId: z.string({ description: "The ID of the category to edit." }), + name: z.string({ description: "New name for the category (optional)." }).optional(), + position: z.number({ description: "New position index for the category (optional)." }).optional(), + reason: z.string({ description: "Optional reason for audit logs when editing the category." }).optional() +}, { + description: "Edit an existing category's properties." }); export const DeleteCategorySchema = z.object({ - categoryId: z.string(), - reason: z.string().optional() + categoryId: z.string({ description: "The ID of the category to delete." }), + reason: z.string({ description: "Optional reason for audit logs when deleting the category." }).optional() +}, { + description: "Delete a category by its ID." }); export const DeleteChannelSchema = z.object({ - channelId: z.string(), - reason: z.string().optional() + channelId: z.string({ description: "The ID of the channel to delete." }), + reason: z.string({ description: "Optional reason for audit logs when deleting the channel." }).optional() +}, { + description: "Delete a channel by its ID." }); export const ReadMessagesSchema = z.object({ - channelId: z.string(), - limit: z.number().min(1).max(100).optional().default(50) + channelId: z.string({ description: "The ID of the channel to read messages from." }), + limit: z.number({ description: "How many recent messages to fetch (1-100)." }).min(1).max(100).optional().default(50) +}, { + description: "Read recent messages from a specified channel." }); export const GetServerInfoSchema = z.object({ - guildId: z.string() + guildId: z.string({ description: "The ID of the server (guild) to get information for." }) +}, { + description: "Get information about a specific server (guild) by its ID." }); export const AddReactionSchema = z.object({ - channelId: z.string(), - messageId: z.string(), - emoji: z.string() + channelId: z.string({ description: "The ID of the channel containing the message to react to." }), + messageId: z.string({ description: "The ID of the message to add a reaction to." }), + emoji: z.string({ description: "The emoji to use for the reaction (unicode or custom)." }) +}, { + description: "Add a reaction to a specific message in a channel." }); export const AddMultipleReactionsSchema = z.object({ - channelId: z.string(), - messageId: z.string(), - emojis: z.array(z.string()) + channelId: z.string({ description: "The ID of the channel containing the message to react to." }), + messageId: z.string({ description: "The ID of the message to add reactions to." }), + emojis: z.array(z.string({ description: "An emoji to add (unicode or custom)." })) +}, { + description: "Add multiple reactions to a specific message in a channel." }); export const RemoveReactionSchema = z.object({ - channelId: z.string(), - messageId: z.string(), - emoji: z.string(), - userId: z.string().optional() + channelId: z.string({ description: "The ID of the channel containing the message to modify reactions on." }), + messageId: z.string({ description: "The ID of the message to remove the reaction from." }), + emoji: z.string({ description: "The emoji reaction to remove." }), + userId: z.string({ description: "Optional ID of the user whose reaction should be removed; if omitted, removes the current bot's reaction." }).optional() +}, { + description: "Remove a reaction from a specific message in a channel." }); export const DeleteForumPostSchema = z.object({ - threadId: z.string(), - reason: z.string().optional() + threadId: z.string({ description: "The ID of the forum thread to delete." }), + reason: z.string({ description: "Optional reason for audit logs when deleting the forum post." }).optional() +}, { + description: "Delete a forum post (thread) by its ID." }); export const DeleteMessageSchema = z.object({ - channelId: z.string(), - messageId: z.string(), - reason: z.string().optional() + channelId: z.string({ description: "The ID of the channel containing the message to delete." }), + messageId: z.string({ description: "The ID of the message to delete." }), + reason: z.string({ description: "Optional reason for audit logs when deleting the message." }).optional() +}, { + description: "Delete a message by its ID in a specified channel." }); export const CreateWebhookSchema = z.object({ - channelId: z.string(), - name: z.string(), - avatar: z.string().optional(), - reason: z.string().optional() + channelId: z.string({ description: "The ID of the channel to create the webhook in." }), + name: z.string({ description: "The name to assign to the webhook." }), + avatar: z.string({ description: "Optional avatar URL or data for the webhook." }).optional(), + reason: z.string({ description: "Optional reason for audit logs when creating the webhook." }).optional() +}, { + description: "Create a webhook in a specified channel." }); export const SendWebhookMessageSchema = z.object({ - webhookId: z.string(), - webhookToken: z.string(), - content: z.string(), - username: z.string().optional(), - avatarURL: z.string().optional(), - threadId: z.string().optional() + webhookId: z.string({ description: "The ID of the webhook to send the message with." }), + webhookToken: z.string({ description: "The token for the webhook (used for authentication)." }), + content: z.string({ description: "The message content to send via the webhook." }), + username: z.string({ description: "Optional username to display for the webhook message." }).optional(), + avatarURL: z.string({ description: "Optional avatar URL to display for the webhook message." }).optional(), + threadId: z.string({ description: "Optional ID of the thread to post the webhook message into." }).optional() +}, { + description: "Send a message using a webhook." }); export const EditWebhookSchema = z.object({ - webhookId: z.string(), - webhookToken: z.string().optional(), - name: z.string().optional(), - avatar: z.string().optional(), - channelId: z.string().optional(), - reason: z.string().optional() + webhookId: z.string({ description: "The ID of the webhook to edit." }), + webhookToken: z.string({ description: "Optional token for the webhook if required to authorize edits." }).optional(), + name: z.string({ description: "Optional new name for the webhook." }).optional(), + avatar: z.string({ description: "Optional new avatar URL or data for the webhook." }).optional(), + channelId: z.string({ description: "Optional channel ID to move the webhook to." }).optional(), + reason: z.string({ description: "Optional reason for audit logs when editing the webhook." }).optional() +}, { + description: "Edit a webhook's properties." }); export const DeleteWebhookSchema = z.object({ - webhookId: z.string(), - webhookToken: z.string().optional(), - reason: z.string().optional() + webhookId: z.string({ description: "The ID of the webhook to delete." }), + webhookToken: z.string({ description: "Optional token for the webhook if required for deletion." }).optional(), + reason: z.string({ description: "Optional reason for audit logs when deleting the webhook." }).optional() +}, { + description: "Delete a webhook by its ID and token." }); -export const ListServersSchema = z.object({}); +export const ListServersSchema = z.object({}, { + description: "List all servers (guilds) the bot is a member of." +}); export const SearchMessagesSchema = z.object({ - guildId: z.string().min(1, "guildId is required"), - // Optional filters - content: z.string().optional(), - authorId: z.string().optional(), - mentions: z.string().optional(), - has: z.enum(['link','embed','file','poll','image','video','sound','sticker','snapshot']).optional(), - maxId: z.string().optional(), - minId: z.string().optional(), - channelId: z.string().optional(), - pinned: z.boolean().optional(), - authorType: z.enum(['user','bot','webhook']).optional(), - sortBy: z.enum(['timestamp','relevance']).optional(), - sortOrder: z.enum(['desc','asc']).optional(), - limit: z.number().min(1).max(100).default(25).optional(), - offset: z.number().min(0).default(0).optional() -}); \ No newline at end of file + guildId: z.string({ description: "The ID of the server (guild) to search in." }).min(1, "guildId is required"), + // Optional filters + content: z.string({ description: "Search for messages that contain this text." }).optional(), + authorId: z.string({ description: "Filter messages to those authored by this user ID." }).optional(), + mentions: z.string({ description: "Filter messages that mention a specific user or role ID." }).optional(), + has: z.enum(['link', 'embed', 'file', 'poll', 'image', 'video', 'sound', 'sticker', 'snapshot'], { description: "Filter messages that contain a specific type of content." }).optional(), + maxId: z.string({ description: "Only include messages with IDs less than or equal to this (pagination)." }).optional(), + minId: z.string({ description: "Only include messages with IDs greater than or equal to this (pagination)." }).optional(), + channelId: z.string({ description: "If provided, restrict search to a specific channel ID." }).optional(), + pinned: z.boolean({ description: "If true, only include pinned messages; if false, only include unpinned; if omitted, include both." }).optional(), + authorType: z.enum(['user', 'bot', 'webhook'], { description: "Filter by the type of author (user, bot, or webhook)." }).optional(), + sortBy: z.enum(['timestamp', 'relevance'], { description: "Field to sort search results by." }).optional(), + sortOrder: z.enum(['desc', 'asc'], { description: "Sort direction for results." }).optional(), + limit: z.number({ description: "Number of results to return (1-100)." }).min(1).max(100).default(25).optional(), + offset: z.number({ description: "Number of results to skip (for pagination)." }).min(0).default(0).optional() +}, { + description: "Search messages in a server with various filters." +});