@agent-native/dispatch 0.8.6 → 0.8.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.
Files changed (31) hide show
  1. package/dist/actions/list_apps.js +1 -1
  2. package/dist/actions/list_apps.js.map +1 -1
  3. package/dist/actions/open_app.d.ts.map +1 -1
  4. package/dist/actions/open_app.js +7 -4
  5. package/dist/actions/open_app.js.map +1 -1
  6. package/dist/components/workspace-app-card.d.ts.map +1 -1
  7. package/dist/components/workspace-app-card.js +4 -2
  8. package/dist/components/workspace-app-card.js.map +1 -1
  9. package/dist/routes/pages/metrics.d.ts.map +1 -1
  10. package/dist/routes/pages/metrics.js +3 -1
  11. package/dist/routes/pages/metrics.js.map +1 -1
  12. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  13. package/dist/server/lib/app-creation-store.js +104 -10
  14. package/dist/server/lib/app-creation-store.js.map +1 -1
  15. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  16. package/dist/server/lib/mcp-gateway.js +160 -4
  17. package/dist/server/lib/mcp-gateway.js.map +1 -1
  18. package/dist/server/lib/usage-metrics-store.d.ts +1 -0
  19. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  20. package/dist/server/lib/usage-metrics-store.js +1 -0
  21. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/actions/list_apps.ts +1 -1
  24. package/src/actions/open_app.ts +11 -4
  25. package/src/components/workspace-app-card.tsx +29 -18
  26. package/src/routes/pages/metrics.tsx +4 -1
  27. package/src/server/lib/app-creation-store.spec.ts +240 -0
  28. package/src/server/lib/app-creation-store.ts +130 -11
  29. package/src/server/lib/mcp-gateway.spec.ts +295 -0
  30. package/src/server/lib/mcp-gateway.ts +187 -4
  31. package/src/server/lib/usage-metrics-store.ts +2 -0
@@ -0,0 +1,295 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ discoverAgents: vi.fn(),
5
+ getUserSetting: vi.fn(),
6
+ getOrgSetting: vi.fn(),
7
+ createEmbedSessionTicket: vi.fn(),
8
+ buildEmbedStartPath: vi.fn((ticket: string) => {
9
+ return `/_agent-native/embed/start?ticket=${encodeURIComponent(ticket)}`;
10
+ }),
11
+ managerStart: vi.fn(),
12
+ managerStop: vi.fn(),
13
+ managerCallTool: vi.fn(),
14
+ managerConstructor: vi.fn(),
15
+ signA2AToken: vi.fn(),
16
+ getOrgA2ASecret: vi.fn(),
17
+ getOrgDomain: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("@agent-native/core/server/agent-discovery", () => ({
21
+ discoverAgents: mocks.discoverAgents,
22
+ }));
23
+
24
+ vi.mock("@agent-native/core/settings", () => ({
25
+ getUserSetting: mocks.getUserSetting,
26
+ getOrgSetting: mocks.getOrgSetting,
27
+ putUserSetting: vi.fn(),
28
+ putOrgSetting: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("@agent-native/core/server", async (importOriginal) => {
32
+ const actual =
33
+ await importOriginal<typeof import("@agent-native/core/server")>();
34
+ return {
35
+ ...actual,
36
+ createEmbedSessionTicket: mocks.createEmbedSessionTicket,
37
+ buildEmbedStartPath: mocks.buildEmbedStartPath,
38
+ };
39
+ });
40
+
41
+ vi.mock("@agent-native/core/a2a", () => ({
42
+ callAgent: vi.fn(),
43
+ signA2AToken: mocks.signA2AToken,
44
+ }));
45
+
46
+ vi.mock("@agent-native/core/org", () => ({
47
+ getOrgA2ASecret: mocks.getOrgA2ASecret,
48
+ getOrgDomain: mocks.getOrgDomain,
49
+ }));
50
+
51
+ vi.mock("@agent-native/core/mcp-client", () => ({
52
+ buildMcpToolName: (serverId: string, toolName: string) =>
53
+ `mcp__${serverId}__${toolName}`,
54
+ McpClientManager: class MockMcpClientManager {
55
+ constructor(config: unknown) {
56
+ mocks.managerConstructor(config);
57
+ }
58
+
59
+ start() {
60
+ return mocks.managerStart();
61
+ }
62
+
63
+ stop() {
64
+ return mocks.managerStop();
65
+ }
66
+
67
+ callTool(name: string, args: unknown) {
68
+ return mocks.managerCallTool(name, args);
69
+ }
70
+ },
71
+ }));
72
+
73
+ import {
74
+ createGrantedDispatchMcpEmbedSession,
75
+ listGrantedDispatchMcpApps,
76
+ openGrantedDispatchMcpApp,
77
+ } from "./mcp-gateway.js";
78
+ import { runWithRequestContext } from "@agent-native/core/server";
79
+
80
+ const analyticsAgent = {
81
+ id: "analytics",
82
+ name: "Analytics",
83
+ description: "Dashboards and metrics",
84
+ url: "http://localhost:8086",
85
+ color: "#6366F1",
86
+ };
87
+
88
+ beforeEach(() => {
89
+ mocks.discoverAgents.mockResolvedValue([analyticsAgent]);
90
+ mocks.getUserSetting.mockResolvedValue(null);
91
+ mocks.getOrgSetting.mockResolvedValue(null);
92
+ mocks.createEmbedSessionTicket.mockResolvedValue({
93
+ ticket: "ticket-123",
94
+ ticketHash: "hash-123",
95
+ expiresAt: 12345,
96
+ });
97
+ mocks.managerStart.mockResolvedValue(undefined);
98
+ mocks.managerStop.mockResolvedValue(undefined);
99
+ mocks.managerCallTool.mockResolvedValue({
100
+ structuredContent: {
101
+ startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
102
+ },
103
+ });
104
+ mocks.signA2AToken.mockResolvedValue("signed-token");
105
+ mocks.getOrgA2ASecret.mockResolvedValue(null);
106
+ mocks.getOrgDomain.mockResolvedValue(null);
107
+ });
108
+
109
+ afterEach(() => {
110
+ vi.unstubAllEnvs();
111
+ vi.clearAllMocks();
112
+ });
113
+
114
+ describe("Dispatch MCP gateway app discovery", () => {
115
+ it("includes Dispatch itself so agents can target extension routes", async () => {
116
+ const apps = await runWithRequestContext(
117
+ {
118
+ userEmail: "owner@example.test",
119
+ requestOrigin: "http://localhost:8092",
120
+ },
121
+ () => listGrantedDispatchMcpApps(),
122
+ );
123
+
124
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "analytics"]);
125
+ expect(apps[0]).toMatchObject({
126
+ id: "dispatch",
127
+ name: "Agent-Native Dispatch",
128
+ url: "http://localhost:8092",
129
+ granted: true,
130
+ });
131
+ });
132
+
133
+ it("honors selected app grants for the Dispatch self target", async () => {
134
+ mocks.getUserSetting.mockResolvedValue({
135
+ mode: "selected-apps",
136
+ selectedAppIds: ["dispatch"],
137
+ });
138
+
139
+ const apps = await runWithRequestContext(
140
+ {
141
+ userEmail: "owner@example.test",
142
+ requestOrigin: "http://localhost:8092",
143
+ },
144
+ () => listGrantedDispatchMcpApps(),
145
+ );
146
+
147
+ expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
148
+ });
149
+ });
150
+
151
+ describe("openGrantedDispatchMcpApp", () => {
152
+ it("opens Dispatch extension routes through the Dispatch app id", async () => {
153
+ const result = await runWithRequestContext(
154
+ {
155
+ userEmail: "owner@example.test",
156
+ requestOrigin: "http://localhost:8092",
157
+ },
158
+ () =>
159
+ openGrantedDispatchMcpApp({
160
+ app: "dispatch",
161
+ path: "/extensions/ext-1/github-stars-over-time",
162
+ embed: true,
163
+ chrome: "minimal",
164
+ }),
165
+ );
166
+
167
+ expect(result).toEqual({
168
+ app: "dispatch",
169
+ path: "/extensions/ext-1/github-stars-over-time",
170
+ url: "http://localhost:8092/extensions/ext-1/github-stars-over-time",
171
+ embed: true,
172
+ chrome: "minimal",
173
+ });
174
+ });
175
+
176
+ it("rejects Dispatch-owned extension routes on sibling apps", async () => {
177
+ await expect(
178
+ runWithRequestContext(
179
+ {
180
+ userEmail: "owner@example.test",
181
+ requestOrigin: "http://localhost:8092",
182
+ },
183
+ () =>
184
+ openGrantedDispatchMcpApp({
185
+ app: "analytics",
186
+ path: "/extensions/ext-1/github-stars-over-time",
187
+ }),
188
+ ),
189
+ ).rejects.toThrow(/belongs to Dispatch/);
190
+ });
191
+
192
+ it("rejects traversal that normalizes into Dispatch-owned routes on sibling apps", async () => {
193
+ await expect(
194
+ runWithRequestContext(
195
+ {
196
+ userEmail: "owner@example.test",
197
+ requestOrigin: "http://localhost:8092",
198
+ },
199
+ () =>
200
+ openGrantedDispatchMcpApp({
201
+ app: "analytics",
202
+ path: "/../dispatch/extensions/ext-1",
203
+ }),
204
+ ),
205
+ ).rejects.toThrow(/safe app-relative route/);
206
+ });
207
+ });
208
+
209
+ describe("createGrantedDispatchMcpEmbedSession", () => {
210
+ it("mints Dispatch self embeds locally instead of recursively calling Dispatch MCP", async () => {
211
+ const result = await runWithRequestContext(
212
+ {
213
+ userEmail: "owner@example.test",
214
+ requestOrigin: "http://localhost:8092",
215
+ },
216
+ () =>
217
+ createGrantedDispatchMcpEmbedSession({
218
+ app: "dispatch",
219
+ path: "/extensions/ext-1/github-stars-over-time",
220
+ chrome: "minimal",
221
+ }),
222
+ );
223
+
224
+ expect(mocks.createEmbedSessionTicket).toHaveBeenCalledWith({
225
+ ownerEmail: "owner@example.test",
226
+ orgId: undefined,
227
+ targetPath: "/extensions/ext-1/github-stars-over-time",
228
+ scope: "minimal",
229
+ });
230
+ expect(mocks.managerConstructor).not.toHaveBeenCalled();
231
+ expect(result).toEqual({
232
+ app: "dispatch",
233
+ startUrl:
234
+ "http://localhost:8092/_agent-native/embed/start?ticket=ticket-123",
235
+ targetPath: "/extensions/ext-1/github-stars-over-time",
236
+ expiresAt: 12345,
237
+ });
238
+ });
239
+
240
+ it("rejects traversal into Dispatch-owned embed routes on sibling apps", async () => {
241
+ await expect(
242
+ runWithRequestContext(
243
+ {
244
+ userEmail: "owner@example.test",
245
+ requestOrigin: "http://localhost:8092",
246
+ },
247
+ () =>
248
+ createGrantedDispatchMcpEmbedSession({
249
+ app: "analytics",
250
+ path: "/../dispatch/extensions/ext-1",
251
+ }),
252
+ ),
253
+ ).rejects.toThrow(/safe app-relative route/);
254
+ });
255
+
256
+ it("routes same-origin mounted app embed URLs to the mounted app", async () => {
257
+ mocks.discoverAgents.mockResolvedValue([
258
+ {
259
+ ...analyticsAgent,
260
+ url: "http://localhost:8092/analytics",
261
+ },
262
+ ]);
263
+
264
+ const result = await runWithRequestContext(
265
+ {
266
+ userEmail: "owner@example.test",
267
+ requestOrigin: "http://localhost:8092",
268
+ },
269
+ () =>
270
+ createGrantedDispatchMcpEmbedSession({
271
+ url: "http://localhost:8092/analytics/dashboards?range=30d",
272
+ }),
273
+ );
274
+
275
+ expect(mocks.createEmbedSessionTicket).not.toHaveBeenCalled();
276
+ expect(mocks.managerConstructor).toHaveBeenCalledWith({
277
+ servers: {
278
+ target: expect.objectContaining({
279
+ url: "http://localhost:8092/analytics/_agent-native/mcp",
280
+ }),
281
+ },
282
+ });
283
+ expect(mocks.managerCallTool).toHaveBeenCalledWith(
284
+ "mcp__target__create_embed_session",
285
+ {
286
+ url: "http://localhost:8092/analytics/dashboards?range=30d",
287
+ chrome: "full",
288
+ },
289
+ );
290
+ expect(result).toEqual({
291
+ app: "analytics",
292
+ startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
293
+ });
294
+ });
295
+ });
@@ -3,7 +3,12 @@ import {
3
3
  buildMcpToolName,
4
4
  McpClientManager,
5
5
  } from "@agent-native/core/mcp-client";
6
- import { buildDeepLink } from "@agent-native/core/server";
6
+ import {
7
+ buildDeepLink,
8
+ buildEmbedStartPath,
9
+ createEmbedSessionTicket,
10
+ getRequestContext,
11
+ } from "@agent-native/core/server";
7
12
  import {
8
13
  discoverAgents,
9
14
  type DiscoveredAgent,
@@ -19,6 +24,12 @@ import {
19
24
  type DispatchMcpAppAccessSettings,
20
25
  } from "./mcp-access-store.js";
21
26
 
27
+ const DISPATCH_APP_ID = "dispatch";
28
+ const DISPATCH_NAME = "Agent-Native Dispatch";
29
+ const DISPATCH_DESCRIPTION =
30
+ "Workspace control plane for extensions, agents, vault, integrations, approvals, and app routing.";
31
+ const DISPATCH_COLOR = "#14B8A6";
32
+
22
33
  export interface DispatchMcpAccessibleApp {
23
34
  id: string;
24
35
  name: string;
@@ -32,6 +43,73 @@ function normalizeAppId(value: string): string {
32
43
  return value.trim().toLowerCase();
33
44
  }
34
45
 
46
+ function normalizeBaseUrl(raw: string | undefined | null): string | null {
47
+ const value = raw?.trim();
48
+ if (!value) return null;
49
+ try {
50
+ const url = new URL(value);
51
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
52
+ return url.toString().replace(/\/+$/, "");
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function normalizeBasePath(value: string | undefined): string {
59
+ const trimmed = value?.trim();
60
+ if (!trimmed || trimmed === "/") return "";
61
+ const normalized = trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
62
+ return normalized ? `/${normalized}` : "";
63
+ }
64
+
65
+ function withConfiguredBasePath(baseUrl: string): string {
66
+ const basePath = normalizeBasePath(
67
+ process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH,
68
+ );
69
+ if (!basePath) return baseUrl;
70
+ try {
71
+ const url = new URL(baseUrl);
72
+ const path = normalizeBasePath(url.pathname);
73
+ if (path === basePath || path.startsWith(`${basePath}/`)) {
74
+ return baseUrl;
75
+ }
76
+ url.pathname = path && path !== "/" ? `${basePath}${path}` : `${basePath}/`;
77
+ return url.toString().replace(/\/+$/, "");
78
+ } catch {
79
+ return baseUrl;
80
+ }
81
+ }
82
+
83
+ function dispatchSelfBaseUrl(): string {
84
+ const requestOrigin = normalizeBaseUrl(getRequestContext()?.requestOrigin);
85
+ if (requestOrigin) return withConfiguredBasePath(requestOrigin);
86
+
87
+ const configured =
88
+ normalizeBaseUrl(process.env.WORKSPACE_GATEWAY_URL) ??
89
+ normalizeBaseUrl(process.env.APP_URL) ??
90
+ normalizeBaseUrl(process.env.URL) ??
91
+ normalizeBaseUrl(process.env.DEPLOY_URL) ??
92
+ normalizeBaseUrl(process.env.BETTER_AUTH_URL);
93
+ if (configured) return withConfiguredBasePath(configured);
94
+
95
+ return process.env.NODE_ENV === "production"
96
+ ? "https://dispatch.agent-native.com"
97
+ : "http://localhost:8092";
98
+ }
99
+
100
+ function dispatchSelfApp(
101
+ settings: DispatchMcpAppAccessSettings,
102
+ ): DispatchMcpAccessibleApp {
103
+ return {
104
+ id: DISPATCH_APP_ID,
105
+ name: DISPATCH_NAME,
106
+ description: DISPATCH_DESCRIPTION,
107
+ url: dispatchSelfBaseUrl(),
108
+ color: DISPATCH_COLOR,
109
+ granted: isAppAllowedByMcpAccess(DISPATCH_APP_ID, settings),
110
+ };
111
+ }
112
+
35
113
  const CONTROL_CHARS = new RegExp("[\\u0000-\\u001f\\u007f]");
36
114
 
37
115
  function safeAppPath(raw: unknown): string | null {
@@ -41,6 +119,15 @@ function safeAppPath(raw: unknown): string | null {
41
119
  if (!value.startsWith("/")) return null;
42
120
  if (value.startsWith("//") || value.startsWith("/\\")) return null;
43
121
  if (/^\/[a-z][a-z0-9+.-]*:/i.test(value)) return null;
122
+ if (/%(?:2f|5c)/i.test(value)) return null;
123
+ const rawPath = value.split(/[?#]/, 1)[0] ?? value;
124
+ let parsed: URL;
125
+ try {
126
+ parsed = new URL(value, "http://agent-native.invalid");
127
+ } catch {
128
+ return null;
129
+ }
130
+ if (parsed.pathname !== rawPath) return null;
44
131
  return value;
45
132
  }
46
133
 
@@ -64,6 +151,51 @@ function appBaseUrl(app: DispatchMcpAccessibleApp): string {
64
151
  return app.url.replace(/\/+$/, "");
65
152
  }
66
153
 
154
+ function appBasePath(app: DispatchMcpAccessibleApp): string {
155
+ const pathname = new URL(appBaseUrl(app)).pathname.replace(/\/+$/, "");
156
+ return pathname === "/" ? "" : pathname;
157
+ }
158
+
159
+ function appMatchesUrlPath(app: DispatchMcpAccessibleApp, url: URL): boolean {
160
+ if (url.origin !== appOrigin(app)) return false;
161
+ const basePath = appBasePath(app);
162
+ if (!basePath) return true;
163
+ return url.pathname === basePath || url.pathname.startsWith(`${basePath}/`);
164
+ }
165
+
166
+ function appPathSpecificity(app: DispatchMcpAccessibleApp): number {
167
+ return appBasePath(app).length;
168
+ }
169
+
170
+ function appRelativePath(app: DispatchMcpAccessibleApp, url: URL): string {
171
+ const basePath = appBasePath(app);
172
+ const path = basePath
173
+ ? url.pathname === basePath
174
+ ? "/"
175
+ : url.pathname.slice(basePath.length)
176
+ : url.pathname;
177
+ return `${path || "/"}${url.search}${url.hash}`;
178
+ }
179
+
180
+ function isDispatchControlPath(path: string | null): boolean {
181
+ if (!path) return false;
182
+ const route = path.split(/[?#]/, 1)[0] ?? path;
183
+ return (
184
+ route === "/extensions" ||
185
+ route.startsWith("/extensions/") ||
186
+ route === "/tools" ||
187
+ route.startsWith("/tools/")
188
+ );
189
+ }
190
+
191
+ function assertAppCanOpenPath(app: DispatchMcpAccessibleApp, path: string) {
192
+ if (app.id !== DISPATCH_APP_ID && isDispatchControlPath(path)) {
193
+ throw new Error(
194
+ `Path "${path}" belongs to Dispatch. Use app: "dispatch" for Dispatch extension and tool routes.`,
195
+ );
196
+ }
197
+ }
198
+
67
199
  function toAccessibleApp(
68
200
  agent: DiscoveredAgent,
69
201
  settings: DispatchMcpAppAccessSettings,
@@ -88,7 +220,12 @@ export async function listDispatchMcpApps(): Promise<{
88
220
  ]);
89
221
  return {
90
222
  settings,
91
- apps: agents.map((agent) => toAccessibleApp(agent, settings)),
223
+ apps: [
224
+ dispatchSelfApp(settings),
225
+ ...agents
226
+ .filter((agent) => normalizeAppId(agent.id) !== DISPATCH_APP_ID)
227
+ .map((agent) => toAccessibleApp(agent, settings)),
228
+ ],
92
229
  };
93
230
  }
94
231
 
@@ -165,9 +302,14 @@ export async function openGrantedDispatchMcpApp(input: {
165
302
  chrome?: "full" | "minimal";
166
303
  }> {
167
304
  const view = input.view?.trim() ?? "";
305
+ const hasPathInput = input.path != null;
168
306
  const path = safeAppPath(input.path);
307
+ if (hasPathInput && !path) {
308
+ throw new Error("path must be a safe app-relative route");
309
+ }
169
310
  if (!view && !path) throw new Error("open_app requires view or path");
170
311
  const target = await resolveGrantedDispatchMcpApp(input.app);
312
+ if (path) assertAppCanOpenPath(target, path);
171
313
  const relUrl = path
172
314
  ? appendParamsToPath(path, input.params)
173
315
  : buildDeepLink({
@@ -214,6 +356,7 @@ async function resolveDispatchEmbedTarget(input: {
214
356
  if (explicitApp && input.path) {
215
357
  const path = safeAppPath(input.path);
216
358
  if (!path) throw new Error("path must be a safe app-relative route");
359
+ assertAppCanOpenPath(explicitApp, path);
217
360
  return {
218
361
  app: explicitApp,
219
362
  path,
@@ -242,17 +385,47 @@ async function resolveDispatchEmbedTarget(input: {
242
385
  }
243
386
 
244
387
  const apps = explicitApp ? [explicitApp] : await listGrantedDispatchMcpApps();
245
- const target = apps.find((app) => parsed.origin === appOrigin(app));
388
+ const target = apps
389
+ .filter((app) => appMatchesUrlPath(app, parsed))
390
+ .sort((a, b) => appPathSpecificity(b) - appPathSpecificity(a))[0];
246
391
  if (!target) {
247
392
  throw new Error(
248
393
  "Embed URL must belong to an app granted through Dispatch.",
249
394
  );
250
395
  }
251
- const path = safeAppPath(`${parsed.pathname}${parsed.search}${parsed.hash}`);
396
+ const path = safeAppPath(appRelativePath(target, parsed));
252
397
  if (!path) throw new Error("Embed URL path is not safe.");
398
+ assertAppCanOpenPath(target, path);
253
399
  return { app: target, path, url: `${appBaseUrl(target)}${path}` };
254
400
  }
255
401
 
402
+ async function createDispatchSelfEmbedSession(input: {
403
+ ownerEmail: string;
404
+ orgId?: string;
405
+ path: string;
406
+ baseUrl: string;
407
+ chrome?: "full" | "minimal";
408
+ }): Promise<{
409
+ startUrl: string;
410
+ targetPath?: string;
411
+ expiresAt?: number;
412
+ app: string;
413
+ }> {
414
+ const ticket = await createEmbedSessionTicket({
415
+ ownerEmail: input.ownerEmail,
416
+ orgId: input.orgId,
417
+ targetPath: input.path,
418
+ scope: input.chrome ?? null,
419
+ });
420
+ const startPath = buildEmbedStartPath(ticket.ticket);
421
+ return {
422
+ startUrl: new URL(startPath, input.baseUrl).toString(),
423
+ targetPath: input.path,
424
+ expiresAt: ticket.expiresAt,
425
+ app: DISPATCH_APP_ID,
426
+ };
427
+ }
428
+
256
429
  export async function createGrantedDispatchMcpEmbedSession(input: {
257
430
  app?: string;
258
431
  url?: string;
@@ -269,6 +442,16 @@ export async function createGrantedDispatchMcpEmbedSession(input: {
269
442
  const target = await resolveDispatchEmbedTarget(input);
270
443
 
271
444
  const orgId = getRequestOrgId();
445
+ if (target.app.id === DISPATCH_APP_ID) {
446
+ return createDispatchSelfEmbedSession({
447
+ ownerEmail: userEmail,
448
+ orgId,
449
+ path: target.path,
450
+ baseUrl: appBaseUrl(target.app),
451
+ chrome: input.chrome,
452
+ });
453
+ }
454
+
272
455
  const [orgDomain, orgSecret] = orgId
273
456
  ? await Promise.all([
274
457
  getOrgDomain(orgId).catch(() => null),
@@ -51,6 +51,7 @@ export interface AppAccessMetric {
51
51
  name: string;
52
52
  path: string;
53
53
  status: WorkspaceAppSummary["status"];
54
+ statusLabel?: string;
54
55
  isDispatch: boolean;
55
56
  accessModel: "workspace" | "solo";
56
57
  accessLabel: string;
@@ -587,6 +588,7 @@ export async function listDispatchUsageMetrics(input: {
587
588
  name: app.name,
588
589
  path: app.path,
589
590
  status: app.status,
591
+ statusLabel: app.statusLabel,
590
592
  isDispatch: app.isDispatch,
591
593
  accessModel,
592
594
  accessLabel,