calendar event formatting. outputSchema support
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+17
@@ -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: {}
|
||||
|
||||
+23
-4
@@ -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<TextResult>[]) {
|
||||
private registerTools(...tools: ToolDef<unknown, TextResult>[]) {
|
||||
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<TextResult>[], sendToolListChangedNotification: boolean = true): void {
|
||||
public handleToolListChange(tools: ToolDef<unknown, TextResult>[], 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<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, 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'),
|
||||
}
|
||||
},
|
||||
handler: async ({ calendarUrl }) => {
|
||||
outputSchema: {
|
||||
events: z.array(FormattedCalendarEventSchema),
|
||||
},
|
||||
},
|
||||
handler: async ({ calendarUrl }, meta) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const conn = await getCaldavConnection();
|
||||
const events = 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'),
|
||||
}
|
||||
},
|
||||
handler: async ({ calendarUrl, eventInput }) => {
|
||||
outputSchema: {
|
||||
event: FormattedCalendarEventSchema,
|
||||
},
|
||||
},
|
||||
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) };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
)
|
||||
}],
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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" }] };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+13
-11
@@ -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<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)) {
|
||||
this.port = port;
|
||||
@@ -58,17 +58,22 @@ export class Server {
|
||||
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)) {
|
||||
// console.log(`Registering new tool ${tool.title}`);
|
||||
const toolLogged = logged(tool);
|
||||
this.tools[tool.title] = {
|
||||
...toolLogged,
|
||||
handler: async (args) => {
|
||||
handler: async (args, meta, extra) => {
|
||||
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')
|
||||
return { content: [{ type: "text", text: res, }] };
|
||||
else
|
||||
@@ -84,13 +89,11 @@ export class Server {
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.emitToolListChange();
|
||||
}
|
||||
|
||||
public unregisterTools(...names: string[]): void {
|
||||
for (const name of names)
|
||||
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));
|
||||
@@ -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>;
|
||||
+46
-5
@@ -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<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;
|
||||
config: {
|
||||
description: string;
|
||||
inputSchema?: ZodRawShape,
|
||||
inputSchema?: TShape;
|
||||
outputSchema?: TOutShape;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handler: (...args: any[]) => Promise<T>;
|
||||
handler: (
|
||||
args: TShape extends InputShape ? z.infer<z.ZodObject<TShape>> : Record<string, unknown>,
|
||||
meta: McpRequestMeta,
|
||||
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
||||
) => Promise<
|
||||
TOutShape extends InputShape
|
||||
? z.infer<z.ZodObject<TOutShape>>
|
||||
: string | TextResult
|
||||
>;
|
||||
};
|
||||
|
||||
export function defineTool<
|
||||
TShape extends InputShape | undefined = undefined,
|
||||
TOutShape extends InputShape | undefined = undefined,
|
||||
>(
|
||||
def: ToolDef<TShape, TOutShape>,
|
||||
): ToolDef<TShape, TOutShape> {
|
||||
return def;
|
||||
}
|
||||
|
||||
export type McpRequestMeta = {
|
||||
env: Env;
|
||||
}
|
||||
|
||||
export type UserInfo = {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export type Env = {
|
||||
user: UserInfo;
|
||||
language: string; // 'en', 'ru', etc.
|
||||
location: {
|
||||
timezone: number; // UTC+hours
|
||||
};
|
||||
}
|
||||
@@ -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<P> {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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());
|
||||
}
|
||||
@@ -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 { TextResult, ToolDef } from "../types";
|
||||
import { InputShape, ToolDef } from "../types";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function logged<A extends any[]>(
|
||||
tool: ToolDef,
|
||||
): ToolDef {
|
||||
export function logged<
|
||||
TShape extends InputShape | undefined = undefined,
|
||||
TOutShape extends InputShape | undefined = undefined,
|
||||
>(
|
||||
tool: ToolDef<TShape, TOutShape>,
|
||||
): ToolDef<TShape, TOutShape> {
|
||||
return {
|
||||
...tool,
|
||||
handler: async (...args: A): Promise<string | TextResult> => {
|
||||
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` +
|
||||
|
||||
Reference in New Issue
Block a user