calendar event formatting. outputSchema support
Build & Deploy / build-app (push) Canceled after 0s
Build & Deploy / deploy (push) Canceled after 0s

This commit is contained in:
azykov@mail.ru
2026-07-26 11:50:35 +03:00
parent d7cd47267a
commit 1ce23cfa6e
17 changed files with 393 additions and 181 deletions
+2
View File
@@ -25,12 +25,14 @@
"fast-xml-parser": "^5.9.3", "fast-xml-parser": "^5.9.3",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"ical.js": "^2.2.1", "ical.js": "^2.2.1",
"luxon": "^3.7.2",
"yaml": "^2.9.0", "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/luxon": "^3.7.2",
"@types/node": "^22.20.0", "@types/node": "^22.20.0",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"eslint": "^9.21.0", "eslint": "^9.21.0",
+17
View File
@@ -222,6 +222,9 @@ importers:
ical.js: ical.js:
specifier: ^2.2.1 specifier: ^2.2.1
version: 2.2.1 version: 2.2.1
luxon:
specifier: ^3.7.2
version: 3.7.2
yaml: yaml:
specifier: ^2.9.0 specifier: ^2.9.0
version: 2.9.0 version: 2.9.0
@@ -235,6 +238,9 @@ 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.20.0 specifier: ^22.20.0
version: 22.20.0 version: 22.20.0
@@ -522,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==}
@@ -1079,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'}
@@ -1642,6 +1655,8 @@ snapshots:
'@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
@@ -2280,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: {}
+23 -4
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 { 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 { export default class McpSession {
private readonly transport: StreamableHTTPServerTransport; private readonly transport: StreamableHTTPServerTransport;
@@ -37,7 +39,7 @@ export default class McpSession {
await this.mcp.connect(this.transport); await this.mcp.connect(this.transport);
} }
private registerTools(...tools: ToolDef<TextResult>[]) { 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
@@ -57,12 +59,12 @@ export default class McpSession {
inputSchema: {}, inputSchema: {},
...tool.config, ...tool.config,
}, },
tool.handler, (args, extra) => tool.handler(args, McpSession.parseRequestMeta(extra), extra),
)) ))
); );
} }
public handleToolListChange(tools: ToolDef<TextResult>[], 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();
@@ -72,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,
}
}
}
}
} }
+72 -41
View File
@@ -1,7 +1,10 @@
import { ToolDef } from "../types"; import { CaldavServerConnection, compareCalendarEvents, RecurrenceFreqValues } from "../utils/caldavServerConnection";
import { CaldavServerConnection, CalendarEvent, RecurrenceFreqValues } from "../utils/caldavServerConnection";
import { config } from '../config'; import { config } from '../config';
import z from "zod"; 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 { function getCaldavConnection(): CaldavServerConnection {
const calendarConfig = config.users.find((u) => u.id === 'anton')?.calendars[0]; 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", title: "caldav-list-calendars",
config: { config: {
description: "List all calendars available to user", description: "List all calendars available to user",
outputSchema: {
calendars: z.array(CalendarInfoSchema),
},
}, },
handler: async () => { handler: async () => {
const calendars = await getCaldavConnection().listCalendars(); 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", title: "caldav-get-calendar-events-for-date-range",
config: { config: {
description: "Get calendar events for date range", 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'), 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'), 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'), 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 conn = await getCaldavConnection();
const events = await conn.getCalendarEventsForDateRange(calendarUrl, new Date(start), new Date(end)); const events =
return JSON.stringify(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", title: "caldav-get-all-calendars-events-for-date-range",
config: { config: {
description: "Get events for date range from all calendars available to user", description: "Get events for date range from all calendars available to user",
inputSchema: { inputSchema: {
start: z.coerce.date().describe('Range start, date or datetime in ISO format'), 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'), 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 conn = await getCaldavConnection();
const events: CalendarEvent[] = []; const events: FormattedCalendarEvent[] = [];
for (const calendar of await conn.listCalendars()) for (const calendar of await conn.listCalendars())
events.push(...await conn.getCalendarEventsForDateRange(calendar.url, new Date(start), new Date(end))); events.push(...formatCalendarEvents(meta.env, await conn.getCalendarEventsForDateRange(calendar.url, start, end)));
CaldavServerConnection.sort(events); events.sort((a, b) => compareCalendarEvents(a.raw, b.raw));
return JSON.stringify(events); return { events }
}, },
}; });
export const caldavGetCalendarEventsForTodayTool: ToolDef = { server.registerTool({
title: "caldav-get-calendar-events-for-today", title: "caldav-get-calendar-events-for-today",
config: { config: {
description: "Get calendar events for today", description: "Get calendar events for today",
inputSchema: { inputSchema: {
calendarUrl: z.string().describe('Target calendar url. Requires prior call to caldav-list-calendars to obtain'), calendarUrl: z.string().describe('Target calendar url. Requires prior call to caldav-list-calendars to obtain'),
}
}, },
handler: async ({ calendarUrl }) => { outputSchema: {
events: z.array(FormattedCalendarEventSchema),
},
},
handler: async ({ calendarUrl }, meta) => {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const conn = await getCaldavConnection(); const conn = await getCaldavConnection();
const events = await conn.getCalendarEventsForDateRange(calendarUrl, today, today); const events = formatCalendarEvents(meta.env, await conn.getCalendarEventsForDateRange(calendarUrl, today, today));
return JSON.stringify(events); return { events };
}, },
}; });
export const caldavGetAllCalendarsEventsForTodayTool: ToolDef = { server.registerTool({
title: "caldav-get-all-calendars-events-for-today", title: "caldav-get-all-calendars-events-for-today",
config: { config: {
description: "Get events for today from all calendars available to user", 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(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const conn = await getCaldavConnection(); const conn = await getCaldavConnection();
const events: CalendarEvent[] = []; const events: FormattedCalendarEvent[] = [];
for (const calendar of await conn.listCalendars()) for (const calendar of await conn.listCalendars())
events.push(...await conn.getCalendarEventsForDateRange(calendar.url, today, today)); events.push(...(await conn.getCalendarEventsForDateRange(calendar.url, today, today)).map((e) => formatCalendarEvent(meta.env, e)));
CaldavServerConnection.sort(events); events.sort((a, b) => compareCalendarEvents(a.raw, b.raw));
return JSON.stringify(events); return { events };
}, },
}; });
export const caldavGetCalendarEventByIdTool: ToolDef = { server.registerTool({
title: "caldav-get-calendar-event-by-id", title: "caldav-get-calendar-event-by-id",
config: { config: {
description: "Lookup calendar event by id", description: "Lookup calendar event by id",
inputSchema: { inputSchema: {
calendarUrl: z.string().describe('Target calendar url. Requires prior call to caldav-list-calendars to obtain'), calendarUrl: z.string().describe('Target calendar url. Requires prior call to caldav-list-calendars to obtain'),
eventId: z.string().describe('Event id'), 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 conn = await getCaldavConnection();
const event = await conn.getCalendarEventById(calendarUrl, eventId); 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", title: "caldav-create-calendar-event",
config: { config: {
description: "Create calendar event. Returns the event created", 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(), byMonth: z.array(z.number().describe("Month number")).optional(),
}).optional().describe('Optional recurrence rule, if needed'), }).optional().describe('Optional recurrence rule, if needed'),
}).describe('Event input'), }).describe('Event input'),
}
}, },
handler: async ({ calendarUrl, eventInput }) => { outputSchema: {
event: FormattedCalendarEventSchema,
},
},
handler: async ({ calendarUrl, eventInput }, meta) => {
const conn = await getCaldavConnection(); const conn = await getCaldavConnection();
const event = await conn.createCalendarEvent(calendarUrl, eventInput); const event = await conn.createCalendarEvent(calendarUrl, eventInput);
return JSON.stringify(event); return { event: formatCalendarEvent(meta.env, event) };
}, },
}; });
+8 -8
View File
@@ -1,9 +1,9 @@
import z from "zod"; import z from "zod";
import { ToolDef } from "../types"
import { GismeteoHttpRequest } from "../utils/gismeteoReqest"; import { GismeteoHttpRequest } from "../utils/gismeteoReqest";
import { gismeteoLocationCache } from "../utils/gismeteoLocationCache"; import { gismeteoLocationCache } from "../utils/gismeteoLocationCache";
import { server } from "../server";
export const weatherFindCitiesByNameTool: ToolDef = { server.registerTool({
title: "weather-find-locations-by-name", title: "weather-find-locations-by-name",
config: { config: {
description: `Authoritative weather forecast with most actual full forecast results from gismeteo.ru. 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", title: "weather-get-weather-by-location-id",
config: { config: {
description: `Authoritative weather forecast with most actual full forecast results from gismeteo.ru. 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`, Returns current weather condition and multi-day forecast for a location. Use weather-find-cities-by-name to find the location id`,
inputSchema: { 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"), 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", title: "weather-get-weather-by-location-name",
config: { config: {
description: `Authoritative weather forecast with most actual full forecast results from gismeteo.ru. 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
}; };
} }
}, },
}; });
+2 -2
View File
@@ -2,5 +2,5 @@ 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 './gismeteo_weather';
export * from './caldav'; // export * from './caldav';
+5 -5
View File
@@ -1,8 +1,7 @@
import z from "zod"; import z from "zod";
import { server } from "../server"; import { server } from "../server";
import { ToolDef } from "../types";
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.",
@@ -11,10 +10,11 @@ export const invokeToolTool: ToolDef = {
args: z.record(z.any()).default({}), args: z.record(z.any()).default({}),
}, },
}, },
handler: 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
}, },
} });
+2 -3
View File
@@ -1,9 +1,8 @@
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";
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.",
@@ -26,4 +25,4 @@ export const listToolsTool: ToolDef = {
) )
}], }],
}), }),
} });
+3 -3
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 } 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.",
@@ -41,4 +41,4 @@ export const pingTool: ToolDef = {
}; };
} }
}, },
}; });
+9 -10
View File
@@ -1,37 +1,36 @@
import { ToolDef } from "../types"
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: 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: 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: 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" }] };
}, },
}; });
+13 -11
View File
@@ -1,8 +1,7 @@
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { TextResult, ToolDef } from "./types"; import { InputShape, ToolDef } from "./types";
import McpSession from "./mcpConnection"; import McpSession from "./mcpConnection";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import * as mcpTools from "./mcpTools";
import { logged } from "./utils/logged"; import { logged } from "./utils/logged";
import cors from '@fastify/cors'; import cors from '@fastify/cors';
@@ -10,7 +9,8 @@ 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<TextResult>> = {}; // 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;
@@ -58,17 +58,22 @@ export class Server {
return this.tools[name]; return this.tools[name];
} }
public registerTools(...tools: ToolDef[]): void {
for (const tool of tools) { public registerTool<
TShape extends InputShape | undefined = undefined,
TOutShape extends InputShape | undefined = undefined,
>(
tool: ToolDef<TShape, TOutShape>,
): void {
if (!this.getTool(tool.title)) { if (!this.getTool(tool.title)) {
// console.log(`Registering new tool ${tool.title}`); // console.log(`Registering new tool ${tool.title}`);
const toolLogged = logged(tool); const toolLogged = logged(tool);
this.tools[tool.title] = { this.tools[tool.title] = {
...toolLogged, ...toolLogged,
handler: async (args) => { handler: async (args, meta, extra) => {
try { try {
const res = await toolLogged.handler(args); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await toolLogged.handler(args as any, meta, extra);
if (typeof res === 'string') if (typeof res === 'string')
return { content: [{ type: "text", text: res, }] }; return { content: [{ type: "text", text: res, }] };
else else
@@ -84,13 +89,11 @@ export class Server {
}, },
}; };
} }
}
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();
@@ -186,4 +189,3 @@ export class Server {
} }
export const server = new Server(); export const server = new Server();
server.registerTools(...Object.values(mcpTools));
+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<T = (string | TextResult)> = { // 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<T>; 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
};
}
+6 -61
View File
@@ -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 ICAL from "ical.js";
import { XMLParser } from "fast-xml-parser"; import { XMLParser } from "fast-xml-parser";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { CalendarEvent } from "../types/calendarEvent";
// --------------------------------------------------------------------------- import { CalendarInfo } from "../types/calendar";
// Public types
// ---------------------------------------------------------------------------
export interface CaldavAuth { export interface CaldavAuth {
username: string; username: string;
password: string; password: string;
} }
export interface CalendarInfo {
url: string;
displayName: string;
ctag?: string;
color?: string;
}
export const RecurrenceFreqValues = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"] as const; export const RecurrenceFreqValues = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"] as const;
export type RecurrenceFreq = typeof RecurrenceFreqValues[number]; export type RecurrenceFreq = typeof RecurrenceFreqValues[number];
@@ -68,21 +32,6 @@ export interface CalendarEventInput {
recurrence?: RecurrenceRule; 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 { export class CaldavError extends Error {
constructor( constructor(
message: string, message: string,
@@ -148,10 +97,6 @@ interface DavMultistatus<P> {
}; };
} }
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export class CaldavServerConnection { export class CaldavServerConnection {
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly auth?: CaldavAuth; private readonly auth?: CaldavAuth;
@@ -275,7 +220,7 @@ export class CaldavServerConnection {
} }
} }
CaldavServerConnection.sort(events); events.sort(compareCalendarEvents);
return events; return events;
} }
@@ -638,8 +583,8 @@ export class CaldavServerConnection {
} }
return results; return results;
} }
}
public static sort(events: CalendarEvent[]) { export function compareCalendarEvents(a: CalendarEvent, b: CalendarEvent): number {
events.sort((a, b) => Math.abs(a.start.getDate() - b.start.getDate()) * 10 + Math.abs(a.end.getDate() - b.end.getDate())); 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));
}
+9 -6
View File
@@ -1,18 +1,21 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { TextResult, ToolDef } 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<
tool: ToolDef, TShape extends InputShape | undefined = undefined,
): ToolDef { TOutShape extends InputShape | undefined = undefined,
>(
tool: ToolDef<TShape, TOutShape>,
): ToolDef<TShape, TOutShape> {
return { return {
...tool, ...tool,
handler: async (...args: A): Promise<string | TextResult> => { handler: async (args, meta, extra) => {
const id = randomUUID().slice(0, 8); const id = randomUUID().slice(0, 8);
const t0 = process.hrtime.bigint(); const t0 = process.hrtime.bigint();
console.log(`[tool>] ${tool.title} #${id} args=${JSON.stringify(args)}`); console.log(`[tool>] ${tool.title} #${id} args=${JSON.stringify(args)}`);
try { try {
const result = await tool.handler(...args); const result = await tool.handler(args, meta, extra);
const ms = Number(process.hrtime.bigint() - t0) / 1e6; const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log( console.log(
`[tool<] ${tool.title} #${id} ${ms.toFixed(1)}ms` + `[tool<] ${tool.title} #${id} ${ms.toFixed(1)}ms` +