driggsby 0.6.0 → 0.6.1
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/dev/dev-command.js +25 -2
- package/dist/dev/host-page.js +20 -1
- package/dist/dev/mcp-broker.js +125 -44
- package/dist/dev/retry-policy.js +58 -0
- package/dist/dev/tool-call-line.js +17 -0
- package/package.json +3 -3
package/dist/dev/dev-command.js
CHANGED
|
@@ -17,6 +17,7 @@ import { DEV_IDLE_MINUTES, DEV_START_COMMAND, DEV_STOP_COMMAND } from "./dev-com
|
|
|
17
17
|
import { startDevServers } from "./dev-servers.js";
|
|
18
18
|
import { pruneDevStatesForPorts, readDevStates, removeDevState, writeDevState } from "./dev-state.js";
|
|
19
19
|
import { McpBroker } from "./mcp-broker.js";
|
|
20
|
+
import { toolCallLine } from "./tool-call-line.js";
|
|
20
21
|
import { watchDirectory } from "./watcher.js";
|
|
21
22
|
export const DEV_HOST_PORT = 4111;
|
|
22
23
|
export const DEV_APP_PORT = 4112;
|
|
@@ -62,12 +63,18 @@ export async function runDev(options = {}, environment = defaultCredentialEnviro
|
|
|
62
63
|
const session = await requireDeploySession(environment);
|
|
63
64
|
await refuseSecondPreview(environment.homeDirectory, projectDirectory);
|
|
64
65
|
const sdkBundle = await loadSdkBundle();
|
|
65
|
-
|
|
66
|
+
// Retries like the production host, so the preview loads the way the
|
|
67
|
+
// deployed app would.
|
|
68
|
+
const broker = new McpBroker({ baseUrl: session.baseUrl, token: session.token, retries: true });
|
|
66
69
|
const hostPort = options.hostPort ?? DEV_HOST_PORT;
|
|
67
70
|
const appPort = options.appPort ?? DEV_APP_PORT;
|
|
68
71
|
const pairs = options.portPairs ?? DEV_PORT_PAIRS;
|
|
69
72
|
// Ephemeral ports (0, in tests) are never in use, so they never move.
|
|
70
73
|
const fixedPorts = hostPort !== 0 && appPort !== 0;
|
|
74
|
+
// A call's terminal line prints only if the page that made it is still
|
|
75
|
+
// the current one and the preview is still running.
|
|
76
|
+
let documentGeneration = 0;
|
|
77
|
+
let stopping = false;
|
|
71
78
|
let servers = null;
|
|
72
79
|
for (let pair = 0; servers === null; pair += 1) {
|
|
73
80
|
try {
|
|
@@ -76,7 +83,15 @@ export async function runDev(options = {}, environment = defaultCredentialEnviro
|
|
|
76
83
|
background: config.background,
|
|
77
84
|
serveDirectory: config.serveDirectory,
|
|
78
85
|
sdkBundle,
|
|
79
|
-
runToolCall: (tool, argumentsObject) =>
|
|
86
|
+
runToolCall: async (tool, argumentsObject) => {
|
|
87
|
+
const started = Date.now();
|
|
88
|
+
const calledIn = documentGeneration;
|
|
89
|
+
const result = await broker.runToolCall(tool, argumentsObject);
|
|
90
|
+
if (!stopping && calledIn === documentGeneration) {
|
|
91
|
+
io.out(toolCallLine(tool, result, Date.now() - started));
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
},
|
|
80
95
|
hostPort: shiftedPort(hostPort, pair),
|
|
81
96
|
appPort: shiftedPort(appPort, pair),
|
|
82
97
|
});
|
|
@@ -92,6 +107,10 @@ export async function runDev(options = {}, environment = defaultCredentialEnviro
|
|
|
92
107
|
}
|
|
93
108
|
}
|
|
94
109
|
const stopWatching = watchDirectory(config.serveDirectory, () => {
|
|
110
|
+
// The app reloads in every open preview, so the old documents'
|
|
111
|
+
// pending calls go.
|
|
112
|
+
documentGeneration += 1;
|
|
113
|
+
broker.dropPending();
|
|
95
114
|
servers.notifyChange();
|
|
96
115
|
});
|
|
97
116
|
const idleTimeoutMs = options.idleTimeoutMs ?? DEV_IDLE_TIMEOUT_MS;
|
|
@@ -110,6 +129,10 @@ export async function runDev(options = {}, environment = defaultCredentialEnviro
|
|
|
110
129
|
return 0;
|
|
111
130
|
}
|
|
112
131
|
finally {
|
|
132
|
+
// No retry outlives the preview: waiting calls are answered, and no
|
|
133
|
+
// new request starts.
|
|
134
|
+
stopping = true;
|
|
135
|
+
broker.close();
|
|
113
136
|
idle.cancel();
|
|
114
137
|
stopWatching();
|
|
115
138
|
// The record goes before the ports are freed, so a dev starting the
|
package/dist/dev/host-page.js
CHANGED
|
@@ -217,7 +217,14 @@ export const HOST_PAGE_JS = [
|
|
|
217
217
|
" );",
|
|
218
218
|
"}",
|
|
219
219
|
"",
|
|
220
|
+
"// Watch ids restart in every fresh document, so a result minted for the",
|
|
221
|
+
"// document before a reload must never reach the new one: same id,",
|
|
222
|
+
"// different watch. Raised on every ready and every new version, like",
|
|
223
|
+
"// the production host's document generation.",
|
|
224
|
+
"let generation = 0;",
|
|
225
|
+
"",
|
|
220
226
|
"async function runToolCall(id, tool, params) {",
|
|
227
|
+
" const calledIn = generation;",
|
|
221
228
|
" const body = JSON.stringify({ tool: tool, arguments: params });",
|
|
222
229
|
" let payload = null;",
|
|
223
230
|
" try {",
|
|
@@ -230,6 +237,9 @@ export const HOST_PAGE_JS = [
|
|
|
230
237
|
" } catch (error) {",
|
|
231
238
|
" payload = null;",
|
|
232
239
|
" }",
|
|
240
|
+
" if (calledIn !== generation) {",
|
|
241
|
+
" return;",
|
|
242
|
+
" }",
|
|
233
243
|
" if (payload && payload.ok === true) {",
|
|
234
244
|
' postToApp({ protocol: PROTOCOL, type: "result", id: id, result: payload.result });',
|
|
235
245
|
" return;",
|
|
@@ -238,7 +248,14 @@ export const HOST_PAGE_JS = [
|
|
|
238
248
|
" payload && payload.error && typeof payload.error.message === \"string\"",
|
|
239
249
|
" ? payload.error.message",
|
|
240
250
|
' : "We couldn\'t run that just now. Try again in a moment.";',
|
|
241
|
-
|
|
251
|
+
" const failure = { message: message };",
|
|
252
|
+
" // Like the production host: only the service's own failure kinds reach",
|
|
253
|
+
" // the app's onError; anything else in the field is dropped.",
|
|
254
|
+
" const kind = payload && payload.error ? payload.error.kind : undefined;",
|
|
255
|
+
' if (kind === "timeout" || kind === "unavailable" || kind === "busy" || kind === "rate_limited") {',
|
|
256
|
+
" failure.kind = kind;",
|
|
257
|
+
" }",
|
|
258
|
+
' postToApp({ protocol: PROTOCOL, type: "result", id: id, error: failure });',
|
|
242
259
|
"}",
|
|
243
260
|
"",
|
|
244
261
|
'window.addEventListener("message", (event) => {',
|
|
@@ -247,6 +264,7 @@ export const HOST_PAGE_JS = [
|
|
|
247
264
|
" }",
|
|
248
265
|
" const data = event.data;",
|
|
249
266
|
' if (data.type === "ready") {',
|
|
267
|
+
" generation += 1;",
|
|
250
268
|
" const hello = { protocol: PROTOCOL, type: \"hello\" };",
|
|
251
269
|
' if (appRoute !== "") {',
|
|
252
270
|
" hello.route = appRoute;",
|
|
@@ -292,6 +310,7 @@ export const HOST_PAGE_JS = [
|
|
|
292
310
|
"// new-version, exactly as it would after a deploy.",
|
|
293
311
|
'const events = new EventSource("/events");',
|
|
294
312
|
'events.addEventListener("change", () => {',
|
|
313
|
+
" generation += 1;",
|
|
295
314
|
' postToApp({ protocol: PROTOCOL, type: "new-version" });',
|
|
296
315
|
"});",
|
|
297
316
|
"",
|
package/dist/dev/mcp-broker.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// The tool-call broker: the only place `driggsby dev` talks to Driggsby.
|
|
2
2
|
// It holds the app token in the CLI process (never in page HTML), calls the
|
|
3
3
|
// Driggsby MCP endpoint, and hands the host page a plain
|
|
4
|
-
// { ok, result | error } envelope. Bounds mirror the production host:
|
|
5
|
-
// calls in flight, a bounded queue,
|
|
6
|
-
//
|
|
4
|
+
// { ok, result | error } envelope. Bounds mirror the production host: two
|
|
5
|
+
// calls in flight, a bounded queue, a hard per-call timeout, and (for the
|
|
6
|
+
// preview) the host's retries by kind of failure, so a preview loads the
|
|
7
|
+
// way the deployed app would and a buggy app can hammer its own laptop but
|
|
8
|
+
// never Driggsby.
|
|
7
9
|
import { capForTerminal, sanitizeForTerminal } from "../terminal-text.js";
|
|
8
10
|
import { GENERIC_TOOL_TROUBLE } from "./dev-servers.js";
|
|
9
|
-
|
|
11
|
+
import { httpRetryHint, refusalKind, refusalRetryHint, RETRY_LIMITS, retryDelayMs, } from "./retry-policy.js";
|
|
12
|
+
export const MAX_IN_FLIGHT_CALLS = 2;
|
|
10
13
|
export const MAX_QUEUED_CALLS = 64;
|
|
11
14
|
export const TOOL_CALL_TIMEOUT_MS = 30_000;
|
|
12
15
|
// Server-supplied error text is untrusted; it reaches the page's console,
|
|
@@ -23,22 +26,44 @@ const DEFAULT_REASON = "Rendering this data in the developer's own Driggsby app
|
|
|
23
26
|
export class McpBroker {
|
|
24
27
|
options;
|
|
25
28
|
queue = [];
|
|
29
|
+
// Calls waiting out a retry delay, outside the queue until it's over.
|
|
30
|
+
waiting = new Set();
|
|
26
31
|
inFlight = 0;
|
|
27
32
|
nextRequestId = 1;
|
|
33
|
+
closed = false;
|
|
34
|
+
epoch = 0;
|
|
28
35
|
constructor(options) {
|
|
29
36
|
this.options = options;
|
|
30
37
|
}
|
|
31
38
|
async runToolCall(tool, argumentsObject) {
|
|
32
|
-
if (this.queue.length >= MAX_QUEUED_CALLS) {
|
|
39
|
+
if (this.closed || this.queue.length + this.waiting.size >= MAX_QUEUED_CALLS) {
|
|
33
40
|
return { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
|
|
34
41
|
}
|
|
35
42
|
return await new Promise((resolve, reject) => {
|
|
36
|
-
this.queue.push({ tool, argumentsObject, resolve, reject });
|
|
43
|
+
this.queue.push({ tool, argumentsObject, attempt: 0, epoch: this.epoch, resolve, reject });
|
|
37
44
|
this.pump();
|
|
38
45
|
});
|
|
39
46
|
}
|
|
47
|
+
// The page reloaded (a file changed): calls still queued or waiting to
|
|
48
|
+
// retry belong to the old document, so they're answered now instead of
|
|
49
|
+
// run, like the production host clearing its queue. A call already in
|
|
50
|
+
// flight gets its answer but is never retried.
|
|
51
|
+
dropPending() {
|
|
52
|
+
this.epoch += 1;
|
|
53
|
+
for (const call of [...this.queue, ...this.waiting]) {
|
|
54
|
+
call.resolve({ ok: false, error: { message: GENERIC_TOOL_TROUBLE } });
|
|
55
|
+
}
|
|
56
|
+
this.queue.length = 0;
|
|
57
|
+
this.waiting.clear();
|
|
58
|
+
}
|
|
59
|
+
// The preview is stopping: pending calls are answered and no new
|
|
60
|
+
// request starts.
|
|
61
|
+
close() {
|
|
62
|
+
this.closed = true;
|
|
63
|
+
this.dropPending();
|
|
64
|
+
}
|
|
40
65
|
pump() {
|
|
41
|
-
while (this.inFlight < MAX_IN_FLIGHT_CALLS) {
|
|
66
|
+
while (!this.closed && this.inFlight < MAX_IN_FLIGHT_CALLS) {
|
|
42
67
|
const call = this.queue.shift();
|
|
43
68
|
if (call === undefined) {
|
|
44
69
|
return;
|
|
@@ -51,18 +76,44 @@ export class McpBroker {
|
|
|
51
76
|
}
|
|
52
77
|
}
|
|
53
78
|
async execute(call) {
|
|
54
|
-
let
|
|
79
|
+
let outcome;
|
|
55
80
|
try {
|
|
56
|
-
|
|
81
|
+
outcome = await this.callMcp(call.tool, call.argumentsObject);
|
|
57
82
|
}
|
|
58
83
|
catch (error) {
|
|
59
84
|
if (this.options.transportErrors === "throw") {
|
|
60
85
|
call.reject(error);
|
|
61
86
|
return;
|
|
62
87
|
}
|
|
63
|
-
|
|
88
|
+
outcome = { ...failed(GENERIC_TOOL_TROUBLE), retry: { kind: "transport", afterMs: null } };
|
|
89
|
+
}
|
|
90
|
+
const current = !this.closed && call.epoch === this.epoch;
|
|
91
|
+
const retry = this.options.retries === true && current ? outcome.retry : null;
|
|
92
|
+
if (retry !== null && call.attempt < RETRY_LIMITS[retry.kind]) {
|
|
93
|
+
this.retryLater(call, retry, outcome.result);
|
|
94
|
+
return;
|
|
64
95
|
}
|
|
65
|
-
call.resolve(result);
|
|
96
|
+
call.resolve(outcome.result);
|
|
97
|
+
}
|
|
98
|
+
// A call waiting to retry gives up its slot and rejoins the back of the
|
|
99
|
+
// queue when its wait is over. If the queue is full by then, it keeps the
|
|
100
|
+
// answer it has; if the pending calls were dropped or the broker closed
|
|
101
|
+
// meanwhile, that already answered it.
|
|
102
|
+
retryLater(call, retry, answer) {
|
|
103
|
+
const delay = retryDelayMs(call.attempt, retry.afterMs, this.options.random);
|
|
104
|
+
call.attempt += 1;
|
|
105
|
+
this.waiting.add(call);
|
|
106
|
+
void (this.options.wait ?? sleep)(delay).then(() => {
|
|
107
|
+
if (!this.waiting.delete(call)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.queue.length >= MAX_QUEUED_CALLS) {
|
|
111
|
+
call.resolve(answer);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.queue.push(call);
|
|
115
|
+
this.pump();
|
|
116
|
+
});
|
|
66
117
|
}
|
|
67
118
|
async callMcp(tool, argumentsObject) {
|
|
68
119
|
const toolArguments = { ...argumentsObject };
|
|
@@ -72,68 +123,98 @@ export class McpBroker {
|
|
|
72
123
|
const requestId = this.nextRequestId;
|
|
73
124
|
this.nextRequestId += 1;
|
|
74
125
|
const doFetch = this.options.fetchImplementation ?? fetch;
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
const signal = AbortSignal.timeout(this.options.timeoutMs ?? TOOL_CALL_TIMEOUT_MS);
|
|
127
|
+
// Our own timeout is retried once, like a server timeout; any other
|
|
128
|
+
// failure to get an answer is retried as a failed request.
|
|
129
|
+
const cutOff = () => ({ kind: signal.aborted ? "timeout" : "transport", afterMs: null });
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
// Bare headers on purpose: the Driggsby endpoint refuses anything that
|
|
133
|
+
// looks like a browser call (an Origin or Sec-Fetch-Site header), and
|
|
134
|
+
// Node's fetch sends neither on its own.
|
|
135
|
+
response = await doFetch(new URL("/mcp", this.options.baseUrl), {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
jsonrpc: "2.0",
|
|
143
|
+
id: requestId,
|
|
144
|
+
method: "tools/call",
|
|
145
|
+
params: { name: tool, arguments: toolArguments },
|
|
146
|
+
}),
|
|
147
|
+
// The bearer token must never travel to a redirect target, and tool
|
|
148
|
+
// results must never come from one either.
|
|
149
|
+
redirect: "error",
|
|
150
|
+
signal,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (this.options.transportErrors === "throw") {
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
return { ...failed(GENERIC_TOOL_TROUBLE), retry: cutOff() };
|
|
158
|
+
}
|
|
95
159
|
if (response.status === 401) {
|
|
96
|
-
return
|
|
160
|
+
return failed(this.options.signInAgainMessage ?? SIGN_IN_AGAIN_MESSAGE);
|
|
97
161
|
}
|
|
98
162
|
if (!response.ok) {
|
|
99
|
-
return {
|
|
163
|
+
return { ...failed(GENERIC_TOOL_TROUBLE), retry: httpRetryHint(response.status, response.headers.get("Retry-After")) };
|
|
100
164
|
}
|
|
101
165
|
let payload;
|
|
102
166
|
try {
|
|
103
167
|
payload = await response.json();
|
|
104
168
|
}
|
|
105
|
-
catch {
|
|
106
|
-
|
|
169
|
+
catch (error) {
|
|
170
|
+
// A body that isn't JSON would come back the same; a read cut off by
|
|
171
|
+
// the timeout or a dropped connection may not.
|
|
172
|
+
return { ...failed(GENERIC_TOOL_TROUBLE), retry: error instanceof SyntaxError ? null : cutOff() };
|
|
107
173
|
}
|
|
108
174
|
return interpretJsonRpc(payload);
|
|
109
175
|
}
|
|
110
176
|
}
|
|
177
|
+
function failed(message) {
|
|
178
|
+
return { result: { ok: false, error: { message } }, retry: null };
|
|
179
|
+
}
|
|
180
|
+
// Unref'd: a retry's wait never keeps a stopped preview's process alive.
|
|
181
|
+
async function sleep(milliseconds) {
|
|
182
|
+
await new Promise((resolve) => {
|
|
183
|
+
setTimeout(resolve, milliseconds).unref();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
111
186
|
// The Driggsby MCP endpoint answers in JSON-RPC 2.0. Three shapes matter:
|
|
112
187
|
// a protocol-level error ({ error: { message } }), a tool-level refusal
|
|
113
188
|
// (result.isError true, message in content[0].text), and success
|
|
114
|
-
// (result.structuredContent is the data the app's watch callback gets).
|
|
189
|
+
// (result.structuredContent is the data the app's watch callback gets). A
|
|
190
|
+
// refusal the service marks retryable carries its kind and retry hint in
|
|
191
|
+
// structuredContent.
|
|
115
192
|
function interpretJsonRpc(payload) {
|
|
116
193
|
if (typeof payload !== "object" || payload === null) {
|
|
117
|
-
return
|
|
194
|
+
return failed(GENERIC_TOOL_TROUBLE);
|
|
118
195
|
}
|
|
119
196
|
const envelope = payload;
|
|
120
197
|
const error = envelope.error;
|
|
121
198
|
if (typeof error === "object" && error !== null) {
|
|
122
199
|
const message = error.message;
|
|
123
|
-
return
|
|
124
|
-
ok: false,
|
|
125
|
-
error: { message: typeof message === "string" ? capMessage(message) : GENERIC_TOOL_TROUBLE },
|
|
126
|
-
};
|
|
200
|
+
return failed(typeof message === "string" ? capMessage(message) : GENERIC_TOOL_TROUBLE);
|
|
127
201
|
}
|
|
128
202
|
const result = envelope.result;
|
|
129
203
|
if (typeof result !== "object" || result === null) {
|
|
130
|
-
return
|
|
204
|
+
return failed(GENERIC_TOOL_TROUBLE);
|
|
131
205
|
}
|
|
132
206
|
const resultRecord = result;
|
|
133
207
|
if (resultRecord.isError === true) {
|
|
134
|
-
|
|
208
|
+
// A known kind reaches the app even when it isn't worth retrying (a
|
|
209
|
+
// daily limit), as the production host forwards it.
|
|
210
|
+
const kind = refusalKind(resultRecord.structuredContent);
|
|
211
|
+
const message = toolRefusalMessage(resultRecord);
|
|
212
|
+
return {
|
|
213
|
+
result: { ok: false, error: kind === null ? { message } : { message, kind } },
|
|
214
|
+
retry: refusalRetryHint(resultRecord.structuredContent),
|
|
215
|
+
};
|
|
135
216
|
}
|
|
136
|
-
return { ok: true, result: resultRecord.structuredContent ?? null };
|
|
217
|
+
return { result: { ok: true, result: resultRecord.structuredContent ?? null }, retry: null };
|
|
137
218
|
}
|
|
138
219
|
function toolRefusalMessage(resultRecord) {
|
|
139
220
|
const content = resultRecord.content;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// How `driggsby dev` retries a failed tool call: the same policy the
|
|
2
|
+
// Driggsby console's dashboard host uses, so a preview loads (or fails)
|
|
3
|
+
// the way the deployed app would. A timeout is retried once (an immediate
|
|
4
|
+
// retry of a timeout usually times out again); a busy or unavailable
|
|
5
|
+
// answer, a rate limit, and a failed request up to three times; a wrong
|
|
6
|
+
// input never. The wait backs off with full jitter and is never shorter
|
|
7
|
+
// than the server asked.
|
|
8
|
+
export const RETRY_LIMITS = {
|
|
9
|
+
timeout: 1,
|
|
10
|
+
unavailable: 3,
|
|
11
|
+
busy: 3,
|
|
12
|
+
rate_limited: 3,
|
|
13
|
+
transport: 3,
|
|
14
|
+
};
|
|
15
|
+
// The failure kinds the Driggsby service names. Only these reach an app's
|
|
16
|
+
// onError; anything else in a kind field is dropped.
|
|
17
|
+
export const SERVICE_FAILURE_KINDS = new Set(["timeout", "unavailable", "busy", "rate_limited"]);
|
|
18
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
19
|
+
const BACKOFF_CAP_MS = 8_000;
|
|
20
|
+
// A server-provided wait is honored, but never beyond this.
|
|
21
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
22
|
+
// Full jitter: a random wait up to an exponentially growing ceiling, and
|
|
23
|
+
// never sooner than the server asked.
|
|
24
|
+
export function retryDelayMs(attempt, afterMs, random = Math.random) {
|
|
25
|
+
const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
|
|
26
|
+
const jitter = Math.floor(random() * ceiling);
|
|
27
|
+
const serverWait = afterMs === null ? 0 : Math.min(Math.max(afterMs, 0), MAX_RETRY_AFTER_MS);
|
|
28
|
+
return Math.max(serverWait, jitter);
|
|
29
|
+
}
|
|
30
|
+
// The service's named kind of a refused tool call (structuredContent.kind),
|
|
31
|
+
// or null for anything else.
|
|
32
|
+
export function refusalKind(structuredContent) {
|
|
33
|
+
if (typeof structuredContent !== "object" || structuredContent === null) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const kind = structuredContent.kind;
|
|
37
|
+
return typeof kind === "string" && SERVICE_FAILURE_KINDS.has(kind) ? kind : null;
|
|
38
|
+
}
|
|
39
|
+
// A retryable refusal in an MCP tool result: a named kind, retryable true,
|
|
40
|
+
// and an optional retry_after_ms in structuredContent.
|
|
41
|
+
export function refusalRetryHint(structuredContent) {
|
|
42
|
+
const kind = refusalKind(structuredContent);
|
|
43
|
+
if (kind === null || structuredContent.retryable !== true) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const after = structuredContent.retry_after_ms;
|
|
47
|
+
return { kind, afterMs: typeof after === "number" && Number.isFinite(after) ? after : null };
|
|
48
|
+
}
|
|
49
|
+
// An HTTP failure that can pass on its own: a request timeout (408), a
|
|
50
|
+
// limit (429), or a server error (5xx). Any other status would fail the
|
|
51
|
+
// same way again.
|
|
52
|
+
export function httpRetryHint(status, retryAfterHeader) {
|
|
53
|
+
if (status !== 408 && status !== 429 && status < 500) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const seconds = retryAfterHeader === null ? Number.NaN : Number.parseInt(retryAfterHeader, 10);
|
|
57
|
+
return { kind: "transport", afterMs: Number.isFinite(seconds) ? seconds * 1_000 : null };
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The terminal line `driggsby dev` prints for each tool call the preview
|
|
2
|
+
// makes, after any retries: a check and the time it took, or a cross and
|
|
3
|
+
// the reason. A developer (or their agent) sees a failing watch in the
|
|
4
|
+
// terminal, not only in the page. The tool name passed the app allowlist
|
|
5
|
+
// before the call ran, and the broker has already sanitized the message;
|
|
6
|
+
// here it's capped shorter and wrapped, its continuation lines indented
|
|
7
|
+
// deeper than the CLI's own command suggestions, so server text can't pass
|
|
8
|
+
// for one.
|
|
9
|
+
import { capForTerminal, wrapProse } from "../terminal-text.js";
|
|
10
|
+
const MAX_LINE_MESSAGE_CHARS = 240;
|
|
11
|
+
export function toolCallLine(tool, result, elapsedMs) {
|
|
12
|
+
if (result.ok) {
|
|
13
|
+
return `✓ ${tool} ${(elapsedMs / 1000).toFixed(2)}s\n`;
|
|
14
|
+
}
|
|
15
|
+
const wrapped = wrapProse(`✗ ${tool}: ${capForTerminal(result.error.message, MAX_LINE_MESSAGE_CHARS)}`, 72);
|
|
16
|
+
return `${wrapped.split("\n").join("\n ")}\n`;
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driggsby",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Set up and build on Driggsby, the MCP server for personal finance.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@driggsby/deploy": "0.6.
|
|
32
|
-
"@driggsby/sdk": "0.6.
|
|
31
|
+
"@driggsby/deploy": "0.6.1",
|
|
32
|
+
"@driggsby/sdk": "0.6.1"
|
|
33
33
|
}
|
|
34
34
|
}
|