stamp-mcp 0.3.0__tar.gz

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.
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: stamp-mcp
3
+ Version: 0.3.0
4
+ Summary: MCP server for NTP time and clock drift: each query is logged, so drift trends (stable vs. accelerating) are visible. Zero dependencies, raw JSON-RPC over stdio.
5
+ Author: theoddden
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/theoddden/stamp-mcp
8
+ Keywords: mcp,model-context-protocol,ntp,time,clock-drift,drift
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ # stamp-mcp
16
+
17
+ <!-- mcp-name: io.github.theoddden/stamp -->
18
+
19
+ An MCP server for NTP time and clock drift. Zero dependencies, fully
20
+ synchronous, raw JSON-RPC 2.0 over stdio -- no MCP library, no asyncio.
21
+
22
+ A single NTP query tells you where your clock is right now. Drift history
23
+ tells you where it is going: a clock that is consistently 200ms fast and
24
+ accelerating is a different problem than one that is stable at 200ms fast.
25
+ `get_time` takes the measurement; every call appends to a local log;
26
+ `get_drift` reads the log and reports the trend.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install stamp-mcp
32
+ ```
33
+
34
+ ## Use with Claude Desktop
35
+
36
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "stamp": {
42
+ "command": "stamp-mcp"
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Or run the module directly:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "stamp": {
54
+ "command": "python3",
55
+ "args": ["-m", "stamp_mcp"]
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ## Tools
62
+
63
+ - **`get_time`** -- one NTP query: UTC time, this clock's offset in ms,
64
+ network delay, stratum. Appends the sample to the drift log. Optional
65
+ argument: `server` (default `time.cloudflare.com`).
66
+ - **`get_drift`** -- analyzes the drift log: sample count, timespan,
67
+ current/mean/stddev offset, drift rate in ms/day (least-squares fit),
68
+ first-half vs. second-half rates, and a verdict: `stable`, `drifting`,
69
+ or `accelerating`. Optional argument: `server` to filter samples.
70
+
71
+ ## The drift log
72
+
73
+ Every `get_time` call appends one JSON line to `~/.stamp/drift.jsonl`
74
+ (override with `STAMP_DRIFT_LOG`). The file is capped at 10,000 samples.
75
+ Call `get_time` periodically -- a cron job, a heartbeat, or just asking
76
+ Claude "check the clock" now and then -- and `get_drift` turns the
77
+ accumulated offsets into a trend.
78
+
79
+ ## How it works
80
+
81
+ The whole server is `stamp_mcp/server.py`:
82
+
83
+ - **JSON-RPC 2.0 over stdio** -- `initialize`, `tools/list`, `tools/call`,
84
+ `ping`, notifications, and the standard error codes (`-32700`, `-32601`).
85
+ - **Raw NTP with real offset math** -- a 48-byte NTPv3 packet over UDP 123
86
+ carrying our transmit timestamp; the response's receive (t2) and
87
+ transmit (t3) timestamps are unpacked with `struct.unpack("!II", ...)`
88
+ as 64-bit fixed point, and offset/delay follow RFC 5905:
89
+ `offset = ((t2-t1)+(t3-t4))/2`, `delay = (t4-t1)-(t3-t2)`.
90
+ - **Two rules that matter**: stdout is the protocol channel (log to stderr
91
+ only), and `flush()` after every write (subprocess stdout is
92
+ block-buffered).
93
+
94
+ ## Development
95
+
96
+ `client.py` is a test harness that plays the role of an MCP host -- it
97
+ spawns the server and performs the real handshake, printing every raw
98
+ frame:
99
+
100
+ ```bash
101
+ python3 client.py # tests server.py (stage 1)
102
+ python3 client.py server_atomic.py # tests the standalone artifact
103
+ python3 client.py stamp_mcp/server.py # tests the package
104
+ ```
105
+
106
+ `server.py` and `server_atomic.py` are the from-scratch learning artifacts;
107
+ `stamp_mcp/` is the packaged, published server.
108
+
109
+ ## Publishing
110
+
111
+ The GitHub Action in `.github/workflows/publish-mcp.yml` runs on version
112
+ tags (`git tag v0.3.0 && git push origin v0.3.0`) and does two things:
113
+
114
+ 1. **Publishes the package to PyPI** -- requires a `PYPI_API_TOKEN`
115
+ repository secret (or configure Trusted Publishing on PyPI and remove
116
+ the `password` line).
117
+ 2. **Publishes metadata to the MCP Registry** -- uses `mcp-publisher` with
118
+ GitHub OIDC (`id-token: write`), no secret needed. The server name
119
+ `io.github.theoddden/stamp` is bound to the GitHub account; the
120
+ `mcp-name` HTML comment at the top of this README is the PyPI ownership
121
+ verification marker.
122
+
123
+ ---
124
+
125
+ # Appendix: how this was built, stage by stage
126
+
127
+ Build an MCP server with no library, one concept at a time. By the end you
128
+ will have written every line yourself and the official MCP SDK becomes a
129
+ convenience you could discard.
130
+
131
+ ## Stage 1 -- raw JSON-RPC over stdio (DONE, verified)
132
+
133
+ - `server.py` -- the entire protocol in ~100 lines: `sys.stdin` -> `json` ->
134
+ dispatch -> `sys.stdout` -> `flush()`.
135
+ - `client.py` -- plays the role of Claude Desktop. Spawns the server and
136
+ performs the real handshake, printing every raw frame.
137
+
138
+ Run it:
139
+
140
+ ```bash
141
+ python3 client.py
142
+ ```
143
+
144
+ Things to notice in the output:
145
+
146
+ - `initialize` returns `protocolVersion`, `capabilities`, `serverInfo`.
147
+ - `notifications/initialized` has **no `id`** and gets **no response**.
148
+ - `tools/list` returns the manifest; `inputSchema` is plain JSON Schema.
149
+ - `tools/call` results are `{"content": [{"type": "text", ...}]}`.
150
+ - Unknown **method** -> JSON-RPC error `-32601`.
151
+ - Unknown **tool** -> a normal result with `isError: true` (so the model can
152
+ read the failure and recover).
153
+ - Malformed JSON -> `-32700`.
154
+
155
+ Two rules that will bite you if ignored:
156
+
157
+ 1. **stdout is the protocol channel.** One stray `print()` corrupts the
158
+ stream. Log to stderr only.
159
+ 2. **flush() after every write.** As a subprocess, stdout is block-buffered;
160
+ without flush the host thinks the server is dead.
161
+
162
+ ## Stage 2 -- asyncio
163
+
164
+ Rewrite the stdin loop as an async coroutine:
165
+
166
+ - `async def main()` + `asyncio.run(main())`
167
+ - Read stdin without blocking the loop:
168
+ `loop.run_in_executor(None, sys.stdin.readline)` or
169
+ `asyncio.StreamReader` hooked to stdin via `loop.connect_read_pipe`.
170
+ - `await` each handler.
171
+
172
+ The payoff comes in stage 4 -- for now it is the same server with a
173
+ different engine.
174
+
175
+ ## Stage 3 -- real NTP
176
+
177
+ Replace the stub `get_time` with a real query.
178
+
179
+ - First pass: `pip install ntplib`, then
180
+ `ntplib.NTPClient().request('pool.ntp.org', version=3)`.
181
+ - Second pass (optional, illuminating): delete ntplib and write the UDP
182
+ query yourself. NTPv3 packet = 48 bytes, first byte `0x1B` (LI=0, VN=3,
183
+ Mode=3), rest zeros. Send to port 123, read 48 bytes back, unpack the
184
+ transmit timestamp (bytes 40-43, seconds since 1900) with
185
+ `struct.unpack('!I', ...)`. Subtract 2208988800 to get Unix time.
186
+ ntplib is ~200 lines of exactly this -- read its source once.
187
+
188
+ ## Stage 4 -- blocking vs. the event loop
189
+
190
+ The lesson you learn by breaking it:
191
+
192
+ 1. Call `ntplib` **directly** inside your `async def` handler.
193
+ 2. While a slow NTP server is being queried, send a `ping` from the client.
194
+ Watch it hang -- the single-threaded event loop is frozen.
195
+ 3. Fix it: `await loop.run_in_executor(None, blocking_ntp_call)`.
196
+ Blocking work goes to the thread pool; async work gets awaited.
197
+
198
+ Rule of thumb: `ntplib`, `requests`, file I/O = blocking. `aiohttp`,
199
+ `httpx` (async mode), `asyncpg` = not blocking.
200
+
201
+ ## Connecting to Claude Desktop
202
+
203
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
204
+
205
+ ```json
206
+ {
207
+ "mcpServers": {
208
+ "ntp-scratch": {
209
+ "command": "/usr/bin/python3",
210
+ "args": ["/Users/theowolfenden/CascadeProjects/mcp-from-scratch/server.py"]
211
+ }
212
+ }
213
+ }
214
+ ```
215
+
216
+ Restart Claude Desktop, then ask it "what tools do you have?" -- `get_time`
217
+ should appear. If it doesn't, check the logs at
218
+ `~/Library/Logs/Claude/mcp*.log` -- a stray print or missing flush is the
219
+ usual culprit.
220
+
221
+ ## The wire protocol, in one glance
222
+
223
+ ```
224
+ >>> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
225
+ <<< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}
226
+ >>> {"jsonrpc":"2.0","method":"notifications/initialized"} (no reply)
227
+ >>> {"jsonrpc":"2.0","id":2,"method":"tools/list"}
228
+ <<< {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
229
+ >>> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_time","arguments":{}}}
230
+ <<< {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"..."}]}}
231
+ ```
232
+
233
+ That is the whole thing. Everything else is plumbing.
@@ -0,0 +1,219 @@
1
+ # stamp-mcp
2
+
3
+ <!-- mcp-name: io.github.theoddden/stamp -->
4
+
5
+ An MCP server for NTP time and clock drift. Zero dependencies, fully
6
+ synchronous, raw JSON-RPC 2.0 over stdio -- no MCP library, no asyncio.
7
+
8
+ A single NTP query tells you where your clock is right now. Drift history
9
+ tells you where it is going: a clock that is consistently 200ms fast and
10
+ accelerating is a different problem than one that is stable at 200ms fast.
11
+ `get_time` takes the measurement; every call appends to a local log;
12
+ `get_drift` reads the log and reports the trend.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install stamp-mcp
18
+ ```
19
+
20
+ ## Use with Claude Desktop
21
+
22
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "stamp": {
28
+ "command": "stamp-mcp"
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Or run the module directly:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "stamp": {
40
+ "command": "python3",
41
+ "args": ["-m", "stamp_mcp"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Tools
48
+
49
+ - **`get_time`** -- one NTP query: UTC time, this clock's offset in ms,
50
+ network delay, stratum. Appends the sample to the drift log. Optional
51
+ argument: `server` (default `time.cloudflare.com`).
52
+ - **`get_drift`** -- analyzes the drift log: sample count, timespan,
53
+ current/mean/stddev offset, drift rate in ms/day (least-squares fit),
54
+ first-half vs. second-half rates, and a verdict: `stable`, `drifting`,
55
+ or `accelerating`. Optional argument: `server` to filter samples.
56
+
57
+ ## The drift log
58
+
59
+ Every `get_time` call appends one JSON line to `~/.stamp/drift.jsonl`
60
+ (override with `STAMP_DRIFT_LOG`). The file is capped at 10,000 samples.
61
+ Call `get_time` periodically -- a cron job, a heartbeat, or just asking
62
+ Claude "check the clock" now and then -- and `get_drift` turns the
63
+ accumulated offsets into a trend.
64
+
65
+ ## How it works
66
+
67
+ The whole server is `stamp_mcp/server.py`:
68
+
69
+ - **JSON-RPC 2.0 over stdio** -- `initialize`, `tools/list`, `tools/call`,
70
+ `ping`, notifications, and the standard error codes (`-32700`, `-32601`).
71
+ - **Raw NTP with real offset math** -- a 48-byte NTPv3 packet over UDP 123
72
+ carrying our transmit timestamp; the response's receive (t2) and
73
+ transmit (t3) timestamps are unpacked with `struct.unpack("!II", ...)`
74
+ as 64-bit fixed point, and offset/delay follow RFC 5905:
75
+ `offset = ((t2-t1)+(t3-t4))/2`, `delay = (t4-t1)-(t3-t2)`.
76
+ - **Two rules that matter**: stdout is the protocol channel (log to stderr
77
+ only), and `flush()` after every write (subprocess stdout is
78
+ block-buffered).
79
+
80
+ ## Development
81
+
82
+ `client.py` is a test harness that plays the role of an MCP host -- it
83
+ spawns the server and performs the real handshake, printing every raw
84
+ frame:
85
+
86
+ ```bash
87
+ python3 client.py # tests server.py (stage 1)
88
+ python3 client.py server_atomic.py # tests the standalone artifact
89
+ python3 client.py stamp_mcp/server.py # tests the package
90
+ ```
91
+
92
+ `server.py` and `server_atomic.py` are the from-scratch learning artifacts;
93
+ `stamp_mcp/` is the packaged, published server.
94
+
95
+ ## Publishing
96
+
97
+ The GitHub Action in `.github/workflows/publish-mcp.yml` runs on version
98
+ tags (`git tag v0.3.0 && git push origin v0.3.0`) and does two things:
99
+
100
+ 1. **Publishes the package to PyPI** -- requires a `PYPI_API_TOKEN`
101
+ repository secret (or configure Trusted Publishing on PyPI and remove
102
+ the `password` line).
103
+ 2. **Publishes metadata to the MCP Registry** -- uses `mcp-publisher` with
104
+ GitHub OIDC (`id-token: write`), no secret needed. The server name
105
+ `io.github.theoddden/stamp` is bound to the GitHub account; the
106
+ `mcp-name` HTML comment at the top of this README is the PyPI ownership
107
+ verification marker.
108
+
109
+ ---
110
+
111
+ # Appendix: how this was built, stage by stage
112
+
113
+ Build an MCP server with no library, one concept at a time. By the end you
114
+ will have written every line yourself and the official MCP SDK becomes a
115
+ convenience you could discard.
116
+
117
+ ## Stage 1 -- raw JSON-RPC over stdio (DONE, verified)
118
+
119
+ - `server.py` -- the entire protocol in ~100 lines: `sys.stdin` -> `json` ->
120
+ dispatch -> `sys.stdout` -> `flush()`.
121
+ - `client.py` -- plays the role of Claude Desktop. Spawns the server and
122
+ performs the real handshake, printing every raw frame.
123
+
124
+ Run it:
125
+
126
+ ```bash
127
+ python3 client.py
128
+ ```
129
+
130
+ Things to notice in the output:
131
+
132
+ - `initialize` returns `protocolVersion`, `capabilities`, `serverInfo`.
133
+ - `notifications/initialized` has **no `id`** and gets **no response**.
134
+ - `tools/list` returns the manifest; `inputSchema` is plain JSON Schema.
135
+ - `tools/call` results are `{"content": [{"type": "text", ...}]}`.
136
+ - Unknown **method** -> JSON-RPC error `-32601`.
137
+ - Unknown **tool** -> a normal result with `isError: true` (so the model can
138
+ read the failure and recover).
139
+ - Malformed JSON -> `-32700`.
140
+
141
+ Two rules that will bite you if ignored:
142
+
143
+ 1. **stdout is the protocol channel.** One stray `print()` corrupts the
144
+ stream. Log to stderr only.
145
+ 2. **flush() after every write.** As a subprocess, stdout is block-buffered;
146
+ without flush the host thinks the server is dead.
147
+
148
+ ## Stage 2 -- asyncio
149
+
150
+ Rewrite the stdin loop as an async coroutine:
151
+
152
+ - `async def main()` + `asyncio.run(main())`
153
+ - Read stdin without blocking the loop:
154
+ `loop.run_in_executor(None, sys.stdin.readline)` or
155
+ `asyncio.StreamReader` hooked to stdin via `loop.connect_read_pipe`.
156
+ - `await` each handler.
157
+
158
+ The payoff comes in stage 4 -- for now it is the same server with a
159
+ different engine.
160
+
161
+ ## Stage 3 -- real NTP
162
+
163
+ Replace the stub `get_time` with a real query.
164
+
165
+ - First pass: `pip install ntplib`, then
166
+ `ntplib.NTPClient().request('pool.ntp.org', version=3)`.
167
+ - Second pass (optional, illuminating): delete ntplib and write the UDP
168
+ query yourself. NTPv3 packet = 48 bytes, first byte `0x1B` (LI=0, VN=3,
169
+ Mode=3), rest zeros. Send to port 123, read 48 bytes back, unpack the
170
+ transmit timestamp (bytes 40-43, seconds since 1900) with
171
+ `struct.unpack('!I', ...)`. Subtract 2208988800 to get Unix time.
172
+ ntplib is ~200 lines of exactly this -- read its source once.
173
+
174
+ ## Stage 4 -- blocking vs. the event loop
175
+
176
+ The lesson you learn by breaking it:
177
+
178
+ 1. Call `ntplib` **directly** inside your `async def` handler.
179
+ 2. While a slow NTP server is being queried, send a `ping` from the client.
180
+ Watch it hang -- the single-threaded event loop is frozen.
181
+ 3. Fix it: `await loop.run_in_executor(None, blocking_ntp_call)`.
182
+ Blocking work goes to the thread pool; async work gets awaited.
183
+
184
+ Rule of thumb: `ntplib`, `requests`, file I/O = blocking. `aiohttp`,
185
+ `httpx` (async mode), `asyncpg` = not blocking.
186
+
187
+ ## Connecting to Claude Desktop
188
+
189
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
190
+
191
+ ```json
192
+ {
193
+ "mcpServers": {
194
+ "ntp-scratch": {
195
+ "command": "/usr/bin/python3",
196
+ "args": ["/Users/theowolfenden/CascadeProjects/mcp-from-scratch/server.py"]
197
+ }
198
+ }
199
+ }
200
+ ```
201
+
202
+ Restart Claude Desktop, then ask it "what tools do you have?" -- `get_time`
203
+ should appear. If it doesn't, check the logs at
204
+ `~/Library/Logs/Claude/mcp*.log` -- a stray print or missing flush is the
205
+ usual culprit.
206
+
207
+ ## The wire protocol, in one glance
208
+
209
+ ```
210
+ >>> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
211
+ <<< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}
212
+ >>> {"jsonrpc":"2.0","method":"notifications/initialized"} (no reply)
213
+ >>> {"jsonrpc":"2.0","id":2,"method":"tools/list"}
214
+ <<< {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
215
+ >>> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_time","arguments":{}}}
216
+ <<< {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"..."}]}}
217
+ ```
218
+
219
+ That is the whole thing. Everything else is plumbing.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stamp-mcp"
7
+ version = "0.3.0"
8
+ description = "MCP server for NTP time and clock drift: each query is logged, so drift trends (stable vs. accelerating) are visible. Zero dependencies, raw JSON-RPC over stdio."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "theoddden" }]
13
+ keywords = ["mcp", "model-context-protocol", "ntp", "time", "clock-drift", "drift"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/theoddden/stamp-mcp"
22
+
23
+ [project.scripts]
24
+ stamp-mcp = "stamp_mcp.server:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["stamp_mcp*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """stamp-mcp: zero-dependency MCP server for NTP time and clock drift."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,4 @@
1
+ from stamp_mcp.server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ stamp -- a minimal MCP server for clock drift, with zero dependencies.
4
+
5
+ No MCP library, no asyncio. The entire protocol is:
6
+
7
+ read line from stdin -> parse JSON-RPC -> dispatch -> write line -> flush
8
+
9
+ Two tools:
10
+
11
+ get_time -- one NTP query: current UTC time + clock offset, and
12
+ appends the sample to a local JSONL drift log.
13
+ get_drift -- reads the drift log and reports the trend: mean offset,
14
+ drift rate (least-squares slope), and whether the offset
15
+ is stable, drifting, or accelerating.
16
+
17
+ A single NTP query tells you where your clock is. The log tells you where
18
+ it is going: +200ms stable is a different problem than +200ms accelerating.
19
+
20
+ The NTP query is raw socket + struct: a 48-byte NTPv3 packet over UDP
21
+ port 123, with full four-timestamp offset/delay math (RFC 5905).
22
+ """
23
+
24
+ import sys
25
+ import os
26
+ import json
27
+ import socket
28
+ import struct
29
+ import time
30
+ from datetime import datetime, timezone
31
+
32
+ NTP_DELTA = 2208988800 # seconds between 1900 (NTP epoch) and 1970 (Unix)
33
+
34
+ # Where drift samples accumulate. One JSON object per line. Override with
35
+ # STAMP_DRIFT_LOG if you want it elsewhere.
36
+ DRIFT_LOG = os.environ.get(
37
+ "STAMP_DRIFT_LOG", os.path.expanduser("~/.stamp/drift.jsonl")
38
+ )
39
+ DRIFT_LOG_MAX = 10000 # keep the file bounded: trim to last half when hit
40
+
41
+ TOOLS = [
42
+ {
43
+ "name": "get_time",
44
+ "description": "Query an NTP server for current UTC time and this "
45
+ "clock's offset, and append the sample to the drift "
46
+ "log. Call it periodically to build drift history.",
47
+ "inputSchema": {
48
+ "type": "object",
49
+ "properties": {
50
+ "server": {
51
+ "type": "string",
52
+ "description": "NTP server to query",
53
+ "default": "time.cloudflare.com",
54
+ }
55
+ },
56
+ },
57
+ },
58
+ {
59
+ "name": "get_drift",
60
+ "description": "Analyze the drift log built by get_time: reports "
61
+ "current offset, drift rate (ms/day), and whether "
62
+ "the clock is stable, drifting, or accelerating.",
63
+ "inputSchema": {
64
+ "type": "object",
65
+ "properties": {
66
+ "server": {
67
+ "type": "string",
68
+ "description": "Only analyze samples from this NTP server",
69
+ }
70
+ },
71
+ },
72
+ },
73
+ ]
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # NTP: the four timestamps of RFC 5905
78
+ #
79
+ # t1 = client send time t2 = server receive time
80
+ # t3 = server transmit time t4 = client receive time
81
+ #
82
+ # offset = ((t2 - t1) + (t3 - t4)) / 2 -- how far our clock is off
83
+ # delay = (t4 - t1) - (t3 - t2) -- round-trip network time
84
+ #
85
+ # NTP timestamps are 64-bit fixed point: 32s seconds + 32s fraction.
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def _ntp_to_unix(data, offset):
89
+ """Read an 8-byte NTP timestamp at byte `offset`, return Unix seconds."""
90
+ sec, frac = struct.unpack("!II", data[offset:offset + 8])
91
+ return sec - NTP_DELTA + frac / 2**32
92
+
93
+
94
+ def query_time(server="time.cloudflare.com"):
95
+ """One NTPv3 query over UDP, returning time AND clock offset."""
96
+ pkt = bytearray(48)
97
+ pkt[0] = 0x1B # LI 0, Version 3, Mode 3 (client)
98
+ # Put our send time in the transmit field; the server echoes it back
99
+ # as the originate timestamp.
100
+ t1 = time.time()
101
+ sec = int(t1) + NTP_DELTA
102
+ frac = int((t1 - int(t1)) * 2**32)
103
+ struct.pack_into("!II", pkt, 40, sec, frac)
104
+
105
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
106
+ s.settimeout(5)
107
+ try:
108
+ s.sendto(bytes(pkt), (server, 123))
109
+ data, _ = s.recvfrom(48)
110
+ t4 = time.time()
111
+ finally:
112
+ s.close()
113
+
114
+ t2 = _ntp_to_unix(data, 32) # server receive timestamp
115
+ t3 = _ntp_to_unix(data, 40) # server transmit timestamp
116
+
117
+ offset = ((t2 - t1) + (t3 - t4)) / 2
118
+ delay = (t4 - t1) - (t3 - t2)
119
+
120
+ return {
121
+ "utc": datetime.fromtimestamp(t3, tz=timezone.utc).isoformat(),
122
+ "offset_ms": round(offset * 1000, 3),
123
+ "delay_ms": round(delay * 1000, 3),
124
+ "stratum": data[1],
125
+ "server": server,
126
+ }
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Drift log: one JSON sample per line, appended on every get_time call.
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def log_sample(sample):
134
+ """Append one sample to the drift log, keeping the file bounded."""
135
+ os.makedirs(os.path.dirname(DRIFT_LOG), exist_ok=True)
136
+ with open(DRIFT_LOG, "a") as f:
137
+ f.write(json.dumps(sample) + "\n")
138
+ _trim_log()
139
+
140
+
141
+ def _trim_log():
142
+ """If the log exceeds DRIFT_LOG_MAX lines, keep the most recent half."""
143
+ try:
144
+ with open(DRIFT_LOG) as f:
145
+ lines = f.readlines()
146
+ if len(lines) > DRIFT_LOG_MAX:
147
+ with open(DRIFT_LOG, "w") as f:
148
+ f.writelines(lines[DRIFT_LOG_MAX // 2:])
149
+ except OSError:
150
+ pass
151
+
152
+
153
+ def read_samples(server=None):
154
+ """Read the drift log, optionally filtered to one server."""
155
+ samples = []
156
+ try:
157
+ with open(DRIFT_LOG) as f:
158
+ for line in f:
159
+ line = line.strip()
160
+ if not line:
161
+ continue
162
+ try:
163
+ s = json.loads(line)
164
+ except json.JSONDecodeError:
165
+ continue
166
+ if server and s.get("server") != server:
167
+ continue
168
+ if "ts" in s and "offset_ms" in s:
169
+ samples.append(s)
170
+ except OSError:
171
+ pass
172
+ return samples
173
+
174
+
175
+ def _slope(points):
176
+ """Least-squares slope of (x, y) points. Returns y-units per x-unit."""
177
+ n = len(points)
178
+ if n < 2:
179
+ return 0.0
180
+ mx = sum(p[0] for p in points) / n
181
+ my = sum(p[1] for p in points) / n
182
+ denom = sum((p[0] - mx) ** 2 for p in points)
183
+ if denom == 0:
184
+ return 0.0
185
+ return sum((p[0] - mx) * (p[1] - my) for p in points) / denom
186
+
187
+
188
+ def analyze_drift(server=None):
189
+ """Trend analysis over the drift log.
190
+
191
+ Fits offset vs. time for the whole log and for each half separately.
192
+ Whole-log slope gives the drift rate; comparing the halves reveals
193
+ acceleration -- a clock gaining time faster now than before.
194
+ """
195
+ samples = read_samples(server)
196
+ if len(samples) < 2:
197
+ return {
198
+ "samples": len(samples),
199
+ "verdict": "insufficient data",
200
+ "hint": "call get_time a few times over minutes or hours, "
201
+ "then ask again",
202
+ }
203
+
204
+ ts = [s["ts"] for s in samples]
205
+ offsets = [s["offset_ms"] for s in samples]
206
+ n = len(samples)
207
+ span_s = ts[-1] - ts[0]
208
+
209
+ # Whole-log drift rate: ms of offset gained per day.
210
+ slope_ms_per_s = _slope(list(zip(ts, offsets)))
211
+ rate_ms_per_day = slope_ms_per_s * 86400
212
+
213
+ # Split-half comparison for acceleration.
214
+ half = n // 2
215
+ rate_first = _slope(list(zip(ts[:half], offsets[:half]))) * 86400
216
+ rate_second = _slope(list(zip(ts[half:], offsets[half:]))) * 86400
217
+
218
+ mean = sum(offsets) / n
219
+ variance = sum((o - mean) ** 2 for o in offsets) / n
220
+ stddev = variance ** 0.5
221
+
222
+ # Heuristic classification. <5 ms/day is within typical NTP-disciplined
223
+ # jitter; beyond that the clock is genuinely drifting. "Accelerating"
224
+ # means the recent rate is at least double the earlier rate and the
225
+ # recent rate itself is significant. A span under a minute is too short
226
+ # for any slope to be meaningful -- network jitter dominates.
227
+ if span_s < 60:
228
+ verdict = "insufficient timespan"
229
+ elif abs(rate_ms_per_day) < 5 and stddev < 10:
230
+ verdict = "stable"
231
+ elif abs(rate_second) > 2 * abs(rate_first) and abs(rate_second) > 5:
232
+ verdict = "accelerating"
233
+ else:
234
+ verdict = "drifting"
235
+
236
+ return {
237
+ "samples": n,
238
+ "span_hours": round(span_s / 3600, 2),
239
+ "current_offset_ms": offsets[-1],
240
+ "mean_offset_ms": round(mean, 3),
241
+ "stddev_ms": round(stddev, 3),
242
+ "drift_rate_ms_per_day": round(rate_ms_per_day, 3),
243
+ "rate_first_half_ms_per_day": round(rate_first, 3),
244
+ "rate_second_half_ms_per_day": round(rate_second, 3),
245
+ "verdict": verdict,
246
+ "log": DRIFT_LOG,
247
+ }
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Tool dispatch + JSON-RPC plumbing (unchanged from the atomic-clock version)
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def call_tool(name, arguments):
255
+ arguments = arguments or {}
256
+ if name == "get_time":
257
+ try:
258
+ data = query_time(arguments.get("server", "time.cloudflare.com"))
259
+ except Exception as e:
260
+ return {"content": [{"type": "text",
261
+ "text": f"NTP query failed: {e}"}],
262
+ "isError": True}
263
+ sample = dict(data)
264
+ sample["ts"] = time.time()
265
+ try:
266
+ log_sample(sample)
267
+ except OSError:
268
+ pass # logging is best-effort; never fail the tool over it
269
+ return {"content": [{"type": "text", "text": json.dumps(data)}]}
270
+ if name == "get_drift":
271
+ return {"content": [{"type": "text",
272
+ "text": json.dumps(analyze_drift(
273
+ arguments.get("server")))}]}
274
+ return {"content": [{"type": "text", "text": f"Unknown tool: {name}"}],
275
+ "isError": True}
276
+
277
+
278
+ def _write(message):
279
+ """Write one JSON-RPC message as a single line, then FLUSH.
280
+
281
+ The flush is not optional. As a subprocess, stdout is block-buffered --
282
+ without flush() responses sit in the buffer and the host thinks the
283
+ server is dead.
284
+ """
285
+ sys.stdout.write(json.dumps(message) + "\n")
286
+ sys.stdout.flush()
287
+
288
+
289
+ def respond(msg_id, result):
290
+ _write({"jsonrpc": "2.0", "id": msg_id, "result": result})
291
+
292
+
293
+ def respond_error(msg_id, code, message):
294
+ _write({"jsonrpc": "2.0", "id": msg_id,
295
+ "error": {"code": code, "message": message}})
296
+
297
+
298
+ def handle(req):
299
+ """Dispatch one parsed JSON-RPC message."""
300
+ method = req.get("method")
301
+ msg_id = req.get("id") # None -> notification -> never respond
302
+
303
+ if method == "initialize":
304
+ respond(msg_id, {
305
+ "protocolVersion": "2024-11-05",
306
+ "capabilities": {"tools": {}},
307
+ "serverInfo": {"name": "stamp", "version": "0.3.0"},
308
+ })
309
+ elif method == "notifications/initialized":
310
+ pass # notification: handshake confirmation, no response allowed
311
+ elif method == "ping":
312
+ respond(msg_id, {})
313
+ elif method == "tools/list":
314
+ respond(msg_id, {"tools": TOOLS})
315
+ elif method == "tools/call":
316
+ params = req.get("params") or {}
317
+ respond(msg_id, call_tool(params.get("name"), params.get("arguments")))
318
+ else:
319
+ if msg_id is not None: # unknown notification: ignore silently
320
+ respond_error(msg_id, -32601, f"Method not found: {method}")
321
+
322
+
323
+ def main():
324
+ # stdout is the protocol channel -- log to stderr only.
325
+ print("stamp: listening on stdin", file=sys.stderr, flush=True)
326
+
327
+ for line in sys.stdin:
328
+ line = line.strip()
329
+ if not line:
330
+ continue
331
+ try:
332
+ req = json.loads(line)
333
+ except json.JSONDecodeError:
334
+ respond_error(None, -32700, "Parse error")
335
+ continue
336
+ handle(req)
337
+
338
+
339
+ if __name__ == "__main__":
340
+ main()
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: stamp-mcp
3
+ Version: 0.3.0
4
+ Summary: MCP server for NTP time and clock drift: each query is logged, so drift trends (stable vs. accelerating) are visible. Zero dependencies, raw JSON-RPC over stdio.
5
+ Author: theoddden
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/theoddden/stamp-mcp
8
+ Keywords: mcp,model-context-protocol,ntp,time,clock-drift,drift
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ # stamp-mcp
16
+
17
+ <!-- mcp-name: io.github.theoddden/stamp -->
18
+
19
+ An MCP server for NTP time and clock drift. Zero dependencies, fully
20
+ synchronous, raw JSON-RPC 2.0 over stdio -- no MCP library, no asyncio.
21
+
22
+ A single NTP query tells you where your clock is right now. Drift history
23
+ tells you where it is going: a clock that is consistently 200ms fast and
24
+ accelerating is a different problem than one that is stable at 200ms fast.
25
+ `get_time` takes the measurement; every call appends to a local log;
26
+ `get_drift` reads the log and reports the trend.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install stamp-mcp
32
+ ```
33
+
34
+ ## Use with Claude Desktop
35
+
36
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "stamp": {
42
+ "command": "stamp-mcp"
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Or run the module directly:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "stamp": {
54
+ "command": "python3",
55
+ "args": ["-m", "stamp_mcp"]
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ## Tools
62
+
63
+ - **`get_time`** -- one NTP query: UTC time, this clock's offset in ms,
64
+ network delay, stratum. Appends the sample to the drift log. Optional
65
+ argument: `server` (default `time.cloudflare.com`).
66
+ - **`get_drift`** -- analyzes the drift log: sample count, timespan,
67
+ current/mean/stddev offset, drift rate in ms/day (least-squares fit),
68
+ first-half vs. second-half rates, and a verdict: `stable`, `drifting`,
69
+ or `accelerating`. Optional argument: `server` to filter samples.
70
+
71
+ ## The drift log
72
+
73
+ Every `get_time` call appends one JSON line to `~/.stamp/drift.jsonl`
74
+ (override with `STAMP_DRIFT_LOG`). The file is capped at 10,000 samples.
75
+ Call `get_time` periodically -- a cron job, a heartbeat, or just asking
76
+ Claude "check the clock" now and then -- and `get_drift` turns the
77
+ accumulated offsets into a trend.
78
+
79
+ ## How it works
80
+
81
+ The whole server is `stamp_mcp/server.py`:
82
+
83
+ - **JSON-RPC 2.0 over stdio** -- `initialize`, `tools/list`, `tools/call`,
84
+ `ping`, notifications, and the standard error codes (`-32700`, `-32601`).
85
+ - **Raw NTP with real offset math** -- a 48-byte NTPv3 packet over UDP 123
86
+ carrying our transmit timestamp; the response's receive (t2) and
87
+ transmit (t3) timestamps are unpacked with `struct.unpack("!II", ...)`
88
+ as 64-bit fixed point, and offset/delay follow RFC 5905:
89
+ `offset = ((t2-t1)+(t3-t4))/2`, `delay = (t4-t1)-(t3-t2)`.
90
+ - **Two rules that matter**: stdout is the protocol channel (log to stderr
91
+ only), and `flush()` after every write (subprocess stdout is
92
+ block-buffered).
93
+
94
+ ## Development
95
+
96
+ `client.py` is a test harness that plays the role of an MCP host -- it
97
+ spawns the server and performs the real handshake, printing every raw
98
+ frame:
99
+
100
+ ```bash
101
+ python3 client.py # tests server.py (stage 1)
102
+ python3 client.py server_atomic.py # tests the standalone artifact
103
+ python3 client.py stamp_mcp/server.py # tests the package
104
+ ```
105
+
106
+ `server.py` and `server_atomic.py` are the from-scratch learning artifacts;
107
+ `stamp_mcp/` is the packaged, published server.
108
+
109
+ ## Publishing
110
+
111
+ The GitHub Action in `.github/workflows/publish-mcp.yml` runs on version
112
+ tags (`git tag v0.3.0 && git push origin v0.3.0`) and does two things:
113
+
114
+ 1. **Publishes the package to PyPI** -- requires a `PYPI_API_TOKEN`
115
+ repository secret (or configure Trusted Publishing on PyPI and remove
116
+ the `password` line).
117
+ 2. **Publishes metadata to the MCP Registry** -- uses `mcp-publisher` with
118
+ GitHub OIDC (`id-token: write`), no secret needed. The server name
119
+ `io.github.theoddden/stamp` is bound to the GitHub account; the
120
+ `mcp-name` HTML comment at the top of this README is the PyPI ownership
121
+ verification marker.
122
+
123
+ ---
124
+
125
+ # Appendix: how this was built, stage by stage
126
+
127
+ Build an MCP server with no library, one concept at a time. By the end you
128
+ will have written every line yourself and the official MCP SDK becomes a
129
+ convenience you could discard.
130
+
131
+ ## Stage 1 -- raw JSON-RPC over stdio (DONE, verified)
132
+
133
+ - `server.py` -- the entire protocol in ~100 lines: `sys.stdin` -> `json` ->
134
+ dispatch -> `sys.stdout` -> `flush()`.
135
+ - `client.py` -- plays the role of Claude Desktop. Spawns the server and
136
+ performs the real handshake, printing every raw frame.
137
+
138
+ Run it:
139
+
140
+ ```bash
141
+ python3 client.py
142
+ ```
143
+
144
+ Things to notice in the output:
145
+
146
+ - `initialize` returns `protocolVersion`, `capabilities`, `serverInfo`.
147
+ - `notifications/initialized` has **no `id`** and gets **no response**.
148
+ - `tools/list` returns the manifest; `inputSchema` is plain JSON Schema.
149
+ - `tools/call` results are `{"content": [{"type": "text", ...}]}`.
150
+ - Unknown **method** -> JSON-RPC error `-32601`.
151
+ - Unknown **tool** -> a normal result with `isError: true` (so the model can
152
+ read the failure and recover).
153
+ - Malformed JSON -> `-32700`.
154
+
155
+ Two rules that will bite you if ignored:
156
+
157
+ 1. **stdout is the protocol channel.** One stray `print()` corrupts the
158
+ stream. Log to stderr only.
159
+ 2. **flush() after every write.** As a subprocess, stdout is block-buffered;
160
+ without flush the host thinks the server is dead.
161
+
162
+ ## Stage 2 -- asyncio
163
+
164
+ Rewrite the stdin loop as an async coroutine:
165
+
166
+ - `async def main()` + `asyncio.run(main())`
167
+ - Read stdin without blocking the loop:
168
+ `loop.run_in_executor(None, sys.stdin.readline)` or
169
+ `asyncio.StreamReader` hooked to stdin via `loop.connect_read_pipe`.
170
+ - `await` each handler.
171
+
172
+ The payoff comes in stage 4 -- for now it is the same server with a
173
+ different engine.
174
+
175
+ ## Stage 3 -- real NTP
176
+
177
+ Replace the stub `get_time` with a real query.
178
+
179
+ - First pass: `pip install ntplib`, then
180
+ `ntplib.NTPClient().request('pool.ntp.org', version=3)`.
181
+ - Second pass (optional, illuminating): delete ntplib and write the UDP
182
+ query yourself. NTPv3 packet = 48 bytes, first byte `0x1B` (LI=0, VN=3,
183
+ Mode=3), rest zeros. Send to port 123, read 48 bytes back, unpack the
184
+ transmit timestamp (bytes 40-43, seconds since 1900) with
185
+ `struct.unpack('!I', ...)`. Subtract 2208988800 to get Unix time.
186
+ ntplib is ~200 lines of exactly this -- read its source once.
187
+
188
+ ## Stage 4 -- blocking vs. the event loop
189
+
190
+ The lesson you learn by breaking it:
191
+
192
+ 1. Call `ntplib` **directly** inside your `async def` handler.
193
+ 2. While a slow NTP server is being queried, send a `ping` from the client.
194
+ Watch it hang -- the single-threaded event loop is frozen.
195
+ 3. Fix it: `await loop.run_in_executor(None, blocking_ntp_call)`.
196
+ Blocking work goes to the thread pool; async work gets awaited.
197
+
198
+ Rule of thumb: `ntplib`, `requests`, file I/O = blocking. `aiohttp`,
199
+ `httpx` (async mode), `asyncpg` = not blocking.
200
+
201
+ ## Connecting to Claude Desktop
202
+
203
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
204
+
205
+ ```json
206
+ {
207
+ "mcpServers": {
208
+ "ntp-scratch": {
209
+ "command": "/usr/bin/python3",
210
+ "args": ["/Users/theowolfenden/CascadeProjects/mcp-from-scratch/server.py"]
211
+ }
212
+ }
213
+ }
214
+ ```
215
+
216
+ Restart Claude Desktop, then ask it "what tools do you have?" -- `get_time`
217
+ should appear. If it doesn't, check the logs at
218
+ `~/Library/Logs/Claude/mcp*.log` -- a stray print or missing flush is the
219
+ usual culprit.
220
+
221
+ ## The wire protocol, in one glance
222
+
223
+ ```
224
+ >>> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
225
+ <<< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...}}
226
+ >>> {"jsonrpc":"2.0","method":"notifications/initialized"} (no reply)
227
+ >>> {"jsonrpc":"2.0","id":2,"method":"tools/list"}
228
+ <<< {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
229
+ >>> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_time","arguments":{}}}
230
+ <<< {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"..."}]}}
231
+ ```
232
+
233
+ That is the whole thing. Everything else is plumbing.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ stamp_mcp/__init__.py
4
+ stamp_mcp/__main__.py
5
+ stamp_mcp/server.py
6
+ stamp_mcp.egg-info/PKG-INFO
7
+ stamp_mcp.egg-info/SOURCES.txt
8
+ stamp_mcp.egg-info/dependency_links.txt
9
+ stamp_mcp.egg-info/entry_points.txt
10
+ stamp_mcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stamp-mcp = stamp_mcp.server:main
@@ -0,0 +1 @@
1
+ stamp_mcp