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
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY build.js ./
|
||||
COPY src ./src
|
||||
|
||||
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
|
||||
container_name: ${SERVICE_NAME}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
labels:
|
||||
caddy: mcp.aggtaa.com
|
||||
caddy.reverse_proxy: "{{upstreams 3000}}"
|
||||
networks:
|
||||
- local
|
||||
- caddy_default
|
||||
|
||||
networks:
|
||||
local:
|
||||
driver: bridge
|
||||
caddy_default:
|
||||
external: true
|
||||
+13
-4
@@ -4,11 +4,11 @@
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsc",
|
||||
"build": "tsc && node build.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -17,19 +17,28 @@
|
||||
"engines": {
|
||||
"pnpm": ">=11.4.0 <12"
|
||||
},
|
||||
"type": "module",
|
||||
"types": "node",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
||||
"fast-xml-parser": "^5.9.3",
|
||||
"fastify": "^5.2.1",
|
||||
"ical.js": "^2.2.1",
|
||||
"luxon": "^3.7.2",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+113
-1
@@ -207,12 +207,27 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fastify/cors':
|
||||
specifier: ^11.2.0
|
||||
version: 11.2.0
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.13.1
|
||||
version: 1.29.0(zod@3.25.76)
|
||||
fast-xml-parser:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
fastify:
|
||||
specifier: ^5.2.1
|
||||
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:
|
||||
specifier: ^3.24.2
|
||||
version: 3.25.76
|
||||
@@ -223,9 +238,15 @@ importers:
|
||||
'@eslint/js':
|
||||
specifier: ^9.21.0
|
||||
version: 9.39.4
|
||||
'@types/luxon':
|
||||
specifier: ^3.7.2
|
||||
version: 3.7.2
|
||||
'@types/node':
|
||||
specifier: ^22.13.5
|
||||
specifier: ^22.20.0
|
||||
version: 22.20.0
|
||||
esbuild:
|
||||
specifier: ^0.28.1
|
||||
version: 0.28.1
|
||||
eslint:
|
||||
specifier: ^9.21.0
|
||||
version: 9.39.4
|
||||
@@ -441,6 +462,9 @@ packages:
|
||||
'@fastify/ajv-compiler@4.0.5':
|
||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||
|
||||
'@fastify/error@4.2.0':
|
||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||
|
||||
@@ -492,6 +516,9 @@ packages:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@nodable/entities@2.2.0':
|
||||
resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
@@ -501,6 +528,9 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/luxon@3.7.2':
|
||||
resolution: {integrity: sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==}
|
||||
|
||||
'@types/node@22.20.0':
|
||||
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
|
||||
|
||||
@@ -598,6 +628,9 @@ packages:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
anynum@1.0.1:
|
||||
resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -847,6 +880,16 @@ packages:
|
||||
fast-uri@3.1.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-VMS5lE0zj+MZlJpQa3Qv5iGjfun0H2N7VRgoBwpcTNQ2bdIQpv7fDpb+HGteGbicBsGkzGS+X+hdx9mmrfWuHQ==}
|
||||
|
||||
@@ -944,6 +987,9 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
ical.js@2.2.1:
|
||||
resolution: {integrity: sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -990,6 +1036,9 @@ packages:
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
is-unsafe@1.0.1:
|
||||
resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -1039,6 +1088,10 @@ packages:
|
||||
lodash.merge@4.6.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1122,6 +1175,10 @@ packages:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
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:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1293,6 +1350,9 @@ packages:
|
||||
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
strnum@2.4.1:
|
||||
resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1370,6 +1430,15 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1514,6 +1583,11 @@ snapshots:
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
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/fast-json-stringify-compiler@5.0.3':
|
||||
@@ -1573,12 +1647,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nodable/entities@2.2.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/luxon@3.7.2': {}
|
||||
|
||||
'@types/node@22.20.0':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -1709,6 +1787,8 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
anynum@1.0.1: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
@@ -2005,6 +2085,22 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
@@ -2119,6 +2215,8 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
ical.js@2.2.1: {}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -2150,6 +2248,8 @@ snapshots:
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-unsafe@1.0.1: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jose@6.2.3: {}
|
||||
@@ -2195,6 +2295,8 @@ snapshots:
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
@@ -2262,6 +2364,8 @@ snapshots:
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-expression-matcher@1.6.1: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
@@ -2451,6 +2555,10 @@ snapshots:
|
||||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
||||
strnum@2.4.1:
|
||||
dependencies:
|
||||
anynum: 1.0.1
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -2519,6 +2627,10 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xml-naming@0.1.0: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
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();
|
||||
+31
-13
@@ -2,7 +2,9 @@ import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.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 {
|
||||
private readonly transport: StreamableHTTPServerTransport;
|
||||
@@ -26,7 +28,7 @@ export default class McpSession {
|
||||
|
||||
private buildMcpServer(): 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 } } },
|
||||
);
|
||||
|
||||
@@ -37,7 +39,7 @@ export default class McpSession {
|
||||
await this.mcp.connect(this.transport);
|
||||
}
|
||||
|
||||
private registerTools(...tools: ToolDef[]) {
|
||||
private registerTools(...tools: ToolDef<unknown, TextResult>[]) {
|
||||
const toolsToRegister = tools
|
||||
.filter((tool) => !this.registeredTools.find((rtool) => rtool.title === tool.title));
|
||||
const toolsToUnregister = this.registeredTools
|
||||
@@ -51,19 +53,18 @@ export default class McpSession {
|
||||
|
||||
this.registeredTools.push(
|
||||
...toolsToRegister
|
||||
.map(
|
||||
(tool) => this.mcp.registerTool(
|
||||
tool.title,
|
||||
// ugly workaround for TS2589 error
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tool.config as any,
|
||||
tool.handler,
|
||||
)
|
||||
)
|
||||
.map((tool) => this.mcp.registerTool(
|
||||
tool.title,
|
||||
{
|
||||
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);
|
||||
if (sendToolListChangedNotification)
|
||||
this.mcp.sendToolListChanged();
|
||||
@@ -73,4 +74,21 @@ export default class McpSession {
|
||||
reply.hijack();
|
||||
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 './listTools';
|
||||
export * from './invokeTool';
|
||||
export * from './testAddRemoveTool';
|
||||
// export * from './listTools';
|
||||
// export * from './invokeTool';
|
||||
// export * from './testAddRemoveTool';
|
||||
// export * from './gismeteo_weather';
|
||||
// export * from './caldav';
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import z from "zod";
|
||||
import { server } from "../server";
|
||||
import { ToolDef } from "../types";
|
||||
import { logged } from "../utils/logged";
|
||||
|
||||
export const invokeToolTool: ToolDef = {
|
||||
server.registerTool({
|
||||
title: "invoke_tool",
|
||||
config: {
|
||||
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({}),
|
||||
},
|
||||
},
|
||||
handler: logged('invoke_tool', async ({ name, args }) => {
|
||||
handler: async ({ name, args }, meta, extra) => {
|
||||
const tool = server.tools[name];
|
||||
if (!tool)
|
||||
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
|
||||
},
|
||||
});
|
||||
|
||||
+10
-18
@@ -1,36 +1,28 @@
|
||||
import z from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import { server } from "../server";
|
||||
import { ToolDef } from "../types";
|
||||
import { logged } from "../utils/logged";
|
||||
|
||||
export const listToolsTool: ToolDef = {
|
||||
server.registerTool({
|
||||
title: "list_tools",
|
||||
config: {
|
||||
description: "Return the authoritative current list of available local tools with their schemas.",
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: logged('list_tools', async () => ({
|
||||
handler: async () => ({
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
Object.values(server.tools)
|
||||
.map((tool) => {
|
||||
// ugly workaround for TS2589 error
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const zodObject = (z.object as any)(tool.config.inputSchema);
|
||||
const inputSchema = zodToJsonSchema(zodObject, { target: "jsonSchema7" });
|
||||
return {
|
||||
...tool,
|
||||
config: {
|
||||
...tool.config,
|
||||
inputSchema,
|
||||
}
|
||||
.map((tool) => ({
|
||||
...tool,
|
||||
config: {
|
||||
...tool.config,
|
||||
inputSchema: zodToJsonSchema(z.object(tool.config.inputSchema ?? {}), { target: "jsonSchema7" }),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}],
|
||||
})),
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import z from "zod";
|
||||
import { ToolDef } from "../types"
|
||||
import { isValidHost, logged } from "../utils/logged";
|
||||
import { isValidHost } from "../utils/logged";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { server } from "../server";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const pingTool: ToolDef = {
|
||||
server.registerTool({
|
||||
title: "ping",
|
||||
config: {
|
||||
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"),
|
||||
},
|
||||
},
|
||||
handler: logged('ping', async ({ host, count }) => {
|
||||
handler: async ({ host, count }) => {
|
||||
if (!isValidHost(host)) {
|
||||
return {
|
||||
isError: true,
|
||||
@@ -40,5 +40,5 @@ export const pingTool: ToolDef = {
|
||||
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 { defineTool } from "../types"
|
||||
|
||||
const dynamicTool: ToolDef = {
|
||||
const dynamicTool = defineTool({
|
||||
title: "say_hello",
|
||||
config: {
|
||||
description: "Says hello",
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: logged('say_hello', async () => {
|
||||
handler: async () => {
|
||||
return { content: [{ type: "text", text: "hello" }] };
|
||||
}),
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const testAddTool: ToolDef = {
|
||||
server.registerTool({
|
||||
title: "test_add_tool",
|
||||
config: {
|
||||
description: "Dynamically add a new tool in runtime",
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: logged('test_add_tool', async () => {
|
||||
server.registerTools(dynamicTool);
|
||||
handler: async () => {
|
||||
server.registerTool(dynamicTool);
|
||||
return { content: [{ type: "text", text: "New tool added" }] };
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const testRemoveTool: ToolDef = {
|
||||
server.registerTool({
|
||||
title: "test_remove_tool",
|
||||
config: {
|
||||
description: "Dynamically remove a tool in runtime",
|
||||
inputSchema: {},
|
||||
},
|
||||
handler: logged('test_remove_tool', async () => {
|
||||
server.unregisterTools(dynamicTool.title);
|
||||
handler: async () => {
|
||||
server.unregisterTool(dynamicTool.title);
|
||||
return { content: [{ type: "text", text: "Tool removed" }] };
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
+63
-38
@@ -1,47 +1,35 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { ToolDef } from "./types";
|
||||
import { InputShape, ToolDef } from "./types";
|
||||
import McpSession from "./mcpConnection";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types";
|
||||
import { pingTool, testAddTool, testRemoveTool, listToolsTool, invokeToolTool } from "./mcpTools";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { logged } from "./utils/logged";
|
||||
import cors from '@fastify/cors';
|
||||
|
||||
export class Server {
|
||||
private readonly app: FastifyInstance;
|
||||
private readonly port: number;
|
||||
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)) {
|
||||
this.port = port;
|
||||
this.app = Fastify({
|
||||
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: {
|
||||
// level: process.env.LOG_LEVEL ?? "info",
|
||||
// ...(process.env.NODE_ENV !== "production"
|
||||
// ? {
|
||||
// transport: {
|
||||
// target: "pino-pretty",
|
||||
// options: { colorize: true, translateTime: "HH:MM:ss.l", ignore: "pid,hostname" },
|
||||
// },
|
||||
// }
|
||||
// : {}),
|
||||
// serializers: {
|
||||
// req(request) {
|
||||
// return {
|
||||
// method: request.method,
|
||||
// url: request.url,
|
||||
// host: request.headers.host,
|
||||
// remoteAddress: request.ip,
|
||||
// headers: request.headers, // <-- full header set
|
||||
// };
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
});
|
||||
this.registerLogging();
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
private registerLogging(): void {
|
||||
@@ -70,26 +58,53 @@ export class Server {
|
||||
return this.tools[name];
|
||||
}
|
||||
|
||||
public registerTools(...tools: ToolDef[]): void {
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!this.getTool(tool.title))
|
||||
this.tools[tool.title] = tool;
|
||||
public registerTool<
|
||||
TShape extends InputShape | undefined = undefined,
|
||||
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();
|
||||
}
|
||||
|
||||
public unregisterTools(...names: string[]): void {
|
||||
for (const name of names)
|
||||
delete (this.tools[name]);
|
||||
public unregisterTool(name: string): void {
|
||||
delete (this.tools[name]);
|
||||
|
||||
this.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))
|
||||
session.handleToolListChange(Object.values(this.tools));
|
||||
session.handleToolListChange(toolList);
|
||||
}
|
||||
|
||||
public getSession(sessionId: string): McpSession | undefined {
|
||||
@@ -101,6 +116,9 @@ export class Server {
|
||||
// underlying Node req/res it expects.
|
||||
private registerRoutes(): void {
|
||||
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;
|
||||
|
||||
@@ -157,10 +175,17 @@ export class Server {
|
||||
}
|
||||
|
||||
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" });
|
||||
this.app.log.info(`MCP ping server listening on http://0.0.0.0:${this.port}/mcp`);
|
||||
}
|
||||
}
|
||||
|
||||
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>;
|
||||
+47
-6
@@ -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 = {
|
||||
content: {
|
||||
@@ -8,12 +10,51 @@ export type TextResult = {
|
||||
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;
|
||||
config: {
|
||||
description: string;
|
||||
inputSchema: ZodRawShape,
|
||||
inputSchema?: TShape;
|
||||
outputSchema?: TOutShape;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handler: (...args: any[]) => Promise<TextResult>;
|
||||
};
|
||||
handler: (
|
||||
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 { TextResult } from "../types";
|
||||
import { InputShape, ToolDef } from "../types";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function logged<A extends any[]>(
|
||||
name: string,
|
||||
handler: (...args: A) => Promise<TextResult>,
|
||||
): (...args: A) => Promise<TextResult> {
|
||||
return async (...args: A) => {
|
||||
const id = randomUUID().slice(0, 8);
|
||||
const t0 = process.hrtime.bigint();
|
||||
console.log(`[tool>] ${name} #${id} args=${JSON.stringify(args)}`);
|
||||
try {
|
||||
const result = await handler(...args);
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
console.log(
|
||||
`[tool<] ${name} #${id} ${ms.toFixed(1)}ms` +
|
||||
`${result.isError ? " ERROR" : ""} result=${JSON.stringify(result).slice(0, 2000)}`,
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
console.error(`[tool!] ${name} #${id} ${ms.toFixed(1)}ms threw:`, err);
|
||||
throw err; // let the SDK turn it into a JSON-RPC error
|
||||
export function logged<
|
||||
TShape extends InputShape | undefined = undefined,
|
||||
TOutShape extends InputShape | undefined = undefined,
|
||||
>(
|
||||
tool: ToolDef<TShape, TOutShape>,
|
||||
): ToolDef<TShape, TOutShape> {
|
||||
return {
|
||||
...tool,
|
||||
handler: async (args, meta, extra) => {
|
||||
const id = randomUUID().slice(0, 8);
|
||||
const t0 = process.hrtime.bigint();
|
||||
console.log(`[tool>] ${tool.title} #${id} args=${JSON.stringify(args)}`);
|
||||
try {
|
||||
const result = await tool.handler(args, meta, extra);
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
console.log(
|
||||
`[tool<] ${tool.title} #${id} ${ms.toFixed(1)}ms` +
|
||||
`${((typeof result !== 'string') && result.isError) ? " ERROR" : ""} result=${JSON.stringify(result).slice(0, 2000)}`,
|
||||
);
|
||||
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 {
|
||||
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user