@zeph-to/mcp-server 1.12.0 → 1.13.0
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.
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/index.js +7 -3
- package/dist/poll.d.ts +2 -1
- package/dist/poll.d.ts.map +1 -1
- package/dist/poll.js +64 -35
- package/dist/tools/ask.d.ts +2 -1
- package/dist/tools/ask.d.ts.map +1 -1
- package/dist/tools/ask.js +2 -2
- package/dist/tools/input.d.ts +2 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +2 -2
- package/dist/tools/prompt.d.ts +2 -1
- package/dist/tools/prompt.d.ts.map +1 -1
- package/dist/tools/prompt.js +2 -2
- package/dist/ws-wait.d.ts +61 -0
- package/dist/ws-wait.d.ts.map +1 -0
- package/dist/ws-wait.js +143 -0
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export interface McpServerConfig {
|
|
2
2
|
apiKey: string;
|
|
3
3
|
baseUrl: string;
|
|
4
|
+
/** WebSocket endpoint for the hook-response fast path (§S3). Optional —
|
|
5
|
+
* without it (or without a WebSocket global) waits are poll-only. */
|
|
6
|
+
wsUrl?: string;
|
|
4
7
|
hookId?: string;
|
|
5
8
|
deviceId?: string;
|
|
6
9
|
sessionId?: string;
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,eAAe;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;CACvB;AAiBD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,aAAa,MAAM,EAAE,OAAO,MAAM,KAAG,MAGpE,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,eAAe;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB;0EACsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;CACvB;AAiBD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,aAAa,MAAM,EAAE,OAAO,MAAM,KAAG,MAGpE,CAAC;AAuGF,eAAO,MAAM,UAAU,QAAO,eAuB7B,CAAC"}
|
package/dist/config.js
CHANGED
|
@@ -136,6 +136,7 @@ const loadConfig = () => {
|
|
|
136
136
|
return {
|
|
137
137
|
apiKey,
|
|
138
138
|
baseUrl: (resolvedEnv('ZEPH_BASE_URL') ?? fileConfig.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''),
|
|
139
|
+
wsUrl: resolvedEnv('ZEPH_WS_URL') ?? fileConfig.wsUrl,
|
|
139
140
|
hookId: resolvedEnv('ZEPH_HOOK_ID') ?? fileConfig.hookId,
|
|
140
141
|
deviceId: resolvedEnv('ZEPH_DEVICE_ID') ?? fileConfig.deviceId,
|
|
141
142
|
sessionId,
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const path_1 = require("path");
|
|
|
6
6
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
7
7
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
8
8
|
const config_js_1 = require("./config.js");
|
|
9
|
+
const ws_wait_js_1 = require("./ws-wait.js");
|
|
9
10
|
const api_client_js_1 = require("./api-client.js");
|
|
10
11
|
const crypto_js_1 = require("./crypto.js");
|
|
11
12
|
const notify_js_1 = require("./tools/notify.js");
|
|
@@ -30,6 +31,9 @@ const getVersion = () => {
|
|
|
30
31
|
};
|
|
31
32
|
const createServer = (config) => {
|
|
32
33
|
const client = new api_client_js_1.ZephApiClient(config);
|
|
34
|
+
// Shared WS fast path for zeph_prompt/input/ask waits — degrades to
|
|
35
|
+
// pure polling when wsUrl or the WebSocket global is missing (§S3).
|
|
36
|
+
const waiter = new ws_wait_js_1.HookResponseWaiter({ wsUrl: config.wsUrl, apiKey: config.apiKey });
|
|
33
37
|
const server = new mcp_js_1.McpServer({
|
|
34
38
|
name: 'zeph',
|
|
35
39
|
version: getVersion(),
|
|
@@ -61,9 +65,9 @@ const createServer = (config) => {
|
|
|
61
65
|
(0, dismiss_js_1.registerDismissAllTool)(server, client);
|
|
62
66
|
(0, broadcast_js_1.registerBroadcastTool)(server, client, config);
|
|
63
67
|
(0, file_js_1.registerFileTool)(server, client, config);
|
|
64
|
-
(0, prompt_js_1.registerPromptTool)(server, client, config);
|
|
65
|
-
(0, input_js_1.registerInputTool)(server, client, config);
|
|
66
|
-
(0, ask_js_1.registerAskTool)(server, client, config);
|
|
68
|
+
(0, prompt_js_1.registerPromptTool)(server, client, config, waiter);
|
|
69
|
+
(0, input_js_1.registerInputTool)(server, client, config, waiter);
|
|
70
|
+
(0, ask_js_1.registerAskTool)(server, client, config, waiter);
|
|
67
71
|
(0, devices_js_1.registerDevicesResource)(server, client);
|
|
68
72
|
(0, channels_js_1.registerChannelsResource)(server, client);
|
|
69
73
|
return server;
|
package/dist/poll.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ServerNotification } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import type { ZephApiClient } from './api-client.js';
|
|
3
3
|
import type { HookEventResponse } from './types.js';
|
|
4
|
+
import type { HookResponseWaiter } from './ws-wait.js';
|
|
4
5
|
export interface PollContext {
|
|
5
6
|
_meta?: {
|
|
6
7
|
progressToken?: string | number;
|
|
7
8
|
};
|
|
8
9
|
sendNotification: (notification: ServerNotification) => Promise<void>;
|
|
9
10
|
}
|
|
10
|
-
export declare const pollForResponse: (client: ZephApiClient, hookId: string, eventId: string, timeoutSeconds: number, ctx: PollContext) => Promise<HookEventResponse | null>;
|
|
11
|
+
export declare const pollForResponse: (client: ZephApiClient, hookId: string, eventId: string, timeoutSeconds: number, ctx: PollContext, waiter?: HookResponseWaiter) => Promise<HookEventResponse | null>;
|
|
11
12
|
//# sourceMappingURL=poll.d.ts.map
|
package/dist/poll.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"poll.d.ts","sourceRoot":"","sources":["../src/poll.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"poll.d.ts","sourceRoot":"","sources":["../src/poll.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACpD,OAAO,KAAK,EAAE,kBAAkB,EAAoB,MAAM,cAAc,CAAC;AAEzE,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAC5C,gBAAgB,EAAE,CAAC,YAAY,EAAE,kBAAkB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvE;AAeD,eAAO,MAAM,eAAe,GAC1B,QAAQ,aAAa,EACrB,QAAQ,MAAM,EACd,SAAS,MAAM,EACf,gBAAgB,MAAM,EACtB,KAAK,WAAW,EAChB,SAAS,kBAAkB,KAC1B,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAqElC,CAAC"}
|
package/dist/poll.js
CHANGED
|
@@ -3,48 +3,77 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.pollForResponse = void 0;
|
|
4
4
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
5
|
const PROGRESS_INTERVAL_SECONDS = 5;
|
|
6
|
-
|
|
6
|
+
// While the WS fast path is live, polling is only the consistency
|
|
7
|
+
// backstop — 10s is plenty. The WS wake races the sleep, so a live
|
|
8
|
+
// answer still lands in sub-second time (SPEC-AGENT-AWARENESS §S3).
|
|
9
|
+
const WS_BACKSTOP_INTERVAL_MS = 10_000;
|
|
10
|
+
// After a WS wake, REST may briefly still read 'pending' (DDB eventual
|
|
11
|
+
// consistency) — retry tightly but boundedly.
|
|
12
|
+
const POST_WAKE_RETRY_MS = 500;
|
|
13
|
+
const pollForResponse = async (client, hookId, eventId, timeoutSeconds, ctx, waiter) => {
|
|
7
14
|
const progressToken = ctx._meta?.progressToken;
|
|
8
15
|
const startTime = Date.now();
|
|
9
16
|
const timeoutMs = timeoutSeconds * 1000;
|
|
10
17
|
let attempt = 0;
|
|
11
18
|
let lastProgressAt = 0;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
19
|
+
let sub;
|
|
20
|
+
let wsSettled = false;
|
|
21
|
+
if (waiter?.canWait()) {
|
|
22
|
+
sub = waiter.subscribe(eventId);
|
|
23
|
+
void sub.settled.then(() => { wsSettled = true; });
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
27
|
+
const event = await client.getHookEvent(hookId, eventId);
|
|
28
|
+
if (event.data.status === 'responded')
|
|
29
|
+
return event;
|
|
30
|
+
if (event.data.status === 'cancelled')
|
|
31
|
+
throw new Error('User cancelled the request');
|
|
32
|
+
if (event.data.status === 'timed_out')
|
|
33
|
+
return null;
|
|
34
|
+
// Only 'pending' should keep us polling. Anything else is server
|
|
35
|
+
// contract drift — fail fast instead of spinning until the timeout.
|
|
36
|
+
if (event.data.status !== 'pending') {
|
|
37
|
+
throw new Error(`Unexpected hook event status: ${String(event.data.status)}`);
|
|
38
|
+
}
|
|
39
|
+
// Throttled progress notification (every 5s)
|
|
40
|
+
if (progressToken !== undefined) {
|
|
41
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
42
|
+
if (elapsed - lastProgressAt >= PROGRESS_INTERVAL_SECONDS) {
|
|
43
|
+
lastProgressAt = elapsed;
|
|
44
|
+
await ctx.sendNotification({
|
|
45
|
+
method: 'notifications/progress',
|
|
46
|
+
params: {
|
|
47
|
+
progressToken,
|
|
48
|
+
progress: elapsed,
|
|
49
|
+
total: timeoutSeconds,
|
|
50
|
+
message: `Waiting for user response... (${elapsed}s / ${timeoutSeconds}s)`,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
39
54
|
}
|
|
55
|
+
if (wsSettled) {
|
|
56
|
+
// Server said settled but this read still saw 'pending' —
|
|
57
|
+
// eventual consistency. Tight bounded retry until reads agree.
|
|
58
|
+
await sleep(POST_WAKE_RETRY_MS);
|
|
59
|
+
attempt++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// Adaptive interval: poll every 1s while the user is likely still at
|
|
63
|
+
// their device (first ~60 attempts ≈ 1 min), then back off to 3s for
|
|
64
|
+
// long waits. The tight 1s window keeps post-tap detection snappy.
|
|
65
|
+
// With a live WS subscription the cadence relaxes to a backstop and
|
|
66
|
+
// the wake race handles snappiness instead.
|
|
67
|
+
const interval = sub?.isConnected()
|
|
68
|
+
? WS_BACKSTOP_INTERVAL_MS
|
|
69
|
+
: (attempt < 60 ? 1000 : 3000);
|
|
70
|
+
await Promise.race([sleep(interval), sub?.settled ?? new Promise(() => undefined)]);
|
|
71
|
+
attempt++;
|
|
40
72
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
await sleep(interval);
|
|
46
|
-
attempt++;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
sub?.dispose();
|
|
47
77
|
}
|
|
48
|
-
return null;
|
|
49
78
|
};
|
|
50
79
|
exports.pollForResponse = pollForResponse;
|
package/dist/tools/ask.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { ZephApiClient } from '../api-client.js';
|
|
3
3
|
import { type McpServerConfig } from '../config.js';
|
|
4
|
-
|
|
4
|
+
import type { HookResponseWaiter } from '../ws-wait.js';
|
|
5
|
+
export declare const registerAskTool: (server: McpServer, client: ZephApiClient, config: McpServerConfig, waiter?: HookResponseWaiter) => void;
|
|
5
6
|
//# sourceMappingURL=ask.d.ts.map
|
package/dist/tools/ask.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/tools/ask.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAItD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/tools/ask.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAItD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAwBxD,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SAiI7H,CAAC"}
|
package/dist/tools/ask.js
CHANGED
|
@@ -21,7 +21,7 @@ const buildAskMarkdown = (title, body, actions) => {
|
|
|
21
21
|
}
|
|
22
22
|
return parts.join('\n');
|
|
23
23
|
};
|
|
24
|
-
const registerAskTool = (server, client, config) => {
|
|
24
|
+
const registerAskTool = (server, client, config, waiter) => {
|
|
25
25
|
server.registerTool('zeph_ask', {
|
|
26
26
|
description: 'Ask the user a question with optional quick-reply buttons and a text input field. Combines prompt (buttons) and input (text) in a single notification. The user can either tap a button or type a response. Blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
|
|
27
27
|
annotations: {
|
|
@@ -113,7 +113,7 @@ const registerAskTool = (server, client, config) => {
|
|
|
113
113
|
files,
|
|
114
114
|
sessionId: config.sessionId,
|
|
115
115
|
});
|
|
116
|
-
const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx);
|
|
116
|
+
const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx, waiter);
|
|
117
117
|
if (!event) {
|
|
118
118
|
if (fallback)
|
|
119
119
|
return (0, error_format_js_1.textResult)({ actionId: fallback, timedOut: true });
|
package/dist/tools/input.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { ZephApiClient } from '../api-client.js';
|
|
3
3
|
import { type McpServerConfig } from '../config.js';
|
|
4
|
-
|
|
4
|
+
import type { HookResponseWaiter } from '../ws-wait.js';
|
|
5
|
+
export declare const registerInputTool: (server: McpServer, client: ZephApiClient, config: McpServerConfig, waiter?: HookResponseWaiter) => void;
|
|
5
6
|
//# sourceMappingURL=input.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/tools/input.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/tools/input.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAErE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAExD,eAAO,MAAM,iBAAiB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SAyD/H,CAAC"}
|
package/dist/tools/input.js
CHANGED
|
@@ -6,7 +6,7 @@ const error_format_js_1 = require("../error-format.js");
|
|
|
6
6
|
const poll_js_1 = require("../poll.js");
|
|
7
7
|
const config_js_1 = require("../config.js");
|
|
8
8
|
const sanitize_js_1 = require("../sanitize.js");
|
|
9
|
-
const registerInputTool = (server, client, config) => {
|
|
9
|
+
const registerInputTool = (server, client, config, waiter) => {
|
|
10
10
|
server.registerTool('zeph_input', {
|
|
11
11
|
description: 'Request text input from the user via push notification. The tool blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
|
|
12
12
|
annotations: {
|
|
@@ -41,7 +41,7 @@ const registerInputTool = (server, client, config) => {
|
|
|
41
41
|
metadata: { placeholder, inputType },
|
|
42
42
|
sessionId: config.sessionId,
|
|
43
43
|
});
|
|
44
|
-
const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx);
|
|
44
|
+
const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx, waiter);
|
|
45
45
|
if (!event)
|
|
46
46
|
return (0, error_format_js_1.timeoutError)(timeout, 'Try again with a longer timeout');
|
|
47
47
|
return (0, error_format_js_1.textResult)({ value: event.data.response?.value ?? '', timedOut: false });
|
package/dist/tools/prompt.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { ZephApiClient } from '../api-client.js';
|
|
3
3
|
import { type McpServerConfig } from '../config.js';
|
|
4
|
-
|
|
4
|
+
import type { HookResponseWaiter } from '../ws-wait.js';
|
|
5
|
+
export declare const registerPromptTool: (server: McpServer, client: ZephApiClient, config: McpServerConfig, waiter?: HookResponseWaiter) => void;
|
|
5
6
|
//# sourceMappingURL=prompt.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/tools/prompt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/tools/prompt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAErE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAExD,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SAwEhI,CAAC"}
|
package/dist/tools/prompt.js
CHANGED
|
@@ -6,7 +6,7 @@ const error_format_js_1 = require("../error-format.js");
|
|
|
6
6
|
const poll_js_1 = require("../poll.js");
|
|
7
7
|
const config_js_1 = require("../config.js");
|
|
8
8
|
const sanitize_js_1 = require("../sanitize.js");
|
|
9
|
-
const registerPromptTool = (server, client, config) => {
|
|
9
|
+
const registerPromptTool = (server, client, config, waiter) => {
|
|
10
10
|
server.registerTool('zeph_prompt', {
|
|
11
11
|
description: 'Ask the user to choose from predefined options via push notification. The tool blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
|
|
12
12
|
annotations: {
|
|
@@ -51,7 +51,7 @@ const registerPromptTool = (server, client, config) => {
|
|
|
51
51
|
hookType: 'interactive',
|
|
52
52
|
sessionId: config.sessionId,
|
|
53
53
|
});
|
|
54
|
-
const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx);
|
|
54
|
+
const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx, waiter);
|
|
55
55
|
if (!event) {
|
|
56
56
|
if (fallback)
|
|
57
57
|
return (0, error_format_js_1.textResult)({ actionId: fallback, timedOut: true });
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket fast path for hook-response waits (SPEC-AGENT-AWARENESS §S3).
|
|
3
|
+
*
|
|
4
|
+
* The server broadcasts `hook.responded` / `hook.cancelled` the moment
|
|
5
|
+
* the user answers. This module subscribes to that feed so a wait can
|
|
6
|
+
* wake instantly instead of sitting out its poll interval.
|
|
7
|
+
*
|
|
8
|
+
* Trust model: the WS message is a WAKE-UP SIGNAL ONLY. The poll loop
|
|
9
|
+
* remains the single source of truth — on wake it immediately re-reads
|
|
10
|
+
* the event over REST and acts on that. A spoofed/garbled/duplicated
|
|
11
|
+
* frame can therefore cost at most one extra GET, never a wrong answer.
|
|
12
|
+
* The same property makes degradation trivial: no WebSocket global
|
|
13
|
+
* (Node < 21), no wsUrl configured, or a dropped socket all just mean
|
|
14
|
+
* "nobody wakes us early" and the existing poll cadence carries on.
|
|
15
|
+
*
|
|
16
|
+
* Connection lifecycle: lazy connect on first subscribe, shared across
|
|
17
|
+
* concurrent waits, app-level ping every 25 s (API GW $default route
|
|
18
|
+
* answers 'pong'; native WebSocket has no protocol-ping API), closed
|
|
19
|
+
* 30 s after the last pending wait ends.
|
|
20
|
+
*/
|
|
21
|
+
export interface WsLike {
|
|
22
|
+
readyState: number;
|
|
23
|
+
send(data: string): void;
|
|
24
|
+
close(): void;
|
|
25
|
+
addEventListener(type: string, listener: (event: {
|
|
26
|
+
data?: unknown;
|
|
27
|
+
}) => void): void;
|
|
28
|
+
}
|
|
29
|
+
export type WsFactory = (url: string) => WsLike;
|
|
30
|
+
export interface WaitSubscription {
|
|
31
|
+
/** Resolves when the server says this event settled (answer or cancel). */
|
|
32
|
+
settled: Promise<void>;
|
|
33
|
+
/** Live socket right now? Drives the caller's poll cadence. */
|
|
34
|
+
isConnected(): boolean;
|
|
35
|
+
dispose(): void;
|
|
36
|
+
}
|
|
37
|
+
export interface HookResponseWaiterOptions {
|
|
38
|
+
wsUrl?: string;
|
|
39
|
+
apiKey: string;
|
|
40
|
+
/** Test seam. Defaults to globalThis.WebSocket when present. */
|
|
41
|
+
factory?: WsFactory;
|
|
42
|
+
}
|
|
43
|
+
export declare class HookResponseWaiter {
|
|
44
|
+
private readonly wsUrl?;
|
|
45
|
+
private readonly apiKey;
|
|
46
|
+
private readonly factory?;
|
|
47
|
+
private sock;
|
|
48
|
+
private connected;
|
|
49
|
+
private readonly pending;
|
|
50
|
+
private pingTimer;
|
|
51
|
+
private idleTimer;
|
|
52
|
+
constructor(opts: HookResponseWaiterOptions);
|
|
53
|
+
/** False when the fast path can't exist — caller skips subscribe(). */
|
|
54
|
+
canWait(): boolean;
|
|
55
|
+
subscribe(eventId: string): WaitSubscription;
|
|
56
|
+
private ensureSocket;
|
|
57
|
+
private handleMessage;
|
|
58
|
+
private scheduleIdleClose;
|
|
59
|
+
private teardownSocket;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=ws-wait.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws-wait.d.ts","sourceRoot":"","sources":["../src/ws-wait.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,MAAM,WAAW,MAAM;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,IAAI,IAAI,CAAC;IACd,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAC;KAAE,KAAK,IAAI,GAAG,IAAI,CAAC;CACxF;AAED,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;AAMhD,MAAM,WAAW,gBAAgB;IAC7B,2EAA2E;IAC3E,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,+DAA+D;IAC/D,WAAW,IAAI,OAAO,CAAC;IACvB,OAAO,IAAI,IAAI,CAAC;CACnB;AAED,MAAM,WAAW,yBAAyB;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,OAAO,CAAC,EAAE,SAAS,CAAC;CACvB;AAQD,qBAAa,kBAAkB;IAC3B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAY;IACrC,OAAO,CAAC,IAAI,CAAuB;IACnC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;IACzD,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,SAAS,CAA8C;gBAEnD,IAAI,EAAE,yBAAyB;IAM3C,uEAAuE;IACvE,OAAO,IAAI,OAAO;IAIlB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB;IAgB5C,OAAO,CAAC,YAAY;IA0BpB,OAAO,CAAC,aAAa;IAerB,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,cAAc;CAUzB"}
|
package/dist/ws-wait.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* WebSocket fast path for hook-response waits (SPEC-AGENT-AWARENESS §S3).
|
|
4
|
+
*
|
|
5
|
+
* The server broadcasts `hook.responded` / `hook.cancelled` the moment
|
|
6
|
+
* the user answers. This module subscribes to that feed so a wait can
|
|
7
|
+
* wake instantly instead of sitting out its poll interval.
|
|
8
|
+
*
|
|
9
|
+
* Trust model: the WS message is a WAKE-UP SIGNAL ONLY. The poll loop
|
|
10
|
+
* remains the single source of truth — on wake it immediately re-reads
|
|
11
|
+
* the event over REST and acts on that. A spoofed/garbled/duplicated
|
|
12
|
+
* frame can therefore cost at most one extra GET, never a wrong answer.
|
|
13
|
+
* The same property makes degradation trivial: no WebSocket global
|
|
14
|
+
* (Node < 21), no wsUrl configured, or a dropped socket all just mean
|
|
15
|
+
* "nobody wakes us early" and the existing poll cadence carries on.
|
|
16
|
+
*
|
|
17
|
+
* Connection lifecycle: lazy connect on first subscribe, shared across
|
|
18
|
+
* concurrent waits, app-level ping every 25 s (API GW $default route
|
|
19
|
+
* answers 'pong'; native WebSocket has no protocol-ping API), closed
|
|
20
|
+
* 30 s after the last pending wait ends.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.HookResponseWaiter = void 0;
|
|
24
|
+
const WS_OPEN = 1;
|
|
25
|
+
const PING_INTERVAL_MS = 25_000;
|
|
26
|
+
const IDLE_CLOSE_MS = 30_000;
|
|
27
|
+
const defaultFactory = () => {
|
|
28
|
+
const WS = globalThis.WebSocket;
|
|
29
|
+
if (!WS)
|
|
30
|
+
return undefined;
|
|
31
|
+
return (url) => new WS(url);
|
|
32
|
+
};
|
|
33
|
+
class HookResponseWaiter {
|
|
34
|
+
wsUrl;
|
|
35
|
+
apiKey;
|
|
36
|
+
factory;
|
|
37
|
+
sock = null;
|
|
38
|
+
connected = false;
|
|
39
|
+
pending = new Map();
|
|
40
|
+
pingTimer = null;
|
|
41
|
+
idleTimer = null;
|
|
42
|
+
constructor(opts) {
|
|
43
|
+
this.wsUrl = opts.wsUrl;
|
|
44
|
+
this.apiKey = opts.apiKey;
|
|
45
|
+
this.factory = opts.factory ?? defaultFactory();
|
|
46
|
+
}
|
|
47
|
+
/** False when the fast path can't exist — caller skips subscribe(). */
|
|
48
|
+
canWait() {
|
|
49
|
+
return !!this.wsUrl && !!this.factory;
|
|
50
|
+
}
|
|
51
|
+
subscribe(eventId) {
|
|
52
|
+
let resolveSettled = () => undefined;
|
|
53
|
+
const settled = new Promise((resolve) => { resolveSettled = resolve; });
|
|
54
|
+
this.pending.set(eventId, resolveSettled);
|
|
55
|
+
if (this.idleTimer) {
|
|
56
|
+
clearTimeout(this.idleTimer);
|
|
57
|
+
this.idleTimer = null;
|
|
58
|
+
}
|
|
59
|
+
this.ensureSocket();
|
|
60
|
+
return {
|
|
61
|
+
settled,
|
|
62
|
+
isConnected: () => this.connected,
|
|
63
|
+
dispose: () => {
|
|
64
|
+
this.pending.delete(eventId);
|
|
65
|
+
if (this.pending.size === 0)
|
|
66
|
+
this.scheduleIdleClose();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
ensureSocket() {
|
|
71
|
+
if (this.sock || !this.wsUrl || !this.factory)
|
|
72
|
+
return;
|
|
73
|
+
try {
|
|
74
|
+
const sep = this.wsUrl.includes('?') ? '&' : '?';
|
|
75
|
+
const sock = this.factory(`${this.wsUrl}${sep}apiKey=${encodeURIComponent(this.apiKey)}`);
|
|
76
|
+
this.sock = sock;
|
|
77
|
+
sock.addEventListener('open', () => {
|
|
78
|
+
this.connected = true;
|
|
79
|
+
this.pingTimer = setInterval(() => {
|
|
80
|
+
try {
|
|
81
|
+
if (sock.readyState === WS_OPEN)
|
|
82
|
+
sock.send(JSON.stringify({ type: 'ping' }));
|
|
83
|
+
}
|
|
84
|
+
catch { /* socket died mid-send — close handler cleans up */ }
|
|
85
|
+
}, PING_INTERVAL_MS);
|
|
86
|
+
// MCP servers are short-lived child processes; an unref'd
|
|
87
|
+
// timer must not keep one alive after stdio closes.
|
|
88
|
+
this.pingTimer.unref?.();
|
|
89
|
+
});
|
|
90
|
+
sock.addEventListener('message', (event) => this.handleMessage(event.data));
|
|
91
|
+
const drop = () => this.teardownSocket();
|
|
92
|
+
sock.addEventListener('close', drop);
|
|
93
|
+
sock.addEventListener('error', drop);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
this.teardownSocket();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
handleMessage(data) {
|
|
100
|
+
if (typeof data !== 'string')
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
const msg = JSON.parse(data);
|
|
104
|
+
if (msg.type !== 'hook.responded' && msg.type !== 'hook.cancelled')
|
|
105
|
+
return;
|
|
106
|
+
const eventId = msg.data?.eventId;
|
|
107
|
+
if (typeof eventId !== 'string')
|
|
108
|
+
return;
|
|
109
|
+
// Resolve-and-keep: the poll loop confirms over REST and then
|
|
110
|
+
// disposes; resolving a missing id is a no-op by design.
|
|
111
|
+
this.pending.get(eventId)?.();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* non-JSON frame — ignore */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
scheduleIdleClose() {
|
|
118
|
+
if (this.idleTimer)
|
|
119
|
+
clearTimeout(this.idleTimer);
|
|
120
|
+
this.idleTimer = setTimeout(() => this.teardownSocket(), IDLE_CLOSE_MS);
|
|
121
|
+
this.idleTimer.unref?.();
|
|
122
|
+
}
|
|
123
|
+
teardownSocket() {
|
|
124
|
+
this.connected = false;
|
|
125
|
+
if (this.pingTimer) {
|
|
126
|
+
clearInterval(this.pingTimer);
|
|
127
|
+
this.pingTimer = null;
|
|
128
|
+
}
|
|
129
|
+
if (this.idleTimer) {
|
|
130
|
+
clearTimeout(this.idleTimer);
|
|
131
|
+
this.idleTimer = null;
|
|
132
|
+
}
|
|
133
|
+
const sock = this.sock;
|
|
134
|
+
this.sock = null;
|
|
135
|
+
try {
|
|
136
|
+
sock?.close();
|
|
137
|
+
}
|
|
138
|
+
catch { /* already closed */ }
|
|
139
|
+
// Pending waits stay pending — they simply lose the fast path and
|
|
140
|
+
// their poll loops (which check isConnected) tighten back up.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
exports.HookResponseWaiter = HookResponseWaiter;
|
package/package.json
CHANGED