28 Commits
Author SHA1 Message Date
azykov@mail.ru 1ce23cfa6e calendar event formatting. outputSchema support
Build & Deploy / deploy (push) Canceled after 0s
Build & Deploy / build-app (push) Canceled after 0s
2026-07-26 11:50:35 +03:00
azykov@mail.ru d7cd47267a calendar event creation 2026-07-10 21:00:10 +03:00
azykov@mail.ru 6bd101cab4 readonly calendar access 2026-07-10 15:26:40 +03:00
azykov@mail.ru 3b46f15e9f code cleanup. gismeteo prompt injection 2026-07-10 15:25:56 +03:00
azykov@mail.ru 6cfff5ef4b typo fix 2026-07-09 10:33:45 +03:00
azykov@mail.ru be295c8f05 gismeteo weather support 2026-07-09 10:32:22 +03:00
azykov@mail.ru bc5b3572df logging refactoring 2026-07-09 10:32:10 +03:00
azykov@mail.ru 812e36d0af caddy-docker-proxy labels 2026-07-01 14:49:41 +03:00
azykov@mail.ru efeb580e46 attach container to caddy_default network 2026-07-01 13:53:38 +03:00
azykov@mail.ru 685492b8a2 Merge branch 'dev' of ssh://10.0.64.33:49022/anton/ClaudeMCP into dev 2026-07-01 13:43:22 +03:00
azykov@mail.ru ab985c8fc1 esbuild instead of tsc 2026-07-01 13:43:05 +03:00
azykov@mail.ru 464d66d82e esbuild instead of tsc 2026-07-01 13:29:04 +03:00
azykov@mail.ru 0d2b6f6067 fix for docker-compose image and container_name 2026-07-01 11:57:40 +03:00
azykov@mail.ru a5a1de354a docker login for deploy 2026-07-01 11:53:08 +03:00
azykov@mail.ru 6deb1c2462 fix main branch lock in deploy 2026-07-01 11:51:43 +03:00
azykov@mail.ru f8039e5007 test 2026-07-01 11:43:05 +03:00
azykov@mail.ru f3772c45cd IMAGE_PREFIX 2026-07-01 11:41:24 +03:00
azykov@mail.ru 68229414a3 IMAGE_PREFIX 2026-07-01 11:36:51 +03:00
azykov@mail.ru fc60f7f268 IMAGE_PREFIX 2026-07-01 11:35:48 +03:00
azykov@mail.ru 7c3f3c5acc IMAGE_PREFIX 2026-07-01 11:34:29 +03:00
azykov@mail.ru c068487103 docker compose in gitea_runner container fix 2026-07-01 11:26:58 +03:00
azykov@mail.ru 21bde6f57d test 1 2026-07-01 11:21:02 +03:00
azykov@mail.ru 1eb921f277 pnpm version lock to 11 2026-07-01 11:06:44 +03:00
azykov@mail.ru 02eaf3606b deploy: git checkout by git ref, not branch name 2026-07-01 11:01:21 +03:00
azykov@mail.ru 4a936b03d6 docker build fix 2026-07-01 10:55:42 +03:00
azykov@mail.ru 604c7cc0d5 fix docker image casing error 2026-07-01 10:52:01 +03:00
azykov@mail.ru aa867e2949 workflow_dispatch trigger for gitea actions 2026-07-01 10:46:48 +03:00
azykov@mail.ru 820ea95f26 deploy scripts 2026-07-01 10:42:49 +03:00
27 changed files with 1941 additions and 123 deletions
+100
View File
@@ -0,0 +1,100 @@
name: Build & Deploy
on:
[workflow_dispatch, push]
env:
REGISTRY: git.aggtaa.com
# IMAGE_PREFIX: ${{ gitea.repository }} # set by a step
jobs:
# ── BUILD SERVER ──────────────────────────────────────────────────────────────
build-app:
runs-on: docker1
outputs:
IMAGE_PREFIX: ${{ steps.prefix.outputs.IMAGE_PREFIX }}
defaults:
run:
shell: sh
working-directory: /workspace
steps:
- name: Checkout
working-directory: /
run: |
mkdir -p /workspace
cd /workspace
if [ -d .git ]; then
git remote set-url origin ${{ gitea.server_url }}/${{ gitea.repository }}.git
else
git init
git remote add origin ${{ gitea.server_url }}/${{ gitea.repository }}.git
fi
git fetch --prune origin ${{ gitea.ref_name }}
git checkout -f FETCH_HEAD
git reset --hard FETCH_HEAD
git clean -fdx
- name: Set lowercase image prefix
id: prefix
run: echo "IMAGE_PREFIX=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Build & push app
run: |
cd /workspace
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
/data/tools/docker-buildx build \
--cache-from type=registry,ref=${{ env.REGISTRY }}/${{ steps.prefix.outputs.IMAGE_PREFIX }}/app:cache \
--cache-to type=registry,ref=${{ env.REGISTRY }}/${{ steps.prefix.outputs.IMAGE_PREFIX }}/app:cache,mode=max \
--tag ${{ env.REGISTRY }}/${{ steps.prefix.outputs.IMAGE_PREFIX }}/app:latest \
--tag ${{ env.REGISTRY }}/${{ steps.prefix.outputs.IMAGE_PREFIX }}/app:${{ gitea.sha }} \
--file Dockerfile \
--push \
.
# ── DEPLOY ────────────────────────────────────────────────────────────────────
deploy:
runs-on: docker1
needs: [build-app]
if: always() && !failure() && !cancelled()
defaults:
run:
shell: sh
steps:
- name: Debug environment
run: |
hostname
cat /etc/hostname
docker --version
docker compose version || echo "compose plugin missing here"
ls /usr/libexec/docker/cli-plugins/ 2>/dev/null || echo "no plugins dir"
mount | grep docker || true
- name: Debug repo contents
run: |
cd "/app-cache/${{ gitea.repository }}"
ls -la
- name: Deploy
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
mkdir -p "/app-cache/${{ gitea.repository }}"
cd "/app-cache/${{ gitea.repository }}"
if [ ! -d .git ]; then
git clone ${{ gitea.server_url }}/${{ gitea.repository }}.git .
fi
git fetch --prune origin ${{ gitea.ref_name }}
git checkout -f FETCH_HEAD
git reset --hard FETCH_HEAD
git clean -fdx
printf 'REGISTRY=%s\nIMAGE_PREFIX=%s\nIMAGE_TAG=%s\nSERVICE_NAME=%s\n' \
'${{ env.REGISTRY }}' \
'${{ needs.build-app.outputs.IMAGE_PREFIX }}' \
'${{ gitea.sha }}' \
"$(basename '${{ needs.build-app.outputs.IMAGE_PREFIX }}')" > .env
/data/tools/docker-compose pull
/data/tools/docker-compose up -d --remove-orphans
docker image prune -f
+2
View File
@@ -1 +1,3 @@
node_modules node_modules
dist
.env
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
FROM node:22.22.3-alpine AS base
RUN corepack enable && corepack prepare pnpm@11 --activate
# ── deps ──────────────────────────────────────────────────────────────────────
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ── build ─────────────────────────────────────────────────────────────────────
FROM deps AS builder
WORKDIR /app
COPY tsconfig.json ./
COPY build.js ./
COPY src ./src
RUN pnpm build
# ── runtime ───────────────────────────────────────────────────────────────────
FROM node:22.22.3-alpine AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
+13
View File
@@ -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));
+6
View File
@@ -0,0 +1,6 @@
users:
- id: anton
calendars:
- uri: https://c.aggtaa.com/
username: anton
password: hP81HfY3a4UTEnaTVZWaZJ6bL6BvMYL2
+14
View File
@@ -0,0 +1,14 @@
services:
server:
image: ${REGISTRY}/${IMAGE_PREFIX}/app:latest
container_name: ${SERVICE_NAME}
restart: unless-stopped
labels:
caddy: mcp.aggtaa.com
caddy.reverse_proxy: "{{upstreams 3000}}"
networks:
- caddy_default
networks:
caddy_default:
external: true
+13 -9
View File
@@ -4,33 +4,37 @@
"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": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devEngines": { "engines": {
"packageManager": { "pnpm": ">=11.4.0 <12"
"name": "pnpm",
"version": "^11.4.0",
"onFail": "download"
}
}, },
"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",
+113 -1
View File
@@ -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):
+36
View File
@@ -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
View File
@@ -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,
}
}
}
}
} }
+177
View File
@@ -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) };
},
});
+86
View File
@@ -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)}` }],
};
}
},
});
+5 -3
View File
@@ -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';
+6 -7
View File
@@ -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
} },
});
+5 -7
View File
@@ -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,
) )
}], }],
})), }),
} });
+6 -6
View File
@@ -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}` }],
}; };
} }
}), },
}; });
+15 -17
View File
@@ -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
View File
@@ -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);
+11
View File
@@ -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>;
+29
View File
@@ -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
View File
@@ -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
};
}
+590
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// -------------------------------------------------------------------
// 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());
}
+114
View File
@@ -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));
}
+63
View File
@@ -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];
}
+336
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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,