@decocms/mesh-sdk 1.2.2 → 1.2.3

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.
@@ -57,6 +57,85 @@ const VirtualMcpPinnedViewSchema = z.object({
57
57
 
58
58
  export type VirtualMcpPinnedView = z.infer<typeof VirtualMcpPinnedViewSchema>;
59
59
 
60
+ /**
61
+ * A single tab declared by an agent in `metadata.ui.layout.tabs`. Rendered
62
+ * after the fixed system tabs (Instructions / Connections / Layout / Env)
63
+ * in the unified chat layout's right panel.
64
+ */
65
+ export const VirtualMcpUILayoutTabSchema = z.object({
66
+ id: z.string().describe("Stable id; used as React key and ?tab= value"),
67
+ title: z.string().describe("Tab label"),
68
+ icon: z.string().optional().describe("Optional lucide icon name"),
69
+ view: z.object({
70
+ type: z.literal("ext-app"),
71
+ appId: z.string(),
72
+ args: z.record(z.string(), z.unknown()).optional(),
73
+ }),
74
+ });
75
+
76
+ export type VirtualMcpUILayoutTab = z.infer<typeof VirtualMcpUILayoutTabSchema>;
77
+
78
+ /**
79
+ * Layout-specific settings stored under `metadata.ui.layout`. Controls which
80
+ * main view opens by default and which additional right-panel tabs are
81
+ * permanently available for the agent.
82
+ */
83
+ export const VirtualMcpUILayoutSchema = z.object({
84
+ defaultMainView: z
85
+ .object({
86
+ type: z.string(),
87
+ id: z.string().optional(),
88
+ toolName: z.string().optional(),
89
+ })
90
+ .nullable()
91
+ .optional(),
92
+ /**
93
+ * When true, the chat panel is open alongside the main view on first
94
+ * load. Ignored when `defaultMainView.type === "chat"` (chat is always
95
+ * open in that case). Absent / null / false → chat is closed unless the
96
+ * default view is chat.
97
+ */
98
+ chatDefaultOpen: z.boolean().nullable().optional(),
99
+ tabs: z.array(VirtualMcpUILayoutTabSchema).optional(),
100
+ });
101
+
102
+ export type VirtualMcpUILayout = z.infer<typeof VirtualMcpUILayoutSchema>;
103
+
104
+ /**
105
+ * Tile UI declared by a home agent. When present, the `/$org` home page
106
+ * renders the resource as an iframe inside the agent's tile (same MCP UI
107
+ * iframe pattern used for tool results in chat).
108
+ *
109
+ * The resource lives on a specific underlying connection — not the virtual
110
+ * MCP gateway — so we store the source `connectionId` here. The host opens
111
+ * an MCP client to that connection directly so tool calls from inside the
112
+ * iframe hit bare tool names (the gateway would otherwise reject calls that
113
+ * don't carry its namespace prefix).
114
+ */
115
+ const VirtualMcpHomeTileSchema = z.object({
116
+ /**
117
+ * Optional for backward compatibility with tiles saved before this field
118
+ * existed. The home API drops tiles that don't carry a connectionId (they
119
+ * can't render correctly without it), but parsing must still succeed so
120
+ * existing virtual MCPs don't fail output validation on COLLECTION_*_LIST.
121
+ */
122
+ connectionId: z
123
+ .string()
124
+ .optional()
125
+ .describe(
126
+ "Connection that owns the resource — the host opens a direct MCP client to this connection so the iframe can call tools by their bare names.",
127
+ ),
128
+ resourceUri: z
129
+ .string()
130
+ .describe(
131
+ "ui:// resource URI exposed by `connectionId`. Read on the home page and rendered via MCPAppRenderer.",
132
+ ),
133
+ minHeight: z.number().int().positive().optional(),
134
+ maxHeight: z.number().int().positive().optional(),
135
+ });
136
+
137
+ export type VirtualMcpHomeTile = z.infer<typeof VirtualMcpHomeTileSchema>;
138
+
60
139
  /**
61
140
  * Virtual MCP UI customization schema
62
141
  */
@@ -66,10 +145,311 @@ const VirtualMcpUISchema = z.object({
66
145
  icon: z.string().nullable().optional(),
67
146
  themeColor: z.string().nullable().optional(),
68
147
  pinnedViews: z.array(VirtualMcpPinnedViewSchema).nullable().optional(),
148
+ layout: VirtualMcpUILayoutSchema.nullable().optional(),
149
+ /**
150
+ * Legacy single-tile slot. Still honored by the home-next-actions
151
+ * endpoint when `homeTiles` is empty/absent. New writes go to
152
+ * `homeTiles` so agents can surface more than one UI on the home
153
+ * board.
154
+ */
155
+ homeTile: VirtualMcpHomeTileSchema.nullable().optional(),
156
+ /**
157
+ * Multiple home tiles per agent. Each entry becomes its own tile on
158
+ * the org home board, rendered via MCPAppRenderer against the
159
+ * resource's owning connection.
160
+ */
161
+ homeTiles: z.array(VirtualMcpHomeTileSchema).nullable().optional(),
162
+ /**
163
+ * Curated list of prompt names to surface on the home board. When
164
+ * absent / null, the BE falls back to listing every prompt the
165
+ * agent's gateway exposes (today's behavior). An explicit empty
166
+ * array means "no prompts" — useful for an agent that only wants to
167
+ * surface its UI tiles.
168
+ */
169
+ homePrompts: z.array(z.string()).nullable().optional(),
69
170
  });
70
171
 
71
172
  export type VirtualMcpUI = z.infer<typeof VirtualMcpUISchema>;
72
173
 
174
+ /**
175
+ * Canonical reader for an agent's pinned home tiles. Prefers the
176
+ * `homeTiles` array; falls back to the legacy single `homeTile` slot so
177
+ * agents written before the multi-tile migration still surface their
178
+ * tile. Returning `[]` (rather than null) keeps callers branchless.
179
+ */
180
+ export function getHomeTiles(
181
+ ui: VirtualMcpUI | null | undefined,
182
+ ): VirtualMcpHomeTile[] {
183
+ const arr = ui?.homeTiles;
184
+ if (Array.isArray(arr) && arr.length > 0) return arr;
185
+ const legacy = ui?.homeTile;
186
+ return legacy ? [legacy] : [];
187
+ }
188
+
189
+ /**
190
+ * Shell-portable env var name: must start with a letter or underscore and
191
+ * contain only letters, digits, and underscores. Same shape parse-dotenv
192
+ * enforces on imports. Single source of truth — the form editor and the
193
+ * paste flow both validate against this.
194
+ */
195
+ export const ENV_VAR_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
196
+
197
+ const envVarKey = z.string().min(1).regex(ENV_VAR_KEY_RE, {
198
+ message:
199
+ "Env var key must start with a letter or underscore and contain only letters, digits, and underscores.",
200
+ });
201
+
202
+ /**
203
+ * One env var declaration on a virtual MCP. Literal values live inline in
204
+ * metadata; secret values store a stable secretId that mesh resolves against
205
+ * the credential vault on every SANDBOX_START. The env var KEY is independent of
206
+ * the secret's NAME — a single secret can back multiple env keys across
207
+ * different agents.
208
+ */
209
+ const RuntimeEnvEntrySchema = z.discriminatedUnion("kind", [
210
+ z.object({
211
+ key: envVarKey,
212
+ kind: z.literal("literal"),
213
+ value: z.string(),
214
+ }),
215
+ z.object({
216
+ key: envVarKey,
217
+ kind: z.literal("secret"),
218
+ secretId: z.string().min(1),
219
+ }),
220
+ ]);
221
+
222
+ export type RuntimeEnvEntry = z.infer<typeof RuntimeEnvEntrySchema>;
223
+
224
+ /**
225
+ * User-pinned runtime configuration stored under `metadata.runtime`. Empty
226
+ * fields fall back to autodetect on the next SANDBOX_START.
227
+ */
228
+ const RuntimeMetadataSchema = z.object({
229
+ selected: z
230
+ .string()
231
+ .nullable()
232
+ .optional()
233
+ .describe(
234
+ "User-selected package manager (npm | pnpm | yarn | bun | deno). Null/absent means autodetect on next SANDBOX_START.",
235
+ ),
236
+ port: z
237
+ .string()
238
+ .nullable()
239
+ .optional()
240
+ .describe(
241
+ "User-selected dev server port as a string (allows '' / null for unset). Null/absent means autodetect.",
242
+ ),
243
+ path: z
244
+ .string()
245
+ .nullable()
246
+ .optional()
247
+ .describe(
248
+ "Optional path (relative to repo root) to the directory containing package.json. Null/absent means repo root. Forwarded as `application.packageManager.path` to the daemon config.",
249
+ ),
250
+ env: z
251
+ .array(RuntimeEnvEntrySchema)
252
+ .nullable()
253
+ .optional()
254
+ .describe(
255
+ "Env vars injected on every SANDBOX_START. Literal entries inline their value; secret entries store a secretId that mesh resolves via the credential vault before posting /_sandbox/config.",
256
+ ),
257
+ });
258
+
259
+ export type RuntimeMetadata = z.infer<typeof RuntimeMetadataSchema>;
260
+
261
+ /**
262
+ * GitHub repository linked to a virtual MCP
263
+ */
264
+ const GithubRepoSchema = z.object({
265
+ url: z.string().describe("GitHub repository URL"),
266
+ owner: z.string().describe("Repository owner"),
267
+ name: z.string().describe("Repository name"),
268
+ installationId: z
269
+ .number()
270
+ .optional()
271
+ .describe(
272
+ "GitHub App installation ID. Absent when the repo was linked without a GitHub connection (public-clone mode).",
273
+ ),
274
+ connectionId: z
275
+ .string()
276
+ .optional()
277
+ .describe(
278
+ "ID of the mcp-github connection used for authentication. Absent for public repos cloned without credentials.",
279
+ ),
280
+ });
281
+
282
+ export type GithubRepo = z.infer<typeof GithubRepoSchema>;
283
+
284
+ /**
285
+ * A single sandbox record in the per-(user, branch, kind) sandbox map — the
286
+ * provider-issued handle plus the preview URL the UI renders.
287
+ *
288
+ * `sandboxProviderKind` lets the UI construct daemon URLs correctly:
289
+ * - cluster: daemon is reached via the mesh proxy; preview URL is the
290
+ * per-claim HTTPRoute host (in-cluster) or a local port-forward (kind dev).
291
+ * - user-desktop: daemon is reached directly via the user's link binary.
292
+ *
293
+ * `previewUrl` is nullable: blank / tool sandboxes (no `workload`, no dev
294
+ * server) have nothing to render. UI code MUST check before constructing
295
+ * an iframe URL.
296
+ */
297
+ export const SandboxRecordSchema = z.object({
298
+ sandboxHandle: z.string().describe("Provider-specific handle"),
299
+ previewUrl: z
300
+ .string()
301
+ .nullable()
302
+ .describe(
303
+ "URL where the sandbox's iframe-proxied UI is served, or null when the sandbox has no dev server (blank / tool sandboxes).",
304
+ ),
305
+ sandboxApiUrl: z
306
+ .string()
307
+ .nullable()
308
+ .optional()
309
+ .describe(
310
+ "Daemon's public URL — what cluster→daemon RPCs target. Equal to previewUrl for user-desktop; null/absent for the cluster provider (routes through cluster ingress).",
311
+ ),
312
+ sandboxProviderKind: z
313
+ // Canonical set. Migration 092 rewrote every persisted legacy value
314
+ // ("docker", "agent-sandbox", "desktop", "remote-user", "host",
315
+ // "freestyle") to a canonical kind, and migration 097 dropped the
316
+ // retired "local-docker" kind; readers no longer accept those strings —
317
+ // Zod will reject them at parse time.
318
+ .enum(["cluster", "user-desktop"])
319
+ .optional(),
320
+ createdAt: z
321
+ .number()
322
+ .optional()
323
+ .describe(
324
+ "Epoch ms the entry was first written by SANDBOX_START. Used by the booting overlay to show a stable elapsed timer that survives browser reloads. Optional for backward compatibility with entries written before this field existed.",
325
+ ),
326
+ startedWith: z
327
+ .object({
328
+ packageManager: z
329
+ .string()
330
+ .nullable()
331
+ .optional()
332
+ .describe("metadata.runtime.selected at the time of SANDBOX_START"),
333
+ port: z
334
+ .string()
335
+ .nullable()
336
+ .optional()
337
+ .describe("metadata.runtime.port at the time of SANDBOX_START"),
338
+ path: z
339
+ .string()
340
+ .nullable()
341
+ .optional()
342
+ .describe("metadata.runtime.path at the time of SANDBOX_START"),
343
+ })
344
+ .optional()
345
+ .describe(
346
+ "Snapshot of metadata.runtime fields (selected/port/path) used at SANDBOX_START. The Preview tab compares the live metadata.runtime against this to decide if a restart is required to apply changes.",
347
+ ),
348
+ });
349
+
350
+ export type SandboxRecord = z.infer<typeof SandboxRecordSchema>;
351
+
352
+ /**
353
+ * Strict parser for a single sandbox record. Migration 091 rewrote every
354
+ * persisted legacy value (`runnerKind`, `vmId`, legacy kind names), so
355
+ * readers no longer accept those shapes — Zod throws on any leftover.
356
+ */
357
+ export function parseSandboxRecord(raw: unknown): SandboxRecord {
358
+ return SandboxRecordSchema.parse(raw);
359
+ }
360
+
361
+ /** The active sandbox provider kinds. */
362
+ export type SandboxProviderKind = "cluster" | "user-desktop";
363
+
364
+ /**
365
+ * Parse a `sandboxMap[user][branch]` cell into the kind-keyed v2 shape.
366
+ *
367
+ * Migration 087 rewrote every cell to the 3-level layout
368
+ * (`sandboxProviderKind → SandboxRecord`) and migration 091 rewrote every
369
+ * legacy kind value; this reader is now strict — entries that fail to parse
370
+ * (e.g. a stray cell missing required fields) are skipped, but no legacy
371
+ * key/value normalization happens.
372
+ */
373
+ export function parseBranchMap(
374
+ raw: unknown,
375
+ ): Partial<Record<SandboxProviderKind, SandboxRecord>> {
376
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
377
+ const obj = raw as Record<string, unknown>;
378
+
379
+ const out: Partial<Record<SandboxProviderKind, SandboxRecord>> = {};
380
+ for (const [k, v] of Object.entries(obj)) {
381
+ if (!v || typeof v !== "object") continue;
382
+ if (k !== "cluster" && k !== "user-desktop") {
383
+ continue;
384
+ }
385
+ try {
386
+ out[k] = SandboxRecordSchema.parse(v);
387
+ } catch {
388
+ // Skip malformed entries rather than throw — readers stay forgiving
389
+ // about unexpected shapes within a known-key cell.
390
+ }
391
+ }
392
+ return out;
393
+ }
394
+
395
+ /**
396
+ * Maps a user to their sandbox records per (branch, sandboxProviderKind).
397
+ * Lookup: sandboxMap[userId][branch][sandboxProviderKind] -> SandboxRecord
398
+ *
399
+ * Multiple threads on the same (userId, branch, kind) share one sandbox.
400
+ * Cloud and local sandboxes can coexist on the same branch as siblings.
401
+ *
402
+ * The schema is strict v2. Strict input/output types here are load-bearing
403
+ * for `useForm<…>(zodResolver(…))` callers, whose generic depends on
404
+ * `z.input` being identical to `z.output`. A `z.preprocess` here widens
405
+ * `z.input` to `unknown` and breaks the form.
406
+ */
407
+ export const SandboxMapSchema = z.record(
408
+ z.string().describe("userId"),
409
+ z.record(
410
+ z.string().describe("branch"),
411
+ z.record(z.string().describe("sandboxProviderKind"), SandboxRecordSchema),
412
+ ),
413
+ );
414
+
415
+ export type SandboxMap = z.infer<typeof SandboxMapSchema>;
416
+
417
+ /**
418
+ * Normalize a raw `metadata.sandboxMap` value into v2 shape on read. Use this
419
+ * in storage adapters BEFORE returning data that will be Zod-validated against
420
+ * `VirtualMCPEntitySchema` (or any schema embedding `SandboxMapSchema`).
421
+ *
422
+ * After migration 091, the stored shape is the strict 3-level
423
+ * `userId → branch → sandboxProviderKind → SandboxRecord`. Returns `{}` for
424
+ * missing / malformed input rather than throwing — readers stay forgiving
425
+ * about unexpected per-row corruption; the strict schema catches any
426
+ * residual issues at validation time.
427
+ */
428
+ export function normalizeSandboxMap(raw: unknown): SandboxMap {
429
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
430
+ const out: SandboxMap = {};
431
+ for (const [userId, userVal] of Object.entries(
432
+ raw as Record<string, unknown>,
433
+ )) {
434
+ if (!userVal || typeof userVal !== "object" || Array.isArray(userVal)) {
435
+ continue;
436
+ }
437
+ const userOut: SandboxMap[string] = {};
438
+ for (const [branch, branchVal] of Object.entries(
439
+ userVal as Record<string, unknown>,
440
+ )) {
441
+ const normalized = parseBranchMap(branchVal);
442
+ if (Object.keys(normalized).length > 0) {
443
+ userOut[branch] = normalized as SandboxMap[string][string];
444
+ }
445
+ }
446
+ if (Object.keys(userOut).length > 0) {
447
+ out[userId] = userOut;
448
+ }
449
+ }
450
+ return out;
451
+ }
452
+
73
453
  /**
74
454
  * Virtual MCP entity schema - single source of truth
75
455
  * Compliant with collections binding pattern
@@ -91,10 +471,7 @@ export const VirtualMCPEntitySchema = z.object({
91
471
  // Entity-specific fields
92
472
  organization_id: z.string().describe("Organization ID this item belongs to"),
93
473
  status: z.enum(["active", "inactive"]).describe("Current status"),
94
- subtype: z
95
- .enum(["agent", "project"])
96
- .nullable()
97
- .describe("Virtual MCP subtype for UI presentation"),
474
+ pinned: z.boolean().describe("Whether this space is pinned to the sidebar"),
98
475
  // Metadata (stored in connections.metadata)
99
476
  // Normalize null/undefined to { instructions: null } for consistent form tracking
100
477
  metadata: z
@@ -111,6 +488,17 @@ export const VirtualMCPEntitySchema = z.object({
111
488
  ui: VirtualMcpUISchema.nullable()
112
489
  .optional()
113
490
  .describe("UI customization settings"),
491
+ githubRepo: GithubRepoSchema.nullable()
492
+ .optional()
493
+ .describe("Linked GitHub repository"),
494
+ runtime: RuntimeMetadataSchema.nullable()
495
+ .optional()
496
+ .describe(
497
+ "User-pinned runtime config (package manager, dev port). Empty fields = autodetect.",
498
+ ),
499
+ sandboxMap: SandboxMapSchema.optional().describe(
500
+ "Per-user, per-branch sandbox mapping: sandboxMap[userId][branch] -> { sandboxHandle, previewUrl }",
501
+ ),
114
502
  })
115
503
  .loose()
116
504
  .describe("Metadata"),
@@ -141,10 +529,7 @@ export const VirtualMCPCreateDataSchema = z.object({
141
529
  .optional()
142
530
  .default("active")
143
531
  .describe("Initial status"),
144
- subtype: z
145
- .enum(["agent", "project"])
146
- .optional()
147
- .describe("Virtual MCP subtype"),
532
+ pinned: z.boolean().optional().default(false).describe("Pin to sidebar"),
148
533
  metadata: z
149
534
  .object({
150
535
  instructions: z
@@ -160,6 +545,17 @@ export const VirtualMCPCreateDataSchema = z.object({
160
545
  ui: VirtualMcpUISchema.nullable()
161
546
  .optional()
162
547
  .describe("UI customization settings"),
548
+ githubRepo: GithubRepoSchema.nullable()
549
+ .optional()
550
+ .describe("Linked GitHub repository"),
551
+ runtime: RuntimeMetadataSchema.nullable()
552
+ .optional()
553
+ .describe(
554
+ "User-pinned runtime config (package manager, dev port). Empty fields = autodetect.",
555
+ ),
556
+ sandboxMap: SandboxMapSchema.optional().describe(
557
+ "Per-user, per-branch sandbox mapping: sandboxMap[userId][branch] -> { sandboxHandle, previewUrl }",
558
+ ),
163
559
  })
164
560
  .loose()
165
561
  .nullable()
@@ -186,7 +582,7 @@ export const VirtualMCPUpdateDataSchema = z.object({
186
582
  .describe("New description (null to clear)"),
187
583
  icon: z.string().nullish().describe("New icon URL"),
188
584
  status: z.enum(["active", "inactive"]).optional().describe("New status"),
189
- subtype: z.enum(["agent", "project"]).optional().describe("New subtype"),
585
+ pinned: z.boolean().optional().describe("Pin/unpin from sidebar"),
190
586
  metadata: z
191
587
  .object({
192
588
  instructions: z
@@ -202,6 +598,17 @@ export const VirtualMCPUpdateDataSchema = z.object({
202
598
  ui: VirtualMcpUISchema.nullable()
203
599
  .optional()
204
600
  .describe("UI customization settings"),
601
+ githubRepo: GithubRepoSchema.nullable()
602
+ .optional()
603
+ .describe("Linked GitHub repository"),
604
+ runtime: RuntimeMetadataSchema.nullable()
605
+ .optional()
606
+ .describe(
607
+ "User-pinned runtime config (package manager, dev port). Empty fields = autodetect.",
608
+ ),
609
+ sandboxMap: SandboxMapSchema.optional().describe(
610
+ "Per-user, per-branch sandbox mapping: sandboxMap[userId][branch] -> { sandboxHandle, previewUrl }",
611
+ ),
205
612
  })
206
613
  .loose()
207
614
  .nullable()