From 1ce23cfa6e1a055a04acab2a911e3b99bdde3076 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Sun, 26 Jul 2026 11:50:35 +0300 Subject: [PATCH] calendar event formatting. outputSchema support --- package.json | 2 + pnpm-lock.yaml | 17 +++++ src/mcpConnection.ts | 27 ++++++- src/mcpTools/caldav.ts | 113 +++++++++++++++++---------- src/mcpTools/gismeteo_weather.ts | 16 ++-- src/mcpTools/index.ts | 4 +- src/mcpTools/invokeTool.ts | 10 +-- src/mcpTools/listTools.ts | 5 +- src/mcpTools/ping.ts | 6 +- src/mcpTools/testAddRemoveTool.ts | 19 +++-- src/server.ts | 66 ++++++++-------- src/types/calendar.ts | 11 +++ src/types/calendarEvent.ts | 29 +++++++ src/types/index.ts | 53 +++++++++++-- src/utils/caldavServerConnection.ts | 67 ++-------------- src/utils/formatCalendarEvent.ts | 114 ++++++++++++++++++++++++++++ src/utils/logged.ts | 15 ++-- 17 files changed, 393 insertions(+), 181 deletions(-) create mode 100644 src/types/calendar.ts create mode 100644 src/types/calendarEvent.ts create mode 100644 src/utils/formatCalendarEvent.ts diff --git a/package.json b/package.json index d0ba6c2..6d42927 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,14 @@ "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/luxon": "^3.7.2", "@types/node": "^22.20.0", "esbuild": "^0.28.1", "eslint": "^9.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11b6999..61aac05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: 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 @@ -235,6 +238,9 @@ 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.20.0 version: 22.20.0 @@ -522,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==} @@ -1079,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'} @@ -1642,6 +1655,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/luxon@3.7.2': {} + '@types/node@22.20.0': dependencies: undici-types: 6.21.0 @@ -2280,6 +2295,8 @@ snapshots: lodash.merge@4.6.2: {} + luxon@3.7.2: {} + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} diff --git a/src/mcpConnection.ts b/src/mcpConnection.ts index 60a3492..48d0003 100644 --- a/src/mcpConnection.ts +++ b/src/mcpConnection.ts @@ -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 { TextResult, 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; @@ -37,7 +39,7 @@ export default class McpSession { await this.mcp.connect(this.transport); } - private registerTools(...tools: ToolDef[]) { + private registerTools(...tools: ToolDef[]) { const toolsToRegister = tools .filter((tool) => !this.registeredTools.find((rtool) => rtool.title === tool.title)); const toolsToUnregister = this.registeredTools @@ -57,12 +59,12 @@ export default class McpSession { inputSchema: {}, ...tool.config, }, - tool.handler, + (args, extra) => tool.handler(args, McpSession.parseRequestMeta(extra), extra), )) ); } - public handleToolListChange(tools: ToolDef[], sendToolListChangedNotification: boolean = true): void { + public handleToolListChange(tools: ToolDef[], sendToolListChangedNotification: boolean = true): void { this.registerTools(...tools); if (sendToolListChangedNotification) this.mcp.sendToolListChanged(); @@ -72,4 +74,21 @@ export default class McpSession { reply.hijack(); await this.transport.handleRequest(request.raw, reply.raw, request.body); } + + private static parseRequestMeta(extra: RequestHandlerExtra): McpRequestMeta { + + const meta = extra._meta?.['com.aggtaa']; + + return { + env: { + user: { + username: 'fake', + }, + language: meta as string, + location: { + timezone: 0, + } + } + } + } } diff --git a/src/mcpTools/caldav.ts b/src/mcpTools/caldav.ts index 62ad91c..31a416a 100644 --- a/src/mcpTools/caldav.ts +++ b/src/mcpTools/caldav.ts @@ -1,7 +1,10 @@ -import { ToolDef } from "../types"; -import { CaldavServerConnection, CalendarEvent, RecurrenceFreqValues } from "../utils/caldavServerConnection"; +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]; @@ -14,18 +17,21 @@ function getCaldavConnection(): CaldavServerConnection { ); } -export const caldavListCalendarsTool: ToolDef = { +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 JSON.stringify(calendars); + return { calendars }; }, -}; +}); -export const caldavGetCalendarEventsForDateRangeTool: ToolDef = { +server.registerTool({ title: "caldav-get-calendar-events-for-date-range", config: { description: "Get calendar events for date range", @@ -33,87 +39,109 @@ export const caldavGetCalendarEventsForDateRangeTool: ToolDef = { 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 }) => { + handler: async ({ calendarUrl, start, end }, meta) => { const conn = await getCaldavConnection(); - const events = await conn.getCalendarEventsForDateRange(calendarUrl, new Date(start), new Date(end)); - return JSON.stringify(events); + const events = + (await conn.getCalendarEventsForDateRange(calendarUrl as string, start, end)) + .map((e) => formatCalendarEvent(meta.env, e)); + return { events } }, -}; +}); -export const caldavGetAllCalendarsEventsForDateRangeTool: ToolDef = { +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 }) => { + handler: async ({ start, end }, meta) => { const conn = await getCaldavConnection(); - const events: CalendarEvent[] = []; + const events: FormattedCalendarEvent[] = []; for (const calendar of await conn.listCalendars()) - events.push(...await conn.getCalendarEventsForDateRange(calendar.url, new Date(start), new Date(end))); - CaldavServerConnection.sort(events); - return JSON.stringify(events); + events.push(...formatCalendarEvents(meta.env, await conn.getCalendarEventsForDateRange(calendar.url, start, end))); + events.sort((a, b) => compareCalendarEvents(a.raw, b.raw)); + return { events } }, -}; +}); -export const caldavGetCalendarEventsForTodayTool: ToolDef = { +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 }) => { + handler: async ({ calendarUrl }, meta) => { const today = new Date(); today.setHours(0, 0, 0, 0); const conn = await getCaldavConnection(); - const events = await conn.getCalendarEventsForDateRange(calendarUrl, today, today); - return JSON.stringify(events); + const events = formatCalendarEvents(meta.env, await conn.getCalendarEventsForDateRange(calendarUrl, today, today)); + return { events }; }, -}; +}); -export const caldavGetAllCalendarsEventsForTodayTool: ToolDef = { +server.registerTool({ title: "caldav-get-all-calendars-events-for-today", config: { description: "Get events for today from all calendars available to user", - inputSchema: { - } + outputSchema: { + events: z.array(FormattedCalendarEventSchema), + }, }, - handler: async () => { + handler: async (_, meta) => { const today = new Date(); today.setHours(0, 0, 0, 0); const conn = await getCaldavConnection(); - const events: CalendarEvent[] = []; + const events: FormattedCalendarEvent[] = []; for (const calendar of await conn.listCalendars()) - events.push(...await conn.getCalendarEventsForDateRange(calendar.url, today, today)); - CaldavServerConnection.sort(events); - return JSON.stringify(events); + 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 }; }, -}; +}); -export const caldavGetCalendarEventByIdTool: ToolDef = { +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 }) => { + handler: async ({ calendarUrl, eventId }, meta) => { const conn = await getCaldavConnection(); const event = await conn.getCalendarEventById(calendarUrl, eventId); - return event ? JSON.stringify(event) : `No event found with id "${eventId}"`; + return { + event: event + ? formatCalendarEvent(meta.env, event) + : `No event found with id "${eventId}"`, + }; }, -}; +}); -export const caldavCreateCalendarEventTool: ToolDef = { +server.registerTool({ title: "caldav-create-calendar-event", config: { description: "Create calendar event. Returns the event created", @@ -136,11 +164,14 @@ export const caldavCreateCalendarEventTool: ToolDef = { 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 }) => { + handler: async ({ calendarUrl, eventInput }, meta) => { const conn = await getCaldavConnection(); const event = await conn.createCalendarEvent(calendarUrl, eventInput); - return JSON.stringify(event); + return { event: formatCalendarEvent(meta.env, event) }; }, -}; +}); diff --git a/src/mcpTools/gismeteo_weather.ts b/src/mcpTools/gismeteo_weather.ts index e9badf8..b359974 100644 --- a/src/mcpTools/gismeteo_weather.ts +++ b/src/mcpTools/gismeteo_weather.ts @@ -1,9 +1,9 @@ import z from "zod"; -import { ToolDef } from "../types" import { GismeteoHttpRequest } from "../utils/gismeteoReqest"; import { gismeteoLocationCache } from "../utils/gismeteoLocationCache"; +import { server } from "../server"; -export const weatherFindCitiesByNameTool: ToolDef = { +server.registerTool({ title: "weather-find-locations-by-name", config: { description: `Authoritative weather forecast with most actual full forecast results from gismeteo.ru. @@ -26,15 +26,15 @@ Find location (city, place or airport) id for futher forecast requests by its fu }; } }, -}; +}); -export const weatherGetWeatherByLocationIdTool: ToolDef = { +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.string().describe("Location id to get weather data for"), + 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"), }, }, @@ -51,9 +51,9 @@ Returns current weather condition and multi-day forecast for a location. Use wea }; } }, -}; +}); -export const weatherGetWeatherByLocationNameTool: ToolDef = { +server.registerTool({ title: "weather-get-weather-by-location-name", config: { description: `Authoritative weather forecast with most actual full forecast results from gismeteo.ru. @@ -83,4 +83,4 @@ Returns current weather condition and multi-day forecast for one or more locatio }; } }, -}; +}); diff --git a/src/mcpTools/index.ts b/src/mcpTools/index.ts index cab13b2..b3503c5 100644 --- a/src/mcpTools/index.ts +++ b/src/mcpTools/index.ts @@ -2,5 +2,5 @@ export * from './ping'; // export * from './listTools'; // export * from './invokeTool'; // export * from './testAddRemoveTool'; -export * from './gismeteo_weather'; -export * from './caldav'; +// export * from './gismeteo_weather'; +// export * from './caldav'; diff --git a/src/mcpTools/invokeTool.ts b/src/mcpTools/invokeTool.ts index 7d7d42e..9f940d8 100644 --- a/src/mcpTools/invokeTool.ts +++ b/src/mcpTools/invokeTool.ts @@ -1,8 +1,7 @@ import z from "zod"; import { server } from "../server"; -import { ToolDef } from "../types"; -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.", @@ -11,10 +10,11 @@ export const invokeToolTool: ToolDef = { args: z.record(z.any()).default({}), }, }, - handler: 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 }, -} +}); diff --git a/src/mcpTools/listTools.ts b/src/mcpTools/listTools.ts index 3f6fb05..5ebe43f 100644 --- a/src/mcpTools/listTools.ts +++ b/src/mcpTools/listTools.ts @@ -1,9 +1,8 @@ import z from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { server } from "../server"; -import { ToolDef } from "../types"; -export const listToolsTool: ToolDef = { +server.registerTool({ title: "list_tools", config: { description: "Return the authoritative current list of available local tools with their schemas.", @@ -26,4 +25,4 @@ export const listToolsTool: ToolDef = { ) }], }), -} +}); diff --git a/src/mcpTools/ping.ts b/src/mcpTools/ping.ts index f5ef1a4..0c3f9fb 100644 --- a/src/mcpTools/ping.ts +++ b/src/mcpTools/ping.ts @@ -1,12 +1,12 @@ import z from "zod"; -import { ToolDef } from "../types" 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.", @@ -41,4 +41,4 @@ export const pingTool: ToolDef = { }; } }, -}; +}); diff --git a/src/mcpTools/testAddRemoveTool.ts b/src/mcpTools/testAddRemoveTool.ts index 72915ae..50c4b8d 100644 --- a/src/mcpTools/testAddRemoveTool.ts +++ b/src/mcpTools/testAddRemoveTool.ts @@ -1,37 +1,36 @@ -import { ToolDef } from "../types" import { server } from "../server"; +import { defineTool } from "../types" -const dynamicTool: ToolDef = { +const dynamicTool = defineTool({ title: "say_hello", config: { description: "Says hello", - inputSchema: {}, }, 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: async () => { - server.registerTools(dynamicTool); + 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: async () => { - server.unregisterTools(dynamicTool.title); + server.unregisterTool(dynamicTool.title); return { content: [{ type: "text", text: "Tool removed" }] }; }, -}; +}); diff --git a/src/server.ts b/src/server.ts index 2963364..299978c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,7 @@ import Fastify, { type FastifyInstance } from "fastify"; -import { TextResult, ToolDef } from "./types"; +import { InputShape, ToolDef } from "./types"; import McpSession from "./mcpConnection"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import * as mcpTools from "./mcpTools"; import { logged } from "./utils/logged"; import cors from '@fastify/cors'; @@ -10,7 +9,8 @@ export class Server { private readonly app: FastifyInstance; private readonly port: number; private readonly sessions: McpSession[] = []; - public readonly tools: Record> = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly tools: Record> = {}; constructor(port: number = Number(process.env.PORT ?? 3000)) { this.port = port; @@ -58,40 +58,43 @@ export class Server { return this.tools[name]; } - public registerTools(...tools: ToolDef[]): void { - for (const tool of tools) { - if (!this.getTool(tool.title)) { - // console.log(`Registering new tool ${tool.title}`); - const toolLogged = logged(tool); - this.tools[tool.title] = { - ...toolLogged, - handler: async (args) => { - try { - const res = await toolLogged.handler(args); - 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) }], - }; - } - }, - }; - } + public registerTool< + TShape extends InputShape | undefined = undefined, + TOutShape extends InputShape | undefined = undefined, + >( + tool: ToolDef, + ): 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(); } @@ -186,4 +189,3 @@ export class Server { } export const server = new Server(); -server.registerTools(...Object.values(mcpTools)); \ No newline at end of file diff --git a/src/types/calendar.ts b/src/types/calendar.ts new file mode 100644 index 0000000..0b52f0d --- /dev/null +++ b/src/types/calendar.ts @@ -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; + diff --git a/src/types/calendarEvent.ts b/src/types/calendarEvent.ts new file mode 100644 index 0000000..ffea139 --- /dev/null +++ b/src/types/calendarEvent.ts @@ -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; + +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; diff --git a/src/types/index.ts b/src/types/index.ts index 9f1af4a..26b7870 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; -}; \ No newline at end of file + handler: ( + args: TShape extends InputShape ? z.infer> : Record, + meta: McpRequestMeta, + extra: RequestHandlerExtra, + ) => Promise< + TOutShape extends InputShape + ? z.infer> + : string | TextResult + >; +}; + +export function defineTool< + TShape extends InputShape | undefined = undefined, + TOutShape extends InputShape | undefined = undefined, +>( + def: ToolDef, +): ToolDef { + 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 + }; +} \ No newline at end of file diff --git a/src/utils/caldavServerConnection.ts b/src/utils/caldavServerConnection.ts index cb4fdcd..5a5c158 100644 --- a/src/utils/caldavServerConnection.ts +++ b/src/utils/caldavServerConnection.ts @@ -1,50 +1,14 @@ -/** - * CaldavServerConnection - * - * Minimal-dependency CalDAV client. Talks CalDAV/WebDAV directly over - * PROPFIND/REPORT/PUT/DELETE using the platform fetch (Node >= 18). - * - * Dependencies: - * npm i ical.js fast-xml-parser - * npm i -D @types/ical.js (types are incomplete/loose; see the `as any` - * casts below where the upstream .d.ts is thin) - * - * Assumption: `serverUrl` passed to the constructor is the CalDAV - * *collection root* for a single user (e.g. a Radicale/Baikal user's home, - * such as https://caldav.example.com/dav/anton/), i.e. the URL you'd PROPFIND - * with Depth: 1 to enumerate calendars. This class does not perform - * .well-known / current-user-principal / calendar-home-set discovery — if - * your server needs that, resolve it once externally and pass the resulting - * home-set URL in here. - * - * Known limitation: recurrence EXDATE/RECURRENCE-ID overrides (exceptions to - * a recurring series) are not merged into expanded occurrences — each - * VEVENT component in a calendar-data block is parsed independently, so an - * override component will surface as its own event rather than replacing - * the corresponding master occurrence. Fine for read-mostly use; flag if you - * need proper exception merging and it can be added. - */ - import ICAL from "ical.js"; import { XMLParser } from "fast-xml-parser"; import { randomUUID } from "node:crypto"; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- +import { CalendarEvent } from "../types/calendarEvent"; +import { CalendarInfo } from "../types/calendar"; export interface CaldavAuth { username: string; password: string; } -export interface CalendarInfo { - url: string; - displayName: string; - ctag?: string; - color?: string; -} - export const RecurrenceFreqValues = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"] as const; export type RecurrenceFreq = typeof RecurrenceFreqValues[number]; @@ -68,21 +32,6 @@ export interface CalendarEventInput { recurrence?: RecurrenceRule; } -export interface CalendarEvent { - id: string; // UID - url: string; // absolute resource URL (the .ics resource for the master) - etag?: string; - summary: string; - description?: string; - location?: string; - start: Date; - end: Date; - allDay: boolean; - recurrenceRule?: string; // raw RRULE value, if any, from the master - isRecurrenceInstance?: boolean; - recurrenceId?: Date; -} - export class CaldavError extends Error { constructor( message: string, @@ -148,10 +97,6 @@ interface DavMultistatus

{ }; } -// --------------------------------------------------------------------------- -// Implementation -// --------------------------------------------------------------------------- - export class CaldavServerConnection { private readonly baseUrl: string; private readonly auth?: CaldavAuth; @@ -275,7 +220,7 @@ export class CaldavServerConnection { } } - CaldavServerConnection.sort(events); + events.sort(compareCalendarEvents); return events; } @@ -638,8 +583,8 @@ export class CaldavServerConnection { } return results; } +} - public static sort(events: CalendarEvent[]) { - events.sort((a, b) => Math.abs(a.start.getDate() - b.start.getDate()) * 10 + Math.abs(a.end.getDate() - b.end.getDate())); - } +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()); } \ No newline at end of file diff --git a/src/utils/formatCalendarEvent.ts b/src/utils/formatCalendarEvent.ts new file mode 100644 index 0000000..3e168a3 --- /dev/null +++ b/src/utils/formatCalendarEvent.ts @@ -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)); +} diff --git a/src/utils/logged.ts b/src/utils/logged.ts index 1c80191..9cb3da0 100644 --- a/src/utils/logged.ts +++ b/src/utils/logged.ts @@ -1,18 +1,21 @@ import { randomUUID } from "node:crypto"; -import { TextResult, ToolDef } from "../types"; +import { InputShape, ToolDef } from "../types"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function logged( - tool: ToolDef, -): ToolDef { +export function logged< + TShape extends InputShape | undefined = undefined, + TOutShape extends InputShape | undefined = undefined, +>( + tool: ToolDef, +): ToolDef { return { ...tool, - handler: async (...args: A): Promise => { + 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); + 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` +