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.
- stamp_mcp-0.3.0/PKG-INFO +233 -0
- stamp_mcp-0.3.0/README.md +219 -0
- stamp_mcp-0.3.0/pyproject.toml +27 -0
- stamp_mcp-0.3.0/setup.cfg +4 -0
- stamp_mcp-0.3.0/stamp_mcp/__init__.py +3 -0
- stamp_mcp-0.3.0/stamp_mcp/__main__.py +4 -0
- stamp_mcp-0.3.0/stamp_mcp/server.py +340 -0
- stamp_mcp-0.3.0/stamp_mcp.egg-info/PKG-INFO +233 -0
- stamp_mcp-0.3.0/stamp_mcp.egg-info/SOURCES.txt +10 -0
- stamp_mcp-0.3.0/stamp_mcp.egg-info/dependency_links.txt +1 -0
- stamp_mcp-0.3.0/stamp_mcp.egg-info/entry_points.txt +2 -0
- stamp_mcp-0.3.0/stamp_mcp.egg-info/top_level.txt +1 -0
stamp_mcp-0.3.0/PKG-INFO
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stamp_mcp
|