examples/MCP-tools Public
Add time_in_zone
Models do not know the current time and get daylight saving wrong. The tool takes an IANA zone (or a city that names one zone) and an optional instant, and answers with the local date, time, weekday and offset from Intl. Instants without an offset are refused, because the Worker would read them in its own zone.
Changed files
5 files changed182 additions5 deletionsagainst ee5ceb5
ChangedREADME.md+1−1
| @@ -1,6 +1,6 @@ | ||
| # mcp-tools | ||
| -A small MCP server with exact, read-only helpers for models, running as a Cloudflare Worker. It serves MCP over Streamable HTTP at `/mcp`. | ||
| +A small MCP server with exact, read-only helpers for models, running as a Cloudflare Worker. Tools: `convert_units` and `time_in_zone`. It serves MCP over Streamable HTTP at `/mcp`. | ||
| ```sh | ||
| npm install | ||
Changedsrc/server.ts+3−2
| @@ -2,12 +2,13 @@ | ||
| import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { convertUnits } from "./tools/convert-units.ts"; | ||
| import { ToolError } from "./tools/errors.ts"; | ||
| +import { timeInZone } from "./tools/time-in-zone.ts"; | ||
| import type { AnyTool } from "./tools/types.ts"; | ||
| export const SERVER_INFO = { name: "mcp-tools", version: "0.1.0" }; | ||
| // Every tool the server offers, in the order tools/list returns them. | ||
| -export const TOOLS: AnyTool[] = [convertUnits]; | ||
| +export const TOOLS: AnyTool[] = [convertUnits, timeInZone]; | ||
| export type ServerOptions = { | ||
| // The clock tools read; tests pin it. | ||
| @@ -18,7 +19,7 @@ | ||
| // so src/worker.ts makes one per request and each test makes its own. | ||
| export function createServer(options: ServerOptions = {}): McpServer { | ||
| const server = new McpServer(SERVER_INFO, { | ||
| - instructions: "Small, exact helpers: unit conversion. Each tool is read-only and answers from its input alone.", | ||
| + instructions: "Small, exact helpers: unit conversion and the time in any time zone. Each tool is read-only and answers from its input alone.", | ||
| }); | ||
| for (const tool of TOOLS) register(server, tool, options); | ||
| return server; | ||
Addedsrc/tools/time-in-zone.ts+103−0
| @@ -0,0 +1,103 @@ | ||
| +import { z } from "zod"; | ||
| +import { ToolError } from "./errors.ts"; | ||
| +import { defineTool } from "./types.ts"; | ||
| + | ||
| +// The canonical IANA names this runtime knows. Intl accepts more than it lists (UTC, links such | ||
| +// as US/Pacific), so this is for matching city names, not for deciding what is valid. | ||
| +const ZONES = Intl.supportedValuesOf("timeZone"); | ||
| + | ||
| +// An ISO 8601 instant with an explicit offset. Without one, Date.parse reads the time in the | ||
| +// Worker's own zone, which is UTC in production and something else on a laptop. | ||
| +const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/i; | ||
| + | ||
| +export function resolveZone(text: string): string { | ||
| + const given = text.trim(); | ||
| + const key = given.toLowerCase().replace(/\s+/g, "_"); | ||
| + const listed = ZONES.find((z) => z.toLowerCase() === key); | ||
| + if (listed) return listed; | ||
| + if (isZone(given)) return given.includes("/") ? given : given.toUpperCase(); | ||
| + const cities = ZONES.filter((z) => z.toLowerCase().split("/").pop() === key); | ||
| + if (cities.length === 1) return cities[0]; | ||
| + throw new ToolError(`Unknown time zone "${text}". Use an IANA name such as Europe/Paris, Asia/Tokyo or America/New_York.`); | ||
| +} | ||
| + | ||
| +function isZone(name: string): boolean { | ||
| + try { | ||
| + new Intl.DateTimeFormat("en-US", { timeZone: name }); | ||
| + return true; | ||
| + } catch { | ||
| + return false; | ||
| + } | ||
| +} | ||
| + | ||
| +function parseInstant(at: string | undefined, now: Date): Date { | ||
| + if (at === undefined) return now; | ||
| + const date = INSTANT.test(at.trim()) ? new Date(at.trim()) : null; | ||
| + if (!date || Number.isNaN(date.getTime())) { | ||
| + throw new ToolError(`"${at}" is not an ISO 8601 instant with an offset. Use a form such as 2026-10-16T14:00:00Z or 2026-10-16T09:00:00-05:00.`); | ||
| + } | ||
| + return date; | ||
| +} | ||
| + | ||
| +// "GMT+05:30" -> "+05:30" and 330; "GMT" -> "+00:00" and 0. | ||
| +function offsetOf(date: Date, zone: string): { offset: string; minutes: number } { | ||
| + const name = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset" }).formatToParts(date).find((p) => p.type === "timeZoneName")?.value ?? "GMT"; | ||
| + const m = /([+-])(\d{2}):(\d{2})/.exec(name); | ||
| + if (!m) return { offset: "+00:00", minutes: 0 }; | ||
| + const minutes = (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])); | ||
| + return { offset: `${m[1]}${m[2]}:${m[3]}`, minutes }; | ||
| +} | ||
| + | ||
| +function localParts(date: Date, zone: string): Record<string, string> { | ||
| + const parts = new Intl.DateTimeFormat("en-US", { | ||
| + timeZone: zone, | ||
| + hourCycle: "h23", | ||
| + weekday: "long", | ||
| + year: "numeric", | ||
| + month: "2-digit", | ||
| + day: "2-digit", | ||
| + hour: "2-digit", | ||
| + minute: "2-digit", | ||
| + second: "2-digit", | ||
| + }).formatToParts(date); | ||
| + return Object.fromEntries(parts.map((p) => [p.type, p.value])); | ||
| +} | ||
| + | ||
| +export const timeInZone = defineTool({ | ||
| + name: "time_in_zone", | ||
| + title: "Time in a time zone", | ||
| + description: | ||
| + "Give the local date, time, weekday and UTC offset in an IANA time zone, for now or for a given instant. " + | ||
| + "Use it for questions like \"what time is it in Tokyo\" or \"when is 15:00 UTC in Chicago\"; " + | ||
| + "your own sense of the current time may be wrong. Daylight saving time is applied.", | ||
| + input: z.object({ | ||
| + zone: z.string().min(1).describe("An IANA time zone such as Europe/Paris or America/New_York. A city that names exactly one zone, such as tokyo, also works."), | ||
| + at: z.string().optional().describe("The instant to convert, in ISO 8601 with Z or an offset, such as 2026-10-16T14:00:00Z. Leave it out for the current time."), | ||
| + }), | ||
| + output: z.object({ | ||
| + zone: z.string(), | ||
| + instant: z.string().describe("The instant in UTC, ISO 8601."), | ||
| + date: z.string().describe("The local date, YYYY-MM-DD."), | ||
| + time: z.string().describe("The local time, HH:MM:SS on a 24-hour clock."), | ||
| + weekday: z.string(), | ||
| + utc_offset: z.string().describe("The offset from UTC at that instant, such as +09:00."), | ||
| + utc_offset_minutes: z.number().int(), | ||
| + formatted: z.string().describe("The local time written out in English."), | ||
| + }), | ||
| + run({ zone, at }, { now }) { | ||
| + const name = resolveZone(zone); | ||
| + const date = parseInstant(at, now); | ||
| + const p = localParts(date, name); | ||
| + const { offset, minutes } = offsetOf(date, name); | ||
| + return { | ||
| + zone: name, | ||
| + instant: date.toISOString(), | ||
| + date: `${p.year}-${p.month}-${p.day}`, | ||
| + time: `${p.hour}:${p.minute}:${p.second}`, | ||
| + weekday: p.weekday, | ||
| + utc_offset: offset, | ||
| + utc_offset_minutes: minutes, | ||
| + formatted: new Intl.DateTimeFormat("en-US", { timeZone: name, dateStyle: "full", timeStyle: "long" }).format(date), | ||
| + }; | ||
| + }, | ||
| +}); | ||
Changedtest/server.test.ts+12−2
| @@ -12,8 +12,8 @@ | ||
| test("tools/list returns each tool with its schemas and read-only hint", async () => { | ||
| const client = await connect(); | ||
| const { tools } = await client.listTools(); | ||
| - assert.deepEqual(tools.map((t) => t.name), ["convert_units"]); | ||
| - const [convert] = tools; | ||
| + assert.deepEqual(tools.map((t) => t.name), ["convert_units", "time_in_zone"]); | ||
| + const convert = tools[0]; | ||
| assert.deepEqual(convert.inputSchema.required, ["value", "from", "to"]); | ||
| assert.ok(convert.outputSchema?.properties?.result); | ||
| assert.equal(convert.annotations?.readOnlyHint, true); | ||
| @@ -26,6 +26,16 @@ | ||
| assert.equal(result.isError, undefined); | ||
| assert.deepEqual(result.structuredContent, { quantity: "length", value: 5, from: "km", result: 5000, to: "m" }); | ||
| assert.deepEqual(JSON.parse(textOf(result)), result.structuredContent); | ||
| + await client.close(); | ||
| +}); | ||
| + | ||
| +test("the server passes its clock to the tools", async () => { | ||
| + const client = await connect({ now: () => new Date("2026-10-06T08:30:00Z") }); | ||
| + const result = await client.callTool({ name: "time_in_zone", arguments: { zone: "Europe/Berlin" } }); | ||
| + assert.deepEqual( | ||
| + { ...(result.structuredContent as object), formatted: undefined }, | ||
| + { zone: "Europe/Berlin", instant: "2026-10-06T08:30:00.000Z", date: "2026-10-06", time: "10:30:00", weekday: "Tuesday", utc_offset: "+02:00", utc_offset_minutes: 120, formatted: undefined }, | ||
| + ); | ||
| await client.close(); | ||
| }); | ||
Addedtest/time-in-zone.test.ts+63−0
| @@ -0,0 +1,63 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { ToolError } from "../src/tools/errors.ts"; | ||
| +import { resolveZone, timeInZone } from "../src/tools/time-in-zone.ts"; | ||
| + | ||
| +const now = new Date("2026-10-16T14:00:00Z"); | ||
| +const at = (zone: string, instant?: string) => timeInZone.run({ zone, at: instant }, { now }); | ||
| + | ||
| +test("gives the local time, weekday and offset for now", () => { | ||
| + const r = at("Asia/Tokyo"); | ||
| + assert.equal(r.instant, "2026-10-16T14:00:00.000Z"); | ||
| + assert.equal(r.date, "2026-10-16"); | ||
| + assert.equal(r.time, "23:00:00"); | ||
| + assert.equal(r.weekday, "Friday"); | ||
| + assert.equal(r.utc_offset, "+09:00"); | ||
| + assert.equal(r.utc_offset_minutes, 540); | ||
| + assert.match(r.formatted, /Friday, October 16, 2026/); | ||
| +}); | ||
| + | ||
| +test("handles half-hour offsets and the other side of the date line", () => { | ||
| + assert.equal(at("Asia/Kathmandu").utc_offset, "+05:45"); | ||
| + const honolulu = at("Pacific/Honolulu"); | ||
| + assert.equal(honolulu.utc_offset, "-10:00"); | ||
| + assert.equal(honolulu.date, "2026-10-16"); | ||
| + assert.equal(honolulu.time, "04:00:00"); | ||
| +}); | ||
| + | ||
| +test("applies daylight saving time at the given instant", () => { | ||
| + assert.equal(at("America/New_York", "2026-07-01T12:00:00Z").utc_offset, "-04:00"); | ||
| + assert.equal(at("America/New_York", "2026-12-01T12:00:00Z").utc_offset, "-05:00"); | ||
| +}); | ||
| + | ||
| +test("accepts an instant with an offset", () => { | ||
| + const r = at("Europe/London", "2026-10-16T09:00:00-05:00"); | ||
| + assert.equal(r.instant, "2026-10-16T14:00:00.000Z"); | ||
| + assert.equal(r.time, "15:00:00"); | ||
| +}); | ||
| + | ||
| +test("UTC has a zero offset", () => { | ||
| + const r = at("utc"); | ||
| + assert.equal(r.zone, "UTC"); | ||
| + assert.equal(r.utc_offset, "+00:00"); | ||
| + assert.equal(r.time, "14:00:00"); | ||
| +}); | ||
| + | ||
| +test("resolves names case-insensitively and by city", () => { | ||
| + assert.equal(resolveZone("europe/paris"), "Europe/Paris"); | ||
| + assert.equal(resolveZone("tokyo"), "Asia/Tokyo"); | ||
| + assert.equal(resolveZone("New York"), "America/New_York"); | ||
| +}); | ||
| + | ||
| +test("an unknown zone says what a valid one looks like", () => { | ||
| + assert.throws(() => at("Mars/Olympus_Mons"), (err: unknown) => { | ||
| + assert.ok(err instanceof ToolError); | ||
| + assert.match(err.message, /Unknown time zone "Mars\/Olympus_Mons"\. Use an IANA name such as Europe\/Paris/); | ||
| + return true; | ||
| + }); | ||
| +}); | ||
| + | ||
| +test("an instant without an offset is refused, since it would be ambiguous", () => { | ||
| + assert.throws(() => at("Asia/Tokyo", "2026-10-16T14:00:00"), /not an ISO 8601 instant with an offset/); | ||
| + assert.throws(() => at("Asia/Tokyo", "next tuesday"), ToolError); | ||
| +}); | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.