calendar event formatting. outputSchema support
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Generated
+17
@@ -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
@@ -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
@@ -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'),
|
||||||
}
|
},
|
||||||
|
outputSchema: {
|
||||||
|
events: z.array(FormattedCalendarEventSchema),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
handler: async ({ calendarUrl }) => {
|
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'),
|
||||||
}
|
},
|
||||||
|
outputSchema: {
|
||||||
|
event: FormattedCalendarEventSchema,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
handler: async ({ calendarUrl, eventInput }) => {
|
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) };
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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 = {
|
|||||||
)
|
)
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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 = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -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" }] };
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|||||||
+34
-32
@@ -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,40 +58,43 @@ 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,
|
||||||
// console.log(`Registering new tool ${tool.title}`);
|
TOutShape extends InputShape | undefined = undefined,
|
||||||
const toolLogged = logged(tool);
|
>(
|
||||||
this.tools[tool.title] = {
|
tool: ToolDef<TShape, TOutShape>,
|
||||||
...toolLogged,
|
): void {
|
||||||
handler: async (args) => {
|
if (!this.getTool(tool.title)) {
|
||||||
try {
|
// console.log(`Registering new tool ${tool.title}`);
|
||||||
const res = await toolLogged.handler(args);
|
const toolLogged = logged(tool);
|
||||||
if (typeof res === 'string')
|
this.tools[tool.title] = {
|
||||||
return { content: [{ type: "text", text: res, }] };
|
...toolLogged,
|
||||||
else
|
handler: async (args, meta, extra) => {
|
||||||
return res;
|
try {
|
||||||
}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
catch (err) {
|
const res = await toolLogged.handler(args as any, meta, extra);
|
||||||
console.error(err);
|
if (typeof res === 'string')
|
||||||
return {
|
return { content: [{ type: "text", text: res, }] };
|
||||||
isError: true,
|
else
|
||||||
content: [{ type: "text", text: (err as Error).message ?? String(err) }],
|
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();
|
||||||
}
|
}
|
||||||
@@ -186,4 +189,3 @@ export class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const server = new Server();
|
export const server = new Server();
|
||||||
server.registerTools(...Object.values(mcpTools));
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const CalendarInfoSchema = z.object({
|
||||||
|
url: z.string().describe('Calendar absolute resource URL'),
|
||||||
|
displayName: z.string().describe('Calendar human-friendly name'),
|
||||||
|
ctag: z.string().optional().describe('Optional calendar CTAG'),
|
||||||
|
color: z.string().optional().describe('Optional calendar color'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CalendarInfo = z.infer<typeof CalendarInfoSchema>;
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const CalendarEventSchema = z.object({
|
||||||
|
id: z.string().describe('Event UID'),
|
||||||
|
url: z.string().describe('Event absolute .ics resource URL'),
|
||||||
|
etag: z.string().optional().describe('Optional event ETAG'),
|
||||||
|
summary: z.string().describe('Event summary'),
|
||||||
|
description: z.string().optional().describe('Optional event description'),
|
||||||
|
location: z.string().optional().describe('Optional event location. May contain toponims, lat/long or custom text'),
|
||||||
|
start: z.date().describe('Event start date and time, inclusive'),
|
||||||
|
end: z.date().describe('Event end date and time, exclusive (e.g. next day for all day events)'),
|
||||||
|
allDay: z.boolean().describe('Is event all day?'),
|
||||||
|
recurrenceRule: z.string().optional().describe('Raw event RRULE value, if any, from master event'),
|
||||||
|
isRecurrenceInstance: z.boolean().optional().optional().describe('Is a recurrence instance?'),
|
||||||
|
recurrenceId: z.date().optional().describe('Event recurrence id'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CalendarEvent = z.infer<typeof CalendarEventSchema>;
|
||||||
|
|
||||||
|
export const FormattedCalendarEventSchema = z.object({
|
||||||
|
id: z.string().describe('Event UID'),
|
||||||
|
summary: z.string().describe('Event summary'),
|
||||||
|
description: z.string().optional().describe('Optional event description'),
|
||||||
|
location: z.string().optional().describe('Optional event location. May contain toponims, lat/long or custom text'),
|
||||||
|
when: z.string().optional().describe('Formatted event dates: start, end, length and allDay flag'),
|
||||||
|
raw: CalendarEventSchema,
|
||||||
|
}).describe('Formatted calendar event and its raw shape');
|
||||||
|
|
||||||
|
export type FormattedCalendarEvent = z.infer<typeof FormattedCalendarEventSchema>;
|
||||||
+47
-6
@@ -1,4 +1,6 @@
|
|||||||
import { ZodRawShape } from "zod";
|
import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol";
|
||||||
|
import { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export type TextResult = {
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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` +
|
||||||
|
|||||||
Reference in New Issue
Block a user