New:Microsoft Teams Notifications Are Now Available in Socket.Learn more →
Get Started

atomic-clock-mcp

Package Overview
Dependencies
Maintainers
0
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

atomic-clock-mcp

MCP server that returns UTC time from NTP atomic clocks. Zero dependencies, raw JSON-RPC over stdio, fully synchronous.

pipPyPI
Version
0.1.1
Weekly downloads
191
Maintainers
0
Weekly downloads
 
Created

atomic-clock-mcp

An MCP server that returns current UTC time from NTP atomic clocks. Zero dependencies, fully synchronous, raw JSON-RPC 2.0 over stdio -- no MCP library, no asyncio.

Install

pip install atomic-clock-mcp

Use with Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "atomic-clock": {
      "command": "atomic-clock-mcp"
    }
  }
}

Or run the script directly:

{
  "mcpServers": {
    "atomic-clock": {
      "command": "python3",
      "args": ["-m", "atomic_clock_mcp"]
    }
  }
}

Restart Claude Desktop and ask "what time is it?" -- Claude will call get_atomic_time, which queries time.cloudflare.com over UDP port 123.

Tools

  • get_atomic_time -- returns UTC time, NTP stratum, and the server queried. Optional argument: server (default time.cloudflare.com).

How it works

The whole server is atomic_clock_mcp/server.py, ~150 lines:

  • JSON-RPC 2.0 over stdio -- initialize, tools/list, tools/call, ping, notifications, and the standard error codes (-32700, -32601).
  • Raw NTP -- a 48-byte NTPv3 packet (0x1B + 47 zero bytes) sent via socket.sendto to UDP 123; the transmit timestamp is unpacked with struct.unpack("!I", ...) and converted from the 1900 NTP epoch to Unix time by subtracting 2208988800.
  • Two rules that matter: stdout is the protocol channel (log to stderr only), and flush() after every write (subprocess stdout is block-buffered).

Development

client.py is a test harness that plays the role of an MCP host -- it spawns the server and performs the real handshake, printing every raw frame:

python3 client.py                              # tests server.py (stage 1)
python3 client.py server_atomic.py             # tests the standalone server
python3 client.py atomic_clock_mcp/server.py   # tests the package

server.py and server_atomic.py are the from-scratch learning artifacts; atomic_clock_mcp/ is the same code packaged for PyPI.

Publishing

The GitHub Action in .github/workflows/publish-mcp.yml runs on version tags (git tag v0.1.0 && git push origin v0.1.0) and does two things:

  • Publishes the package to PyPI -- requires a PYPI_API_TOKEN repository secret (or configure Trusted Publishing on PyPI and remove the password line).
  • Publishes metadata to the MCP Registry -- uses mcp-publisher with GitHub OIDC (id-token: write), no secret needed. The server name io.github.theoddden/atomic-clock is bound to the GitHub account; the mcp-name HTML comment at the top of this README is the PyPI ownership verification marker.

Appendix: how this was built, stage by stage

Build an MCP server with no library, one concept at a time. By the end you will have written every line yourself and the official MCP SDK becomes a convenience you could discard.

Stage 1 -- raw JSON-RPC over stdio (DONE, verified)

  • server.py -- the entire protocol in ~100 lines: sys.stdin -> json -> dispatch -> sys.stdout -> flush().
  • client.py -- plays the role of Claude Desktop. Spawns the server and performs the real handshake, printing every raw frame.

Run it:

python3 client.py

Things to notice in the output:

  • initialize returns protocolVersion, capabilities, serverInfo.
  • notifications/initialized has no id and gets no response.
  • tools/list returns the manifest; inputSchema is plain JSON Schema.
  • tools/call results are {"content": [{"type": "text", ...}]}.
  • Unknown method -> JSON-RPC error -32601.
  • Unknown tool -> a normal result with isError: true (so the model can read the failure and recover).
  • Malformed JSON -> -32700.

Two rules that will bite you if ignored:

  • stdout is the protocol channel. One stray print() corrupts the stream. Log to stderr only.
  • flush() after every write. As a subprocess, stdout is block-buffered; without flush the host thinks the server is dead.

Stage 2 -- asyncio

Rewrite the stdin loop as an async coroutine:

  • async def main() + asyncio.run(main())
  • Read stdin without blocking the loop: loop.run_in_executor(None, sys.stdin.readline) or asyncio.StreamReader hooked to stdin via loop.connect_read_pipe.
  • await each handler.

The payoff comes in stage 4 -- for now it is the same server with a different engine.

Stage 3 -- real NTP

Replace the stub get_time with a real query.

  • First pass: pip install ntplib, then ntplib.NTPClient().request('pool.ntp.org', version=3).
  • Second pass (optional, illuminating): delete ntplib and write the UDP query yourself. NTPv3 packet = 48 bytes, first byte 0x1B (LI=0, VN=3, Mode=3), rest zeros. Send to port 123, read 48 bytes back, unpack the transmit timestamp (bytes 40-43, seconds since 1900) with struct.unpack('!I', ...). Subtract 2208988800 to get Unix time. ntplib is ~200 lines of exactly this -- read its source once.

Stage 4 -- blocking vs. the event loop

The lesson you learn by breaking it:

  • Call ntplib directly inside your async def handler.
  • While a slow NTP server is being queried, send a ping from the client. Watch it hang -- the single-threaded event loop is frozen.
  • Fix it: await loop.run_in_executor(None, blocking_ntp_call). Blocking work goes to the thread pool; async work gets awaited.

Rule of thumb: ntplib, requests, file I/O = blocking. aiohttp, httpx (async mode), asyncpg = not blocking.

Connecting to Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "ntp-scratch": {
      "command": "/usr/bin/python3",
      "args": ["/Users/theowolfenden/CascadeProjects/mcp-from-scratch/server.py"]
    }
  }
}

Restart Claude Desktop, then ask it "what tools do you have?" -- get_time should appear. If it doesn't, check the logs at ~/Library/Logs/Claude/mcp*.log -- a stray print or missing flush is the usual culprit.

The wire protocol, in one glance

>>> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
<<< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}
>>> {"jsonrpc":"2.0","method":"notifications/initialized"}     (no reply)
>>> {"jsonrpc":"2.0","id":2,"method":"tools/list"}
<<< {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
>>> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_time","arguments":{}}}
<<< {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"..."}]}}

That is the whole thing. Everything else is plumbing.

Keywords

mcp

FAQs

Related posts