From 73d816aa49d6cb2d3fe06158965e2952f74fd7bb Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Wed, 19 Aug 2026 19:42:04 +0100 Subject: [PATCH] mcp-didi: MCP para Open Notebook self-hosted (Didi) via REST + Bearer token --- .env.example | 2 + .gitignore | 4 + package-lock.json | 621 +++++++++++++++++ package.json | 28 + src/client.ts | 146 ++++ src/index.ts | 1652 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 16 + 7 files changed, 2469 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/client.ts create mode 100755 src/index.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6cad883 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DIDI_BASE_URL=https://didi.descomplicar.pt +DIDI_API_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd8fe26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.env diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a156fab --- /dev/null +++ b/package-lock.json @@ -0,0 +1,621 @@ +{ + "name": "mcp-didi", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-didi", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "dotenv": "^16.4.0", + "zod": "^4.2.0" + }, + "bin": { + "mcp-didi": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/dotenv": { + "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" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "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", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..22f77c0 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "mcp-didi", + "version": "1.0.0", + "description": "MCP server para a instância self-hosted do Open Notebook (\"Didi\", didi.descomplicar.pt) — REST + Bearer token: notebooks, sources, notes, search, chat, credenciais, modelos, transformações, embeddings.", + "type": "module", + "main": "dist/index.js", + "bin": { + "mcp-didi": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "dotenv": "^16.4.0", + "zod": "^4.2.0", + "@modelcontextprotocol/server": "^2.0.0" + }, + "devDependencies": { + "typescript": "^5.7.2", + "tsx": "^4.19.2", + "@types/node": "^22.10.2" + }, + "author": "Descomplicar®", + "license": "UNLICENSED", + "private": true +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..71333ba --- /dev/null +++ b/src/client.ts @@ -0,0 +1,146 @@ +/** + * Cliente REST mínimo para a API do Open Notebook self-hosted ("Didi", + * didi.descomplicar.pt) — Bearer token, JSON, base path `/api`. + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ + +const REQUEST_TIMEOUT_MS = 30_000; + +type QueryValue = string | number | boolean | undefined; +type Query = Record; + +/** Um erro Pydantic de validação (422), um item de `[{type,loc,msg,...}]`. */ +interface PydanticValidationError { + type?: string; + loc?: Array; + msg?: string; + [key: string]: unknown; +} + +/** + * Erro HTTP não-2xx da API do Open Notebook. Preserva o `detail` bruto + * (string, ou array de erros Pydantic num 422) para diagnóstico — nunca + * o engole, é a única forma de perceber um 422 de schema (ex. enum + * `provider` inválido). + */ +export class DidiApiError extends Error { + constructor( + public status: number, + public detail: unknown, + public path: string, + ) { + super(`Open Notebook API ${status} em ${path}: ${DidiApiError.formatDetail(detail)}`); + this.name = "DidiApiError"; + } + + private static formatDetail(detail: unknown): string { + if (detail === undefined || detail === null) return "sem detalhe"; + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) { + return detail + .map((e: PydanticValidationError) => { + if (e && typeof e === "object" && ("loc" in e || "msg" in e)) { + const loc = Array.isArray(e.loc) ? e.loc.join(".") : "?"; + return `[${loc}] ${e.msg ?? JSON.stringify(e)}`; + } + return JSON.stringify(e); + }) + .join("; "); + } + try { + return JSON.stringify(detail); + } catch { + return String(detail); + } + } +} + +function buildUrl(baseUrl: string, path: string, query?: Query): string { + const url = new URL(`${baseUrl.replace(/\/+$/, "")}/api${path}`); + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + } + return url.toString(); +} + +export class DidiClient { + constructor( + private readonly baseUrl: string, + private readonly token: string, + ) {} + + private async request( + method: "GET" | "POST" | "PUT" | "DELETE", + path: string, + opts?: { query?: Query; body?: unknown }, + ): Promise { + const url = buildUrl(this.baseUrl, path, opts?.query); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let res: Response; + try { + res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: controller.signal, + }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new DidiApiError(0, `Timeout após ${REQUEST_TIMEOUT_MS / 1000}s`, path); + } + throw new DidiApiError(0, err instanceof Error ? err.message : String(err), path); + } finally { + clearTimeout(timer); + } + + const raw = await res.text(); + let parsed: unknown = undefined; + if (raw.length > 0) { + try { + parsed = JSON.parse(raw); + } catch { + parsed = raw; + } + } + + if (!res.ok) { + const detail = + parsed && typeof parsed === "object" && "detail" in (parsed as Record) + ? (parsed as Record).detail + : parsed; + throw new DidiApiError(res.status, detail, path); + } + + return parsed as T; + } + + get(path: string, query?: Query): Promise { + return this.request("GET", path, { query }); + } + + post(path: string, body?: unknown, query?: Query): Promise { + return this.request("POST", path, { body, query }); + } + + put(path: string, body?: unknown): Promise { + return this.request("PUT", path, { body }); + } + + /** + * `query` é uma extensão ao contrato mínimo — alguns deletes da API + * precisam de query params (`delete_notebook?delete_exclusive_sources=`, + * `delete_credential?migrate_to=`). + */ + delete(path: string, query?: Query): Promise { + return this.request("DELETE", path, { query }); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100755 index 0000000..9ef491a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,1652 @@ +#!/usr/bin/env node +import "dotenv/config"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod/v4"; +import { DidiClient, DidiApiError } from "./client.js"; + +const baseUrl = process.env.DIDI_BASE_URL ?? "https://didi.descomplicar.pt"; +const token = process.env.DIDI_API_TOKEN; + +if (!token) { + console.error( + "DIDI_API_TOKEN em falta. Definir no .env (ver .env.example) ou no env do mcp.json — token = OPEN_NOTEBOOK_PASSWORD do serviço EasyPanel open-notebook.", + ); + process.exit(1); +} + +const didi = new DidiClient(baseUrl, token); + +const server = new McpServer( + { name: "didi", version: "1.0.0" }, + { capabilities: { tools: {}, resources: {}, prompts: {} } }, +); + +function errorResult(err: unknown) { + const message = err instanceof DidiApiError ? err.message : err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text" as const, text: `Erro: ${message}` }], + isError: true, + }; +} + +function jsonResult(data: unknown, summary: string) { + const isPlainObject = typeof data === "object" && data !== null && !Array.isArray(data); + return { + content: [{ type: "text" as const, text: `${summary}\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\`` }], + ...(isPlainObject ? { structuredContent: data as Record } : {}), + }; +} + +/** Remove chaves de valor `undefined` (mas preserva `null` explícito) — implementa a + * semântica de partial-update-por-presença da API (gotcha #5): só envia no corpo os + * campos que o utilizador do tool realmente forneceu. */ +function presentFields>(obj: T): Partial { + const out: Partial = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) { + (out as Record)[key] = value; + } + } + return out; +} + +const confirmSchema = z.literal("CONFIRMO").describe('Operação destrutiva — passar exactamente "CONFIRMO" para prosseguir.'); + +const PROVIDER_VALUES = [ + "openai", + "anthropic", + "google", + "groq", + "mistral", + "deepseek", + "xai", + "openrouter", + "dashscope", + "minimax", + "novita", + "ppq", + "cohere", + "voyage", + "elevenlabs", + "deepgram", + "ollama", + "omlx", + "azure", + "vertex", + "openai_compatible", + "anthropic_compatible", +] as const; + +const providerSchema = z + .enum(PROVIDER_VALUES) + .describe( + 'Provider (enum Pydantic estrito, underscore não hífen — ex. "openai_compatible", NUNCA "openai-compatible").', + ); + +const MODEL_TYPE_VALUES = ["language", "embedding", "text_to_speech", "speech_to_text"] as const; +const modelTypeSchema = z.enum(MODEL_TYPE_VALUES).describe("Tipo de modelo."); + +// --------------------------------------------------------------------------- +// 0. Meta / diagnóstico +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_get_config", + { + title: "Config e saúde da instância", + description: + "Lê versão instalada, se há update disponível (GitHub), e estado da base de dados (online/offline). Bom primeiro tool para confirmar que a instância está viva.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/config"); + return jsonResult(data, "Config Didi:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_get_auth_status", + { + title: "Estado de autenticação", + description: "Confirma se a instância exige password (OPEN_NOTEBOOK_PASSWORD) para aceder à API.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/auth/status"); + return jsonResult(data, "Estado de autenticação:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_get_capabilities", + { + title: "Runtimes de extracção disponíveis", + description: + "Reporta se Docling (OCR/documentos) e Crawl4AI (local ou remoto) estão disponíveis no container — usar antes de escolher engines de processamento avançados.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/capabilities"); + return jsonResult(data, "Capacidades de extracção:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_list_languages", + { + title: "Lista idiomas suportados", + description: "Lista os códigos de idioma BCP 47 (ex. pt-PT, en-US) suportados pela instância (usado em preferências de podcast/TTS).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/languages"); + return jsonResult(data, "Idiomas suportados:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_list_providers", + { + title: "Lista providers de IA suportados", + description: + "Lista o registo completo de providers de IA suportados (nome, nome de exibição, modalidades, docs_url, se está configurado via env vars).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/providers"); + return jsonResult(data, "Providers suportados:"); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 1. Settings +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_get_settings", + { + title: "Lê definições da aplicação", + description: + "Lê as definições globais: engine de processamento de documentos/URLs por omissão, opção de embedding por omissão, auto-delete de ficheiros, toggles Docling (OCR/fórmulas/visão), idiomas preferidos para YouTube.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/settings"); + return jsonResult(data, "Definições da aplicação:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_update_settings", + { + title: "Actualiza definições da aplicação", + description: "Actualiza uma ou mais definições globais. Só os campos fornecidos são alterados.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + default_content_processing_engine_doc: z.enum(["auto", "docling", "simple"]).optional(), + default_content_processing_engine_url: z.enum(["auto", "firecrawl", "jina", "crawl4ai", "simple"]).optional(), + default_embedding_option: z.enum(["ask", "always", "never"]).optional(), + auto_delete_files: z.enum(["yes", "no"]).optional(), + docling_ocr: z.boolean().optional(), + docling_formulas: z.boolean().optional(), + docling_vision: z.boolean().optional(), + youtube_preferred_languages: z.array(z.string()).optional(), + }), + }, + async (args) => { + try { + const data = await didi.put("/settings", presentFields(args)); + return jsonResult(data, "Definições actualizadas:"); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 2. Notebooks +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_notebook_list", + { + title: "Lista notebooks", + description: "Lista todos os notebooks, com contagem de sources/notes. Filtro opcional por arquivado; ordenação por name/created/updated.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + archived: z.boolean().optional().describe("Filtrar por estado arquivado."), + order_by: z.string().optional().default("updated desc").describe('Campo + direcção, ex. "name asc", "updated desc". Campos válidos: name, created, updated.'), + }), + }, + async ({ archived, order_by }) => { + try { + const data = await didi.get("/notebooks", { archived, order_by }); + return jsonResult(data, "Notebooks:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_notebook_list_recently_viewed", + { + title: "Notebooks e sources vistos recentemente", + description: "Lista os notebooks e sources vistos mais recentemente (write-on-read timestamp), ordenados por last_viewed_at desc.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + limit: z.number().int().min(1).max(50).optional().default(12), + }), + }, + async ({ limit }) => { + try { + const data = await didi.get("/recently-viewed", { limit }); + return jsonResult(data, "Vistos recentemente:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_notebook_get", + { + title: "Lê um notebook", + description: "Lê um notebook por ID, com contagem de sources/notes. Marca last_viewed_at.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ notebook_id: z.string() }), + }, + async ({ notebook_id }) => { + try { + const data = await didi.get(`/notebooks/${encodeURIComponent(notebook_id)}`); + return jsonResult(data, `Notebook "${notebook_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_notebook_create", + { + title: "Cria um notebook", + description: "Cria um novo notebook.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + name: z.string(), + description: z.string().optional().default(""), + }), + }, + async ({ name, description }) => { + try { + const data = await didi.post("/notebooks", { name, description }); + return jsonResult(data, "Notebook criado:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_notebook_update", + { + title: "Actualiza um notebook", + description: "Actualiza nome, descrição e/ou estado arquivado de um notebook. Só os campos fornecidos são alterados.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + notebook_id: z.string(), + name: z.string().optional(), + description: z.string().optional(), + archived: z.boolean().optional(), + }), + }, + async ({ notebook_id, ...rest }) => { + try { + const data = await didi.put(`/notebooks/${encodeURIComponent(notebook_id)}`, presentFields(rest)); + return jsonResult(data, `Notebook "${notebook_id}" actualizado:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_notebook_delete_preview", + { + title: "Pré-visualiza o que apagar de um notebook", + description: + "Conta quantas notas, sources exclusivas (só neste notebook) e sources partilhadas seriam afectadas por um delete deste notebook. Chamar sempre antes de didi_notebook_delete.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ notebook_id: z.string() }), + }, + async ({ notebook_id }) => { + try { + const data = await didi.get(`/notebooks/${encodeURIComponent(notebook_id)}/delete-preview`); + return jsonResult(data, `Preview de eliminação de "${notebook_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_notebook_delete", + { + title: "Apaga um notebook", + description: + "Apaga um notebook com cascade: apaga sempre todas as notas do notebook. Se delete_exclusive_sources=true, apaga também as sources que só pertencem a este notebook (as partilhadas com outros notebooks ficam só desligadas). Chamar didi_notebook_delete_preview antes para saber o impacto.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + notebook_id: z.string(), + delete_exclusive_sources: z.boolean().optional().default(false), + confirm: confirmSchema, + }), + }, + async ({ notebook_id, delete_exclusive_sources }) => { + try { + const data = await didi.delete(`/notebooks/${encodeURIComponent(notebook_id)}`, { delete_exclusive_sources }); + return jsonResult(data, `Notebook "${notebook_id}" apagado:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_notebook_add_source", + { + title: "Liga uma source existente a um notebook", + description: "Cria a referência (RELATE) entre uma source já existente e um notebook. Idempotente.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ notebook_id: z.string(), source_id: z.string() }), + }, + async ({ notebook_id, source_id }) => { + try { + const data = await didi.post( + `/notebooks/${encodeURIComponent(notebook_id)}/sources/${encodeURIComponent(source_id)}`, + ); + return jsonResult(data, `Source "${source_id}" ligada ao notebook "${notebook_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_notebook_remove_source", + { + title: "Desliga uma source de um notebook", + description: + "Remove a referência entre uma source e um notebook (a source em si NÃO é apagada, mesmo que fique sem nenhum notebook — usar didi_source_delete para isso).", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ notebook_id: z.string(), source_id: z.string() }), + }, + async ({ notebook_id, source_id }) => { + try { + const data = await didi.delete( + `/notebooks/${encodeURIComponent(notebook_id)}/sources/${encodeURIComponent(source_id)}`, + ); + return jsonResult(data, `Source "${source_id}" desligada do notebook "${notebook_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 3. Sources +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_source_list", + { + title: "Lista sources", + description: "Lista sources com paginação e ordenação. Filtro opcional por notebook.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + notebook_id: z.string().optional(), + limit: z.number().int().min(1).max(100).optional().default(50), + offset: z.number().int().min(0).optional().default(0), + sort_by: z.enum(["created", "updated", "title", "insights_count", "embedded", "type"]).optional().default("updated"), + sort_order: z.enum(["asc", "desc"]).optional().default("desc"), + }), + }, + async ({ notebook_id, limit, offset, sort_by, sort_order }) => { + try { + const data = await didi.get("/sources", { notebook_id, limit, offset, sort_by, sort_order }); + return jsonResult(data, "Sources:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_source_get", + { + title: "Lê uma source", + description: "Lê uma source por ID: texto completo, topics, asset (url/file_path), estado de embedding, notebooks associados, estado de processamento. Marca last_viewed_at.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ source_id: z.string() }), + }, + async ({ source_id }) => { + try { + const data = await didi.get(`/sources/${encodeURIComponent(source_id)}`); + return jsonResult(data, `Source "${source_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_source_add", + { + title: "Adiciona uma source (link ou texto)", + description: + 'Cria uma nova source do tipo "link" (URL — web/YouTube/PDF remoto, bloqueado contra SSRF) ou "text" (conteúdo colado directamente). Tipo "upload" (ficheiro local via multipart) NÃO é suportado por este tool — fase 2. Por omissão processa ASSINCRONAMENTE (async_processing=true): devolve logo um command_id/status "new" e o processamento corre em background — usar didi_source_get_status ou didi_get_job_status para acompanhar. Passar async_processing=false para esperar pelo resultado completo na mesma chamada (risco de timeout em sources grandes).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + type: z.enum(["link", "text"]), + url: z.string().optional().describe('Obrigatório quando type="link".'), + content: z.string().optional().describe('Obrigatório quando type="text".'), + title: z.string().optional(), + notebook_ids: z.array(z.string()).max(50).optional().describe("Notebooks a associar (0 ou mais)."), + transformation_ids: z.array(z.string()).max(50).optional().describe("Transformações a aplicar automaticamente após ingestão."), + embed: z.boolean().optional().default(false).describe("Gerar embeddings para pesquisa vectorial."), + async_processing: z.boolean().optional().default(true), + }), + }, + async ({ type, url, content, title, notebook_ids, transformation_ids, embed, async_processing }) => { + try { + const data = await didi.post("/sources/json", { + type, + url, + content, + title, + notebooks: notebook_ids, + transformations: transformation_ids, + embed, + async_processing, + }); + return jsonResult(data, "Source criada:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_source_update", + { + title: "Actualiza uma source", + description: "Actualiza título e/ou topics de uma source. Não reprocessa o conteúdo — usar didi_source_retry para isso.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + source_id: z.string(), + title: z.string().optional(), + topics: z.array(z.string()).optional(), + }), + }, + async ({ source_id, ...rest }) => { + try { + const data = await didi.put(`/sources/${encodeURIComponent(source_id)}`, presentFields(rest)); + return jsonResult(data, `Source "${source_id}" actualizada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_source_delete", + { + title: "Apaga uma source", + description: "Apaga uma source permanentemente (todos os notebooks que a referenciam perdem a ligação).", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ source_id: z.string(), confirm: confirmSchema }), + }, + async ({ source_id }) => { + try { + const data = await didi.delete(`/sources/${encodeURIComponent(source_id)}`); + return jsonResult(data, `Source "${source_id}" apagada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_source_get_status", + { + title: "Estado de processamento de uma source", + description: 'Lê o estado do processamento assíncrono de uma source (queued/running/completed/failed/unknown, ou "legacy source" se não tiver command associado).', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ source_id: z.string() }), + }, + async ({ source_id }) => { + try { + const data = await didi.get(`/sources/${encodeURIComponent(source_id)}/status`); + return jsonResult(data, `Estado de processamento de "${source_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_source_retry", + { + title: "Repete o processamento de uma source falhada", + description: "Volta a submeter o processamento de uma source (falhada ou bloqueada) usando o asset original (URL, ficheiro ou texto). Falha se já houver processamento em curso.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ source_id: z.string() }), + }, + async ({ source_id }) => { + try { + const data = await didi.post(`/sources/${encodeURIComponent(source_id)}/retry`); + return jsonResult(data, `Reprocessamento de "${source_id}" submetido:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_source_list_insights", + { + title: "Lista insights de uma source", + description: "Lista os insights (resumos, análises geradas por transformações) de uma source.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ source_id: z.string() }), + }, + async ({ source_id }) => { + try { + const data = await didi.get(`/sources/${encodeURIComponent(source_id)}/insights`); + return jsonResult(data, `Insights de "${source_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_source_create_insight", + { + title: "Gera um insight para uma source", + description: + "Inicia a geração de um insight aplicando uma transformação a uma source (processamento assíncrono via job — devolve 202 imediato com command_id). Poll com didi_get_job_status ou volte a chamar didi_source_list_insights.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + source_id: z.string(), + transformation_id: z.string(), + model_id: z.string().optional().describe("Usa o modelo por omissão da transformação se omitido."), + }), + }, + async ({ source_id, transformation_id, model_id }) => { + try { + const data = await didi.post(`/sources/${encodeURIComponent(source_id)}/insights`, { transformation_id, model_id }); + return jsonResult(data, `Geração de insight iniciada para "${source_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 4. Insights (standalone) +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_insight_get", + { + title: "Lê um insight", + description: "Lê um insight por ID (inclui o source_id de origem).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ insight_id: z.string() }), + }, + async ({ insight_id }) => { + try { + const data = await didi.get(`/insights/${encodeURIComponent(insight_id)}`); + return jsonResult(data, `Insight "${insight_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_insight_delete", + { + title: "Apaga um insight", + description: "Apaga um insight permanentemente.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ insight_id: z.string(), confirm: confirmSchema }), + }, + async ({ insight_id }) => { + try { + const data = await didi.delete(`/insights/${encodeURIComponent(insight_id)}`); + return jsonResult(data, `Insight "${insight_id}" apagado:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_insight_save_as_note", + { + title: "Converte um insight numa nota", + description: "Copia o conteúdo de um insight para uma nova nota, opcionalmente associada a um notebook.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + insight_id: z.string(), + notebook_id: z.string().optional(), + }), + }, + async ({ insight_id, notebook_id }) => { + try { + const data = await didi.post(`/insights/${encodeURIComponent(insight_id)}/save-as-note`, { notebook_id }); + return jsonResult(data, `Insight "${insight_id}" guardado como nota:`); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 5. Notes +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_note_list", + { + title: "Lista notas", + description: "Lista todas as notas, ou as de um notebook específico.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ notebook_id: z.string().optional() }), + }, + async ({ notebook_id }) => { + try { + const data = await didi.get("/notes", { notebook_id }); + return jsonResult(data, "Notas:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_note_get", + { + title: "Lê uma nota", + description: "Lê uma nota por ID.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ note_id: z.string() }), + }, + async ({ note_id }) => { + try { + const data = await didi.get(`/notes/${encodeURIComponent(note_id)}`); + return jsonResult(data, `Nota "${note_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_note_create", + { + title: "Cria uma nota", + description: + 'Cria uma nova nota. Se note_type="ai" e title for omitido, a API gera automaticamente um título a partir do conteúdo (chamada LLM extra).', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + content: z.string(), + title: z.string().optional(), + note_type: z.enum(["human", "ai"]).optional().default("human"), + notebook_id: z.string().optional(), + }), + }, + async (args) => { + try { + const data = await didi.post("/notes", args); + return jsonResult(data, "Nota criada:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_note_update", + { + title: "Actualiza uma nota", + description: "Actualiza título, conteúdo e/ou tipo de uma nota. Só os campos fornecidos são alterados.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + note_id: z.string(), + title: z.string().optional(), + content: z.string().optional(), + note_type: z.enum(["human", "ai"]).optional(), + }), + }, + async ({ note_id, ...rest }) => { + try { + const data = await didi.put(`/notes/${encodeURIComponent(note_id)}`, presentFields(rest)); + return jsonResult(data, `Nota "${note_id}" actualizada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_note_delete", + { + title: "Apaga uma nota", + description: "Apaga uma nota permanentemente.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ note_id: z.string(), confirm: confirmSchema }), + }, + async ({ note_id }) => { + try { + const data = await didi.delete(`/notes/${encodeURIComponent(note_id)}`); + return jsonResult(data, `Nota "${note_id}" apagada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 6. Search +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_search", + { + title: "Pesquisa na base de conhecimento", + description: + 'Pesquisa sources e/ou notas por texto (BM25) ou por vector (semântica — requer um embedding model configurado, ver didi_model_get_defaults). type="vector" ignora resultados abaixo de minimum_score.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + query: z.string(), + type: z.enum(["text", "vector"]).optional().default("text"), + limit: z.number().int().min(1).max(1000).optional().default(100), + search_sources: z.boolean().optional().default(true), + search_notes: z.boolean().optional().default(true), + minimum_score: z.number().min(0).max(1).optional().default(0.2).describe("Só aplicável a type=vector."), + }), + }, + async (args) => { + try { + const data = await didi.post("/search", args); + return jsonResult(data, `Resultados para "${args.query}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_ask", + { + title: "Pergunta à base de conhecimento (RAG, resposta única)", + description: + "Faz uma pergunta usando os 3 modelos configurados (estratégia de pesquisa, resposta por sub-pergunta, resposta final) e devolve a resposta completa numa única chamada (não-streaming — usa /search/ask/simple). Requer um embedding model configurado. Os 3 IDs de modelo podem ser obtidos com didi_model_list ou didi_model_get_defaults.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + question: z.string(), + strategy_model: z.string().describe("ID do modelo para desenhar a estratégia de pesquisa."), + answer_model: z.string().describe("ID do modelo para responder a cada sub-pesquisa."), + final_answer_model: z.string().describe("ID do modelo para compor a resposta final."), + }), + }, + async (args) => { + try { + const data = await didi.post("/search/ask/simple", args); + return jsonResult(data, `Resposta para "${args.question}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 7. Chat +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_chat_list_sessions", + { + title: "Lista sessões de chat de um notebook", + description: "Lista as sessões de chat associadas a um notebook, com contagem de mensagens.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ notebook_id: z.string() }), + }, + async ({ notebook_id }) => { + try { + const data = await didi.get("/chat/sessions", { notebook_id }); + return jsonResult(data, `Sessões de chat do notebook "${notebook_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_chat_create_session", + { + title: "Cria uma sessão de chat", + description: "Cria uma nova sessão de chat associada a um notebook.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + notebook_id: z.string(), + title: z.string().optional(), + model_override: z.string().optional().describe("ID de modelo a usar nesta sessão em vez do default_chat_model."), + }), + }, + async (args) => { + try { + const data = await didi.post("/chat/sessions", args); + return jsonResult(data, "Sessão de chat criada:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_chat_get_session", + { + title: "Lê uma sessão de chat", + description: "Lê uma sessão de chat com o histórico completo de mensagens.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ session_id: z.string() }), + }, + async ({ session_id }) => { + try { + const data = await didi.get(`/chat/sessions/${encodeURIComponent(session_id)}`); + return jsonResult(data, `Sessão de chat "${session_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_chat_update_session", + { + title: "Actualiza uma sessão de chat", + description: "Actualiza o título e/ou o model_override de uma sessão de chat.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + session_id: z.string(), + title: z.string().optional(), + model_override: z.string().optional(), + }), + }, + async ({ session_id, ...rest }) => { + try { + const data = await didi.put(`/chat/sessions/${encodeURIComponent(session_id)}`, presentFields(rest)); + return jsonResult(data, `Sessão de chat "${session_id}" actualizada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_chat_delete_session", + { + title: "Apaga uma sessão de chat", + description: "Apaga uma sessão de chat e o seu histórico.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ session_id: z.string(), confirm: confirmSchema }), + }, + async ({ session_id }) => { + try { + const data = await didi.delete(`/chat/sessions/${encodeURIComponent(session_id)}`); + return jsonResult(data, `Sessão de chat "${session_id}" apagada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_chat_build_context", + { + title: "Constrói o contexto de chat de um notebook", + description: + 'Monta o objecto de contexto (sources + notas seleccionadas, com short/full content conforme o status) a passar em didi_chat_send_message. context_config tem a forma {"sources": {"": status}, "notes": {"": status}}, onde status é uma string humana: para sources — "not in context" (ignora) | "insights" (contexto curto) | "full content" (texto completo); para notes só "full content" tem efeito (qualquer outro valor = ignora). Omitir context_config inclui tudo com contexto curto.', + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + notebook_id: z.string(), + context_config: z.record(z.string(), z.unknown()).optional(), + }), + }, + async ({ notebook_id, context_config }) => { + try { + const data = await didi.post("/chat/context", { notebook_id, context_config: context_config ?? {} }); + return jsonResult(data, `Contexto construído para o notebook "${notebook_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_chat_send_message", + { + title: "Envia uma mensagem numa sessão de chat", + description: + "Envia uma mensagem do utilizador para uma sessão de chat e devolve a lista completa de mensagens actualizada (chamada síncrona e bloqueante — não é streaming, por isso não há perda de conteúdo parcial). `context` é o objecto devolvido por didi_chat_build_context (ou {\"sources\":[],\"notes\":[]} para sem contexto).", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + session_id: z.string(), + message: z.string(), + context: z.record(z.string(), z.unknown()), + model_override: z.string().optional(), + }), + }, + async (args) => { + try { + const data = await didi.post("/chat/execute", args); + return jsonResult(data, `Resposta na sessão "${args.session_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 8. Credentials +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_credential_status", + { + title: "Estado de configuração de credenciais", + description: "Lê o estado da chave de encriptação (OPEN_NOTEBOOK_ENCRYPTION_KEY) e, por provider, se está configurado e de onde vem (base de dados/env/nenhum).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/credentials/status"); + return jsonResult(data, "Estado de credenciais:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_env_status", + { + title: "Estado de credenciais via variáveis de ambiente", + description: "Verifica o que está configurado via variáveis de ambiente do container (candidatas a migração para a base de dados).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/credentials/env-status"); + return jsonResult(data, "Estado via env vars:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_list", + { + title: "Lista credenciais", + description: "Lista todas as credenciais configuradas (NUNCA inclui api_key em claro, só has_api_key). Filtro opcional por provider.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ provider: z.string().optional() }), + }, + async ({ provider }) => { + try { + const data = await didi.get("/credentials", { provider }); + return jsonResult(data, "Credenciais:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_get", + { + title: "Lê uma credencial", + description: "Lê uma credencial por ID (NUNCA devolve api_key em claro, só has_api_key).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ credential_id: z.string() }), + }, + async ({ credential_id }) => { + try { + const data = await didi.get(`/credentials/${encodeURIComponent(credential_id)}`); + return jsonResult(data, `Credencial "${credential_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_create", + { + title: "Cria uma credencial", + description: + 'Cria uma nova credencial de provider de IA. Requer OPEN_NOTEBOOK_ENCRYPTION_KEY configurada no servidor. provider="anthropic_compatible" exige base_url + api_key. num_ctx só é usado por provider="ollama".', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + name: z.string(), + provider: providerSchema, + modalities: z.array(z.string()).optional().default([]).describe("language, embedding, text_to_speech, speech_to_text."), + api_key: z.string().optional(), + base_url: z.string().optional(), + endpoint: z.string().optional().describe("Azure."), + api_version: z.string().optional().describe("Azure."), + endpoint_llm: z.string().optional(), + endpoint_embedding: z.string().optional(), + endpoint_stt: z.string().optional(), + endpoint_tts: z.string().optional(), + project: z.string().optional().describe("Vertex."), + location: z.string().optional().describe("Vertex."), + credentials_path: z.string().optional().describe("Vertex — path para o JSON da service account."), + num_ctx: z.number().int().optional().describe("Ollama — janela de contexto, default 8192."), + }), + }, + async (args) => { + try { + const data = await didi.post("/credentials", args); + return jsonResult(data, "Credencial criada:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_update", + { + title: "Actualiza uma credencial", + description: + "Actualiza uma credencial existente — partial-update por PRESENÇA do campo, não por valor: um campo omitido fica intocado; passar `null` explícito limpa esse campo. NUNCA passar null nos campos que não se quer alterar.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + credential_id: z.string(), + name: z.string().optional(), + modalities: z.array(z.string()).optional(), + api_key: z.string().optional(), + base_url: z.string().nullable().optional(), + endpoint: z.string().nullable().optional(), + api_version: z.string().nullable().optional(), + endpoint_llm: z.string().nullable().optional(), + endpoint_embedding: z.string().nullable().optional(), + endpoint_stt: z.string().nullable().optional(), + endpoint_tts: z.string().nullable().optional(), + project: z.string().nullable().optional(), + location: z.string().nullable().optional(), + credentials_path: z.string().nullable().optional(), + num_ctx: z.number().int().nullable().optional(), + }), + }, + async ({ credential_id, ...rest }) => { + try { + const data = await didi.put(`/credentials/${encodeURIComponent(credential_id)}`, presentFields(rest)); + return jsonResult(data, `Credencial "${credential_id}" actualizada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_delete", + { + title: "Apaga uma credencial", + description: + "Apaga uma credencial. Se tiver modelos ligados: sem migrate_to, esses modelos são apagados em cascata; com migrate_to=, são reatribuídos a essa outra credencial.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + credential_id: z.string(), + migrate_to: z.string().optional(), + confirm: confirmSchema, + }), + }, + async ({ credential_id, migrate_to }) => { + try { + const data = await didi.delete(`/credentials/${encodeURIComponent(credential_id)}`, { migrate_to }); + return jsonResult(data, `Credencial "${credential_id}" apagada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_test", + { + title: "Testa a ligação de uma credencial", + description: "Testa a ligação usando a configuração de uma credencial (chama o provider real).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ credential_id: z.string() }), + }, + async ({ credential_id }) => { + try { + const data = await didi.post(`/credentials/${encodeURIComponent(credential_id)}/test`); + return jsonResult(data, `Teste da credencial "${credential_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_discover_models", + { + title: "Descobre modelos disponíveis via uma credencial", + description: "Consulta a API do provider (usando a API key da credencial) para listar os modelos disponíveis, sem os registar. Usar didi_credential_register_models a seguir para os registar.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ credential_id: z.string() }), + }, + async ({ credential_id }) => { + try { + const data = await didi.post(`/credentials/${encodeURIComponent(credential_id)}/discover`); + return jsonResult(data, `Modelos descobertos para a credencial "${credential_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_credential_register_models", + { + title: "Regista modelos descobertos numa credencial", + description: "Regista uma lista de modelos (de didi_credential_discover_models ou manuais) e liga-os a esta credencial.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + credential_id: z.string(), + models: z + .array( + z.object({ + name: z.string(), + provider: z.string(), + model_type: modelTypeSchema, + }), + ) + .min(1), + }), + }, + async ({ credential_id, models }) => { + try { + const data = await didi.post(`/credentials/${encodeURIComponent(credential_id)}/register-models`, { models }); + return jsonResult(data, `Modelos registados para a credencial "${credential_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 9. Models +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_model_list", + { + title: "Lista modelos configurados", + description: "Lista os modelos registados. Filtro opcional por tipo (language, embedding, text_to_speech, speech_to_text).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ type: modelTypeSchema.optional() }), + }, + async ({ type }) => { + try { + const data = await didi.get("/models", { type }); + return jsonResult(data, "Modelos configurados:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_model_create", + { + title: "Regista um modelo manualmente", + description: "Regista um modelo manualmente (nome + provider + tipo), opcionalmente ligado a uma credencial. Falha se já existir um modelo com o mesmo nome/provider/tipo.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + name: z.string(), + provider: z.string(), + type: modelTypeSchema, + credential: z.string().optional().describe("ID da credencial a ligar."), + }), + }, + async (args) => { + try { + const data = await didi.post("/models", args); + return jsonResult(data, "Modelo registado:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_model_delete", + { + title: "Apaga um modelo", + description: "Apaga um modelo registado.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ model_id: z.string(), confirm: confirmSchema }), + }, + async ({ model_id }) => { + try { + const data = await didi.delete(`/models/${encodeURIComponent(model_id)}`); + return jsonResult(data, `Modelo "${model_id}" apagado:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_model_test", + { + title: "Testa um modelo", + description: "Testa se um modelo está correctamente configurado e funcional (chamada real ao provider).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ model_id: z.string() }), + }, + async ({ model_id }) => { + try { + const data = await didi.post(`/models/${encodeURIComponent(model_id)}/test`); + return jsonResult(data, `Teste do modelo "${model_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_model_get_defaults", + { + title: "Lê os modelos por omissão", + description: + "Lê os slots de modelo por omissão: chat, transformation, large_context, TTS, STT, embedding, tools. Só chat e embedding são obrigatórios — os restantes, se vazios, caem para o chat model.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/models/defaults"); + return jsonResult(data, "Modelos por omissão:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_model_set_defaults", + { + title: "Define os modelos por omissão", + description: + "Atribui/reatribui slots de modelo por omissão — partial-update por PRESENÇA do campo, não por valor: um campo omitido fica intocado; `null` explícito limpa esse slot (excepto default_chat_model e default_embedding_model, que são obrigatórios e nunca podem ficar null, só reatribuídos). NUNCA passar null nos campos que não se quer alterar.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + default_chat_model: z.string().optional().describe("Obrigatório — não pode ser limpo, só reatribuído."), + default_transformation_model: z.string().nullable().optional(), + large_context_model: z.string().nullable().optional(), + default_text_to_speech_model: z.string().nullable().optional(), + default_speech_to_text_model: z.string().nullable().optional(), + default_embedding_model: z.string().optional().describe("Obrigatório — não pode ser limpo, só reatribuído."), + default_tools_model: z.string().nullable().optional(), + }), + }, + async (args) => { + try { + const data = await didi.put("/models/defaults", presentFields(args)); + return jsonResult(data, "Modelos por omissão actualizados:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_model_auto_assign_defaults", + { + title: "Auto-atribui os modelos por omissão obrigatórios", + description: + "Atribui automaticamente o melhor modelo disponível (por prioridade de provider) aos slots OBRIGATÓRIOS ainda vazios (default_chat_model, default_embedding_model). Slots opcionais (transformation/tools/large_context/TTS/STT) são intencionalmente ignorados mesmo vazios — caem para o chat model por omissão.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.post("/models/auto-assign"); + return jsonResult(data, "Auto-atribuição de modelos:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_model_provider_availability", + { + title: "Disponibilidade de providers", + description: "Lista providers disponíveis/indisponíveis (credencial na base de dados OU variável de ambiente) e os tipos de modelo suportados por cada um disponível.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/models/providers"); + return jsonResult(data, "Disponibilidade de providers:"); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 10. Transformations +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_transformation_list", + { + title: "Lista transformações", + description: "Lista todas as transformações configuradas (prompts reutilizáveis aplicados a sources/insights).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/transformations"); + return jsonResult(data, "Transformações:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_transformation_get", + { + title: "Lê uma transformação", + description: "Lê uma transformação por ID.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ transformation_id: z.string() }), + }, + async ({ transformation_id }) => { + try { + const data = await didi.get(`/transformations/${encodeURIComponent(transformation_id)}`); + return jsonResult(data, `Transformação "${transformation_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_transformation_create", + { + title: "Cria uma transformação", + description: "Cria uma nova transformação (nome interno, título de exibição, descrição, prompt, se aplica por omissão a novas sources, modelo por omissão).", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + name: z.string(), + title: z.string(), + description: z.string(), + prompt: z.string(), + apply_default: z.boolean().optional().default(false), + model_id: z.string().optional(), + }), + }, + async (args) => { + try { + const data = await didi.post("/transformations", args); + return jsonResult(data, "Transformação criada:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_transformation_update", + { + title: "Actualiza uma transformação", + description: "Actualiza uma transformação existente. Só os campos fornecidos são alterados; model_id aceita null explícito para limpar (usa o modelo global por omissão).", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ + transformation_id: z.string(), + name: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + prompt: z.string().optional(), + apply_default: z.boolean().optional(), + model_id: z.string().nullable().optional(), + }), + }, + async ({ transformation_id, ...rest }) => { + try { + const data = await didi.put(`/transformations/${encodeURIComponent(transformation_id)}`, presentFields(rest)); + return jsonResult(data, `Transformação "${transformation_id}" actualizada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_transformation_delete", + { + title: "Apaga uma transformação", + description: "Apaga uma transformação.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ transformation_id: z.string(), confirm: confirmSchema }), + }, + async ({ transformation_id }) => { + try { + const data = await didi.delete(`/transformations/${encodeURIComponent(transformation_id)}`); + return jsonResult(data, `Transformação "${transformation_id}" apagada:`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_transformation_execute", + { + title: "Executa uma transformação num texto", + description: "Executa uma transformação directamente sobre um texto arbitrário (sem precisar de uma source), devolvendo o resultado de imediato.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + transformation_id: z.string(), + input_text: z.string(), + model_id: z.string().optional().describe("Usa o modelo por omissão da transformação, ou o global, se omitido."), + }), + }, + async (args) => { + try { + const data = await didi.post("/transformations/execute", args); + return jsonResult(data, `Resultado da transformação "${args.transformation_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_transformation_get_default_prompt", + { + title: "Lê as instruções por omissão de transformação", + description: "Lê o texto de instruções aplicado por omissão a todas as transformações.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({}), + }, + async () => { + try { + const data = await didi.get("/transformations/default-prompt"); + return jsonResult(data, "Instruções por omissão:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_transformation_set_default_prompt", + { + title: "Actualiza as instruções por omissão de transformação", + description: "Substitui o texto de instruções aplicado por omissão a todas as transformações.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ transformation_instructions: z.string() }), + }, + async (args) => { + try { + const data = await didi.put("/transformations/default-prompt", args); + return jsonResult(data, "Instruções por omissão actualizadas:"); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 11. Embedding +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_embed_content", + { + title: "Gera embeddings para uma source ou nota", + description: "Submete uma source ou nota para geração de embeddings (pesquisa vectorial). Requer um embedding model configurado.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + item_id: z.string(), + item_type: z.enum(["source", "note"]), + async_processing: z.boolean().optional().default(false), + }), + }, + async (args) => { + try { + const data = await didi.post("/embed", args); + return jsonResult(data, `Embedding submetido para ${args.item_type} "${args.item_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_rebuild_embeddings", + { + title: "Reconstrói embeddings em massa", + description: + 'Inicia um job em background para reconstruir embeddings. mode="existing" só reprocessa itens que já têm embeddings; mode="all" embebe tudo. Devolve command_id — acompanhar com didi_get_rebuild_status.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: z.object({ + mode: z.enum(["existing", "all"]), + include_sources: z.boolean().optional().default(true), + include_notes: z.boolean().optional().default(true), + include_insights: z.boolean().optional().default(true), + }), + }, + async (args) => { + try { + const data = await didi.post("/rebuild", args); + return jsonResult(data, "Reconstrução de embeddings iniciada:"); + } catch (err) { + return errorResult(err); + } + }, +); + +server.registerTool( + "didi_get_rebuild_status", + { + title: "Estado de uma reconstrução de embeddings", + description: "Lê o progresso (processados/total/percentagem) e estatísticas por tipo (sources/notas/insights/falhas) de um job de reconstrução de embeddings.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ command_id: z.string() }), + }, + async ({ command_id }) => { + try { + const data = await didi.get(`/rebuild/${encodeURIComponent(command_id)}/status`); + return jsonResult(data, `Estado da reconstrução "${command_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +// --------------------------------------------------------------------------- +// 12. Jobs (genérico) +// --------------------------------------------------------------------------- + +server.registerTool( + "didi_get_job_status", + { + title: "Estado de um job em background", + description: + "Lê o estado genérico de qualquer job/command assíncrono (processamento de source, geração de insight, embedding, etc.) pelo seu job_id/command_id.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: z.object({ job_id: z.string() }), + }, + async ({ job_id }) => { + try { + const data = await didi.get(`/commands/jobs/${encodeURIComponent(job_id)}`); + return jsonResult(data, `Estado do job "${job_id}":`); + } catch (err) { + return errorResult(err); + } + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..53b4083 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +}