Skip to content

Commit 697e9b9

Browse files
feat(hub-mcp): add wait_for_change long-poll tool (#283)
Add a `wait_for_change` MCP tool that blocks until a watched file is edited by any collaborator, then returns its new content — letting an agent react to a live collaborator without busy-polling `read_file`. - ConnectionManager.waitForChange() registers a one-shot waiter against the existing onFileChanged/onFileRemoved sync callbacks and resolves with the new payload, or times out (default 25s, clamped to 1-55s). - `since_hash` closes the gap between polls: if the file already differs from the caller's last-known hash the change returns immediately, so an edit landing between two calls is never missed. - Read-only tool; available even under --read-only. - README: add a Tools section documenting every tool plus wait_for_change. Tests: 4 new ConnectionManager unit tests (resolve-on-change, timeout, gap-close via since_hash, other-path isolation); protocol tests updated to assert the tool is registered (read-write + read-only lists). Full suite 163 passing. Verified live end-to-end against a running hub this session (watch -> collaborator edits -> agent appends -> re-arm).
1 parent c6737a9 commit 697e9b9

5 files changed

Lines changed: 341 additions & 6 deletions

File tree

ts-packages/quarto-hub-mcp/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,61 @@ The design lives in
1616
operators registering a hub for end-user use should consult
1717
[`claude-notes/instructions/hub-mcp-operator-runbook.md`](../../claude-notes/instructions/hub-mcp-operator-runbook.md).
1818

19+
## Tools
20+
21+
Once configured, the agent has the tools below. Each operates on a project
22+
identified by its Automerge **index document ID**, passed as the `project`
23+
argument.
24+
25+
### Reading
26+
27+
| Tool | What it does |
28+
|------|--------------|
29+
| `connect_project` | Connect to a project by its index doc ID; returns the file list. Triggers the sign-in flow if the hub requires auth. |
30+
| `list_files` | List the files in a connected project. |
31+
| `read_file` | Read a text file's content. |
32+
| `wait_for_change` | **Long-poll**: block until a file is edited by any collaborator, then return its new content (see below). |
33+
34+
### Writing
35+
36+
| Tool | What it does |
37+
|------|--------------|
38+
| `write_file` | Replace a text file's entire content (creates it if absent). |
39+
| `patch_file` | Replace one unique substring in a text file. |
40+
| `create_file` | Create a new text file. |
41+
| `delete_file` | Delete a file. |
42+
| `rename_file` | Rename / move a file. |
43+
| `create_project` | Create a new project on the sync server with optional initial files. |
44+
45+
The write tools are hidden when the server is started with `--read-only`.
46+
47+
### Authentication
48+
49+
`authenticate` and `authenticate_clear` are present only when the OAuth env
50+
vars are configured (see [Setup](#setup)). On a no-auth hub they are unused.
51+
52+
### `wait_for_change` — reacting to a live collaborator
53+
54+
`wait_for_change` lets an agent respond to another editor without busy-polling
55+
`read_file`. It blocks until the watched `path` changes, then returns
56+
`{ changed: true, content, hash }`; on timeout it returns
57+
`{ changed: false, hash }`, so the agent just calls again to keep watching.
58+
59+
```jsonc
60+
wait_for_change({
61+
project: "<index-doc-id>",
62+
path: "report.qmd",
63+
timeout_seconds: 25, // optional, clamped to 1–55
64+
since_hash: "sha256:…" // optional — see below
65+
})
66+
```
67+
68+
Pass the `hash` from the previous result back as `since_hash`: if the file
69+
already differs from it, the call returns **immediately**. This closes the gap
70+
between polls, so an edit that lands *between* two `wait_for_change` calls is
71+
never missed. The tool is read-only and is available even in `--read-only`
72+
mode.
73+
1974
## Setup
2075

2176
You need two values from your hub operator:

ts-packages/quarto-hub-mcp/src/connection-manager.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,103 @@ describe('lastObservedAuthMode state machine', () => {
605605
});
606606
});
607607

608+
// ---------------------------------------------------------------------------
609+
// waitForChange (long-poll)
610+
// ---------------------------------------------------------------------------
611+
612+
/**
613+
* Sync-client factory that captures the callbacks object so a test can
614+
* drive `onFileChanged` / `onFileRemoved` to simulate remote edits. The
615+
* `connect` stub seeds one text file ('test.qmd' = 'hello') via
616+
* `onFileAdded`, mirroring a real initial sync.
617+
*/
618+
function capturingSyncClientFactory() {
619+
let captured: SyncClientCallbacks | undefined;
620+
const factory = (cbs: SyncClientCallbacks): SyncClient => {
621+
captured = cbs;
622+
const stub: Partial<SyncClient> = {
623+
connect: vi.fn(async () => {
624+
cbs.onFileAdded?.('test.qmd', { type: 'text', text: 'hello' });
625+
return [];
626+
}) as unknown as SyncClient['connect'],
627+
disconnect: vi.fn().mockResolvedValue(undefined) as unknown as SyncClient['disconnect'],
628+
};
629+
return stub as SyncClient;
630+
};
631+
return { factory, cbs: () => captured! };
632+
}
633+
634+
describe('waitForChange (long-poll)', () => {
635+
it('resolves when the watched file changes', async () => {
636+
const fetchSpy = scriptedFetch([200]);
637+
const cap = capturingSyncClientFactory();
638+
const mgr = new ConnectionManager({
639+
serverUrl: 'wss://hub.example.com/ws',
640+
fetch: fetchSpy.fetch,
641+
syncClientFactory: cap.factory,
642+
});
643+
await mgr.connect('idx-1');
644+
645+
const pending = mgr.waitForChange('idx-1', 'test.qmd', 5000);
646+
// Simulate a remote edit arriving on the watched path.
647+
cap.cbs().onFileChanged('test.qmd', 'hello world', []);
648+
649+
const res = await pending;
650+
expect(res.changed).toBe(true);
651+
expect(res.payload?.type).toBe('text');
652+
expect((res.payload as { type: 'text'; text: string }).text).toBe('hello world');
653+
expect(res.hash).toMatch(/^sha256:/);
654+
});
655+
656+
it('times out with changed=false when nothing changes', async () => {
657+
const fetchSpy = scriptedFetch([200]);
658+
const cap = capturingSyncClientFactory();
659+
const mgr = new ConnectionManager({
660+
serverUrl: 'wss://hub.example.com/ws',
661+
fetch: fetchSpy.fetch,
662+
syncClientFactory: cap.factory,
663+
});
664+
await mgr.connect('idx-1');
665+
666+
const res = await mgr.waitForChange('idx-1', 'test.qmd', 20);
667+
expect(res.changed).toBe(false);
668+
});
669+
670+
it('returns immediately when since_hash differs from current (gap-close)', async () => {
671+
const fetchSpy = scriptedFetch([200]);
672+
const cap = capturingSyncClientFactory();
673+
const mgr = new ConnectionManager({
674+
serverUrl: 'wss://hub.example.com/ws',
675+
fetch: fetchSpy.fetch,
676+
syncClientFactory: cap.factory,
677+
});
678+
await mgr.connect('idx-1');
679+
680+
// No onFileChanged fired; current is the seeded 'hello'. A stale
681+
// baseline hash means a change already happened in the gap → resolve now.
682+
const res = await mgr.waitForChange('idx-1', 'test.qmd', 5000, 'sha256:stale');
683+
expect(res.changed).toBe(true);
684+
expect((res.payload as { type: 'text'; text: string }).text).toBe('hello');
685+
});
686+
687+
it('ignores changes to other paths', async () => {
688+
const fetchSpy = scriptedFetch([200]);
689+
const cap = capturingSyncClientFactory();
690+
const mgr = new ConnectionManager({
691+
serverUrl: 'wss://hub.example.com/ws',
692+
fetch: fetchSpy.fetch,
693+
syncClientFactory: cap.factory,
694+
});
695+
await mgr.connect('idx-1');
696+
697+
const pending = mgr.waitForChange('idx-1', 'test.qmd', 40);
698+
// A change to a different file must not satisfy the waiter.
699+
cap.cbs().onFileChanged('other.qmd', 'nope', []);
700+
const res = await pending;
701+
expect(res.changed).toBe(false);
702+
});
703+
});
704+
608705
// ---------------------------------------------------------------------------
609706
// Redaction invariants
610707
// ---------------------------------------------------------------------------

ts-packages/quarto-hub-mcp/src/connection-manager.ts

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
* not require auth.
2424
*/
2525

26+
import { createHash } from 'node:crypto';
27+
2628
import {
2729
createSyncClient,
2830
type DisconnectOptions,
@@ -84,9 +86,58 @@ export interface ConnectionManagerDeps {
8486
readonly probePath?: string;
8587
}
8688

89+
/**
90+
* A one-shot listener registered by {@link ConnectionManager.waitForChange}.
91+
* Fired (and removed) the next time the watched `path` changes.
92+
*/
93+
interface ChangeWaiter {
94+
path: string;
95+
fire: (payload: FilePayload | null) => void;
96+
}
97+
8798
interface ProjectState {
8899
client: SyncClient;
89100
files: Map<string, FilePayload>;
101+
/** Pending long-poll waiters, keyed implicitly by their `path` field. */
102+
waiters: Set<ChangeWaiter>;
103+
}
104+
105+
/**
106+
* Result of a {@link ConnectionManager.waitForChange} long-poll.
107+
* `changed: false` means the call timed out with no edit observed.
108+
* `payload: null` means the file was removed.
109+
*/
110+
export interface ChangeResult {
111+
changed: boolean;
112+
payload: FilePayload | null;
113+
/** sha256 (`sha256:<hex>`) of the payload, or null when absent/removed. */
114+
hash: string | null;
115+
}
116+
117+
/** Content hash used for the long-poll gap-close check. */
118+
function hashPayload(p: FilePayload | undefined | null): string | null {
119+
if (!p) return null;
120+
const h = createHash('sha256');
121+
if (p.type === 'text') h.update(p.text, 'utf8');
122+
else h.update(Buffer.from(p.data));
123+
return `sha256:${h.digest('hex')}`;
124+
}
125+
126+
/**
127+
* Fire (and remove) every waiter registered for `path`, handing it the
128+
* new payload (`null` on removal). Other paths' waiters are untouched.
129+
*/
130+
function fireWaiters(
131+
waiters: Set<ChangeWaiter>,
132+
path: string,
133+
payload: FilePayload | null,
134+
): void {
135+
for (const w of [...waiters]) {
136+
if (w.path === path) {
137+
waiters.delete(w);
138+
w.fire(payload);
139+
}
140+
}
90141
}
91142

92143
// ---------------------------------------------------------------------------
@@ -180,18 +231,25 @@ export class ConnectionManager {
180231
const auth = await this.resolveAuthForConnect();
181232

182233
const files = new Map<string, FilePayload>();
234+
const waiters = new Set<ChangeWaiter>();
183235
const callbacks: SyncClientCallbacks = {
184236
onFileAdded(path: string, file: FilePayload) {
185237
files.set(path, file);
238+
fireWaiters(waiters, path, file);
186239
},
187240
onFileChanged(path: string, text: string, _patches: Patch[]) {
188-
files.set(path, { type: 'text', text });
241+
const payload: FilePayload = { type: 'text', text };
242+
files.set(path, payload);
243+
fireWaiters(waiters, path, payload);
189244
},
190245
onBinaryChanged(path: string, data: Uint8Array, mimeType: string) {
191-
files.set(path, { type: 'binary', data, mimeType });
246+
const payload: FilePayload = { type: 'binary', data, mimeType };
247+
files.set(path, payload);
248+
fireWaiters(waiters, path, payload);
192249
},
193250
onFileRemoved(path: string) {
194251
files.delete(path);
252+
fireWaiters(waiters, path, null);
195253
},
196254
onError(err: Error) {
197255
console.error(
@@ -215,11 +273,56 @@ export class ConnectionManager {
215273
peerTimeoutMs: PEER_TIMEOUT_MS,
216274
});
217275

218-
const state: ProjectState = { client, files };
276+
const state: ProjectState = { client, files, waiters };
219277
this.projects.set(indexDocId, state);
220278
return state;
221279
}
222280

281+
/**
282+
* Long-poll: resolve the next time `path` changes in the project, or
283+
* after `timeoutMs` with `changed: false`. Connects first if needed.
284+
*
285+
* `sinceHash` closes the gap between polls: if the file already differs
286+
* from the caller's last-known hash, the change is returned immediately
287+
* (so an edit that lands between two `waitForChange` calls is never
288+
* missed). Pass back the `hash` from the previous result each call.
289+
*/
290+
async waitForChange(
291+
indexDocId: string,
292+
path: string,
293+
timeoutMs: number,
294+
sinceHash?: string,
295+
): Promise<ChangeResult> {
296+
// Register the waiter synchronously when already connected so an edit
297+
// arriving immediately after the call can't slip through the await gap.
298+
// (First-time connects still pay one await; `sinceHash` covers that gap.)
299+
const state = this.projects.get(indexDocId) ?? (await this.connect(indexDocId));
300+
301+
const current = state.files.get(path);
302+
const currentHash = hashPayload(current);
303+
// Gap-close: a change already happened relative to the caller's baseline.
304+
if (sinceHash !== undefined && sinceHash !== currentHash) {
305+
return { changed: true, payload: current ?? null, hash: currentHash };
306+
}
307+
308+
return await new Promise<ChangeResult>((resolve) => {
309+
let timer: ReturnType<typeof setTimeout>;
310+
const waiter: ChangeWaiter = {
311+
path,
312+
fire: (payload) => {
313+
clearTimeout(timer);
314+
resolve({ changed: true, payload, hash: hashPayload(payload) });
315+
},
316+
};
317+
state.waiters.add(waiter);
318+
timer = setTimeout(() => {
319+
state.waiters.delete(waiter);
320+
const latest = state.files.get(path);
321+
resolve({ changed: false, payload: latest ?? null, hash: hashPayload(latest) });
322+
}, timeoutMs);
323+
});
324+
}
325+
223326
/**
224327
* Create a new project. Currently this path always runs *after* the
225328
* agent has authenticated (or the hub is no-auth), so we run the
@@ -231,18 +334,25 @@ export class ConnectionManager {
231334
const auth = await this.resolveAuthForConnect();
232335

233336
const tempFiles = new Map<string, FilePayload>();
337+
const waiters = new Set<ChangeWaiter>();
234338
const callbacks: SyncClientCallbacks = {
235339
onFileAdded(path: string, file: FilePayload) {
236340
tempFiles.set(path, file);
341+
fireWaiters(waiters, path, file);
237342
},
238343
onFileChanged(path: string, text: string, _patches: Patch[]) {
239-
tempFiles.set(path, { type: 'text', text });
344+
const payload: FilePayload = { type: 'text', text };
345+
tempFiles.set(path, payload);
346+
fireWaiters(waiters, path, payload);
240347
},
241348
onBinaryChanged(path: string, data: Uint8Array, mimeType: string) {
242-
tempFiles.set(path, { type: 'binary', data, mimeType });
349+
const payload: FilePayload = { type: 'binary', data, mimeType };
350+
tempFiles.set(path, payload);
351+
fireWaiters(waiters, path, payload);
243352
},
244353
onFileRemoved(path: string) {
245354
tempFiles.delete(path);
355+
fireWaiters(waiters, path, null);
246356
},
247357
};
248358

@@ -261,7 +371,7 @@ export class ConnectionManager {
261371
peerTimeoutMs: PEER_TIMEOUT_MS,
262372
});
263373

264-
const state: ProjectState = { client, files: tempFiles };
374+
const state: ProjectState = { client, files: tempFiles, waiters };
265375
this.projects.set(result.indexDocId, state);
266376
return { indexDocId: result.indexDocId, files: result.files };
267377
}

ts-packages/quarto-hub-mcp/src/hub-mcp.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('MCP protocol', () => {
4545
'patch_file',
4646
'read_file',
4747
'rename_file',
48+
'wait_for_change',
4849
'write_file',
4950
]);
5051
});
@@ -99,6 +100,7 @@ describe('MCP protocol (read-only mode)', () => {
99100
'connect_project',
100101
'list_files',
101102
'read_file',
103+
'wait_for_change',
102104
]);
103105
});
104106

0 commit comments

Comments
 (0)