<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: mumuzhang</title>
    <description>The latest articles on DEV Community by mumuzhang (@mumuzhang).</description>
    <link>https://dev.to/mumuzhang</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4199458%2F486cfcf3-8352-4b01-83b5-428765fa9beb.png</url>
      <title>DEV Community: mumuzhang</title>
      <link>https://dev.to/mumuzhang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mumuzhang"/>
    <language>en</language>
    <item>
      <title>Convert datetimes across IANA timezones and rewrite ICS timestamps</title>
      <dc:creator>mumuzhang</dc:creator>
      <pubDate>Tue, 27 Oct 2026 03:27:27 +0000</pubDate>
      <link>https://dev.to/mumuzhang/convert-datetimes-across-iana-timezones-and-rewrite-ics-timestamps-5b42</link>
      <guid>https://dev.to/mumuzhang/convert-datetimes-across-iana-timezones-and-rewrite-ics-timestamps-5b42</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;I kept hitting the same friction: wiring one tool into every client by hand, then rewriting the same glue code each time a new client showed up. Copying a previous project helped for about an hour, and then the small differences took over: different payload shapes, different failure modes, different places to look when nothing arrived. So I built &lt;strong&gt;timezone-convert-mcp&lt;/strong&gt; to make the whole thing repeatable instead of memorable.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingest&lt;/strong&gt; — accept the inbound request and normalize it into one shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; — check the signature before trusting anything in the body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard&lt;/strong&gt; — drop events that were already processed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliver&lt;/strong&gt; — forward the normalized record and route failures somewhere visible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order matters more than the individual pieces. Verifying before parsing means a malformed or hostile body never reaches the part of the system that would act on it. The idempotency guard is the piece I used to skip, and it is the piece that actually saves time: once duplicates are dropped at the door, every downstream failure is a real failure instead of noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;The structure below is taken directly from the generated artifact, not rewritten for the post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"io.github.your-scope/timezone-convert-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Convert datetimes across IANA timezones and rewrite ICS timestamps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"registryType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timezone-convert-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"transport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repository"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/your-scope/REPLACE_ME"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here is clever on purpose. Each node does one thing, and the thing it does is readable from its name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;The first version passed every happy-path test and then lost events in production for two reasons that only show up under load. The first was treating a signature check as optional on internal traffic — it is not optional, because "internal" stops being true the moment someone copies the webhook URL into a second service. The second was writing failures to a log nobody read. An error branch that only appends to a file is not an error branch; it has to end somewhere a human looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Start smaller. The first version had features I could not explain to myself a week later, and every one of them became a place for a silent failure to hide. Cutting it down to trigger, verify, guard, deliver made the whole thing easier to trust, and trust is the actual feature — the automation is only useful if you stop checking it. I would also write the idempotency key format down before writing code, because retrofitting a key scheme onto events already in flight is the least fun kind of migration.&lt;/p&gt;

&lt;p&gt;Full workflow and setup live in the &lt;a href="https://github.com/your-name/your-repo" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the project actually does
&lt;/h2&gt;

&lt;p&gt;Convert datetimes across IANA timezones and rewrite ICS timestamps&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; server. It exposes 3 tools over stdio and has no hosted component — everything runs on your machine.&lt;/p&gt;

&lt;p&gt;Most MCP servers wrap a SaaS API, which means an OAuth dance before you get anything useful. This one does not. Convert datetimes across IANA timezones and rewrite ICS timestamps is a small, bounded problem, so the server implements it directly with Node's standard library and returns plain JSON you can inspect. There is no account, no API key, and no data leaving your process.&lt;/p&gt;

&lt;p&gt;That trade-off has a cost, and it is stated plainly in the limitations section below. If your use case needs more than this server does, the source is short enough to read in one sitting and extend.&lt;/p&gt;

&lt;p&gt;Run it directly with npx:&lt;/p&gt;

&lt;p&gt;Or install it globally:&lt;/p&gt;

&lt;p&gt;Requires Node 18 or newer.&lt;/p&gt;

&lt;p&gt;Add the server to your client configuration (Claude Desktop, Cursor, or any MCP-compatible host):&lt;/p&gt;

&lt;p&gt;Restart the client after editing the configuration. The 3 tools below should appear in the tool list.&lt;/p&gt;

&lt;p&gt;Convert a datetime from one IANA timezone to another&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;datetime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;from&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;to&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Search IANA timezone names&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;optional&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rewrite every DTSTART/DTEND in an ICS payload to another timezone&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;to&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every tool takes plain strings or numbers and returns a JSON string in a text content block. Errors come back as &lt;code&gt;isError: true&lt;/code&gt; with a readable message rather than a stack trace, so an agent can recover instead of crashing.&lt;/p&gt;

&lt;p&gt;The general pattern is: call the read tool first to see what the payload actually looks like, then call the transform tool with explicit parameters. Tools never mutate their input and never write to disk — running the same call twice gives the same result.&lt;/p&gt;

&lt;p&gt;Optional dependencies are loaded lazily. If a tool needs &lt;code&gt;pdf-parse&lt;/code&gt; or &lt;code&gt;qrcode&lt;/code&gt; and the package is missing, the call fails with a message telling you exactly what to install, while the rest of the server keeps working.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Uses the ICU timezone database bundled with Node — keep Node updated for recent rule changes.&lt;/li&gt;
&lt;li&gt;convert_ics only rewrites UTC timestamps (ending in Z); already-local times are left alone.&lt;/li&gt;
&lt;li&gt;Ambiguous times during a DST fall-back resolve to the first occurrence.&lt;/li&gt;
&lt;li&gt;No duration formatting: callers convert both endpoints themselves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are deliberate scope limits, not bugs. Each one exists because doing it properly needs a dependency, a network service, or an amount of code that no longer belongs in a single-file server.&lt;/p&gt;

&lt;p&gt;To test a tool by hand, pipe a JSON-RPC request into stdin:&lt;/p&gt;

&lt;p&gt;Because the transport is stdio, all diagnostics go to stderr. If you add a &lt;code&gt;console.log&lt;/code&gt; anywhere in a handler, you will corrupt the protocol stream — use &lt;code&gt;console.error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;MIT.&lt;/p&gt;




&lt;p&gt;Production version with documentation and support: &lt;a href="https://your-checkout.example.com" rel="noopener noreferrer"&gt;https://your-checkout.example.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>integration</category>
    </item>
  </channel>
</rss>
