mclocks-datetime-util 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +32 -0
  2. package/server.js +509 -0
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "mclocks-datetime-util",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for datetime and timezone conversion, powered by mclocks config",
5
+ "type": "module",
6
+ "bin": {
7
+ "mclocks-datetime-util": "server.js"
8
+ },
9
+ "files": [
10
+ "server.js"
11
+ ],
12
+ "keywords": [
13
+ "mcp",
14
+ "datetime",
15
+ "timezone",
16
+ "epoch",
17
+ "mclocks",
18
+ "model-context-protocol"
19
+ ],
20
+ "scripts": {
21
+ "test": "mocha test/**/*.test.js --timeout 10000"
22
+ },
23
+ "license": "Artistic-2.0",
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.26.0",
26
+ "cdate": "0.0.7",
27
+ "zod": "^4.3.6"
28
+ },
29
+ "devDependencies": {
30
+ "mocha": "^11.0.0"
31
+ }
32
+ }
package/server.js ADDED
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { cdate } from "cdate";
7
+ import { readFileSync } from "fs";
8
+ import { join } from "path";
9
+
10
+ const APP_IDENTIFIER = "com.bayashi.mclocks";
11
+ const CONFIG_FILE = "config.json";
12
+
13
+ const FALLBACK_TIMEZONES = [
14
+ "UTC",
15
+ "America/New_York",
16
+ "America/Los_Angeles",
17
+ "Europe/London",
18
+ "Europe/Berlin",
19
+ "Asia/Tokyo",
20
+ "Asia/Shanghai",
21
+ "Asia/Kolkata",
22
+ "Australia/Sydney",
23
+ ];
24
+
25
+ const EPOCH_UNITS = {
26
+ seconds: 1000,
27
+ milliseconds: 1,
28
+ microseconds: 1 / 1000,
29
+ nanoseconds: 1 / 1000 / 1000,
30
+ };
31
+
32
+ const WEEKDAY_MAP = {
33
+ sunday: 0, sun: 0, su: 0,
34
+ monday: 1, mon: 1, mo: 1,
35
+ tuesday: 2, tue: 2, tu: 2,
36
+ wednesday: 3, wed: 3, we: 3,
37
+ thursday: 4, thu: 4, th: 4,
38
+ friday: 5, fri: 5, fr: 5,
39
+ saturday: 6, sat: 6, sa: 6,
40
+ };
41
+
42
+ // Return the OS-specific config directory (same as Rust `directories::BaseDirs::config_dir`)
43
+ function configDir() {
44
+ if (process.platform === "win32") {
45
+ return process.env.APPDATA;
46
+ } else if (process.platform === "darwin") {
47
+ return `${process.env.HOME}/Library/Application Support`;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ // Auto-detect the mclocks config.json path
53
+ function autoDetectConfigPath() {
54
+ const dir = configDir();
55
+ if (!dir) {
56
+ return null;
57
+ }
58
+ return join(dir, APP_IDENTIFIER, CONFIG_FILE);
59
+ }
60
+
61
+ // Resolve config path from --config arg, MCLOCKS_CONFIG_PATH env, or auto-detect
62
+ function resolveConfigPath() {
63
+ const argIdx = process.argv.indexOf("--config");
64
+ if (argIdx !== -1 && process.argv[argIdx + 1]) {
65
+ return process.argv[argIdx + 1];
66
+ }
67
+ return process.env.MCLOCKS_CONFIG_PATH || autoDetectConfigPath();
68
+ }
69
+
70
+ // Load mclocks config.json
71
+ // Priority: --config arg > MCLOCKS_CONFIG_PATH env > auto-detect > null
72
+ function loadMclocksConfig() {
73
+ const configPath = resolveConfigPath();
74
+ if (!configPath) {
75
+ return null;
76
+ }
77
+ try {
78
+ const raw = readFileSync(configPath, "utf-8");
79
+ return JSON.parse(raw);
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // Extract unique timezones from clocks config
86
+ function extractTimezones(config) {
87
+ if (!config?.clocks || !Array.isArray(config.clocks)) {
88
+ return null;
89
+ }
90
+ const tzSet = new Set();
91
+ for (const clock of config.clocks) {
92
+ if (clock.timezone?.length > 0) {
93
+ tzSet.add(clock.timezone);
94
+ }
95
+ }
96
+ return tzSet.size > 0 ? Array.from(tzSet) : null;
97
+ }
98
+
99
+ const mclocksConfig = loadMclocksConfig();
100
+ const configTimezones = extractTimezones(mclocksConfig);
101
+ const defaultTimezones = configTimezones || FALLBACK_TIMEZONES;
102
+ // Per-field override: env > config.json > fallback
103
+ const configUseTZ = process.env.MCLOCKS_USETZ === "true" || (mclocksConfig?.usetz ?? false);
104
+ const configConvTZ = process.env.MCLOCKS_CONVTZ || mclocksConfig?.convtz || "";
105
+ const configLocale = process.env.MCLOCKS_LOCALE || mclocksConfig?.locale || "en";
106
+
107
+ // Parse weekday name to day-of-week number (0=Sunday, 6=Saturday)
108
+ function parseWeekday(input) {
109
+ const key = input.trim().toLowerCase();
110
+ return key in WEEKDAY_MAP ? WEEKDAY_MAP[key] : null;
111
+ }
112
+
113
+ // Normalize datetime strings for common formats that may fail to parse
114
+ function normalizeDT(src) {
115
+ // BQ datetime format: "2024-01-01 12:00:00 UTC" -> "2024-01-01 12:00:00Z"
116
+ const m = src.match(/^(\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d(?:\.\d+)?) UTC$/);
117
+ if (m) {
118
+ return m[1] + "Z";
119
+ }
120
+ return src;
121
+ }
122
+
123
+ // Convert a datetime value to a specific timezone
124
+ function convertToTimezone(cdt, src, tz, usetz) {
125
+ try {
126
+ let result;
127
+ if (usetz) {
128
+ result = cdt(src).tz(tz).text();
129
+ } else {
130
+ const offset = cdt().tz(tz).utcOffset();
131
+ result = cdt(src).utcOffset(offset).text();
132
+ }
133
+ return { timezone: tz, result };
134
+ } catch (error) {
135
+ return { timezone: tz, error: String(error) };
136
+ }
137
+ }
138
+
139
+ const server = new McpServer({
140
+ name: "mclocks-datetime-util",
141
+ version: "0.1.0",
142
+ });
143
+
144
+ server.tool(
145
+ "convert-time",
146
+ "Convert a datetime string or epoch timestamp to multiple timezones. " +
147
+ "Accepts ISO 8601 datetime, common date formats, or epoch numbers.",
148
+ {
149
+ source: z.string().describe(
150
+ "The source value to convert. Can be a datetime string (e.g. '2024-01-15T10:30:00Z', '2024-01-15 10:30:00 UTC') or an epoch number (e.g. '1705312200')."
151
+ ),
152
+ timezones: z.array(z.string()).optional().describe(
153
+ "Target timezones to convert to (e.g. ['Asia/Tokyo', 'America/New_York']). If omitted, uses timezones from mclocks config or built-in defaults."
154
+ ),
155
+ source_timezone: z.string().optional().describe(
156
+ "Timezone of the source datetime for interpretation (e.g. 'Asia/Tokyo'). Only used when source is a datetime string without timezone info. If omitted, uses convtz from mclocks config if available."
157
+ ),
158
+ epoch_unit: z.enum(["seconds", "milliseconds", "microseconds", "nanoseconds"]).optional().describe(
159
+ "Unit of the epoch timestamp. Defaults to 'seconds'. Only used when source is a numeric value."
160
+ ),
161
+ },
162
+ async ({ source, timezones, source_timezone, epoch_unit }) => {
163
+ const targetTimezones = timezones?.length > 0 ? timezones : defaultTimezones;
164
+ const convtz = source_timezone || configConvTZ;
165
+ const usetz = configUseTZ;
166
+ const src = source.trim();
167
+ const isNumeric = /^-?[0-9]+(\.[0-9]+)?$/.test(src);
168
+
169
+ let parsedSrc;
170
+ let inputDescription;
171
+
172
+ if (isNumeric) {
173
+ const unit = epoch_unit || "seconds";
174
+ const multiplier = EPOCH_UNITS[unit];
175
+ parsedSrc = Number(src) * multiplier;
176
+ inputDescription = `${src} (epoch ${unit})`;
177
+ } else {
178
+ parsedSrc = normalizeDT(src);
179
+ inputDescription = src;
180
+
181
+ // Validate the datetime string
182
+ try {
183
+ const d = new Date(parsedSrc);
184
+ if (isNaN(d.getTime())) {
185
+ return {
186
+ content: [{ type: "text", text: `Error: Could not parse "${src}" as a valid datetime.` }],
187
+ isError: true,
188
+ };
189
+ }
190
+ } catch (error) {
191
+ return {
192
+ content: [{ type: "text", text: `Error: Could not parse "${src}" as a valid datetime. ${error}` }],
193
+ isError: true,
194
+ };
195
+ }
196
+ }
197
+
198
+ let cdt;
199
+ if (!isNumeric && convtz) {
200
+ cdt = cdate().tz(convtz).cdateFn();
201
+ } else {
202
+ cdt = cdate().cdateFn();
203
+ }
204
+
205
+ const results = [];
206
+ for (const tz of targetTimezones) {
207
+ results.push(convertToTimezone(cdt, parsedSrc, tz, usetz));
208
+ }
209
+
210
+ const lines = [`Input: ${inputDescription}`, ""];
211
+ for (const r of results) {
212
+ if (r.error) {
213
+ lines.push(` ${r.timezone}: ERROR - ${r.error}`);
214
+ } else {
215
+ lines.push(` ${r.timezone}: ${r.result}`);
216
+ }
217
+ }
218
+
219
+ // Add epoch values when input is a datetime string
220
+ if (!isNumeric) {
221
+ const epochMs = cdt(parsedSrc).t;
222
+ lines.push("");
223
+ lines.push(`Epoch (seconds): ${epochMs / 1000}`);
224
+ lines.push(`Epoch (milliseconds): ${epochMs}`);
225
+ }
226
+
227
+ return {
228
+ content: [{ type: "text", text: lines.join("\n") }],
229
+ };
230
+ }
231
+ );
232
+
233
+ server.tool(
234
+ "current-time",
235
+ "Get the current time in specified timezones.",
236
+ {
237
+ timezones: z.array(z.string()).optional().describe(
238
+ "Timezones to show current time in (e.g. ['Asia/Tokyo', 'UTC']). If omitted, uses timezones from mclocks config or built-in defaults."
239
+ ),
240
+ },
241
+ async ({ timezones }) => {
242
+ const targetTimezones = timezones?.length > 0 ? timezones : defaultTimezones;
243
+ const now = new Date();
244
+ const cdt = cdate().cdateFn();
245
+
246
+ const lines = [];
247
+ for (const tz of targetTimezones) {
248
+ try {
249
+ const offset = cdt().tz(tz).utcOffset();
250
+ const result = cdt(now).utcOffset(offset).text();
251
+ lines.push(` ${tz}: ${result}`);
252
+ } catch (error) {
253
+ lines.push(` ${tz}: ERROR - ${error}`);
254
+ }
255
+ }
256
+
257
+ const epochSec = Math.floor(now.getTime() / 1000);
258
+ lines.push("");
259
+ lines.push(`Epoch (seconds): ${epochSec}`);
260
+ lines.push(`Epoch (milliseconds): ${now.getTime()}`);
261
+
262
+ return {
263
+ content: [{ type: "text", text: lines.join("\n") }],
264
+ };
265
+ }
266
+ );
267
+
268
+ server.tool(
269
+ "next-weekday",
270
+ "Find the date of the next occurrence of a given weekday from today.",
271
+ {
272
+ weekday: z.string().describe(
273
+ "Day of the week (e.g. 'Monday', 'friday', 'mon', 'thu'). Case-insensitive. English only."
274
+ ),
275
+ timezone: z.string().optional().describe(
276
+ "Timezone to determine 'today' (e.g. 'Asia/Tokyo'). If omitted, uses convtz from mclocks config if available, otherwise UTC."
277
+ ),
278
+ },
279
+ async ({ weekday, timezone }) => {
280
+ const targetDow = parseWeekday(weekday);
281
+ if (targetDow === null) {
282
+ return {
283
+ content: [{ type: "text", text: `Error: Could not parse "${weekday}" as a weekday name. Use English names like "Monday", "Tue", "friday".` }],
284
+ isError: true,
285
+ };
286
+ }
287
+
288
+ const tz = timezone || configConvTZ || "UTC";
289
+ const cdt = cdate().locale(configLocale).cdateFn();
290
+ const offset = cdt().tz(tz).utcOffset();
291
+ const now = cdt().utcOffset(offset);
292
+ const todayDow = Number(now.format("d"));
293
+
294
+ let daysUntil = targetDow - todayDow;
295
+ if (daysUntil <= 0) daysUntil += 7;
296
+
297
+ const nextDate = now.add(daysUntil, "day");
298
+ const dateStr = nextDate.format("YYYY-MM-DD");
299
+ const dayName = nextDate.format("dddd");
300
+
301
+ const lines = [
302
+ `Next ${dayName}: ${dateStr}`,
303
+ `(${daysUntil} day${daysUntil !== 1 ? "s" : ""} from today, ${now.format("YYYY-MM-DD")})`,
304
+ `Timezone: ${tz}`,
305
+ ];
306
+
307
+ return {
308
+ content: [{ type: "text", text: lines.join("\n") }],
309
+ };
310
+ }
311
+ );
312
+
313
+ server.tool(
314
+ "date-to-weekday",
315
+ "Get the day of the week for a given date.",
316
+ {
317
+ date: z.string().describe(
318
+ "Date to check (e.g. '2026-02-20', '2026/3/15', 'March 15, 2026')."
319
+ ),
320
+ },
321
+ async ({ date }) => {
322
+ const src = date.trim();
323
+ let d;
324
+ try {
325
+ d = new Date(src);
326
+ if (isNaN(d.getTime())) {
327
+ return {
328
+ content: [{ type: "text", text: `Error: Could not parse "${src}" as a valid date.` }],
329
+ isError: true,
330
+ };
331
+ }
332
+ } catch (error) {
333
+ return {
334
+ content: [{ type: "text", text: `Error: Could not parse "${src}" as a valid date. ${error}` }],
335
+ isError: true,
336
+ };
337
+ }
338
+
339
+ // Use UTC-based date string to avoid timezone issues with date-only strings
340
+ const y = d.getUTCFullYear();
341
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
342
+ const dd = String(d.getUTCDate()).padStart(2, "0");
343
+ const utcDateStr = `${y}-${m}-${dd}T00:00:00Z`;
344
+ const dayName = cdate(utcDateStr).locale(configLocale).format("dddd");
345
+
346
+ return {
347
+ content: [{ type: "text", text: `${y}-${m}-${dd}: ${dayName}` }],
348
+ };
349
+ }
350
+ );
351
+
352
+ server.tool(
353
+ "days-until",
354
+ "Count the number of days from today until a specified date. " +
355
+ "If year is omitted, uses the current year (or next year if the date has already passed). " +
356
+ "If only day is specified, uses the current month (or next month if the day has already passed). " +
357
+ "If month is omitted, defaults to January. If day is omitted, defaults to the 1st.",
358
+ {
359
+ year: z.number().int().optional().describe(
360
+ "Target year (e.g. 2026). If omitted, uses current year or next year if the date has already passed this year."
361
+ ),
362
+ month: z.number().int().min(1).max(12).optional().describe(
363
+ "Target month (1-12). Defaults to 1 (January) if omitted."
364
+ ),
365
+ day: z.number().int().min(1).max(31).optional().describe(
366
+ "Target day of month (1-31). Defaults to 1 if omitted."
367
+ ),
368
+ timezone: z.string().optional().describe(
369
+ "Timezone to determine 'today' (e.g. 'Asia/Tokyo'). If omitted, uses convtz from mclocks config if available, otherwise UTC."
370
+ ),
371
+ },
372
+ async ({ year, month, day, timezone }) => {
373
+ const tz = timezone || configConvTZ || "UTC";
374
+ const cdt = cdate().locale(configLocale).cdateFn();
375
+ const offset = cdt().tz(tz).utcOffset();
376
+ const now = cdt().utcOffset(offset);
377
+ const [nowY, nowM, nowD] = ["YYYY", "M", "D"].map((f) => Number(now.format(f)));
378
+
379
+ let [y, m, d] = [year ?? nowY, month, day || 1];
380
+
381
+ if (year != null) {
382
+ m = m || 1;
383
+ } else if (month == null && day != null) {
384
+ // Only day specified: use current month, advance to next month if passed
385
+ m = nowM;
386
+ if (d < nowD) {
387
+ m++;
388
+ if (m > 12) { m = 1; y++; }
389
+ }
390
+ } else {
391
+ // Month specified (or both omitted): default month to 1, advance to next year if passed
392
+ m = m || 1;
393
+ if (m < nowM || (m === nowM && d < nowD)) { y++; }
394
+ }
395
+
396
+ // Validate the target date
397
+ const dateStr = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
398
+ const dateObj = new Date(`${dateStr}T00:00:00Z`);
399
+ if (isNaN(dateObj.getTime()) || dateObj.getUTCDate() !== d) {
400
+ return {
401
+ content: [{ type: "text", text: `Error: Invalid date: ${dateStr}` }],
402
+ isError: true,
403
+ };
404
+ }
405
+
406
+ // Calculate days difference using UTC dates to avoid DST issues
407
+ const todayStr = now.format("YYYY-MM-DD");
408
+ const diffDays = Math.round((dateObj.getTime() - new Date(`${todayStr}T00:00:00Z`).getTime()) / 86400000);
409
+ const dayName = cdate(`${dateStr}T00:00:00Z`).locale(configLocale).format("dddd");
410
+
411
+ let label;
412
+ if (diffDays > 0) {
413
+ label = `${diffDays} day${diffDays !== 1 ? "s" : ""} from today`;
414
+ } else if (diffDays === 0) {
415
+ label = "Today";
416
+ } else {
417
+ label = `${-diffDays} day${diffDays !== -1 ? "s" : ""} ago`;
418
+ }
419
+
420
+ return {
421
+ content: [{ type: "text", text: `${dateStr} (${dayName}): ${label}\nToday: ${todayStr}\nTimezone: ${tz}` }],
422
+ };
423
+ }
424
+ );
425
+
426
+ server.tool(
427
+ "days-between",
428
+ "Count the number of days between two dates. The start date is not included in the count (e.g. Jan 1 to Jan 3 = 2 days).",
429
+ {
430
+ from: z.string().describe(
431
+ "Start date (e.g. '2026-01-01', '2026/3/15', 'March 15, 2026')."
432
+ ),
433
+ to: z.string().describe(
434
+ "End date (e.g. '2026-12-31', '2026/6/1', 'June 1, 2026')."
435
+ ),
436
+ },
437
+ async ({ from, to }) => {
438
+ const fromDate = new Date(from.trim());
439
+ const toDate = new Date(to.trim());
440
+
441
+ if (isNaN(fromDate.getTime())) {
442
+ return {
443
+ content: [{ type: "text", text: `Error: Could not parse "${from}" as a valid date.` }],
444
+ isError: true,
445
+ };
446
+ }
447
+ if (isNaN(toDate.getTime())) {
448
+ return {
449
+ content: [{ type: "text", text: `Error: Could not parse "${to}" as a valid date.` }],
450
+ isError: true,
451
+ };
452
+ }
453
+
454
+ const diffDays = Math.round((toDate.getTime() - fromDate.getTime()) / 86400000);
455
+ const abs = Math.abs(diffDays);
456
+ const fromStr = `${fromDate.getUTCFullYear()}-${String(fromDate.getUTCMonth() + 1).padStart(2, "0")}-${String(fromDate.getUTCDate()).padStart(2, "0")}`;
457
+ const toStr = `${toDate.getUTCFullYear()}-${String(toDate.getUTCMonth() + 1).padStart(2, "0")}-${String(toDate.getUTCDate()).padStart(2, "0")}`;
458
+ const fromDay = cdate(`${fromStr}T00:00:00Z`).locale(configLocale).format("dddd");
459
+ const toDay = cdate(`${toStr}T00:00:00Z`).locale(configLocale).format("dddd");
460
+
461
+ return {
462
+ content: [{ type: "text", text: `${fromStr} (${fromDay}) → ${toStr} (${toDay}): ${abs} day${abs !== 1 ? "s" : ""}` }],
463
+ };
464
+ }
465
+ );
466
+
467
+ server.tool(
468
+ "date-offset",
469
+ "Calculate the date that is N days before or after a given date. Use a positive number for future dates and a negative number for past dates.",
470
+ {
471
+ date: z.string().describe(
472
+ "Base date (e.g. '2026-01-01', '2026/3/15', 'March 15, 2026')."
473
+ ),
474
+ days: z.number().int().describe(
475
+ "Number of days to add (positive) or subtract (negative)."
476
+ ),
477
+ },
478
+ async ({ date, days }) => {
479
+ const baseDate = new Date(date.trim());
480
+ if (isNaN(baseDate.getTime())) {
481
+ return {
482
+ content: [{ type: "text", text: `Error: Could not parse "${date}" as a valid date.` }],
483
+ isError: true,
484
+ };
485
+ }
486
+
487
+ const resultDate = new Date(baseDate.getTime() + days * 86400000);
488
+ const baseStr = `${baseDate.getUTCFullYear()}-${String(baseDate.getUTCMonth() + 1).padStart(2, "0")}-${String(baseDate.getUTCDate()).padStart(2, "0")}`;
489
+ const resultStr = `${resultDate.getUTCFullYear()}-${String(resultDate.getUTCMonth() + 1).padStart(2, "0")}-${String(resultDate.getUTCDate()).padStart(2, "0")}`;
490
+ const baseDay = cdate(`${baseStr}T00:00:00Z`).locale(configLocale).format("dddd");
491
+ const resultDay = cdate(`${resultStr}T00:00:00Z`).locale(configLocale).format("dddd");
492
+ const abs = Math.abs(days);
493
+ const direction = days >= 0 ? "after" : "before";
494
+
495
+ return {
496
+ content: [{ type: "text", text: `${abs} day${abs !== 1 ? "s" : ""} ${direction} ${baseStr} (${baseDay}) → ${resultStr} (${resultDay})` }],
497
+ };
498
+ }
499
+ );
500
+
501
+ async function main() {
502
+ const transport = new StdioServerTransport();
503
+ await server.connect(transport);
504
+ }
505
+
506
+ main().catch((error) => {
507
+ console.error("Fatal error:", error);
508
+ process.exit(1);
509
+ });