Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ce23cfa6e | ||
|
|
d7cd47267a | ||
|
|
6bd101cab4 | ||
|
|
3b46f15e9f | ||
|
|
6cfff5ef4b | ||
|
|
be295c8f05 | ||
|
|
bc5b3572df | ||
|
|
812e36d0af | ||
|
|
efeb580e46 | ||
|
|
685492b8a2 | ||
|
|
ab985c8fc1 | ||
|
|
464d66d82e |
@@ -14,6 +14,7 @@ FROM deps AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
COPY build.js ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// build.js
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
|
||||||
|
build({
|
||||||
|
entryPoints: ['src/index.ts'],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node22',
|
||||||
|
format: 'esm',
|
||||||
|
outfile: 'dist/index.js',
|
||||||
|
sourcemap: true,
|
||||||
|
packages: 'external',
|
||||||
|
}).catch(() => process.exit(1));
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
users:
|
||||||
|
- id: anton
|
||||||
|
calendars:
|
||||||
|
- uri: https://c.aggtaa.com/
|
||||||
|
username: anton
|
||||||
|
password: hP81HfY3a4UTEnaTVZWaZJ6bL6BvMYL2
|
||||||
+6
-5
@@ -3,11 +3,12 @@ services:
|
|||||||
image: ${REGISTRY}/${IMAGE_PREFIX}/app:latest
|
image: ${REGISTRY}/${IMAGE_PREFIX}/app:latest
|
||||||
container_name: ${SERVICE_NAME}
|
container_name: ${SERVICE_NAME}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
labels:
|
||||||
- "${APP_PORT:-3000}:3000"
|
caddy: mcp.aggtaa.com
|
||||||
|
caddy.reverse_proxy: "{{upstreams 3000}}"
|
||||||
networks:
|
networks:
|
||||||
- local
|
- caddy_default
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
local:
|
caddy_default:
|
||||||
driver: bridge
|
external: true
|
||||||
+11
-3
@@ -4,11 +4,11 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc",
|
||||||
|
"build": "tsc && node build.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -18,15 +18,23 @@
|
|||||||
"pnpm": ">=11.4.0 <12"
|
"pnpm": ">=11.4.0 <12"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"types": "node",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
"@modelcontextprotocol/sdk": "^1.13.1",
|
||||||
|
"fast-xml-parser": "^5.9.3",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
|
"yaml": "^2.9.0",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
"zod-to-json-schema": "^3.25.2"
|
"zod-to-json-schema": "^3.25.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/luxon": "^3.7.2",
|
||||||
|
"@types/node": "^22.20.0",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
|
|||||||
Generated
+113
-1
@@ -207,12 +207,27 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@fastify/cors':
|
||||||
|
specifier: ^11.2.0
|
||||||
|
version: 11.2.0
|
||||||
'@modelcontextprotocol/sdk':
|
'@modelcontextprotocol/sdk':
|
||||||
specifier: ^1.13.1
|
specifier: ^1.13.1
|
||||||
version: 1.29.0(zod@3.25.76)
|
version: 1.29.0(zod@3.25.76)
|
||||||
|
fast-xml-parser:
|
||||||
|
specifier: ^5.9.3
|
||||||
|
version: 5.9.3
|
||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.9.0
|
version: 5.9.0
|
||||||
|
ical.js:
|
||||||
|
specifier: ^2.2.1
|
||||||
|
version: 2.2.1
|
||||||
|
luxon:
|
||||||
|
specifier: ^3.7.2
|
||||||
|
version: 3.7.2
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.9.0
|
||||||
|
version: 2.9.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.2
|
specifier: ^3.24.2
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -223,9 +238,15 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.21.0
|
specifier: ^9.21.0
|
||||||
version: 9.39.4
|
version: 9.39.4
|
||||||
|
'@types/luxon':
|
||||||
|
specifier: ^3.7.2
|
||||||
|
version: 3.7.2
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.13.5
|
specifier: ^22.20.0
|
||||||
version: 22.20.0
|
version: 22.20.0
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.28.1
|
||||||
|
version: 0.28.1
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.21.0
|
specifier: ^9.21.0
|
||||||
version: 9.39.4
|
version: 9.39.4
|
||||||
@@ -441,6 +462,9 @@ packages:
|
|||||||
'@fastify/ajv-compiler@4.0.5':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||||
|
|
||||||
|
'@fastify/cors@11.2.0':
|
||||||
|
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||||
|
|
||||||
'@fastify/error@4.2.0':
|
'@fastify/error@4.2.0':
|
||||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
@@ -492,6 +516,9 @@ packages:
|
|||||||
'@cfworker/json-schema':
|
'@cfworker/json-schema':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nodable/entities@2.2.0':
|
||||||
|
resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@@ -501,6 +528,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/luxon@3.7.2':
|
||||||
|
resolution: {integrity: sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==}
|
||||||
|
|
||||||
'@types/node@22.20.0':
|
'@types/node@22.20.0':
|
||||||
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
|
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
|
||||||
|
|
||||||
@@ -598,6 +628,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
anynum@1.0.1:
|
||||||
|
resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
|
||||||
|
|
||||||
argparse@2.0.1:
|
argparse@2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
@@ -847,6 +880,16 @@ packages:
|
|||||||
fast-uri@3.1.2:
|
fast-uri@3.1.2:
|
||||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||||
|
|
||||||
|
fast-xml-builder@1.2.1:
|
||||||
|
resolution: {integrity: sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==}
|
||||||
|
|
||||||
|
fast-xml-parser@5.9.3:
|
||||||
|
resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
fastify-plugin@5.1.0:
|
||||||
|
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||||
|
|
||||||
fastify@5.9.0:
|
fastify@5.9.0:
|
||||||
resolution: {integrity: sha512-VMS5lE0zj+MZlJpQa3Qv5iGjfun0H2N7VRgoBwpcTNQ2bdIQpv7fDpb+HGteGbicBsGkzGS+X+hdx9mmrfWuHQ==}
|
resolution: {integrity: sha512-VMS5lE0zj+MZlJpQa3Qv5iGjfun0H2N7VRgoBwpcTNQ2bdIQpv7fDpb+HGteGbicBsGkzGS+X+hdx9mmrfWuHQ==}
|
||||||
|
|
||||||
@@ -944,6 +987,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
ical.js@2.2.1:
|
||||||
|
resolution: {integrity: sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==}
|
||||||
|
|
||||||
iconv-lite@0.7.2:
|
iconv-lite@0.7.2:
|
||||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -990,6 +1036,9 @@ packages:
|
|||||||
is-promise@4.0.0:
|
is-promise@4.0.0:
|
||||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||||
|
|
||||||
|
is-unsafe@1.0.1:
|
||||||
|
resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==}
|
||||||
|
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
@@ -1039,6 +1088,10 @@ packages:
|
|||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
|
luxon@3.7.2:
|
||||||
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1122,6 +1175,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
path-expression-matcher@1.6.1:
|
||||||
|
resolution: {integrity: sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
path-key@3.1.1:
|
path-key@3.1.1:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1293,6 +1350,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
strnum@2.4.1:
|
||||||
|
resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1370,6 +1430,15 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
xml-naming@0.1.0:
|
||||||
|
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
||||||
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
yaml@2.9.0:
|
||||||
|
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||||
|
engines: {node: '>= 14.6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
yocto-queue@0.1.0:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1514,6 +1583,11 @@ snapshots:
|
|||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 3.1.2
|
fast-uri: 3.1.2
|
||||||
|
|
||||||
|
'@fastify/cors@11.2.0':
|
||||||
|
dependencies:
|
||||||
|
fastify-plugin: 5.1.0
|
||||||
|
toad-cache: 3.7.1
|
||||||
|
|
||||||
'@fastify/error@4.2.0': {}
|
'@fastify/error@4.2.0': {}
|
||||||
|
|
||||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||||
@@ -1573,12 +1647,16 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@nodable/entities@2.2.0': {}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/luxon@3.7.2': {}
|
||||||
|
|
||||||
'@types/node@22.20.0':
|
'@types/node@22.20.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -1709,6 +1787,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
color-convert: 2.0.1
|
color-convert: 2.0.1
|
||||||
|
|
||||||
|
anynum@1.0.1: {}
|
||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
@@ -2005,6 +2085,22 @@ snapshots:
|
|||||||
|
|
||||||
fast-uri@3.1.2: {}
|
fast-uri@3.1.2: {}
|
||||||
|
|
||||||
|
fast-xml-builder@1.2.1:
|
||||||
|
dependencies:
|
||||||
|
path-expression-matcher: 1.6.1
|
||||||
|
xml-naming: 0.1.0
|
||||||
|
|
||||||
|
fast-xml-parser@5.9.3:
|
||||||
|
dependencies:
|
||||||
|
'@nodable/entities': 2.2.0
|
||||||
|
fast-xml-builder: 1.2.1
|
||||||
|
is-unsafe: 1.0.1
|
||||||
|
path-expression-matcher: 1.6.1
|
||||||
|
strnum: 2.4.1
|
||||||
|
xml-naming: 0.1.0
|
||||||
|
|
||||||
|
fastify-plugin@5.1.0: {}
|
||||||
|
|
||||||
fastify@5.9.0:
|
fastify@5.9.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/ajv-compiler': 4.0.5
|
'@fastify/ajv-compiler': 4.0.5
|
||||||
@@ -2119,6 +2215,8 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
ical.js@2.2.1: {}
|
||||||
|
|
||||||
iconv-lite@0.7.2:
|
iconv-lite@0.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -2150,6 +2248,8 @@ snapshots:
|
|||||||
|
|
||||||
is-promise@4.0.0: {}
|
is-promise@4.0.0: {}
|
||||||
|
|
||||||
|
is-unsafe@1.0.1: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
jose@6.2.3: {}
|
jose@6.2.3: {}
|
||||||
@@ -2195,6 +2295,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
@@ -2262,6 +2364,8 @@ snapshots:
|
|||||||
|
|
||||||
path-exists@4.0.0: {}
|
path-exists@4.0.0: {}
|
||||||
|
|
||||||
|
path-expression-matcher@1.6.1: {}
|
||||||
|
|
||||||
path-key@3.1.1: {}
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
@@ -2451,6 +2555,10 @@ snapshots:
|
|||||||
|
|
||||||
strip-json-comments@5.0.3: {}
|
strip-json-comments@5.0.3: {}
|
||||||
|
|
||||||
|
strnum@2.4.1:
|
||||||
|
dependencies:
|
||||||
|
anynum: 1.0.1
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
@@ -2519,6 +2627,10 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
xml-naming@0.1.0: {}
|
||||||
|
|
||||||
|
yaml@2.9.0: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zod-to-json-schema@3.25.2(zod@3.25.76):
|
zod-to-json-schema@3.25.2(zod@3.25.76):
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// src/config/index.ts
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const ConfigSchema = z.object({
|
||||||
|
users: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
calendars: z.array(z.object({
|
||||||
|
uri: z.string().url(),
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppConfig = z.infer<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
function loadConfig(): AppConfig {
|
||||||
|
const path = `config.yaml`;
|
||||||
|
|
||||||
|
const raw = readFileSync(path, "utf-8");
|
||||||
|
const parsed = parse(raw);
|
||||||
|
|
||||||
|
const result = ConfigSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("Invalid config:", result.error.format());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = loadConfig();
|
||||||
+28
-6
@@ -2,7 +2,9 @@ import { FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { ToolDef } from "./types";
|
import { McpRequestMeta, TextResult, ToolDef } from "./types";
|
||||||
|
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol";
|
||||||
|
import { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types";
|
||||||
|
|
||||||
export default class McpSession {
|
export default class McpSession {
|
||||||
private readonly transport: StreamableHTTPServerTransport;
|
private readonly transport: StreamableHTTPServerTransport;
|
||||||
@@ -26,7 +28,7 @@ export default class McpSession {
|
|||||||
|
|
||||||
private buildMcpServer(): McpServer {
|
private buildMcpServer(): McpServer {
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{ name: "mcp-ping-server", version: "1.0.0" },
|
{ name: "home-mcp-server", version: "1.0.0" },
|
||||||
{ capabilities: { tools: { listChanged: true } } },
|
{ capabilities: { tools: { listChanged: true } } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ export default class McpSession {
|
|||||||
await this.mcp.connect(this.transport);
|
await this.mcp.connect(this.transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerTools(...tools: ToolDef[]) {
|
private registerTools(...tools: ToolDef<unknown, TextResult>[]) {
|
||||||
const toolsToRegister = tools
|
const toolsToRegister = tools
|
||||||
.filter((tool) => !this.registeredTools.find((rtool) => rtool.title === tool.title));
|
.filter((tool) => !this.registeredTools.find((rtool) => rtool.title === tool.title));
|
||||||
const toolsToUnregister = this.registeredTools
|
const toolsToUnregister = this.registeredTools
|
||||||
@@ -53,13 +55,16 @@ export default class McpSession {
|
|||||||
...toolsToRegister
|
...toolsToRegister
|
||||||
.map((tool) => this.mcp.registerTool(
|
.map((tool) => this.mcp.registerTool(
|
||||||
tool.title,
|
tool.title,
|
||||||
tool.config,
|
{
|
||||||
tool.handler,
|
inputSchema: {},
|
||||||
|
...tool.config,
|
||||||
|
},
|
||||||
|
(args, extra) => tool.handler(args, McpSession.parseRequestMeta(extra), extra),
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleToolListChange(tools: ToolDef[], sendToolListChangedNotification: boolean = true): void {
|
public handleToolListChange(tools: ToolDef<unknown, TextResult>[], sendToolListChangedNotification: boolean = true): void {
|
||||||
this.registerTools(...tools);
|
this.registerTools(...tools);
|
||||||
if (sendToolListChangedNotification)
|
if (sendToolListChangedNotification)
|
||||||
this.mcp.sendToolListChanged();
|
this.mcp.sendToolListChanged();
|
||||||
@@ -69,4 +74,21 @@ export default class McpSession {
|
|||||||
reply.hijack();
|
reply.hijack();
|
||||||
await this.transport.handleRequest(request.raw, reply.raw, request.body);
|
await this.transport.handleRequest(request.raw, reply.raw, request.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static parseRequestMeta(extra: RequestHandlerExtra<ServerRequest, ServerNotification>): McpRequestMeta {
|
||||||
|
|
||||||
|
const meta = extra._meta?.['com.aggtaa'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
env: {
|
||||||
|
user: {
|
||||||
|
username: 'fake',
|
||||||
|
},
|
||||||
|
language: meta as string,
|
||||||
|
location: {
|
||||||
|
timezone: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { CaldavServerConnection, compareCalendarEvents, RecurrenceFreqValues } from "../utils/caldavServerConnection";
|
||||||
|
import { config } from '../config';
|
||||||
|
import z from "zod";
|
||||||
|
import { formatCalendarEvent, formatCalendarEvents } from "../utils/formatCalendarEvent";
|
||||||
|
import { FormattedCalendarEvent, FormattedCalendarEventSchema } from "../types/calendarEvent";
|
||||||
|
import { CalendarInfoSchema } from "../types/calendar";
|
||||||
|
import { server } from "../server";
|
||||||
|
|
||||||
|
function getCaldavConnection(): CaldavServerConnection {
|
||||||
|
const calendarConfig = config.users.find((u) => u.id === 'anton')?.calendars[0];
|
||||||
|
if (!calendarConfig)
|
||||||
|
throw new Error('User not found');
|
||||||
|
|
||||||
|
return new CaldavServerConnection(
|
||||||
|
calendarConfig.uri,
|
||||||
|
{ username: calendarConfig.username, password: calendarConfig.password },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "caldav-list-calendars",
|
||||||
|
config: {
|
||||||
|
description: "List all calendars available to user",
|
||||||
|
outputSchema: {
|
||||||
|
calendars: z.array(CalendarInfoSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async () => {
|
||||||
|
const calendars = await getCaldavConnection().listCalendars();
|
||||||
|
return { calendars };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "caldav-get-calendar-events-for-date-range",
|
||||||
|
config: {
|
||||||
|
description: "Get calendar events for date range",
|
||||||
|
inputSchema: {
|
||||||
|
calendarUrl: z.string().describe('Target calendar url. Requires prior call to caldav-list-calendars to obtain'),
|
||||||
|
start: z.coerce.date().describe('Range start, date or datetime in ISO format'),
|
||||||
|
end: z.coerce.date().describe('Range end, date or datetime in ISO format'),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
events: z.array(FormattedCalendarEventSchema),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async ({ calendarUrl, start, end }, meta) => {
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const events =
|
||||||
|
(await conn.getCalendarEventsForDateRange(calendarUrl as string, start, end))
|
||||||
|
.map((e) => formatCalendarEvent(meta.env, e));
|
||||||
|
return { events }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "caldav-get-all-calendars-events-for-date-range",
|
||||||
|
config: {
|
||||||
|
description: "Get events for date range from all calendars available to user",
|
||||||
|
inputSchema: {
|
||||||
|
start: z.coerce.date().describe('Range start, date or datetime in ISO format'),
|
||||||
|
end: z.coerce.date().describe('Range end, date or datetime in ISO format'),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
events: z.array(FormattedCalendarEventSchema),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async ({ start, end }, meta) => {
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const events: FormattedCalendarEvent[] = [];
|
||||||
|
for (const calendar of await conn.listCalendars())
|
||||||
|
events.push(...formatCalendarEvents(meta.env, await conn.getCalendarEventsForDateRange(calendar.url, start, end)));
|
||||||
|
events.sort((a, b) => compareCalendarEvents(a.raw, b.raw));
|
||||||
|
return { events }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "caldav-get-calendar-events-for-today",
|
||||||
|
config: {
|
||||||
|
description: "Get calendar events for today",
|
||||||
|
inputSchema: {
|
||||||
|
calendarUrl: z.string().describe('Target calendar url. Requires prior call to caldav-list-calendars to obtain'),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
events: z.array(FormattedCalendarEventSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async ({ calendarUrl }, meta) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const events = formatCalendarEvents(meta.env, await conn.getCalendarEventsForDateRange(calendarUrl, today, today));
|
||||||
|
return { events };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "caldav-get-all-calendars-events-for-today",
|
||||||
|
config: {
|
||||||
|
description: "Get events for today from all calendars available to user",
|
||||||
|
outputSchema: {
|
||||||
|
events: z.array(FormattedCalendarEventSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (_, meta) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const events: FormattedCalendarEvent[] = [];
|
||||||
|
for (const calendar of await conn.listCalendars())
|
||||||
|
events.push(...(await conn.getCalendarEventsForDateRange(calendar.url, today, today)).map((e) => formatCalendarEvent(meta.env, e)));
|
||||||
|
events.sort((a, b) => compareCalendarEvents(a.raw, b.raw));
|
||||||
|
return { events };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "caldav-get-calendar-event-by-id",
|
||||||
|
config: {
|
||||||
|
description: "Lookup calendar event by id",
|
||||||
|
inputSchema: {
|
||||||
|
calendarUrl: z.string().describe('Target calendar url. Requires prior call to caldav-list-calendars to obtain'),
|
||||||
|
eventId: z.string().describe('Event id'),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
event: z.union([
|
||||||
|
FormattedCalendarEventSchema,
|
||||||
|
z.string().describe('No event reason'),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async ({ calendarUrl, eventId }, meta) => {
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const event = await conn.getCalendarEventById(calendarUrl, eventId);
|
||||||
|
return {
|
||||||
|
event: event
|
||||||
|
? formatCalendarEvent(meta.env, event)
|
||||||
|
: `No event found with id "${eventId}"`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "caldav-create-calendar-event",
|
||||||
|
config: {
|
||||||
|
description: "Create calendar event. Returns the event created",
|
||||||
|
inputSchema: {
|
||||||
|
calendarUrl: z.string().describe('Target calendar url. Requires prior call to caldav-list-calendars to obtain'),
|
||||||
|
eventInput: z.object({
|
||||||
|
summary: z.string().describe('Event summary'),
|
||||||
|
description: z.string().optional().describe('Optional event description'),
|
||||||
|
location: z.string().optional().describe('Optional event location'),
|
||||||
|
start: z.coerce.date().describe('Event start date or date and time'),
|
||||||
|
end: z.coerce.date().describe('Event end date or date and time'),
|
||||||
|
allDay: z.coerce.boolean().describe('Is fullday event?'),
|
||||||
|
recurrence: z.object({
|
||||||
|
freq: z.enum(RecurrenceFreqValues).describe('Frequency'),
|
||||||
|
interval: z.number().optional().describe('Optional interval'),
|
||||||
|
count: z.number().optional().describe('Optional repetition count'),
|
||||||
|
until: z.coerce.date().optional().describe('Optional end date or date and time'),
|
||||||
|
byDay: z.array(z.string().describe("Day of week, e.g. MO, TU, WE, TH, FR, TU, SU")).optional(),
|
||||||
|
byMonthDay: z.array(z.number().describe("Day of month")).optional(),
|
||||||
|
byMonth: z.array(z.number().describe("Month number")).optional(),
|
||||||
|
}).optional().describe('Optional recurrence rule, if needed'),
|
||||||
|
}).describe('Event input'),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
event: FormattedCalendarEventSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async ({ calendarUrl, eventInput }, meta) => {
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const event = await conn.createCalendarEvent(calendarUrl, eventInput);
|
||||||
|
return { event: formatCalendarEvent(meta.env, event) };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import { GismeteoHttpRequest } from "../utils/gismeteoReqest";
|
||||||
|
import { gismeteoLocationCache } from "../utils/gismeteoLocationCache";
|
||||||
|
import { server } from "../server";
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "weather-find-locations-by-name",
|
||||||
|
config: {
|
||||||
|
description: `Authoritative weather forecast with most actual full forecast results from gismeteo.ru.
|
||||||
|
Find location (city, place or airport) id for futher forecast requests by its full or partial name.`,
|
||||||
|
inputSchema: {
|
||||||
|
nameStartsWith: z.string().describe("String location name starts with"),
|
||||||
|
language: z.string().default('ru').describe("Language 2-letter code, e.g. ru, en"),
|
||||||
|
count: z.number().default(3).describe("Limit number of items to return"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args) => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = await gismeteoLocationCache(args.language).getLocationsByName(args.nameStartsWith, args.count);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(locations), }] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text", text: `ping failed:\n${(err as Error).message ?? String(err)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "weather-get-weather-by-location-id",
|
||||||
|
config: {
|
||||||
|
description: `Authoritative weather forecast with most actual full forecast results from gismeteo.ru.
|
||||||
|
Returns current weather condition and multi-day forecast for a location. Use weather-find-cities-by-name to find the location id`,
|
||||||
|
inputSchema: {
|
||||||
|
locationId: z.number().describe("Location id to get weather data for"),
|
||||||
|
language: z.string().default('ru').describe("Language 2-letter code, e.g. ru, en"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args) => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new GismeteoHttpRequest({ lang: args.language });
|
||||||
|
const data = await request.getWeatherDataForLocations(args.locationId);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(data.weather.location), }] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text", text: `ping failed:\n${(err as Error).message ?? String(err)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.registerTool({
|
||||||
|
title: "weather-get-weather-by-location-name",
|
||||||
|
config: {
|
||||||
|
description: `Authoritative weather forecast with most actual full forecast results from gismeteo.ru.
|
||||||
|
Returns current weather condition and multi-day forecast for one or more locations`,
|
||||||
|
inputSchema: {
|
||||||
|
locationNameStartsWith: z.string().describe("Location name to get weather data for. All locations starting with this will be returned"),
|
||||||
|
language: z.string().default('ru').describe("Language 2-letter code, e.g. ru, en"),
|
||||||
|
count: z.number().default(1).describe("Limit number of items to return"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args) => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locations = await gismeteoLocationCache(args.language).getLocationsByName(args.locationNameStartsWith, args.count);
|
||||||
|
|
||||||
|
const request = new GismeteoHttpRequest({ lang: args.language });
|
||||||
|
|
||||||
|
const data = (await Promise.all(
|
||||||
|
locations.map((loc) => request.getWeatherDataForLocations(loc.id))
|
||||||
|
))
|
||||||
|
.map((response) => response.weather.location)
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(data), }] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text", text: `ping failed:\n${(err as Error).message ?? String(err)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from './ping';
|
export * from './ping';
|
||||||
export * from './listTools';
|
// export * from './listTools';
|
||||||
export * from './invokeTool';
|
// export * from './invokeTool';
|
||||||
export * from './testAddRemoveTool';
|
// export * from './testAddRemoveTool';
|
||||||
|
// export * from './gismeteo_weather';
|
||||||
|
// export * from './caldav';
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { server } from "../server";
|
import { server } from "../server";
|
||||||
import { ToolDef } from "../types";
|
|
||||||
import { logged } from "../utils/logged";
|
|
||||||
|
|
||||||
export const invokeToolTool: ToolDef = {
|
server.registerTool({
|
||||||
title: "invoke_tool",
|
title: "invoke_tool",
|
||||||
config: {
|
config: {
|
||||||
description: "Invoke a local tool by name. Use list_tools first to see valid names and their argument schemas.",
|
description: "Invoke a local tool by name. Use list_tools first to see valid names and their argument schemas.",
|
||||||
@@ -12,10 +10,11 @@ export const invokeToolTool: ToolDef = {
|
|||||||
args: z.record(z.any()).default({}),
|
args: z.record(z.any()).default({}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: logged('invoke_tool', async ({ name, args }) => {
|
handler: async ({ name, args }, meta, extra) => {
|
||||||
const tool = server.tools[name];
|
const tool = server.tools[name];
|
||||||
if (!tool)
|
if (!tool)
|
||||||
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
||||||
return tool.handler(args); // dispatch to the real implementation
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
}),
|
return tool.handler(args, meta, extra) as any; // dispatch to the real implementation
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||||
import { server } from "../server";
|
import { server } from "../server";
|
||||||
import { ToolDef } from "../types";
|
|
||||||
import { logged } from "../utils/logged";
|
|
||||||
|
|
||||||
export const listToolsTool: ToolDef = {
|
server.registerTool({
|
||||||
title: "list_tools",
|
title: "list_tools",
|
||||||
config: {
|
config: {
|
||||||
description: "Return the authoritative current list of available local tools with their schemas.",
|
description: "Return the authoritative current list of available local tools with their schemas.",
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
},
|
},
|
||||||
handler: logged('list_tools', async () => ({
|
handler: async () => ({
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(
|
text: JSON.stringify(
|
||||||
@@ -19,12 +17,12 @@ export const listToolsTool: ToolDef = {
|
|||||||
...tool,
|
...tool,
|
||||||
config: {
|
config: {
|
||||||
...tool.config,
|
...tool.config,
|
||||||
inputSchema: zodToJsonSchema(z.object(tool.config.inputSchema), { target: "jsonSchema7" }),
|
inputSchema: zodToJsonSchema(z.object(tool.config.inputSchema ?? {}), { target: "jsonSchema7" }),
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
}],
|
}],
|
||||||
})),
|
}),
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { ToolDef } from "../types"
|
import { isValidHost } from "../utils/logged";
|
||||||
import { isValidHost, logged } from "../utils/logged";
|
|
||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { server } from "../server";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
export const pingTool: ToolDef = {
|
server.registerTool({
|
||||||
title: "ping",
|
title: "ping",
|
||||||
config: {
|
config: {
|
||||||
description: "ICMP-ping a host or IP address and return the result.",
|
description: "ICMP-ping a host or IP address and return the result.",
|
||||||
@@ -15,7 +15,7 @@ export const pingTool: ToolDef = {
|
|||||||
count: z.number().int().min(1).max(10).default(4).describe("Echo requests to send"),
|
count: z.number().int().min(1).max(10).default(4).describe("Echo requests to send"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: logged('ping', async ({ host, count }) => {
|
handler: async ({ host, count }) => {
|
||||||
if (!isValidHost(host)) {
|
if (!isValidHost(host)) {
|
||||||
return {
|
return {
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -40,5 +40,5 @@ export const pingTool: ToolDef = {
|
|||||||
content: [{ type: "text", text: `ping failed:\n${message}` }],
|
content: [{ type: "text", text: `ping failed:\n${message}` }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
import { ToolDef } from "../types"
|
|
||||||
import { logged } from "../utils/logged";
|
|
||||||
import { server } from "../server";
|
import { server } from "../server";
|
||||||
|
import { defineTool } from "../types"
|
||||||
|
|
||||||
const dynamicTool: ToolDef = {
|
const dynamicTool = defineTool({
|
||||||
title: "say_hello",
|
title: "say_hello",
|
||||||
config: {
|
config: {
|
||||||
description: "Says hello",
|
description: "Says hello",
|
||||||
inputSchema: {},
|
|
||||||
},
|
},
|
||||||
handler: logged('say_hello', async () => {
|
handler: async () => {
|
||||||
return { content: [{ type: "text", text: "hello" }] };
|
return { content: [{ type: "text", text: "hello" }] };
|
||||||
}),
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
export const testAddTool: ToolDef = {
|
server.registerTool({
|
||||||
title: "test_add_tool",
|
title: "test_add_tool",
|
||||||
config: {
|
config: {
|
||||||
description: "Dynamically add a new tool in runtime",
|
description: "Dynamically add a new tool in runtime",
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
},
|
},
|
||||||
handler: logged('test_add_tool', async () => {
|
handler: async () => {
|
||||||
server.registerTools(dynamicTool);
|
server.registerTool(dynamicTool);
|
||||||
return { content: [{ type: "text", text: "New tool added" }] };
|
return { content: [{ type: "text", text: "New tool added" }] };
|
||||||
}),
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export const testRemoveTool: ToolDef = {
|
server.registerTool({
|
||||||
title: "test_remove_tool",
|
title: "test_remove_tool",
|
||||||
config: {
|
config: {
|
||||||
description: "Dynamically remove a tool in runtime",
|
description: "Dynamically remove a tool in runtime",
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
},
|
},
|
||||||
handler: logged('test_remove_tool', async () => {
|
handler: async () => {
|
||||||
server.unregisterTools(dynamicTool.title);
|
server.unregisterTool(dynamicTool.title);
|
||||||
return { content: [{ type: "text", text: "Tool removed" }] };
|
return { content: [{ type: "text", text: "Tool removed" }] };
|
||||||
}),
|
},
|
||||||
};
|
});
|
||||||
|
|||||||
+63
-38
@@ -1,47 +1,35 @@
|
|||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { ToolDef } from "./types";
|
import { InputShape, ToolDef } from "./types";
|
||||||
import McpSession from "./mcpConnection";
|
import McpSession from "./mcpConnection";
|
||||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types";
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { pingTool, testAddTool, testRemoveTool, listToolsTool, invokeToolTool } from "./mcpTools";
|
import { logged } from "./utils/logged";
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
|
||||||
export class Server {
|
export class Server {
|
||||||
private readonly app: FastifyInstance;
|
private readonly app: FastifyInstance;
|
||||||
private readonly port: number;
|
private readonly port: number;
|
||||||
private readonly sessions: McpSession[] = [];
|
private readonly sessions: McpSession[] = [];
|
||||||
public readonly tools: Record<string, ToolDef> = {};
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
public readonly tools: Record<string, ToolDef<any, any>> = {};
|
||||||
|
|
||||||
constructor(port: number = Number(process.env.PORT ?? 3000)) {
|
constructor(port: number = Number(process.env.PORT ?? 3000)) {
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.app = Fastify({
|
this.app = Fastify({
|
||||||
trustProxy: true,
|
trustProxy: true,
|
||||||
logger: {
|
|
||||||
serializers: {
|
|
||||||
req(request) {
|
|
||||||
return {
|
|
||||||
method: request.method,
|
|
||||||
url: request.url,
|
|
||||||
host: request.headers.host,
|
|
||||||
remoteAddress: request.ip,
|
|
||||||
headers: request.headers, // <-- full header set
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// logger: {
|
// logger: {
|
||||||
// level: process.env.LOG_LEVEL ?? "info",
|
// serializers: {
|
||||||
// ...(process.env.NODE_ENV !== "production"
|
// req(request) {
|
||||||
// ? {
|
// return {
|
||||||
// transport: {
|
// method: request.method,
|
||||||
// target: "pino-pretty",
|
// url: request.url,
|
||||||
// options: { colorize: true, translateTime: "HH:MM:ss.l", ignore: "pid,hostname" },
|
// host: request.headers.host,
|
||||||
// },
|
// remoteAddress: request.ip,
|
||||||
// }
|
// headers: request.headers, // <-- full header set
|
||||||
// : {}),
|
// };
|
||||||
|
// },
|
||||||
|
// },
|
||||||
// },
|
// },
|
||||||
});
|
});
|
||||||
this.registerLogging();
|
|
||||||
this.registerRoutes();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerLogging(): void {
|
private registerLogging(): void {
|
||||||
@@ -70,26 +58,53 @@ export class Server {
|
|||||||
return this.tools[name];
|
return this.tools[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
public registerTools(...tools: ToolDef[]): void {
|
|
||||||
|
|
||||||
for (const tool of tools) {
|
public registerTool<
|
||||||
if (!this.getTool(tool.title))
|
TShape extends InputShape | undefined = undefined,
|
||||||
this.tools[tool.title] = tool;
|
TOutShape extends InputShape | undefined = undefined,
|
||||||
|
>(
|
||||||
|
tool: ToolDef<TShape, TOutShape>,
|
||||||
|
): void {
|
||||||
|
if (!this.getTool(tool.title)) {
|
||||||
|
// console.log(`Registering new tool ${tool.title}`);
|
||||||
|
const toolLogged = logged(tool);
|
||||||
|
this.tools[tool.title] = {
|
||||||
|
...toolLogged,
|
||||||
|
handler: async (args, meta, extra) => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const res = await toolLogged.handler(args as any, meta, extra);
|
||||||
|
if (typeof res === 'string')
|
||||||
|
return { content: [{ type: "text", text: res, }] };
|
||||||
|
else
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: "text", text: (err as Error).message ?? String(err) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emitToolListChange();
|
this.emitToolListChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
public unregisterTools(...names: string[]): void {
|
public unregisterTool(name: string): void {
|
||||||
for (const name of names)
|
delete (this.tools[name]);
|
||||||
delete (this.tools[name]);
|
|
||||||
|
|
||||||
this.emitToolListChange();
|
this.emitToolListChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitToolListChange() {
|
private emitToolListChange() {
|
||||||
|
const toolList = Object.values(this.tools);
|
||||||
|
console.log(`Advertising updated tool list: ${toolList.map((tool) => tool.title)}`);
|
||||||
|
|
||||||
for (const session of Object.values(this.sessions))
|
for (const session of Object.values(this.sessions))
|
||||||
session.handleToolListChange(Object.values(this.tools));
|
session.handleToolListChange(toolList);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSession(sessionId: string): McpSession | undefined {
|
public getSession(sessionId: string): McpSession | undefined {
|
||||||
@@ -101,6 +116,9 @@ export class Server {
|
|||||||
// underlying Node req/res it expects.
|
// underlying Node req/res it expects.
|
||||||
private registerRoutes(): void {
|
private registerRoutes(): void {
|
||||||
this.app.post("/mcp", async (request, reply) => {
|
this.app.post("/mcp", async (request, reply) => {
|
||||||
|
reply.raw.setHeader('Access-Control-Allow-Origin', request.headers.origin ?? '*');
|
||||||
|
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
reply.raw.setHeader('Vary', 'Origin');
|
||||||
|
|
||||||
let session: McpSession | undefined;
|
let session: McpSession | undefined;
|
||||||
|
|
||||||
@@ -157,10 +175,17 @@ export class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
await this.app.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.registerLogging();
|
||||||
|
await this.registerRoutes();
|
||||||
|
|
||||||
await this.app.listen({ port: this.port, host: "0.0.0.0" });
|
await this.app.listen({ port: this.port, host: "0.0.0.0" });
|
||||||
this.app.log.info(`MCP ping server listening on http://0.0.0.0:${this.port}/mcp`);
|
this.app.log.info(`MCP ping server listening on http://0.0.0.0:${this.port}/mcp`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const server = new Server();
|
export const server = new Server();
|
||||||
server.registerTools(pingTool, listToolsTool, invokeToolTool, testAddTool, testRemoveTool);
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const CalendarInfoSchema = z.object({
|
||||||
|
url: z.string().describe('Calendar absolute resource URL'),
|
||||||
|
displayName: z.string().describe('Calendar human-friendly name'),
|
||||||
|
ctag: z.string().optional().describe('Optional calendar CTAG'),
|
||||||
|
color: z.string().optional().describe('Optional calendar color'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CalendarInfo = z.infer<typeof CalendarInfoSchema>;
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const CalendarEventSchema = z.object({
|
||||||
|
id: z.string().describe('Event UID'),
|
||||||
|
url: z.string().describe('Event absolute .ics resource URL'),
|
||||||
|
etag: z.string().optional().describe('Optional event ETAG'),
|
||||||
|
summary: z.string().describe('Event summary'),
|
||||||
|
description: z.string().optional().describe('Optional event description'),
|
||||||
|
location: z.string().optional().describe('Optional event location. May contain toponims, lat/long or custom text'),
|
||||||
|
start: z.date().describe('Event start date and time, inclusive'),
|
||||||
|
end: z.date().describe('Event end date and time, exclusive (e.g. next day for all day events)'),
|
||||||
|
allDay: z.boolean().describe('Is event all day?'),
|
||||||
|
recurrenceRule: z.string().optional().describe('Raw event RRULE value, if any, from master event'),
|
||||||
|
isRecurrenceInstance: z.boolean().optional().optional().describe('Is a recurrence instance?'),
|
||||||
|
recurrenceId: z.date().optional().describe('Event recurrence id'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CalendarEvent = z.infer<typeof CalendarEventSchema>;
|
||||||
|
|
||||||
|
export const FormattedCalendarEventSchema = z.object({
|
||||||
|
id: z.string().describe('Event UID'),
|
||||||
|
summary: z.string().describe('Event summary'),
|
||||||
|
description: z.string().optional().describe('Optional event description'),
|
||||||
|
location: z.string().optional().describe('Optional event location. May contain toponims, lat/long or custom text'),
|
||||||
|
when: z.string().optional().describe('Formatted event dates: start, end, length and allDay flag'),
|
||||||
|
raw: CalendarEventSchema,
|
||||||
|
}).describe('Formatted calendar event and its raw shape');
|
||||||
|
|
||||||
|
export type FormattedCalendarEvent = z.infer<typeof FormattedCalendarEventSchema>;
|
||||||
+46
-5
@@ -1,4 +1,6 @@
|
|||||||
import { ZodRawShape } from "zod";
|
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol";
|
||||||
|
import { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export type TextResult = {
|
export type TextResult = {
|
||||||
content: {
|
content: {
|
||||||
@@ -8,12 +10,51 @@ export type TextResult = {
|
|||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolDef = {
|
// MCP SDK ожидает ZodRawShape (объект { key: ZodType }, не готовый ZodObject)
|
||||||
|
export type InputShape = z.ZodRawShape;
|
||||||
|
|
||||||
|
export type ToolDef<
|
||||||
|
TShape extends InputShape | undefined = undefined,
|
||||||
|
TOutShape extends InputShape | undefined = undefined,
|
||||||
|
> = {
|
||||||
title: string;
|
title: string;
|
||||||
config: {
|
config: {
|
||||||
description: string;
|
description: string;
|
||||||
inputSchema: ZodRawShape,
|
inputSchema?: TShape;
|
||||||
|
outputSchema?: TOutShape;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
handler: (
|
||||||
handler: (...args: any[]) => Promise<TextResult>;
|
args: TShape extends InputShape ? z.infer<z.ZodObject<TShape>> : Record<string, unknown>,
|
||||||
|
meta: McpRequestMeta,
|
||||||
|
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
||||||
|
) => Promise<
|
||||||
|
TOutShape extends InputShape
|
||||||
|
? z.infer<z.ZodObject<TOutShape>>
|
||||||
|
: string | TextResult
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function defineTool<
|
||||||
|
TShape extends InputShape | undefined = undefined,
|
||||||
|
TOutShape extends InputShape | undefined = undefined,
|
||||||
|
>(
|
||||||
|
def: ToolDef<TShape, TOutShape>,
|
||||||
|
): ToolDef<TShape, TOutShape> {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpRequestMeta = {
|
||||||
|
env: Env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserInfo = {
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Env = {
|
||||||
|
user: UserInfo;
|
||||||
|
language: string; // 'en', 'ru', etc.
|
||||||
|
location: {
|
||||||
|
timezone: number; // UTC+hours
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import ICAL from "ical.js";
|
||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { CalendarEvent } from "../types/calendarEvent";
|
||||||
|
import { CalendarInfo } from "../types/calendar";
|
||||||
|
|
||||||
|
export interface CaldavAuth {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecurrenceFreqValues = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"] as const;
|
||||||
|
export type RecurrenceFreq = typeof RecurrenceFreqValues[number];
|
||||||
|
|
||||||
|
export interface RecurrenceRule {
|
||||||
|
freq: RecurrenceFreq;
|
||||||
|
interval?: number;
|
||||||
|
count?: number;
|
||||||
|
until?: Date;
|
||||||
|
byDay?: string[]; // e.g. ["MO", "WE", "FR"]
|
||||||
|
byMonthDay?: number[];
|
||||||
|
byMonth?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventInput {
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
allDay?: boolean;
|
||||||
|
recurrence?: RecurrenceRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CaldavError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status?: number,
|
||||||
|
public readonly url?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CaldavError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaf DAV element value: bare string if no attributes, `{ "#text": ... }`
|
||||||
|
* if it has any (fast-xml-parser's convention with ignoreAttributes:false).
|
||||||
|
* Everything reading a leaf goes through `textOf()`, which normalizes both. */
|
||||||
|
type DavLeaf = string | { "#text": string };
|
||||||
|
|
||||||
|
/** A self-closing flag element, e.g. `<D:collection/>`, parses to `""`. */
|
||||||
|
type DavFlag = "";
|
||||||
|
|
||||||
|
/** <D:resourcetype> children — only `collection`/`calendar` are read by this
|
||||||
|
* client, but servers can report others (principal, addressbook, ...), so
|
||||||
|
* the index signature keeps unrecognized flags from being a type error. */
|
||||||
|
interface DavResourceType {
|
||||||
|
collection?: DavFlag;
|
||||||
|
calendar?: DavFlag; // urn:ietf:params:xml:ns:caldav
|
||||||
|
[key: string]: DavFlag | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <D:prop> shape for the PROPFIND in listCalendars(). */
|
||||||
|
interface DavCalendarProp {
|
||||||
|
resourcetype?: DavResourceType;
|
||||||
|
displayname?: DavLeaf;
|
||||||
|
getctag?: DavLeaf; // CS:getctag
|
||||||
|
"calendar-color"?: DavLeaf; // IC:calendar-color
|
||||||
|
}
|
||||||
|
|
||||||
|
type DavUserPrincipalProp = {
|
||||||
|
"current-user-principal": {
|
||||||
|
href: DavLeaf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <D:prop> shape for the REPORT (calendar-query) in
|
||||||
|
* getCalendarEventsForDateRange() and getCalendarEventById(). */
|
||||||
|
type DavCalendarObjectProp = {
|
||||||
|
getetag?: DavLeaf;
|
||||||
|
"calendar-data"?: DavLeaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavPropstat<P> {
|
||||||
|
status: DavLeaf;
|
||||||
|
prop?: P;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavResponse<P> {
|
||||||
|
href: DavLeaf;
|
||||||
|
propstat?: DavPropstat<P> | DavPropstat<P>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavMultistatus<P> {
|
||||||
|
multistatus?: {
|
||||||
|
response?: DavResponse<P> | DavResponse<P>[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CaldavServerConnection {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly auth?: CaldavAuth;
|
||||||
|
|
||||||
|
constructor(serverUrl: string, auth?: CaldavAuth) {
|
||||||
|
console.log(serverUrl);
|
||||||
|
this.baseUrl = CaldavServerConnection.ensureTrailingSlash(serverUrl);
|
||||||
|
this.auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPrincipalUrl(): Promise<string> {
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:IC="http://apple.com/ns/ical/">
|
||||||
|
<D:prop>
|
||||||
|
<D:current-user-principal/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>`;
|
||||||
|
const res = await this.request("PROPFIND", this.baseUrl, {
|
||||||
|
headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const parsed = this.parseXml<DavMultistatus<DavUserPrincipalProp>>(await res.text());
|
||||||
|
const responses = this.asArray(parsed?.multistatus?.response);
|
||||||
|
const rootResponse = responses.find((r) => r.href === this.okProp(r)?.["current-user-principal"].href);
|
||||||
|
if (!rootResponse)
|
||||||
|
throw new Error('No root principal returned');
|
||||||
|
|
||||||
|
const rootHref = this.textOf(rootResponse.href);
|
||||||
|
if (!rootHref)
|
||||||
|
throw new Error('Invalid root principal returned');
|
||||||
|
|
||||||
|
return this.resolveUrl(rootHref);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
async listCalendars(): Promise<CalendarInfo[]> {
|
||||||
|
const userPrincipalUrl = await this.getUserPrincipalUrl();
|
||||||
|
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:IC="http://apple.com/ns/ical/">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:displayname/>
|
||||||
|
<CS:getctag/>
|
||||||
|
<IC:calendar-color/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>`;
|
||||||
|
|
||||||
|
const res = await this.request("PROPFIND", userPrincipalUrl, {
|
||||||
|
headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const parsed = this.parseXml<DavMultistatus<DavCalendarProp>>(await res.text());
|
||||||
|
const responses = this.asArray(parsed?.multistatus?.response);
|
||||||
|
|
||||||
|
const calendars: CalendarInfo[] = [];
|
||||||
|
for (const r of responses) {
|
||||||
|
const prop = this.okProp(r);
|
||||||
|
if (!prop)
|
||||||
|
continue;
|
||||||
|
const resourcetype = prop.resourcetype;
|
||||||
|
const isCalendar = resourcetype != null && typeof resourcetype === "object" && "calendar" in resourcetype;
|
||||||
|
if (!isCalendar)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
calendars.push({
|
||||||
|
url: this.resolveUrl(this.hrefOf(r)),
|
||||||
|
displayName: this.textOf(prop.displayname) ?? this.hrefOf(r),
|
||||||
|
ctag: this.textOf(prop.getctag),
|
||||||
|
color: this.textOf(prop["calendar-color"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return calendars;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarEventsForDateRange(
|
||||||
|
calendarUrl: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date,
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag/>
|
||||||
|
<C:calendar-data/>
|
||||||
|
</D:prop>
|
||||||
|
<C:filter>
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:time-range start="${this.formatICalUTC(start)}" end="${this.formatICalUTC(end)}"/>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:filter>
|
||||||
|
</C:calendar-query>`;
|
||||||
|
|
||||||
|
const res = await this.request("REPORT", calendarUrl, {
|
||||||
|
headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const parsed = this.parseXml<DavMultistatus<DavCalendarObjectProp>>(await res.text());
|
||||||
|
const responses = this.asArray(parsed?.multistatus?.response);
|
||||||
|
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
for (const r of responses) {
|
||||||
|
const prop = this.okProp(r);
|
||||||
|
if (!prop)
|
||||||
|
continue;
|
||||||
|
const icalText = this.textOf(prop["calendar-data"]);
|
||||||
|
if (!icalText)
|
||||||
|
continue;
|
||||||
|
const etag = this.textOf(prop.getetag);
|
||||||
|
const resourceUrl = this.resolveUrl(this.hrefOf(r));
|
||||||
|
|
||||||
|
for (const masterComp of this.masterVeventComponents(icalText)) {
|
||||||
|
const { master, icalEvent } = this.toCalendarEvent(masterComp, resourceUrl, etag);
|
||||||
|
const occurences = this.expandOccurrences(icalEvent, master, start, end);
|
||||||
|
events.push(...occurences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.sort(compareCalendarEvents);
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarEventById(calendarUrl: string, eventId: string): Promise<CalendarEvent | undefined> {
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag/>
|
||||||
|
<C:calendar-data/>
|
||||||
|
</D:prop>
|
||||||
|
<C:filter>
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:prop-filter name="UID">
|
||||||
|
<C:text-match collation="i;octet">${CaldavServerConnection.escapeXml(eventId)}</C:text-match>
|
||||||
|
</C:prop-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:filter>
|
||||||
|
</C:calendar-query>`;
|
||||||
|
|
||||||
|
const res = await this.request("REPORT", calendarUrl, {
|
||||||
|
headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const parsed = this.parseXml<DavMultistatus<DavCalendarObjectProp>>(await res.text());
|
||||||
|
const responses = this.asArray(parsed?.multistatus?.response);
|
||||||
|
if (responses.length === 0)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const prop = this.okProp(responses[0]);
|
||||||
|
if (!prop)
|
||||||
|
return undefined;
|
||||||
|
const icalText = this.textOf(prop["calendar-data"]);
|
||||||
|
if (!icalText)
|
||||||
|
return undefined;
|
||||||
|
const etag = this.textOf(prop.getetag);
|
||||||
|
const resourceUrl = this.resolveUrl(this.hrefOf(responses[0]));
|
||||||
|
|
||||||
|
const masters = this.masterVeventComponents(icalText);
|
||||||
|
const master = masters.find((v) => v.getFirstPropertyValue("uid") === eventId) ?? masters[0];
|
||||||
|
if (!master)
|
||||||
|
return undefined;
|
||||||
|
return this.toCalendarEvent(master, resourceUrl, etag).master;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCalendarEvent(calendarUrl: string, input: CalendarEventInput): Promise<CalendarEvent> {
|
||||||
|
const uid = randomUUID();
|
||||||
|
const ics = this.buildEventIcs(uid, input);
|
||||||
|
const eventUrl = new URL(`${uid}.ics`, CaldavServerConnection.ensureTrailingSlash(calendarUrl)).toString();
|
||||||
|
|
||||||
|
const res = await this.request("PUT", eventUrl, {
|
||||||
|
headers: { "Content-Type": 'text/calendar; charset="utf-8"', "If-None-Match": "*" },
|
||||||
|
body: ics,
|
||||||
|
});
|
||||||
|
const etag = res.headers.get("etag") ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uid,
|
||||||
|
url: eventUrl,
|
||||||
|
etag,
|
||||||
|
summary: input.summary,
|
||||||
|
description: input.description,
|
||||||
|
location: input.location,
|
||||||
|
start: input.start,
|
||||||
|
end: input.end,
|
||||||
|
allDay: !!input.allDay,
|
||||||
|
recurrenceRule: input.recurrence ? this.buildRRuleValue(input.recurrence) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async editCalendarEvent(
|
||||||
|
eventUrl: string,
|
||||||
|
updates: Partial<CalendarEventInput>,
|
||||||
|
): Promise<CalendarEvent> {
|
||||||
|
const getRes = await this.request("GET", eventUrl, {});
|
||||||
|
const currentEtag = getRes.headers.get("etag") ?? undefined;
|
||||||
|
const icalText = await getRes.text();
|
||||||
|
|
||||||
|
const vevents = this.veventComponents(icalText);
|
||||||
|
if (vevents.length === 0) {
|
||||||
|
throw new CaldavError("No VEVENT found in resource", getRes.status, eventUrl);
|
||||||
|
}
|
||||||
|
const vevent = vevents[0];
|
||||||
|
const existing = this.toCalendarEvent(vevent, eventUrl, currentEtag).master;
|
||||||
|
const uid = existing.id;
|
||||||
|
|
||||||
|
const merged: CalendarEventInput = {
|
||||||
|
summary: updates.summary ?? existing.summary,
|
||||||
|
description: updates.description ?? existing.description,
|
||||||
|
location: updates.location ?? existing.location,
|
||||||
|
start: updates.start ?? existing.start,
|
||||||
|
end: updates.end ?? existing.end,
|
||||||
|
allDay: updates.allDay ?? existing.allDay,
|
||||||
|
recurrence: updates.recurrence, // explicit: omit to drop, pass to replace
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSeq = Number(vevent.getFirstPropertyValue("sequence") ?? 0);
|
||||||
|
const ics = this.buildEventIcs(uid, merged, currentSeq + 1);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { "Content-Type": 'text/calendar; charset="utf-8"' };
|
||||||
|
if (currentEtag) headers["If-Match"] = currentEtag;
|
||||||
|
|
||||||
|
const putRes = await this.request("PUT", eventUrl, { headers, body: ics });
|
||||||
|
const newEtag = putRes.headers.get("etag") ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uid,
|
||||||
|
url: eventUrl,
|
||||||
|
etag: newEtag,
|
||||||
|
summary: merged.summary,
|
||||||
|
description: merged.description,
|
||||||
|
location: merged.location,
|
||||||
|
start: merged.start,
|
||||||
|
end: merged.end,
|
||||||
|
allDay: !!merged.allDay,
|
||||||
|
recurrenceRule: merged.recurrence ? this.buildRRuleValue(merged.recurrence) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCalendarEvent(eventUrl: string, etag?: string): Promise<void> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (etag) headers["If-Match"] = etag;
|
||||||
|
await this.request("DELETE", eventUrl, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// HTTP plumbing
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
private authHeader(): string | undefined {
|
||||||
|
if (!this.auth) return undefined;
|
||||||
|
return "Basic " + Buffer.from(`${this.auth.username}:${this.auth.password}`).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
opts: { headers?: Record<string, string>; body?: string },
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = { ...opts.headers };
|
||||||
|
const auth = this.authHeader();
|
||||||
|
if (auth) headers.Authorization = auth;
|
||||||
|
|
||||||
|
const res = await fetch(url, { method, headers, body: opts.body });
|
||||||
|
// 207 Multi-Status is the normal success code for PROPFIND/REPORT.
|
||||||
|
if (!res.ok && res.status !== 207) {
|
||||||
|
throw new CaldavError(`${method} ${url} failed: ${res.status} ${res.statusText}`, res.status, url);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// XML (WebDAV multistatus) helpers
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
private parseXml<T>(xml: string): T {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
removeNSPrefix: true,
|
||||||
|
parseTagValue: false,
|
||||||
|
});
|
||||||
|
return parser.parse(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
private asArray<T>(v: T | T[] | undefined | null): T[] {
|
||||||
|
if (v == null) return [];
|
||||||
|
return Array.isArray(v) ? v : [v];
|
||||||
|
}
|
||||||
|
|
||||||
|
private hrefOf<T>(response: DavResponse<T>): string {
|
||||||
|
return this.textOf(response?.href) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private okProp<T>(response: DavResponse<T>): T | undefined {
|
||||||
|
const propstats = this.asArray(response?.propstat);
|
||||||
|
const ok = propstats.find((p) => this.textOf(p?.status)?.includes("200"));
|
||||||
|
return ok?.prop ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private textOf(v: DavLeaf | undefined | null): string | undefined {
|
||||||
|
if (!v)
|
||||||
|
return;
|
||||||
|
if (typeof v === "string")
|
||||||
|
return v;
|
||||||
|
if (typeof v === "object" && "#text" in v)
|
||||||
|
return String(v["#text"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveUrl(href: string): string {
|
||||||
|
return new URL(href, this.baseUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ensureTrailingSlash(url: string): string {
|
||||||
|
return url.endsWith("/") ? url : `${url}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static escapeXml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// iCalendar (ical.js) helpers
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
private veventComponents(icalText: string): ICAL.Component[] {
|
||||||
|
const jcal = ICAL.parse(icalText);
|
||||||
|
const vcalendar = new ICAL.Component(jcal);
|
||||||
|
return vcalendar.getAllSubcomponents("vevent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns one component per UID: the master (no RECURRENCE-ID). Override
|
||||||
|
* components (RECURRENCE-ID set — single-instance edits/exceptions to a
|
||||||
|
* recurring series) are deliberately excluded from the returned list so
|
||||||
|
* they don't surface as standalone top-level events. They remain attached
|
||||||
|
* to the master via `component.parent` (the shared VCALENDAR), which lets
|
||||||
|
* `new ICAL.Event(master)` auto-discover and merge them (see ical.js'
|
||||||
|
* `relateException`), so `expandOccurrences` can apply them per-instance.
|
||||||
|
*
|
||||||
|
* A resource containing only override components with no master (an
|
||||||
|
* orphaned exception — shouldn't normally happen from a spec-compliant
|
||||||
|
* server) is skipped, since there's nothing to safely expand.
|
||||||
|
*/
|
||||||
|
private masterVeventComponents(icalText: string): ICAL.Component[] {
|
||||||
|
return this.veventComponents(icalText).filter((v) => !v.hasProperty("recurrence-id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatICalUTC(date: Date): string {
|
||||||
|
return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRRuleValue(rule: RecurrenceRule): string {
|
||||||
|
const parts: string[] = [`FREQ=${rule.freq}`];
|
||||||
|
if (rule.interval) parts.push(`INTERVAL=${rule.interval}`);
|
||||||
|
if (rule.count) parts.push(`COUNT=${rule.count}`);
|
||||||
|
if (rule.until) parts.push(`UNTIL=${this.formatICalUTC(rule.until)}`);
|
||||||
|
if (rule.byDay?.length) parts.push(`BYDAY=${rule.byDay.join(",")}`);
|
||||||
|
if (rule.byMonthDay?.length) parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`);
|
||||||
|
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`);
|
||||||
|
return parts.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEventIcs(uid: string, input: CalendarEventInput, sequence = 0): string {
|
||||||
|
const vcalendar = new ICAL.Component(["vcalendar", [], []]);
|
||||||
|
vcalendar.updatePropertyWithValue("prodid", "-//CaldavServerConnection//EN");
|
||||||
|
vcalendar.updatePropertyWithValue("version", "2.0");
|
||||||
|
|
||||||
|
const vevent = new ICAL.Component("vevent");
|
||||||
|
vevent.updatePropertyWithValue("uid", uid);
|
||||||
|
vevent.updatePropertyWithValue("summary", input.summary);
|
||||||
|
if (input.description) vevent.updatePropertyWithValue("description", input.description);
|
||||||
|
if (input.location) vevent.updatePropertyWithValue("location", input.location);
|
||||||
|
vevent.updatePropertyWithValue("sequence", sequence);
|
||||||
|
vevent.updatePropertyWithValue("dtstamp", ICAL.Time.now());
|
||||||
|
|
||||||
|
if (input.allDay) {
|
||||||
|
const dtstart = ICAL.Time.fromJSDate(input.start, false);
|
||||||
|
dtstart.isDate = true;
|
||||||
|
const dtend = ICAL.Time.fromJSDate(input.end, false);
|
||||||
|
dtend.isDate = true;
|
||||||
|
vevent.updatePropertyWithValue("dtstart", dtstart);
|
||||||
|
vevent.updatePropertyWithValue("dtend", dtend);
|
||||||
|
} else {
|
||||||
|
vevent.updatePropertyWithValue("dtstart", ICAL.Time.fromJSDate(input.start, true));
|
||||||
|
vevent.updatePropertyWithValue("dtend", ICAL.Time.fromJSDate(input.end, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.recurrence) {
|
||||||
|
const rruleProp = new ICAL.Property("rrule");
|
||||||
|
rruleProp.setValue(ICAL.Recur.fromString(this.buildRRuleValue(input.recurrence)));
|
||||||
|
vevent.addProperty(rruleProp);
|
||||||
|
}
|
||||||
|
|
||||||
|
vcalendar.addSubcomponent(vevent);
|
||||||
|
return vcalendar.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCalendarEvent(
|
||||||
|
vevent: ICAL.Component,
|
||||||
|
resourceUrl: string,
|
||||||
|
etag?: string,
|
||||||
|
): { master: CalendarEvent; icalEvent: ICAL.Event } {
|
||||||
|
const icalEvent = new ICAL.Event(vevent);
|
||||||
|
const allDay = icalEvent.startDate.isDate;
|
||||||
|
const rruleProp = vevent.getFirstProperty("rrule");
|
||||||
|
|
||||||
|
const master: CalendarEvent = {
|
||||||
|
id: icalEvent.uid,
|
||||||
|
url: resourceUrl,
|
||||||
|
etag,
|
||||||
|
summary: icalEvent.summary ?? "",
|
||||||
|
description: icalEvent.description || undefined,
|
||||||
|
location: icalEvent.location || undefined,
|
||||||
|
start: icalEvent.startDate.toJSDate(),
|
||||||
|
end: icalEvent.endDate.toJSDate(),
|
||||||
|
allDay,
|
||||||
|
recurrenceRule: rruleProp ? String(rruleProp.getFirstValue()) : undefined,
|
||||||
|
};
|
||||||
|
return { master, icalEvent };
|
||||||
|
}
|
||||||
|
|
||||||
|
private expandOccurrences(
|
||||||
|
icalEvent: ICAL.Event,
|
||||||
|
master: CalendarEvent,
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
): CalendarEvent[] {
|
||||||
|
if (!icalEvent.isRecurring()) {
|
||||||
|
return master.start < rangeEnd && master.end > rangeStart ? [master] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: CalendarEvent[] = [];
|
||||||
|
const iterator = icalEvent.iterator();
|
||||||
|
|
||||||
|
// Safety valve against unbounded/huge recurrences (e.g. FREQ=DAILY with
|
||||||
|
// no COUNT/UNTIL) — stop once we're well past the requested range or
|
||||||
|
// after a generous cap on iterations.
|
||||||
|
let next: ICAL.Time | null;
|
||||||
|
let guard = 0;
|
||||||
|
while ((next = iterator.next()) && guard < 10_000) {
|
||||||
|
guard++;
|
||||||
|
const occStart = next.toJSDate();
|
||||||
|
if (occStart.getTime() > rangeEnd.getTime()) break;
|
||||||
|
|
||||||
|
// getOccurrenceDetails resolves RECURRENCE-ID overrides automatically
|
||||||
|
// (relateException, wired up when the ICAL.Event was constructed from
|
||||||
|
// a component with a `.parent` containing sibling override VEVENTs).
|
||||||
|
// `details.item === icalEvent` for a plain, unmodified instance;
|
||||||
|
// any other value means this occurrence was overridden.
|
||||||
|
const details = icalEvent.getOccurrenceDetails(next);
|
||||||
|
const override = details.item !== icalEvent ? details.item : undefined;
|
||||||
|
|
||||||
|
// A cancelled override subtracts this occurrence from the series
|
||||||
|
// entirely — it must not appear in results, moved or otherwise.
|
||||||
|
// (EXDATE-cancelled occurrences never reach this point at all: the
|
||||||
|
// iterator itself skips dates listed in EXDATE.)
|
||||||
|
if (override) {
|
||||||
|
const status = override.component.getFirstPropertyValue("status");
|
||||||
|
if (typeof status === "string" && status.toUpperCase() === "CANCELLED") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const occEnd = details.endDate.toJSDate();
|
||||||
|
const occRealStart = details.startDate.toJSDate();
|
||||||
|
if (occEnd.getTime() > rangeStart.getTime()) {
|
||||||
|
results.push({
|
||||||
|
...master,
|
||||||
|
summary: override?.summary ?? master.summary,
|
||||||
|
description: override?.description || master.description,
|
||||||
|
location: override?.location || master.location,
|
||||||
|
start: occRealStart,
|
||||||
|
end: occEnd,
|
||||||
|
isRecurrenceInstance: true,
|
||||||
|
recurrenceId: occStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareCalendarEvents(a: CalendarEvent, b: CalendarEvent): number {
|
||||||
|
return Math.abs(a.start.getDate() - b.start.getDate()) * 10 + Math.abs(a.end.getDate() - b.end.getDate());
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Env } from "../types";
|
||||||
|
import * as Luxon from 'luxon';
|
||||||
|
import { CalendarEvent, FormattedCalendarEvent } from "../types/calendarEvent";
|
||||||
|
|
||||||
|
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
|
function getDayStart(value: Date) {
|
||||||
|
const result = new Date(value);
|
||||||
|
result.setHours(0, 0, 0, 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value: number, unit: 'day' | 'hour' | 'minute', locale: string): string {
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
style: 'unit',
|
||||||
|
unit,
|
||||||
|
unitDisplay: 'long', // 'long' | 'short' | 'narrow'
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcOffset(offsetHours: number): string {
|
||||||
|
const sign = offsetHours >= 0 ? '+' : '-';
|
||||||
|
const abs = Math.abs(offsetHours);
|
||||||
|
const wholeHours = Math.trunc(abs);
|
||||||
|
const minutes = Math.round((abs - wholeHours) * 60);
|
||||||
|
const hh = String(wholeHours).padStart(2, '0');
|
||||||
|
return minutes === 0
|
||||||
|
? `UTC${sign}${hh}`
|
||||||
|
: `UTC${sign}${hh}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedOffsetZoneName(utcHours: number): string {
|
||||||
|
const sign = utcHours < 0 ? '-' : '+';
|
||||||
|
const abs = Math.abs(utcHours);
|
||||||
|
const hours = Math.floor(abs);
|
||||||
|
const minutes = Math.round((abs - hours) * 60);
|
||||||
|
return `UTC${sign}${hours}${minutes ? `:${String(minutes).padStart(2, '0')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCalendarDate(env: Env, value: Date): string {
|
||||||
|
const dt = Luxon.DateTime.fromJSDate(value)
|
||||||
|
.setZone(fixedOffsetZoneName(env.location.timezone))
|
||||||
|
.setLocale(env.language);
|
||||||
|
const isDateOnly = dt.hour === 0 && dt.minute === 0 && dt.second === 0 && dt.millisecond === 0;
|
||||||
|
// DATE_FULL is locale-native (month-day-year for en, day-month-year for
|
||||||
|
// ru, year-month-day for lt, ...); hour/minute are appended to it rather
|
||||||
|
// than using DATETIME_FULL so we don't also pull in the time zone name.
|
||||||
|
const localized = dt.toLocaleString(
|
||||||
|
isDateOnly
|
||||||
|
? Luxon.DateTime.DATE_FULL
|
||||||
|
: { ...Luxon.DateTime.DATE_FULL, hour: 'numeric', minute: '2-digit' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${localized} (${formatUtcOffset(env.location.timezone)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCalendarDuration(env: Env, seconds: number): string {
|
||||||
|
const duration = Luxon.Duration.fromObject({ seconds: Math.abs(seconds) }).shiftTo('days', 'hours', 'minutes');
|
||||||
|
const days = duration.days;
|
||||||
|
const hours = duration.hours;
|
||||||
|
const minutes = Math.ceil(duration.minutes);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (days)
|
||||||
|
parts.push(formatDuration(days, 'day', env.language));
|
||||||
|
if (hours)
|
||||||
|
parts.push(formatDuration(hours, 'hour', env.language));
|
||||||
|
if (minutes || (!days && !hours))
|
||||||
|
parts.push(formatDuration(minutes, 'minute', env.language));
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCalendarDates(env: Env, start: Date, end: Date, allDay: boolean) {
|
||||||
|
const startFormatted = formatCalendarDate(env, allDay ? getDayStart(start) : start);
|
||||||
|
let lengthFormatted: string | undefined
|
||||||
|
let endFormatted: string | undefined
|
||||||
|
const length = end.getTime() - start.getTime() - (allDay ? MS_PER_DAY : 0);
|
||||||
|
if (length) {
|
||||||
|
lengthFormatted = formatCalendarDuration(env, length / 1000);
|
||||||
|
endFormatted = formatCalendarDate(env, allDay ? getDayStart(end) : end);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
allDay
|
||||||
|
? 'Весь день'
|
||||||
|
: (length ? lengthFormatted : ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (endFormatted)
|
||||||
|
parts.push('c');
|
||||||
|
|
||||||
|
parts.push(startFormatted);
|
||||||
|
|
||||||
|
if (endFormatted)
|
||||||
|
parts.push('по', endFormatted);
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCalendarEvent(env: Env, value: CalendarEvent): FormattedCalendarEvent {
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
summary: value.summary,
|
||||||
|
description: value.description,
|
||||||
|
location: value.location,
|
||||||
|
when: formatCalendarDates(env, value.start, value.end, value.allDay),
|
||||||
|
raw: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCalendarEvents(env: Env, values: CalendarEvent[]): FormattedCalendarEvent[] {
|
||||||
|
return values.map((e) => formatCalendarEvent(env, e));
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { GismeteoHttpRequest, GismeteoLocation } from "./gismeteoReqest";
|
||||||
|
|
||||||
|
function normalizeName(name: string, locale: Intl.LocalesArgument): string {
|
||||||
|
return name
|
||||||
|
.toLocaleLowerCase(locale)
|
||||||
|
.replace(/ё/g, 'е')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GismeteoLocationCache {
|
||||||
|
public readonly language: string;
|
||||||
|
|
||||||
|
private readonly cache: GismeteoLocation[] = [];
|
||||||
|
|
||||||
|
constructor(language: string) {
|
||||||
|
this.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedLocations(nameStartsWith: string): GismeteoLocation[] {
|
||||||
|
return this.cache
|
||||||
|
.filter((loc) => normalizeName(loc.name, this.language).startsWith(normalizeName(nameStartsWith, this.language)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// gismeteo returns different structures for different requests:
|
||||||
|
// getLocationsByName contains n: string, getWeatherDataForLocations contans name: string
|
||||||
|
private cacheLocation(location: GismeteoLocation<{ n: string }>) {
|
||||||
|
const normalized = {
|
||||||
|
...location,
|
||||||
|
n: undefined,
|
||||||
|
name: location.n,
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = this.cache.findIndex((loc) => loc.id === location.id);
|
||||||
|
if (idx >= 0)
|
||||||
|
this.cache[idx] = normalized;
|
||||||
|
else
|
||||||
|
this.cache.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLocationsByName(nameStartsWith: string, count: number = 1): Promise<GismeteoLocation[]> {
|
||||||
|
|
||||||
|
let cached = this.getCachedLocations(nameStartsWith);
|
||||||
|
if (cached.length < count) {
|
||||||
|
const request = new GismeteoHttpRequest({ lang: this.language });
|
||||||
|
const data = await request.getLocationsByName(nameStartsWith, count, false);
|
||||||
|
for (const location of data.document.item)
|
||||||
|
this.cacheLocation(location);
|
||||||
|
|
||||||
|
cached = this.getCachedLocations(nameStartsWith);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.slice(0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachesByLanguage: Record<string, GismeteoLocationCache> = {};
|
||||||
|
export function gismeteoLocationCache(language: string) {
|
||||||
|
if (!cachesByLanguage[language])
|
||||||
|
cachesByLanguage[language] = new GismeteoLocationCache(language);
|
||||||
|
|
||||||
|
return cachesByLanguage[language];
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
|
||||||
|
// Порт ru.gismeteo.Gismeteonetworking.GismeteoHttpRequest в TypeScript/Node.js.
|
||||||
|
|
||||||
|
const SERVICE_URL = "https://services.gismeteo.net/inform-service/inf_android/";
|
||||||
|
const USER_AGENT = "Gismeteo Android v2";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
const CATEGORY_CITIES = "cities";
|
||||||
|
const CATEGORY_FORECAST = "forecast";
|
||||||
|
|
||||||
|
const HEADER_PARAM_UTC_TIMESTAMP = "x-gismeteo-timestamp";
|
||||||
|
|
||||||
|
const PARAM_CELLID = "cellid";
|
||||||
|
const PARAM_CITY = "city";
|
||||||
|
const PARAM_COUNT = "count";
|
||||||
|
const PARAM_DOMAIN = "domain";
|
||||||
|
const PARAM_ID = "id";
|
||||||
|
const PARAM_LAC = "lac";
|
||||||
|
const PARAM_LANG = "lang";
|
||||||
|
const PARAM_LAT = "lat";
|
||||||
|
const PARAM_LAT_LNG = "lat_lng";
|
||||||
|
const PARAM_LNG = "lng";
|
||||||
|
const PARAM_MCC = "mcc";
|
||||||
|
const PARAM_MNC = "mnc";
|
||||||
|
const PARAM_MODE = "mode";
|
||||||
|
const PARAM_NOCACHE = "nocache";
|
||||||
|
const PARAM_REAL_DISTANCE = "real_distance";
|
||||||
|
const PARAM_SEARCH_ALL = "search_all";
|
||||||
|
const PARAM_STARTSWITH = "startsWith";
|
||||||
|
const PARAM_WITH_FACTS = "with_facts";
|
||||||
|
const PARAM_WITH_TZONE = "with_tzone";
|
||||||
|
|
||||||
|
export enum GismeteoErrorCode {
|
||||||
|
UNKNOWN_HOST = 8,
|
||||||
|
MALFORMED_URL_OR_IO_ERROR = 9,
|
||||||
|
TIMEOUT = 10,
|
||||||
|
BAD_RESPONSE = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GismeteoError extends Error {
|
||||||
|
constructor(public readonly code: GismeteoErrorCode, message?: string) {
|
||||||
|
super(message ?? `GismeteoError(${code})`);
|
||||||
|
this.name = "GismeteoError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GismeteoHttpRequestListener {
|
||||||
|
onUpdateUTCDate(utcMillis: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GismeteoHttpRequestDeviceInfo = {
|
||||||
|
manufacturer: string;
|
||||||
|
device: string;
|
||||||
|
osVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GismeteoHttpRequestOptions {
|
||||||
|
lang: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
listener?: GismeteoHttpRequestListener;
|
||||||
|
deviceInfo?: Partial<GismeteoHttpRequestDeviceInfo>;
|
||||||
|
/**
|
||||||
|
* В оригинале domain=ua добавлялся, если системная локаль устройства
|
||||||
|
* содержала "ua". В Node-окружении нет "локали устройства" в этом смысле,
|
||||||
|
* так что решение делегируется вызывающему коду.
|
||||||
|
*/
|
||||||
|
isUaDomainParamRequired?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GismeteoLocationFact = {
|
||||||
|
values: {
|
||||||
|
t: number;
|
||||||
|
tflt: number;
|
||||||
|
tcflt: number;
|
||||||
|
p: number;
|
||||||
|
ws: number;
|
||||||
|
wd: number;
|
||||||
|
hum: number;
|
||||||
|
hi: number;
|
||||||
|
cl: number;
|
||||||
|
pt: number;
|
||||||
|
pr: number;
|
||||||
|
ts: number;
|
||||||
|
water_t: number;
|
||||||
|
icon: number;
|
||||||
|
descr: number;
|
||||||
|
grade: number;
|
||||||
|
};
|
||||||
|
valid: string;
|
||||||
|
tod: number;
|
||||||
|
risem: number;
|
||||||
|
setm: number;
|
||||||
|
durm: number;
|
||||||
|
sunrise: number;
|
||||||
|
sunset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GismeteoLocation<TAdditionalData = { name: string }> = TAdditionalData & {
|
||||||
|
fact: GismeteoLocationFact;
|
||||||
|
id: number;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
tzone: number;
|
||||||
|
country_id: number;
|
||||||
|
country_name: string;
|
||||||
|
disctrict_id: number;
|
||||||
|
district_name: string;
|
||||||
|
kind: 'T' | 'A' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GismeteoGetLocationsByNameResponse = {
|
||||||
|
document: {
|
||||||
|
item: GismeteoLocation<{
|
||||||
|
n: string;
|
||||||
|
}>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GismeteoGetWeatherDataForLocationsResponse = {
|
||||||
|
weather: {
|
||||||
|
location: GismeteoLocation<{
|
||||||
|
day: {
|
||||||
|
[x: string]: unknown;
|
||||||
|
}[],
|
||||||
|
name: string;
|
||||||
|
name_r: string;
|
||||||
|
cur_time: string;
|
||||||
|
nowcast_url: string;
|
||||||
|
nowcast_teaser: true;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GismeteoHttpRequest {
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
private readonly listener?: GismeteoHttpRequestListener;
|
||||||
|
private readonly deviceInfo: GismeteoHttpRequestDeviceInfo;
|
||||||
|
private readonly isUaDomainParamRequired: boolean;
|
||||||
|
private readonly lang: string;
|
||||||
|
|
||||||
|
constructor(options: GismeteoHttpRequestOptions = { lang: 'ru' }) {
|
||||||
|
this.lang = options.lang;
|
||||||
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
this.listener = options.listener;
|
||||||
|
this.deviceInfo = {
|
||||||
|
// Samsung S23 by default
|
||||||
|
manufacturer: 'samsung',
|
||||||
|
device: 'dm3q',
|
||||||
|
osVersion: '14',
|
||||||
|
...options.deviceInfo,
|
||||||
|
};
|
||||||
|
this.isUaDomainParamRequired = options.isUaDomainParamRequired ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUserAgent(): string {
|
||||||
|
const { manufacturer, device, osVersion } = this.deviceInfo;
|
||||||
|
return `${USER_AGENT}, ${manufacturer} ${device}, ${osVersion}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLocationsByName(
|
||||||
|
startsWith: string,
|
||||||
|
count: number,
|
||||||
|
withFacts: boolean,
|
||||||
|
): Promise<GismeteoGetLocationsByNameResponse> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
[PARAM_STARTSWITH]: startsWith, // кодирование делает URLSearchParams в prepareUrlString
|
||||||
|
[PARAM_SEARCH_ALL]: "1",
|
||||||
|
[PARAM_LANG]: this.lang,
|
||||||
|
[PARAM_WITH_FACTS]: withFacts ? "1" : "0",
|
||||||
|
[PARAM_LAT_LNG]: "1",
|
||||||
|
[PARAM_WITH_TZONE]: "1",
|
||||||
|
[PARAM_COUNT]: String(count),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.sendRequest(params, CATEGORY_CITIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocationsByCoordinates(
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
count: number,
|
||||||
|
withFacts: boolean,
|
||||||
|
realDistance: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
[PARAM_LAT]: String(lat),
|
||||||
|
[PARAM_LNG]: String(lng),
|
||||||
|
[PARAM_LAT_LNG]: "1",
|
||||||
|
[PARAM_COUNT]: String(count),
|
||||||
|
[PARAM_LANG]: this.lang,
|
||||||
|
[PARAM_WITH_FACTS]: withFacts ? "1" : "0",
|
||||||
|
};
|
||||||
|
if (realDistance)
|
||||||
|
params[PARAM_REAL_DISTANCE] = "1";
|
||||||
|
|
||||||
|
return this.sendRequest(params, CATEGORY_CITIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocationsByCellInfo(
|
||||||
|
mnc: number,
|
||||||
|
mcc: number,
|
||||||
|
lac: number,
|
||||||
|
cellId: number,
|
||||||
|
count: number,
|
||||||
|
withFacts: boolean,
|
||||||
|
realDistance: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
[PARAM_MNC]: String(mnc),
|
||||||
|
[PARAM_MCC]: String(mcc),
|
||||||
|
[PARAM_LAC]: String(lac),
|
||||||
|
[PARAM_CELLID]: String(cellId),
|
||||||
|
[PARAM_COUNT]: String(count),
|
||||||
|
[PARAM_LANG]: this.lang,
|
||||||
|
[PARAM_WITH_FACTS]: withFacts ? "1" : "0",
|
||||||
|
};
|
||||||
|
if (realDistance)
|
||||||
|
params[PARAM_REAL_DISTANCE] = "1";
|
||||||
|
|
||||||
|
return this.sendRequest(params, CATEGORY_CITIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocationsByIP(withFacts: boolean, count: number): Promise<string> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
[PARAM_MODE]: "ip",
|
||||||
|
[PARAM_NOCACHE]: "1",
|
||||||
|
[PARAM_LANG]: this.lang,
|
||||||
|
[PARAM_LAT_LNG]: "1",
|
||||||
|
[PARAM_WITH_FACTS]: withFacts ? "1" : "0",
|
||||||
|
[PARAM_COUNT]: String(count),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.sendRequest(params, CATEGORY_CITIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocalizedNamesForLocations(ids: number[]): Promise<string> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
[PARAM_ID]: ids.join(","),
|
||||||
|
[PARAM_LANG]: this.lang,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.sendRequest(params, CATEGORY_CITIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWeatherDataForLocations(cityId: number): Promise<GismeteoGetWeatherDataForLocationsResponse> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
[PARAM_CITY]: String(cityId),
|
||||||
|
[PARAM_LANG]: this.lang,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.sendRequest(params, CATEGORY_FORECAST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// static getWeatherDataForLocationsURL(
|
||||||
|
// cityId: number,
|
||||||
|
// lang: string,
|
||||||
|
// isDomainParamRequired = false,
|
||||||
|
// ): string {
|
||||||
|
// const params: Record<string, string> = {
|
||||||
|
// [PARAM_CITY]: String(cityId),
|
||||||
|
// [PARAM_LANG]: lang,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return GismeteoHttpRequest.prepareUrlString(params, CATEGORY_FORECAST);
|
||||||
|
// }
|
||||||
|
|
||||||
|
private static prepareUrlString(params: Record<string, string>, category: string): string {
|
||||||
|
const search = new URLSearchParams(params).toString();
|
||||||
|
const url = `${SERVICE_URL}${category}/?${search}`;
|
||||||
|
// console.debug("Gismeteo.GismeteoHttpRequest", url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendRequest<T>(params: Record<string, string>, category: string): Promise<T> {
|
||||||
|
|
||||||
|
if (this.isUaDomainParamRequired)
|
||||||
|
params[PARAM_DOMAIN] = "ua";
|
||||||
|
|
||||||
|
const url = GismeteoHttpRequest.prepareUrlString(params, category);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "User-Agent": this.getUserAgent() },
|
||||||
|
signal: controller.signal,
|
||||||
|
// cache: "no-store", // fetch has no built-in cache
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error;
|
||||||
|
if (err.name === "AbortError") {
|
||||||
|
throw new GismeteoError(GismeteoErrorCode.TIMEOUT, err.message);
|
||||||
|
}
|
||||||
|
// fetch кидает TypeError на DNS-ошибки/сетевые сбои — оригинал различал
|
||||||
|
// UnknownHostException отдельно, здесь для простоты сводим к IO_ERROR,
|
||||||
|
// если нужно точное различие — парсить err.cause.code === "ENOTFOUND".
|
||||||
|
const cause = (err as { cause?: { code?: string } }).cause;
|
||||||
|
if (cause?.code === "ENOTFOUND") {
|
||||||
|
throw new GismeteoError(GismeteoErrorCode.UNKNOWN_HOST, err.message);
|
||||||
|
}
|
||||||
|
throw new GismeteoError(GismeteoErrorCode.MALFORMED_URL_OR_IO_ERROR, err.message);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const utcTimestampHeader = response.headers.get(HEADER_PARAM_UTC_TIMESTAMP);
|
||||||
|
if (this.listener && utcTimestampHeader !== null) {
|
||||||
|
const seconds = parseInt(utcTimestampHeader, 10) || 0;
|
||||||
|
this.listener.onUpdateUTCDate(seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new GismeteoError(GismeteoErrorCode.BAD_RESPONSE, `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseXml = await response.text();
|
||||||
|
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false, // атрибуты (t=, p=, ws= и т.д.) — это почти все данные в этом XML
|
||||||
|
attributeNamePrefix: "", // без префикса @_ — атрибуты будут как обычные поля
|
||||||
|
parseAttributeValue: true, // "11" -> 11 (число), "1783486385" -> число и т.п.
|
||||||
|
isArray: (tagName) => ["day", "forecast", "item"].includes(tagName),
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(params), url, JSON.stringify(responseXml));
|
||||||
|
|
||||||
|
const responseObj = parser.parse(responseXml);
|
||||||
|
|
||||||
|
// console.log(JSON.stringify(params), JSON.stringify(responseObj));
|
||||||
|
|
||||||
|
return responseObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-22
@@ -1,29 +1,34 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { TextResult } from "../types";
|
import { InputShape, ToolDef } from "../types";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function logged<A extends any[]>(
|
export function logged<
|
||||||
name: string,
|
TShape extends InputShape | undefined = undefined,
|
||||||
handler: (...args: A) => Promise<TextResult>,
|
TOutShape extends InputShape | undefined = undefined,
|
||||||
): (...args: A) => Promise<TextResult> {
|
>(
|
||||||
return async (...args: A) => {
|
tool: ToolDef<TShape, TOutShape>,
|
||||||
const id = randomUUID().slice(0, 8);
|
): ToolDef<TShape, TOutShape> {
|
||||||
const t0 = process.hrtime.bigint();
|
return {
|
||||||
console.log(`[tool>] ${name} #${id} args=${JSON.stringify(args)}`);
|
...tool,
|
||||||
try {
|
handler: async (args, meta, extra) => {
|
||||||
const result = await handler(...args);
|
const id = randomUUID().slice(0, 8);
|
||||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
const t0 = process.hrtime.bigint();
|
||||||
console.log(
|
console.log(`[tool>] ${tool.title} #${id} args=${JSON.stringify(args)}`);
|
||||||
`[tool<] ${name} #${id} ${ms.toFixed(1)}ms` +
|
try {
|
||||||
`${result.isError ? " ERROR" : ""} result=${JSON.stringify(result).slice(0, 2000)}`,
|
const result = await tool.handler(args, meta, extra);
|
||||||
);
|
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||||
return result;
|
console.log(
|
||||||
} catch (err) {
|
`[tool<] ${tool.title} #${id} ${ms.toFixed(1)}ms` +
|
||||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
`${((typeof result !== 'string') && result.isError) ? " ERROR" : ""} result=${JSON.stringify(result).slice(0, 2000)}`,
|
||||||
console.error(`[tool!] ${name} #${id} ${ms.toFixed(1)}ms threw:`, err);
|
);
|
||||||
throw err; // let the SDK turn it into a JSON-RPC error
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||||
|
console.error(`[tool!] ${tool.title} #${id} ${ms.toFixed(1)}ms threw:`, err);
|
||||||
|
throw err; // let the SDK turn it into a JSON-RPC error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidHost(host: string): boolean {
|
export function isValidHost(host: string): boolean {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
"noEmit": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user