@agent-native/dispatch 0.14.7 → 0.14.8
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/actions/ask_app.d.ts +16 -1
- package/dist/actions/ask_app.js +12 -1
- package/dist/actions/ask_app.js.map +1 -1
- package/dist/actions/ask_app_status.d.ts +23 -0
- package/dist/actions/ask_app_status.d.ts.map +1 -0
- package/dist/actions/ask_app_status.js +14 -0
- package/dist/actions/ask_app_status.js.map +1 -0
- package/dist/actions/create-workspace-resource.d.ts +13 -13
- package/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/delete-workspace-resource.d.ts +13 -13
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +2 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/update-workspace-resource.d.ts +13 -13
- package/dist/actions/upsert-destination.d.ts +12 -12
- package/dist/routes/pages/agents.d.ts.map +1 -1
- package/dist/routes/pages/agents.js +2 -2
- package/dist/routes/pages/agents.js.map +1 -1
- package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
- package/dist/server/lib/mcp-access-store.js +2 -2
- package/dist/server/lib/mcp-access-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts +25 -5
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +196 -6
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/package.json +2 -2
- package/src/actions/ask_app.ts +13 -1
- package/src/actions/ask_app_status.ts +16 -0
- package/src/actions/index.spec.ts +1 -0
- package/src/actions/index.ts +2 -0
- package/src/routes/pages/agents.tsx +1 -2
- package/src/server/lib/mcp-access-store.spec.ts +3 -3
- package/src/server/lib/mcp-access-store.ts +2 -3
- package/src/server/lib/mcp-gateway.spec.ts +299 -14
- package/src/server/lib/mcp-gateway.ts +264 -6
|
@@ -12,7 +12,9 @@ const mocks = vi.hoisted(() => ({
|
|
|
12
12
|
managerStop: vi.fn(),
|
|
13
13
|
managerCallTool: vi.fn(),
|
|
14
14
|
managerConstructor: vi.fn(),
|
|
15
|
-
|
|
15
|
+
a2aConstructor: vi.fn(),
|
|
16
|
+
a2aSend: vi.fn(),
|
|
17
|
+
a2aGetTask: vi.fn(),
|
|
16
18
|
signA2AToken: vi.fn(),
|
|
17
19
|
getOrgA2ASecret: vi.fn(),
|
|
18
20
|
getOrgDomain: vi.fn(),
|
|
@@ -40,7 +42,19 @@ vi.mock("@agent-native/core/server", async (importOriginal) => {
|
|
|
40
42
|
});
|
|
41
43
|
|
|
42
44
|
vi.mock("@agent-native/core/a2a", () => ({
|
|
43
|
-
|
|
45
|
+
A2AClient: class MockA2AClient {
|
|
46
|
+
constructor(...args: unknown[]) {
|
|
47
|
+
mocks.a2aConstructor(...args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
send(...args: unknown[]) {
|
|
51
|
+
return mocks.a2aSend(...args);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getTask(...args: unknown[]) {
|
|
55
|
+
return mocks.a2aGetTask(...args);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
44
58
|
signA2AToken: mocks.signA2AToken,
|
|
45
59
|
}));
|
|
46
60
|
|
|
@@ -76,6 +90,7 @@ import { runWithRequestContext } from "@agent-native/core/server";
|
|
|
76
90
|
import {
|
|
77
91
|
createGrantedDispatchMcpEmbedSession,
|
|
78
92
|
askGrantedDispatchMcpApp,
|
|
93
|
+
getGrantedDispatchMcpAppTask,
|
|
79
94
|
listGrantedDispatchMcpApps,
|
|
80
95
|
listGrantedDispatchMcpAppOrigins,
|
|
81
96
|
openGrantedDispatchMcpApp,
|
|
@@ -91,6 +106,10 @@ const analyticsAgent = {
|
|
|
91
106
|
};
|
|
92
107
|
|
|
93
108
|
beforeEach(() => {
|
|
109
|
+
mocks.a2aConstructor.mockReset();
|
|
110
|
+
mocks.a2aSend.mockReset();
|
|
111
|
+
mocks.a2aGetTask.mockReset();
|
|
112
|
+
mocks.signA2AToken.mockReset();
|
|
94
113
|
mocks.discoverAgents.mockResolvedValue([analyticsAgent]);
|
|
95
114
|
mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
|
|
96
115
|
mocks.getOrgSetting.mockResolvedValue({ mode: "all-apps" });
|
|
@@ -106,19 +125,29 @@ beforeEach(() => {
|
|
|
106
125
|
startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
|
|
107
126
|
},
|
|
108
127
|
});
|
|
109
|
-
mocks.
|
|
128
|
+
mocks.a2aSend.mockResolvedValue({
|
|
129
|
+
id: "task-1",
|
|
130
|
+
status: {
|
|
131
|
+
state: "completed",
|
|
132
|
+
message: {
|
|
133
|
+
role: "agent",
|
|
134
|
+
parts: [{ type: "text", text: "Created the requested dashboard." }],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
110
138
|
mocks.signA2AToken.mockResolvedValue("signed-token");
|
|
111
139
|
mocks.getOrgA2ASecret.mockResolvedValue(null);
|
|
112
140
|
mocks.getOrgDomain.mockResolvedValue(null);
|
|
113
141
|
});
|
|
114
142
|
|
|
115
143
|
afterEach(() => {
|
|
144
|
+
vi.useRealTimers();
|
|
116
145
|
vi.unstubAllEnvs();
|
|
117
146
|
vi.clearAllMocks();
|
|
118
147
|
});
|
|
119
148
|
|
|
120
149
|
describe("Dispatch MCP gateway app discovery", () => {
|
|
121
|
-
it("defaults to exposing
|
|
150
|
+
it("defaults to exposing every discovered app", async () => {
|
|
122
151
|
mocks.getUserSetting.mockResolvedValue(null);
|
|
123
152
|
const apps = await runWithRequestContext(
|
|
124
153
|
{
|
|
@@ -128,7 +157,7 @@ describe("Dispatch MCP gateway app discovery", () => {
|
|
|
128
157
|
() => listGrantedDispatchMcpApps(),
|
|
129
158
|
);
|
|
130
159
|
|
|
131
|
-
expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
|
|
160
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch", "analytics"]);
|
|
132
161
|
});
|
|
133
162
|
|
|
134
163
|
it("includes Dispatch itself so agents can target extension routes", async () => {
|
|
@@ -275,20 +304,258 @@ describe("askGrantedDispatchMcpApp", () => {
|
|
|
275
304
|
),
|
|
276
305
|
);
|
|
277
306
|
|
|
278
|
-
expect(mocks.
|
|
307
|
+
expect(mocks.a2aConstructor).toHaveBeenCalledWith(
|
|
279
308
|
"http://localhost:8086",
|
|
280
|
-
"
|
|
309
|
+
"signed-token",
|
|
310
|
+
{ requestTimeoutMs: 10_000 },
|
|
311
|
+
);
|
|
312
|
+
expect(mocks.a2aSend).toHaveBeenCalledWith(
|
|
313
|
+
{
|
|
314
|
+
role: "user",
|
|
315
|
+
parts: [
|
|
316
|
+
{ type: "text", text: "Build a weekly active users dashboard." },
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
async: true,
|
|
321
|
+
metadata: {
|
|
322
|
+
userEmail: "owner@example.test",
|
|
323
|
+
orgDomain: "builder.io",
|
|
324
|
+
requestOrigin: "http://localhost:8092",
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
expect(result).toMatchObject({
|
|
329
|
+
app: "analytics",
|
|
330
|
+
routedVia: "a2a",
|
|
331
|
+
response: "Created the requested dashboard.",
|
|
332
|
+
taskId: "task-1",
|
|
333
|
+
status: "completed",
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("returns a durable polling handle when the downstream task is still working", async () => {
|
|
338
|
+
mocks.a2aSend.mockResolvedValueOnce({
|
|
339
|
+
id: "task-working",
|
|
340
|
+
status: { state: "working" },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const result = await runWithRequestContext(
|
|
281
344
|
{
|
|
282
345
|
userEmail: "owner@example.test",
|
|
283
|
-
|
|
284
|
-
orgSecret: "org-specific-secret",
|
|
285
|
-
timeoutMs: 5 * 60_000,
|
|
346
|
+
requestOrigin: "http://localhost:8092",
|
|
286
347
|
},
|
|
348
|
+
() =>
|
|
349
|
+
askGrantedDispatchMcpApp("analytics", "Build the report.", {
|
|
350
|
+
async: true,
|
|
351
|
+
}),
|
|
287
352
|
);
|
|
353
|
+
|
|
288
354
|
expect(result).toEqual({
|
|
289
355
|
app: "analytics",
|
|
290
356
|
routedVia: "a2a",
|
|
291
|
-
|
|
357
|
+
taskId: "task-working",
|
|
358
|
+
status: "working",
|
|
359
|
+
pollAfterMs: 1_500,
|
|
360
|
+
poll: {
|
|
361
|
+
tool: "ask_app_status",
|
|
362
|
+
arguments: { app: "analytics", taskId: "task-working" },
|
|
363
|
+
},
|
|
364
|
+
message:
|
|
365
|
+
'ask_app is still working. Call ask_app_status with taskId "task-working" to retrieve the final response.',
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("counts submission and every poll against one inline deadline", async () => {
|
|
370
|
+
vi.useFakeTimers();
|
|
371
|
+
vi.setSystemTime(0);
|
|
372
|
+
mocks.a2aSend.mockImplementationOnce(
|
|
373
|
+
() =>
|
|
374
|
+
new Promise((resolve) => {
|
|
375
|
+
setTimeout(
|
|
376
|
+
() =>
|
|
377
|
+
resolve({
|
|
378
|
+
id: "task-deadline",
|
|
379
|
+
status: { state: "working" },
|
|
380
|
+
}),
|
|
381
|
+
3_000,
|
|
382
|
+
);
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
mocks.a2aGetTask.mockImplementationOnce(() => new Promise(() => undefined));
|
|
386
|
+
|
|
387
|
+
const resultPromise = runWithRequestContext(
|
|
388
|
+
{
|
|
389
|
+
userEmail: "owner@example.test",
|
|
390
|
+
requestOrigin: "http://localhost:8092",
|
|
391
|
+
},
|
|
392
|
+
() =>
|
|
393
|
+
askGrantedDispatchMcpApp("analytics", "Build the report.", {
|
|
394
|
+
maxWaitMs: 5_000,
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
399
|
+
|
|
400
|
+
await expect(resultPromise).resolves.toMatchObject({
|
|
401
|
+
taskId: "task-deadline",
|
|
402
|
+
status: "working",
|
|
403
|
+
pollAfterMs: 1_500,
|
|
404
|
+
});
|
|
405
|
+
expect(Date.now()).toBe(5_000);
|
|
406
|
+
expect(mocks.a2aConstructor).toHaveBeenCalledWith(
|
|
407
|
+
"http://localhost:8086",
|
|
408
|
+
undefined,
|
|
409
|
+
{ requestTimeoutMs: 5_000 },
|
|
410
|
+
);
|
|
411
|
+
expect(mocks.a2aGetTask).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it.each([401, 403, 404])(
|
|
415
|
+
"surfaces permanent %s status errors without waiting until the deadline",
|
|
416
|
+
async (statusCode) => {
|
|
417
|
+
vi.useFakeTimers();
|
|
418
|
+
vi.setSystemTime(0);
|
|
419
|
+
mocks.a2aSend.mockResolvedValueOnce({
|
|
420
|
+
id: "task-missing",
|
|
421
|
+
status: { state: "working" },
|
|
422
|
+
});
|
|
423
|
+
mocks.a2aGetTask.mockRejectedValueOnce(
|
|
424
|
+
new Error(`A2A request failed (${statusCode}): Task not found`),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const resultPromise = runWithRequestContext(
|
|
428
|
+
{
|
|
429
|
+
userEmail: "owner@example.test",
|
|
430
|
+
requestOrigin: "http://localhost:8092",
|
|
431
|
+
},
|
|
432
|
+
() =>
|
|
433
|
+
askGrantedDispatchMcpApp("analytics", "Build the report.", {
|
|
434
|
+
maxWaitMs: 20_000,
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
const rejection = resultPromise.catch((err) => err);
|
|
438
|
+
|
|
439
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
440
|
+
await vi.advanceTimersByTimeAsync(1_500);
|
|
441
|
+
|
|
442
|
+
await expect(rejection).resolves.toEqual(
|
|
443
|
+
expect.objectContaining({
|
|
444
|
+
message: expect.stringMatching(
|
|
445
|
+
new RegExp(`${statusCode}.*not found`, "i"),
|
|
446
|
+
),
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
expect(Date.now()).toBe(1_500);
|
|
450
|
+
expect(mocks.a2aGetTask).toHaveBeenCalledTimes(1);
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
it("retries transient status failures within the same deadline", async () => {
|
|
455
|
+
vi.useFakeTimers();
|
|
456
|
+
vi.setSystemTime(0);
|
|
457
|
+
mocks.a2aSend.mockResolvedValueOnce({
|
|
458
|
+
id: "task-transient",
|
|
459
|
+
status: { state: "working" },
|
|
460
|
+
});
|
|
461
|
+
mocks.a2aGetTask
|
|
462
|
+
.mockRejectedValueOnce(new Error("A2A request failed (503): retry"))
|
|
463
|
+
.mockResolvedValueOnce({
|
|
464
|
+
id: "task-transient",
|
|
465
|
+
status: {
|
|
466
|
+
state: "completed",
|
|
467
|
+
message: {
|
|
468
|
+
role: "agent",
|
|
469
|
+
parts: [{ type: "text", text: "The report is ready." }],
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const resultPromise = runWithRequestContext(
|
|
475
|
+
{
|
|
476
|
+
userEmail: "owner@example.test",
|
|
477
|
+
requestOrigin: "http://localhost:8092",
|
|
478
|
+
},
|
|
479
|
+
() =>
|
|
480
|
+
askGrantedDispatchMcpApp("analytics", "Build the report.", {
|
|
481
|
+
maxWaitMs: 20_000,
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
486
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
487
|
+
|
|
488
|
+
await expect(resultPromise).resolves.toMatchObject({
|
|
489
|
+
taskId: "task-transient",
|
|
490
|
+
status: "completed",
|
|
491
|
+
response: "The report is ready.",
|
|
492
|
+
});
|
|
493
|
+
expect(mocks.a2aGetTask).toHaveBeenCalledTimes(2);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("returns input-required as a terminal handoff instead of a poll loop", async () => {
|
|
497
|
+
mocks.a2aSend.mockResolvedValueOnce({
|
|
498
|
+
id: "task-input",
|
|
499
|
+
status: {
|
|
500
|
+
state: "input-required",
|
|
501
|
+
message: {
|
|
502
|
+
role: "agent",
|
|
503
|
+
parts: [{ type: "text", text: "Which date range should I use?" }],
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const result = await runWithRequestContext(
|
|
509
|
+
{
|
|
510
|
+
userEmail: "owner@example.test",
|
|
511
|
+
requestOrigin: "http://localhost:8092",
|
|
512
|
+
},
|
|
513
|
+
() => askGrantedDispatchMcpApp("analytics", "Build the report."),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
expect(result).toEqual({
|
|
517
|
+
app: "analytics",
|
|
518
|
+
routedVia: "a2a",
|
|
519
|
+
taskId: "task-input",
|
|
520
|
+
status: "input-required",
|
|
521
|
+
response: "Which date range should I use?",
|
|
522
|
+
inputRequired: "Which date range should I use?",
|
|
523
|
+
message: "Which date range should I use?",
|
|
524
|
+
});
|
|
525
|
+
expect(mocks.a2aGetTask).not.toHaveBeenCalled();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("polls a granted app task through the same authenticated A2A route", async () => {
|
|
529
|
+
mocks.a2aGetTask.mockResolvedValueOnce({
|
|
530
|
+
id: "task-working",
|
|
531
|
+
status: {
|
|
532
|
+
state: "completed",
|
|
533
|
+
message: {
|
|
534
|
+
role: "agent",
|
|
535
|
+
parts: [{ type: "text", text: "The report is ready." }],
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
mocks.getUserSetting.mockResolvedValue({
|
|
540
|
+
mode: "selected-apps",
|
|
541
|
+
selectedAppIds: ["analytics"],
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const result = await runWithRequestContext(
|
|
545
|
+
{
|
|
546
|
+
userEmail: "owner@example.test",
|
|
547
|
+
requestOrigin: "http://localhost:8092",
|
|
548
|
+
},
|
|
549
|
+
() => getGrantedDispatchMcpAppTask("analytics", "task-working"),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
expect(mocks.a2aGetTask).toHaveBeenCalledWith("task-working");
|
|
553
|
+
expect(result).toEqual({
|
|
554
|
+
app: "analytics",
|
|
555
|
+
routedVia: "a2a",
|
|
556
|
+
taskId: "task-working",
|
|
557
|
+
status: "completed",
|
|
558
|
+
response: "The report is ready.",
|
|
292
559
|
});
|
|
293
560
|
});
|
|
294
561
|
|
|
@@ -307,7 +574,25 @@ describe("askGrantedDispatchMcpApp", () => {
|
|
|
307
574
|
() => askGrantedDispatchMcpApp("analytics", "Show signups."),
|
|
308
575
|
),
|
|
309
576
|
).rejects.toThrow(/not granted/);
|
|
310
|
-
expect(mocks.
|
|
577
|
+
expect(mocks.a2aSend).not.toHaveBeenCalled();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("rejects polling a task for an app outside the grant", async () => {
|
|
581
|
+
mocks.getUserSetting.mockResolvedValue({
|
|
582
|
+
mode: "selected-apps",
|
|
583
|
+
selectedAppIds: ["dispatch"],
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
await expect(
|
|
587
|
+
runWithRequestContext(
|
|
588
|
+
{
|
|
589
|
+
userEmail: "owner@example.test",
|
|
590
|
+
requestOrigin: "http://localhost:8092",
|
|
591
|
+
},
|
|
592
|
+
() => getGrantedDispatchMcpAppTask("analytics", "task-working"),
|
|
593
|
+
),
|
|
594
|
+
).rejects.toThrow(/not granted/);
|
|
595
|
+
expect(mocks.a2aGetTask).not.toHaveBeenCalled();
|
|
311
596
|
});
|
|
312
597
|
});
|
|
313
598
|
|
|
@@ -547,7 +832,7 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
|
|
|
547
832
|
expect(mocks.managerConstructor).toHaveBeenCalledWith({
|
|
548
833
|
servers: {
|
|
549
834
|
target: expect.objectContaining({
|
|
550
|
-
url: "http://localhost:8092/analytics/
|
|
835
|
+
url: "http://localhost:8092/analytics/mcp",
|
|
551
836
|
}),
|
|
552
837
|
},
|
|
553
838
|
});
|
|
@@ -596,7 +881,7 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
|
|
|
596
881
|
expect(mocks.managerConstructor).toHaveBeenCalledWith({
|
|
597
882
|
servers: {
|
|
598
883
|
target: expect.objectContaining({
|
|
599
|
-
url: "https://mail.agent-native.com/
|
|
884
|
+
url: "https://mail.agent-native.com/mcp",
|
|
600
885
|
}),
|
|
601
886
|
},
|
|
602
887
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A2AClient, signA2AToken, type Task } from "@agent-native/core/a2a";
|
|
2
2
|
import {
|
|
3
3
|
buildMcpToolName,
|
|
4
4
|
McpClientManager,
|
|
@@ -30,6 +30,23 @@ const DISPATCH_DESCRIPTION =
|
|
|
30
30
|
const DISPATCH_COLOR = "#14B8A6";
|
|
31
31
|
const TARGET_EMBED_SESSION_ATTEMPTS = 3;
|
|
32
32
|
const TARGET_EMBED_SESSION_RETRY_BASE_MS = 250;
|
|
33
|
+
const DISPATCH_ASK_APP_DEFAULT_INLINE_WAIT_MS = 20_000;
|
|
34
|
+
const DISPATCH_ASK_APP_MAX_INLINE_WAIT_MS = 25_000;
|
|
35
|
+
const DISPATCH_ASK_APP_POLL_INTERVAL_MS = 1_500;
|
|
36
|
+
const DISPATCH_A2A_REQUEST_TIMEOUT_MS = 10_000;
|
|
37
|
+
const DISPATCH_ASK_APP_TERMINAL_STATES = new Set([
|
|
38
|
+
"completed",
|
|
39
|
+
"failed",
|
|
40
|
+
"canceled",
|
|
41
|
+
"input-required",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
class DispatchAskAppInlineDeadlineError extends Error {
|
|
45
|
+
constructor() {
|
|
46
|
+
super("ask_app inline wait deadline reached");
|
|
47
|
+
this.name = "DispatchAskAppInlineDeadlineError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
33
50
|
|
|
34
51
|
export interface DispatchMcpAccessibleApp {
|
|
35
52
|
id: string;
|
|
@@ -44,6 +61,204 @@ function normalizeAppId(value: string): string {
|
|
|
44
61
|
return value.trim().toLowerCase();
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
function boundedDispatchAskAppWaitMs(raw: unknown): number {
|
|
65
|
+
if (raw == null || raw === "") {
|
|
66
|
+
return DISPATCH_ASK_APP_DEFAULT_INLINE_WAIT_MS;
|
|
67
|
+
}
|
|
68
|
+
const parsed = Number(raw);
|
|
69
|
+
if (!Number.isFinite(parsed)) return DISPATCH_ASK_APP_DEFAULT_INLINE_WAIT_MS;
|
|
70
|
+
return Math.max(
|
|
71
|
+
0,
|
|
72
|
+
Math.min(DISPATCH_ASK_APP_MAX_INLINE_WAIT_MS, Math.trunc(parsed)),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isTerminalDispatchTask(task: Task): boolean {
|
|
77
|
+
return DISPATCH_ASK_APP_TERMINAL_STATES.has(String(task.status.state));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function dispatchTaskText(task: Task): string {
|
|
81
|
+
return (
|
|
82
|
+
task.status.message?.parts
|
|
83
|
+
?.filter(
|
|
84
|
+
(part): part is { type: "text"; text: string } => part.type === "text",
|
|
85
|
+
)
|
|
86
|
+
.map((part) => part.text)
|
|
87
|
+
.join("\n")
|
|
88
|
+
.trim() ?? ""
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function dispatchAskAppTaskResult(
|
|
93
|
+
app: string,
|
|
94
|
+
task: Task,
|
|
95
|
+
): {
|
|
96
|
+
app: string;
|
|
97
|
+
routedVia: "a2a";
|
|
98
|
+
taskId: string;
|
|
99
|
+
status: string;
|
|
100
|
+
response?: string;
|
|
101
|
+
error?: string;
|
|
102
|
+
inputRequired?: string;
|
|
103
|
+
pollAfterMs?: number;
|
|
104
|
+
poll?: { tool: "ask_app_status"; arguments: { app: string; taskId: string } };
|
|
105
|
+
message?: string;
|
|
106
|
+
} {
|
|
107
|
+
const status = String(task.status.state);
|
|
108
|
+
const response = dispatchTaskText(task);
|
|
109
|
+
const base = {
|
|
110
|
+
app,
|
|
111
|
+
routedVia: "a2a" as const,
|
|
112
|
+
taskId: task.id,
|
|
113
|
+
status,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (status === "completed") {
|
|
117
|
+
return { ...base, response: response || "(no response)" };
|
|
118
|
+
}
|
|
119
|
+
if (status === "failed" || status === "canceled") {
|
|
120
|
+
return {
|
|
121
|
+
...base,
|
|
122
|
+
...(response ? { response } : {}),
|
|
123
|
+
error: response || `ask_app task ${status}.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (status === "input-required") {
|
|
127
|
+
const inputRequired =
|
|
128
|
+
response || "The agent needs additional input before it can continue.";
|
|
129
|
+
return {
|
|
130
|
+
...base,
|
|
131
|
+
response: inputRequired,
|
|
132
|
+
inputRequired,
|
|
133
|
+
message: inputRequired,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
...base,
|
|
138
|
+
pollAfterMs: DISPATCH_ASK_APP_POLL_INTERVAL_MS,
|
|
139
|
+
poll: {
|
|
140
|
+
tool: "ask_app_status",
|
|
141
|
+
arguments: { app, taskId: task.id },
|
|
142
|
+
},
|
|
143
|
+
message:
|
|
144
|
+
`ask_app is still ${status}. Call ask_app_status with ` +
|
|
145
|
+
`taskId "${task.id}" to retrieve the final response.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function createDispatchA2AClient(input: {
|
|
150
|
+
targetUrl: string;
|
|
151
|
+
userEmail: string;
|
|
152
|
+
orgDomain?: string;
|
|
153
|
+
orgSecret?: string;
|
|
154
|
+
deadline?: number;
|
|
155
|
+
}): Promise<{
|
|
156
|
+
client: A2AClient;
|
|
157
|
+
metadata: Record<string, unknown>;
|
|
158
|
+
}> {
|
|
159
|
+
const apiKeys: string[] = [];
|
|
160
|
+
const addSignedToken = async (preferGlobalSecret: boolean) => {
|
|
161
|
+
try {
|
|
162
|
+
const token = await signA2AToken(
|
|
163
|
+
input.userEmail,
|
|
164
|
+
input.orgDomain,
|
|
165
|
+
input.orgSecret,
|
|
166
|
+
{ preferGlobalSecret },
|
|
167
|
+
);
|
|
168
|
+
if (token && !apiKeys.includes(token)) apiKeys.push(token);
|
|
169
|
+
} catch {
|
|
170
|
+
// A2A can still be configured for local/dev unauthenticated calls. If
|
|
171
|
+
// signing is unavailable, let the target return its own auth error.
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (process.env.A2A_SECRET?.trim()) await addSignedToken(true);
|
|
176
|
+
if (input.orgSecret) await addSignedToken(false);
|
|
177
|
+
|
|
178
|
+
const metadata: Record<string, unknown> = {
|
|
179
|
+
userEmail: input.userEmail,
|
|
180
|
+
...(input.orgDomain ? { orgDomain: input.orgDomain } : {}),
|
|
181
|
+
...(getRequestContext()?.requestOrigin
|
|
182
|
+
? { requestOrigin: getRequestContext()?.requestOrigin }
|
|
183
|
+
: {}),
|
|
184
|
+
};
|
|
185
|
+
const remainingMs =
|
|
186
|
+
input.deadline == null ? null : input.deadline - Date.now();
|
|
187
|
+
return {
|
|
188
|
+
client: new A2AClient(input.targetUrl, apiKeys[0], {
|
|
189
|
+
requestTimeoutMs:
|
|
190
|
+
remainingMs == null
|
|
191
|
+
? DISPATCH_A2A_REQUEST_TIMEOUT_MS
|
|
192
|
+
: Math.max(1, Math.min(DISPATCH_A2A_REQUEST_TIMEOUT_MS, remainingMs)),
|
|
193
|
+
...(apiKeys.length > 1 ? { fallbackApiKeys: apiKeys.slice(1) } : {}),
|
|
194
|
+
}),
|
|
195
|
+
metadata,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isTransientDispatchAskAppStatusError(err: unknown): boolean {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err ?? "");
|
|
201
|
+
return (
|
|
202
|
+
/\bfetch failed\b|failed to fetch|networkerror|socket hang up|econnreset|etimedout|timeout|aborted/i.test(
|
|
203
|
+
message,
|
|
204
|
+
) || /A2A request failed \((?:429|500|502|503|504)\)/i.test(message)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function runBeforeDispatchAskAppDeadline<T>(
|
|
209
|
+
operation: () => Promise<T>,
|
|
210
|
+
deadline: number,
|
|
211
|
+
): Promise<T> {
|
|
212
|
+
const remainingMs = deadline - Date.now();
|
|
213
|
+
if (remainingMs <= 0) throw new DispatchAskAppInlineDeadlineError();
|
|
214
|
+
|
|
215
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
216
|
+
try {
|
|
217
|
+
return await Promise.race([
|
|
218
|
+
operation(),
|
|
219
|
+
new Promise<T>((_resolve, reject) => {
|
|
220
|
+
timeout = setTimeout(
|
|
221
|
+
() => reject(new DispatchAskAppInlineDeadlineError()),
|
|
222
|
+
remainingMs,
|
|
223
|
+
);
|
|
224
|
+
}),
|
|
225
|
+
]);
|
|
226
|
+
} finally {
|
|
227
|
+
if (timeout) clearTimeout(timeout);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function waitForDispatchA2ATask(
|
|
232
|
+
client: A2AClient,
|
|
233
|
+
task: Task,
|
|
234
|
+
deadline: number | undefined,
|
|
235
|
+
): Promise<Task> {
|
|
236
|
+
if (deadline == null || isTerminalDispatchTask(task)) return task;
|
|
237
|
+
let current = task;
|
|
238
|
+
while (!isTerminalDispatchTask(current)) {
|
|
239
|
+
const remaining = deadline - Date.now();
|
|
240
|
+
if (remaining <= 0) return current;
|
|
241
|
+
await new Promise((resolve) =>
|
|
242
|
+
setTimeout(
|
|
243
|
+
resolve,
|
|
244
|
+
Math.min(DISPATCH_ASK_APP_POLL_INTERVAL_MS, remaining),
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
if (Date.now() >= deadline) return current;
|
|
248
|
+
try {
|
|
249
|
+
current = await runBeforeDispatchAskAppDeadline(
|
|
250
|
+
() => client.getTask(task.id),
|
|
251
|
+
deadline,
|
|
252
|
+
);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (err instanceof DispatchAskAppInlineDeadlineError) return current;
|
|
255
|
+
if (!isTransientDispatchAskAppStatusError(err)) throw err;
|
|
256
|
+
if (Date.now() >= deadline) return current;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return current;
|
|
260
|
+
}
|
|
261
|
+
|
|
47
262
|
function normalizeBaseUrl(raw: string | undefined | null): string | null {
|
|
48
263
|
const value = raw?.trim();
|
|
49
264
|
if (!value) return null;
|
|
@@ -281,7 +496,8 @@ export async function resolveGrantedDispatchMcpApp(
|
|
|
281
496
|
export async function askGrantedDispatchMcpApp(
|
|
282
497
|
app: string,
|
|
283
498
|
message: string,
|
|
284
|
-
|
|
499
|
+
options?: { async?: boolean; maxWaitMs?: number },
|
|
500
|
+
): Promise<ReturnType<typeof dispatchAskAppTaskResult>> {
|
|
285
501
|
const trimmedMessage = message.trim();
|
|
286
502
|
if (!trimmedMessage) throw new Error("message is required");
|
|
287
503
|
const target = await resolveGrantedDispatchMcpApp(app);
|
|
@@ -296,13 +512,55 @@ export async function askGrantedDispatchMcpApp(
|
|
|
296
512
|
])
|
|
297
513
|
: [null, null];
|
|
298
514
|
|
|
299
|
-
const
|
|
515
|
+
const inlineWaitMs =
|
|
516
|
+
options?.async === true
|
|
517
|
+
? 0
|
|
518
|
+
: boundedDispatchAskAppWaitMs(options?.maxWaitMs);
|
|
519
|
+
const deadline = inlineWaitMs > 0 ? Date.now() + inlineWaitMs : undefined;
|
|
520
|
+
|
|
521
|
+
const { client, metadata } = await createDispatchA2AClient({
|
|
522
|
+
targetUrl: target.url,
|
|
523
|
+
userEmail,
|
|
524
|
+
orgDomain: orgDomain ?? undefined,
|
|
525
|
+
orgSecret: orgSecret ?? undefined,
|
|
526
|
+
deadline,
|
|
527
|
+
});
|
|
528
|
+
const task = await client.send(
|
|
529
|
+
{
|
|
530
|
+
role: "user",
|
|
531
|
+
parts: [{ type: "text", text: trimmedMessage }],
|
|
532
|
+
},
|
|
533
|
+
{ async: true, metadata },
|
|
534
|
+
);
|
|
535
|
+
const finalOrRunning = await waitForDispatchA2ATask(client, task, deadline);
|
|
536
|
+
return dispatchAskAppTaskResult(target.id, finalOrRunning);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function getGrantedDispatchMcpAppTask(
|
|
540
|
+
app: string,
|
|
541
|
+
taskId: string,
|
|
542
|
+
): Promise<ReturnType<typeof dispatchAskAppTaskResult>> {
|
|
543
|
+
const trimmedTaskId = taskId.trim();
|
|
544
|
+
if (!trimmedTaskId) throw new Error("taskId is required");
|
|
545
|
+
const target = await resolveGrantedDispatchMcpApp(app);
|
|
546
|
+
const userEmail = getRequestUserEmail();
|
|
547
|
+
if (!userEmail) throw new Error("no authenticated user");
|
|
548
|
+
|
|
549
|
+
const orgId = getRequestOrgId();
|
|
550
|
+
const [orgDomain, orgSecret] = orgId
|
|
551
|
+
? await Promise.all([
|
|
552
|
+
getOrgDomain(orgId).catch(() => null),
|
|
553
|
+
getOrgA2ASecret(orgId).catch(() => null),
|
|
554
|
+
])
|
|
555
|
+
: [null, null];
|
|
556
|
+
const { client } = await createDispatchA2AClient({
|
|
557
|
+
targetUrl: target.url,
|
|
300
558
|
userEmail,
|
|
301
559
|
orgDomain: orgDomain ?? undefined,
|
|
302
560
|
orgSecret: orgSecret ?? undefined,
|
|
303
|
-
timeoutMs: 5 * 60_000,
|
|
304
561
|
});
|
|
305
|
-
|
|
562
|
+
const task = await client.getTask(trimmedTaskId);
|
|
563
|
+
return dispatchAskAppTaskResult(target.id, task);
|
|
306
564
|
}
|
|
307
565
|
|
|
308
566
|
export async function openGrantedDispatchMcpApp(input: {
|
|
@@ -434,7 +692,7 @@ async function callTargetCreateEmbedSession(input: {
|
|
|
434
692
|
servers: {
|
|
435
693
|
[serverId]: {
|
|
436
694
|
type: "http",
|
|
437
|
-
url: `${appBaseUrl(input.app)}/
|
|
695
|
+
url: `${appBaseUrl(input.app)}/mcp`,
|
|
438
696
|
headers: {
|
|
439
697
|
Authorization: `Bearer ${input.token}`,
|
|
440
698
|
},
|