@mrfentmen/timezone-mcp 0.0.0-stage → 1.0.0

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.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # Time Zones
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ IANA time zone maths offline - the time right now in any zone, wall-clock conversion between zones, and the zone list, keyless.
4
+
5
+ Keyless — no signup, no API key, no environment variables.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx -y @mrfentmen/timezone-mcp
11
+ ```
12
+
13
+ Or add it to your MCP client:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "timezone-mcp": { "command": "npx", "args": ["-y", "@mrfentmen/timezone-mcp"] }
19
+ }
20
+ }
21
+ ```
22
+
23
+ ## Tools
24
+
25
+ | Tool | What it does |
26
+ |---|---|
27
+ | `time_in_zone` | Current local time in one IANA zone, with weekday, abbreviation and UTC offset. |
28
+ | `convert_time` | Show one moment in two zones; a bare wall-clock time is read in the from zone, handling daylight saving. |
29
+ | `list_timezones` | Search the IANA zone list this runtime knows. |
30
+
31
+ ## Data source
32
+
33
+ Runs fully offline — all work happens inside the server with no API calls and no keys.
package/dist/api.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Time Zones — keyless public API client.
3
+ */
4
+ export declare class SourceError extends Error {
5
+ }
6
+ export declare function clean(s: unknown): string;
7
+ export declare function timeInZone(a: {
8
+ zone: string;
9
+ at?: string;
10
+ }): Promise<string>;
11
+ export declare function convertTime(a: {
12
+ time: string;
13
+ from: string;
14
+ to: string;
15
+ }): Promise<string>;
16
+ export declare function listTimezones(a: {
17
+ search?: string;
18
+ }): Promise<string>;
package/dist/api.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Time Zones — keyless public API client.
3
+ */
4
+ export class SourceError extends Error {
5
+ }
6
+ export function clean(s) {
7
+ return String(s ?? "").replace(/\s+/g, " ").trim();
8
+ }
9
+ const WEEKDAY_OPTS = { hour12: false, weekday: "long", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: "short" };
10
+ function checkZone(value) {
11
+ const zone = clean(value);
12
+ try {
13
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
14
+ }
15
+ catch {
16
+ throw new SourceError(`"${value}" is not a known IANA time zone (try list_timezones)`);
17
+ }
18
+ return zone;
19
+ }
20
+ function zoneOffsetMinutes(zone, date) {
21
+ const parts = new Intl.DateTimeFormat("en-GB", {
22
+ timeZone: zone,
23
+ hour12: false,
24
+ year: "numeric",
25
+ month: "2-digit",
26
+ day: "2-digit",
27
+ hour: "2-digit",
28
+ minute: "2-digit",
29
+ second: "2-digit",
30
+ }).formatToParts(date);
31
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? "0");
32
+ const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
33
+ return Math.round((asUtc - date.getTime()) / 60000);
34
+ }
35
+ function offsetText(minutes) {
36
+ const sign = minutes < 0 ? "-" : "+";
37
+ const abs = Math.abs(minutes);
38
+ return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
39
+ }
40
+ function describeIn(zone, date) {
41
+ const parts = new Intl.DateTimeFormat("en-GB", { timeZone: zone, ...WEEKDAY_OPTS }).formatToParts(date);
42
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
43
+ return {
44
+ wall: `${get("year")}-${get("month")}-${get("day")} ${String(Number(get("hour")) % 24).padStart(2, "0")}:${get("minute")}:${get("second")}`,
45
+ weekday: get("weekday"),
46
+ abbreviation: get("timeZoneName"),
47
+ offset: zoneOffsetMinutes(zone, date),
48
+ };
49
+ }
50
+ const NAIVE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/;
51
+ const HAS_OFFSET = /(Z|[+-]\d{2}:?\d{2})$/;
52
+ function instantFromWall(zone, text) {
53
+ const m = NAIVE.exec(text.trim());
54
+ if (!m)
55
+ throw new SourceError(`time must be ISO 8601, e.g. 2026-01-01T09:00 or 2026-01-01T09:00Z (got "${text}")`);
56
+ const [, y, mo, d, h, mi, s] = m;
57
+ const wallUtc = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? 0));
58
+ let instant = new Date(wallUtc);
59
+ for (let i = 0; i < 3; i++) {
60
+ const offset = zoneOffsetMinutes(zone, instant);
61
+ instant = new Date(wallUtc - offset * 60000);
62
+ }
63
+ const back = describeIn(zone, instant).wall;
64
+ const wanted = `${y}-${mo}-${d} ${h}:${mi}:${s ?? "00"}`;
65
+ return { instant, exact: back === wanted };
66
+ }
67
+ export async function timeInZone(a) {
68
+ const zone = checkZone(a.zone);
69
+ let when = new Date();
70
+ if (a.at) {
71
+ when = a.at.trim() && HAS_OFFSET.test(a.at.trim()) ? new Date(a.at) : instantFromWall(checkZone("UTC"), a.at).instant;
72
+ if (Number.isNaN(when.getTime()))
73
+ throw new SourceError(`at must be an ISO 8601 instant, got "${a.at}"`);
74
+ }
75
+ const info = describeIn(zone, when);
76
+ const utc = describeIn("UTC", when);
77
+ return [
78
+ `${info.wall} ${info.weekday} — ${zone}`,
79
+ "",
80
+ `local time: ${info.wall} (${info.abbreviation}, ${offsetText(info.offset)})`,
81
+ `UTC: ${utc.wall}`,
82
+ `unix: ${Math.floor(when.getTime() / 1000)}`,
83
+ ].join("\n");
84
+ }
85
+ export async function convertTime(a) {
86
+ const from = checkZone(a.from);
87
+ const to = checkZone(a.to);
88
+ const text = clean(a.time);
89
+ let result;
90
+ if (HAS_OFFSET.test(text)) {
91
+ const instant = new Date(text);
92
+ if (Number.isNaN(instant.getTime()))
93
+ throw new SourceError(`time must be ISO 8601, got "${a.time}"`);
94
+ result = { instant, exact: true };
95
+ }
96
+ else {
97
+ result = instantFromWall(from, text);
98
+ }
99
+ const inFrom = describeIn(from, result.instant);
100
+ const inTo = describeIn(to, result.instant);
101
+ return [
102
+ `${result.instant.toISOString()} (UTC)`,
103
+ "",
104
+ `${from.padEnd(22)} ${inFrom.wall} ${inFrom.weekday} (${inFrom.abbreviation}, ${offsetText(inFrom.offset)})`,
105
+ `${to.padEnd(22)} ${inTo.wall} ${inTo.weekday} (${inTo.abbreviation}, ${offsetText(inTo.offset)})`,
106
+ !result.exact ? "\nNote: that wall-clock time does not exist in the source zone (a daylight-saving jump); the nearest valid instant is shown." : "",
107
+ ]
108
+ .filter(Boolean)
109
+ .join("\n");
110
+ }
111
+ export async function listTimezones(a) {
112
+ const search = clean(a.search).toLowerCase();
113
+ const supported = Intl.supportedValuesOf;
114
+ if (typeof supported !== "function")
115
+ throw new SourceError("this Node build cannot list time zones; pass a zone your platform knows");
116
+ const zones = supported.call(Intl, "timeZone");
117
+ const hits = zones.filter((z) => !search || z.toLowerCase().includes(search));
118
+ if (!hits.length)
119
+ throw new SourceError(`no time zone matches "${clean(a.search)}"`);
120
+ const shown = hits.slice(0, 60);
121
+ return [
122
+ `${hits.length} zone(s)${search ? ` matching "${clean(a.search)}"` : ""}${hits.length > shown.length ? `, showing ${shown.length}` : ""}:`,
123
+ "",
124
+ ...shown,
125
+ ].join("\n");
126
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { createServer } from "./server.js";
3
+ async function main() {
4
+ const server = createServer();
5
+ const transport = new StdioServerTransport();
6
+ await server.connect(transport);
7
+ console.error("timezone-mcp running on stdio");
8
+ }
9
+ main().catch((err) => {
10
+ console.error("Fatal error:", err);
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function createServer(): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,43 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { convertTime, listTimezones, timeInZone, SourceError } from "./api.js";
4
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
5
+ function errorMessage(e) {
6
+ if (e instanceof SourceError || e instanceof Error)
7
+ return `Error: ${e.message}`;
8
+ return `Error: ${String(e)}`;
9
+ }
10
+ export function createServer() {
11
+ const server = new McpServer({ name: "timezone-mcp", version: "1.0.0" });
12
+ server.tool("time_in_zone", "Current local time in one IANA zone, with weekday, abbreviation and UTC offset.", {
13
+ zone: z.string().describe('IANA zone, e.g. Asia/Tokyo'), at: z.string().optional().describe('Optional ISO 8601 instant to use instead of now')
14
+ }, async (a) => {
15
+ try {
16
+ return text(await timeInZone(a));
17
+ }
18
+ catch (e) {
19
+ return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
20
+ }
21
+ });
22
+ server.tool("convert_time", "Show one moment in two zones; a bare wall-clock time is read in the from zone, handling daylight saving.", {
23
+ time: z.string().describe('ISO 8601 instant (2026-01-01T09:00Z) or wall-clock time (2026-01-01 09:00)'), from: z.string().describe('Zone the time belongs to when no offset is given, e.g. Europe/Berlin'), to: z.string().describe('Zone to convert to, e.g. America/Los_Angeles')
24
+ }, async (a) => {
25
+ try {
26
+ return text(await convertTime(a));
27
+ }
28
+ catch (e) {
29
+ return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
30
+ }
31
+ });
32
+ server.tool("list_timezones", "Search the IANA zone list this runtime knows.", {
33
+ search: z.string().optional().describe('Substring filter, e.g. tokyo')
34
+ }, async (a) => {
35
+ try {
36
+ return text(await listTimezones(a));
37
+ }
38
+ catch (e) {
39
+ return { content: [{ type: "text", text: errorMessage(e) }], isError: true };
40
+ }
41
+ });
42
+ return server;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@mrfentmen/timezone-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "IANA time zone maths offline - the time right now in any zone, wall-clock conversion between zones, and the zone list, keyless.",
5
+ "type": "module",
6
+ "mcpName": "io.github.mrfentmen/timezone-mcp",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/mrfentmen/awesome-servers.git"
10
+ },
11
+ "bin": {
12
+ "timezone-mcp": "./dist/index.js"
13
+ },
14
+ "main": "./dist/index.js",
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "start": "node dist/index.js"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "timezone",
25
+ "time",
26
+ "utc",
27
+ "iana",
28
+ "offline",
29
+ "keyless"
30
+ ],
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.0.4",
34
+ "zod": "^3.23.8"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "typescript": "^5.6.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=20"
42
+ }
43
+ }