@cloudgrid-io/mcp 0.21.15 → 0.21.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.21.15",
3
+ "version": "0.21.17",
4
4
  "description": "MCP access to CloudGrid — the live runtime environment where the user's apps run WITH the infrastructure they need out of the box: managed MongoDB, Redis (cache/queues/pub-sub), a pgvector store, persistent disk, and built-in AI with no API keys, in any language or stack, no Docker or Kubernetes to manage. Brainstorm, build, plug, and improve apps, games, dashboards, and tools; instant single-page publishes and full runtime-app deploys with a live URL — for developers and non-technical users. Three editions: local stdio (full toolset), hosted web (light, CLI-free), and a public docs-search server.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,6 +74,7 @@
74
74
  "test:web-restart-recovery": "node --test test/web-restart-recovery.test.mjs",
75
75
  "test:oauth-client-persistence": "node --test test/oauth-client-persistence.test.mjs",
76
76
  "test:oauth-token-exchange": "node --test test/oauth-token-exchange.test.mjs",
77
+ "test:oauth-resource-indicators": "node --test test/oauth-resource-indicators.test.mjs",
77
78
  "test:oauth-observability": "node --test test/oauth-flow-observability.test.mjs",
78
79
  "test:web-capabilities-log": "node --test test/web-capabilities-log.test.mjs",
79
80
  "test:mcp-transport-observability": "node --test test/mcp-transport-observability.test.mjs",
package/src/oauth.js CHANGED
@@ -21,8 +21,8 @@ const CODE_TTL_MS = 5 * 60 * 1000; // authorize sessions + auth codes live 5 min
21
21
  // are 5-minute, retry-on-failure sessions: losing them to a restart just means
22
22
  // the user clicks "sign in" again, never that they must re-add the connector.
23
23
  // Client REGISTRATIONS are NOT here — they are stateless (see below, #3060).
24
- const authSessions = new Map(); // sid -> { cgCode, client_id, redirect_uri, state, code_challenge, created }
25
- const authCodes = new Map(); // code -> { jwt, client_id, redirect_uri, code_challenge, created }
24
+ const authSessions = new Map(); // sid -> { cgCode, client_id, redirect_uri, state, code_challenge, resource, created }
25
+ const authCodes = new Map(); // code -> { jwt, client_id, redirect_uri, code_challenge, resource, created }
26
26
 
27
27
  function sweep(map) {
28
28
  const now = Date.now();
@@ -58,6 +58,38 @@ function hostOf(uri) {
58
58
  }
59
59
  }
60
60
 
61
+ // --- RFC 8707 Resource Indicators (#329) --------------------------------------
62
+ // The MCP 2025-06-18 authorization spec has clients send `resource` — the
63
+ // canonical URI of the MCP server the token is for — on BOTH the authorize and
64
+ // token requests. We accept it, validate it against this server's own resource
65
+ // identifier (the `${base}/mcp` value /.well-known/oauth-protected-resource
66
+ // returns), and bind it to the code. A mismatch is `invalid_target` (RFC 8707
67
+ // §2). A MISSING resource stays acceptable: Claude web / Claude Code work today
68
+ // and may not send it, and the spec is explicit that clients "MUST send this
69
+ // parameter regardless of whether authorization servers support it" — i.e. the
70
+ // server may still honour requests without it.
71
+ //
72
+ // Comparison is on the CANONICAL form, not a raw string, so a cosmetically
73
+ // different but equivalent URI (uppercase scheme/host, a trailing slash, a
74
+ // default port) is not falsely rejected. Per the spec: lowercase scheme and
75
+ // host (URL parsing does this), no fragment, and the trailing-slash-free path.
76
+ // The QUERY STRING is also dropped by construction (we rebuild from protocol +
77
+ // host + path only), so `…/mcp?x=1` compares EQUAL to `…/mcp`. That is the
78
+ // permissive direction and fine here — our resource identifier carries no
79
+ // query — but it is stated because nobody would predict it from the code.
80
+ // Returns null for anything that is not an absolute URI — that then fails the
81
+ // equality check as a mismatch.
82
+ function normalizeResource(uri) {
83
+ try {
84
+ const u = new URL(String(uri));
85
+ if (!u.host) return null;
86
+ const path = u.pathname.replace(/\/+$/, ""); // …/mcp/ ≡ …/mcp
87
+ return `${u.protocol}//${u.host}${path}`; // protocol + host are already lowercased & default-port-stripped
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
61
93
  // --- Stateless client registration (#3060) -----------------------------------
62
94
  // A `client_id` is a signed token that CARRIES its own redirect-URI set, not a
63
95
  // random handle into a process-local Map. Registration signs the (sorted,
@@ -198,6 +230,10 @@ function interstitialHtml(sid, loginUrl) {
198
230
  export function mountOAuth(app, publicBase, opts = {}) {
199
231
  if (!opts.requireAuth) return;
200
232
  const base = publicBase.replace(/\/+$/, "");
233
+ // This server's resource identifier — the exact value the protected-resource
234
+ // metadata returns and the canonical `resource` a client must send (#329).
235
+ const resourceId = `${base}/mcp`;
236
+ const expectedResource = normalizeResource(resourceId);
201
237
 
202
238
  // Resolve the client-registration signing secret(s) once at mount. Log which
203
239
  // mode is active — durable vs. ephemeral — so a production deploy can be
@@ -236,6 +272,14 @@ export function mountOAuth(app, publicBase, opts = {}) {
236
272
  code_challenge_methods_supported: ["S256"],
237
273
  token_endpoint_auth_methods_supported: ["none"],
238
274
  scopes_supported: ["cloudgrid"],
275
+ // RFC 8707 Resource Indicators support (#329). RFC 8707 defines NO
276
+ // authorization-server metadata field of its own, and neither does the MCP
277
+ // spec; the only IANA-registered resource-related AS-metadata field is
278
+ // `protected_resources` (RFC 9728 §4) — a JSON array of the resource
279
+ // identifiers this AS issues tokens for. Advertising ours here lets a client
280
+ // discover the canonical `resource` value to send, and signals that we honour
281
+ // the parameter rather than ignoring it.
282
+ protected_resources: [resourceId],
239
283
  };
240
284
 
241
285
  app.options(/^\/(\.well-known|oauth)\/.*/, (_req, res) => {
@@ -302,16 +346,19 @@ export function mountOAuth(app, publicBase, opts = {}) {
302
346
  // Authorization endpoint — render the bridge interstitial.
303
347
  app.get("/oauth/authorize", (req, res) => {
304
348
  sweep(authSessions);
305
- const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type } = req.query;
306
- // Observability (#345): the request SHAPE the client actually sent, logged
307
- // before any check so a rejected request is still visible. Presence, not
308
- // values — nothing here is reusable in a log aggregator.
349
+ const { client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type, resource } = req.query;
350
+ // Observability (#345/#329): the request SHAPE the client actually sent,
351
+ // logged before any check so a rejected request is still visible. Presence,
352
+ // not values — nothing here is reusable in a log aggregator. The resource
353
+ // HOST is included: it is not a secret, and its presence/absence is the exact
354
+ // signal that distinguishes ChatGPT (sends it) from Claude (does not).
309
355
  console.error(
310
356
  `[oauth] authorize request: client_id=${clientIdPrefix(client_id)} ` +
311
357
  `response_type=${response_type ? String(response_type) : "absent"} ` +
312
358
  `state=${state ? "present" : "absent"} ` +
313
359
  `pkce=${code_challenge ? (code_challenge_method ? String(code_challenge_method) : "present(no-method)") : "absent"} ` +
314
- `redirect_host=${hostOf(redirect_uri)}`,
360
+ `redirect_host=${hostOf(redirect_uri)} ` +
361
+ `resource_host=${hostOf(resource)}`,
315
362
  );
316
363
  const client = verifyClientId(client_id, clientSecrets.secrets);
317
364
  if (!client || !client.redirect_uris.includes(String(redirect_uri))) {
@@ -322,6 +369,39 @@ export function mountOAuth(app, publicBase, opts = {}) {
322
369
  res.status(400).send("This server requires response_type=code with PKCE (S256).");
323
370
  return;
324
371
  }
372
+ // RFC 8707 (#329): if a `resource` is present it must be THIS server's
373
+ // resource identifier. Compared on the canonical form so an equivalent URI
374
+ // (case, trailing slash, default port) is accepted. A mismatch is
375
+ // invalid_target; a MISSING resource stays acceptable (Claude compatibility).
376
+ // The bound value is the canonical string, so the token endpoint can compare
377
+ // apples to apples later.
378
+ let boundResource = "";
379
+ if (resource !== undefined && String(resource) !== "") {
380
+ const requested = normalizeResource(resource);
381
+ if (!requested || requested !== expectedResource) {
382
+ // RFC 6749 §4.1.2.1: the redirect_uri is already verified as registered
383
+ // (the render-path 400 above), so an authorization error is returned by
384
+ // REDIRECTING to it with error= and state=, NOT by rendering an opaque
385
+ // body the client cannot read — RFC 8707 §2 defines invalid_target as
386
+ // exactly such an OAuth error code. This is the whole point of the PR:
387
+ // a spec fix that makes a silent failure legible must not add a new
388
+ // illegible one (an opaque 400 where ChatGPT expects
389
+ // error=invalid_target&state=… would just be another #329-shaped
390
+ // symptom). The two 400s above STAY renders: an unverified client or
391
+ // redirect_uri must NEVER be redirected to, so they are not precedent.
392
+ console.error(
393
+ `[oauth] authorize refused (redirect): invalid_target resource_host=${hostOf(resource)} expected_host=${hostOf(resourceId)}`,
394
+ );
395
+ const sep = String(redirect_uri).includes("?") ? "&" : "?";
396
+ let location = `${redirect_uri}${sep}error=invalid_target&error_description=${encodeURIComponent(
397
+ "The requested resource does not identify this server.",
398
+ )}`;
399
+ if (state) location += `&state=${encodeURIComponent(String(state))}`;
400
+ res.redirect(302, location);
401
+ return;
402
+ }
403
+ boundResource = requested;
404
+ }
325
405
  const sid = randomUUID();
326
406
  const cgCode = newLoginCode();
327
407
  authSessions.set(sid, {
@@ -330,6 +410,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
330
410
  redirect_uri: String(redirect_uri),
331
411
  state: state ? String(state) : "",
332
412
  code_challenge: String(code_challenge),
413
+ resource: boundResource,
333
414
  created: Date.now(),
334
415
  });
335
416
  res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -358,6 +439,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
358
439
  client_id: sess.client_id,
359
440
  redirect_uri: sess.redirect_uri,
360
441
  code_challenge: sess.code_challenge,
442
+ resource: sess.resource, // RFC 8707 binding carried from the authorize session (#329)
361
443
  created: Date.now(),
362
444
  });
363
445
  authSessions.delete(String(req.query.sid));
@@ -420,7 +502,7 @@ export function mountOAuth(app, publicBase, opts = {}) {
420
502
  app.post("/oauth/token", (req, res) => {
421
503
  corsOk(res);
422
504
  sweep(authCodes);
423
- const { grant_type, code, code_verifier, redirect_uri, client_id } = req.body ?? {};
505
+ const { grant_type, code, code_verifier, redirect_uri, client_id, resource } = req.body ?? {};
424
506
  if (grant_type !== "authorization_code") {
425
507
  denyToken(res, "unsupported_grant_type", "grant_type must be authorization_code", `(got ${JSON.stringify(grant_type ?? null)})`);
426
508
  return;
@@ -448,6 +530,23 @@ export function mountOAuth(app, publicBase, opts = {}) {
448
530
  denyToken(res, "invalid_grant", "redirect_uri does not match the authorize request", `(sent ${JSON.stringify(redirect_uri ?? null)}, expected ${JSON.stringify(rec.redirect_uri)})`);
449
531
  return;
450
532
  }
533
+ // RFC 8707 (#329): if the client repeats `resource` at the token endpoint it
534
+ // must be the one bound at authorize. Mismatch → invalid_target, refused via
535
+ // denyToken so it is logged. A token request with NO resource stays
536
+ // acceptable (Claude compatibility), even when one was bound. Only the HOSTS
537
+ // are logged — the resource URI is not a secret but nothing bearer-shaped is.
538
+ if (resource !== undefined && String(resource) !== "") {
539
+ const requested = normalizeResource(resource);
540
+ if (!requested || requested !== rec.resource) {
541
+ denyToken(
542
+ res,
543
+ "invalid_target",
544
+ "resource does not match the authorize request",
545
+ `(resource_host ${hostOf(resource)} vs bound ${hostOf(rec.resource)})`,
546
+ );
547
+ return;
548
+ }
549
+ }
451
550
  const challenge = b64url(createHash("sha256").update(String(code_verifier ?? "")).digest());
452
551
  if (challenge !== rec.code_challenge) {
453
552
  denyToken(res, "invalid_grant", code_verifier === undefined ? "code_verifier is required (PKCE)" : "PKCE verification failed");
@@ -64,25 +64,36 @@ export const VISIBILITY_LABELS = {
64
64
  // the widget HTML is verified to render. The resources stay registered either
65
65
  // way (harmless when no tool references them via outputTemplate).
66
66
  //
67
- // 2026-08-25 (#308): the black frame was diagnosed as a MIME mismatch. ChatGPT
68
- // discovers an Apps-SDK widget by a tool carrying openai/outputTemplate PLUS a
69
- // ui:// resource whose mimeType is `text/html+skybridge` (verified against
70
- // OpenAI's shipped example servers — openai/openai-apps-sdk-examples
71
- // solar-system/shopping_cart/kitchen_sink main.py). Our two ChatGPT widgets
72
- // declared `text/html;profile=mcp-app` — the SEP-1865/MCP-Apps MIME that CLAUDE
73
- // reads (RESOURCE_MIME_TYPE in @modelcontextprotocol/ext-apps). ChatGPT saw the
74
- // outputTemplate pointer, opened the skybridge iframe (dark chrome), but the
75
- // resource was not a skybridge document, so the HTML/bridge never populated —
76
- // the empty dark frame covering the text. CHATGPT_WIDGET_MIME below is the
77
- // correct type. The flag STAYS OFF until a card is seen rendering in real
78
- // ChatGPT (Developer Mode custom connector against staging); this only makes the
79
- // machinery correct for that verification.
67
+ // 2026-08-31 (#308, corrected): the widgets targeted a RETIRED MIME. At DevDay
68
+ // (Oct 2025) ChatGPT's Apps SDK used `text/html+skybridge`, and #309 switched us
69
+ // to it. But the Apps SDK merged with MCP Apps into SEP-1865, ratified
70
+ // 2026-01-26, and OpenAI's CURRENT docs (developers.openai.com/apps-sdk
71
+ // build/custom-ux, deploy/troubleshooting) now specify `text/html;profile=mcp-app`
72
+ // — the same MIME Claude reads. skybridge is not mentioned anywhere in the
73
+ // current docs. So ChatGPT opened the iframe on our skybridge resource, could not
74
+ // recognise it, and never populated it — the empty frame over the text.
75
+ //
76
+ // The clincher: our own Claude sign-in card (#303) RENDERS, and it declares
77
+ // `text/html;profile=mcp-app`. The MIME that renders is the standard one; the one
78
+ // that does not is skybridge. The two hosts converged; the old separation was the
79
+ // defect. CHATGPT_WIDGET_MIME below now equals RESOURCE_MIME_TYPE by value. The
80
+ // widget bridge (window.openai.toolOutput / openExternal / callTool /
81
+ // ui/notifications/tool-result) is already current — verified against
82
+ // developers.openai.com/apps-sdk/reference — so only the MIME changes.
83
+ //
84
+ // The flag stays where the platform sets it; this only makes the resource the
85
+ // type ChatGPT now expects. Done is a card seen rendering in real ChatGPT.
80
86
  export const APPS_WIDGETS_ENABLED = process.env.MCP_APPS_WIDGETS === "1";
81
87
  // The mimeType ChatGPT's Apps-SDK renderer requires for a ui:// widget resource.
82
- // Distinct from RESOURCE_MIME_TYPE (`text/html;profile=mcp-app`, the SEP-1865
83
- // key Claude reads) — the two hosts key off different MIME types, so the ChatGPT
84
- // widgets and the Claude sign-in card must NOT share one.
85
- export const CHATGPT_WIDGET_MIME = "text/html+skybridge";
88
+ // Since SEP-1865 (2026-01-26) this EQUALS RESOURCE_MIME_TYPE (`text/html;profile=
89
+ // mcp-app`): both hosts now render the standard MCP-Apps MIME. Both hosts also
90
+ // read the SAME binding key, `_meta.ui.resourceUri`; ChatGPT honors
91
+ // `openai/outputTemplate` only as a compatibility alias (OpenAI's own
92
+ // deploy/troubleshooting page). So our two mechanisms stay separate not because
93
+ // the hosts key off different MIMEs or different keys, but because WE bind them
94
+ // on different keys: the ChatGPT widget via `openai/outputTemplate` (the alias,
95
+ // which Claude ignores), the Claude card via `_meta.ui.resourceUri`.
96
+ export const CHATGPT_WIDGET_MIME = "text/html;profile=mcp-app";
86
97
  export const LIVE_RESULT_URI = "ui://cloudgrid/live-result.html";
87
98
  // URI/resource-name/filename stay `org-picker` — that's the stable contract the
88
99
  // web card is registered under; only the JS identifier moves toward grid.
@@ -2382,15 +2382,16 @@ export async function runPlug(ctx, input, deps = {}) {
2382
2382
  // /grids/<slug> form 404s; /home?grid= does not. Falls back to the root when
2383
2383
  // the slug is unknown.
2384
2384
  //
2385
- // NOT on the building branch: the line says "view it live", and a still-building
2386
- // app is not live — emitting it two lines under "Do NOT tell the user it is live"
2387
- // would hand a model the word "live" next to a pending app. For an async runtime
2388
- // build the console line arrives when liveness is confirmed, in runCheckDeploy's
2389
- // success branch.
2385
+ // NOT on the building branch. This line invites the user to "see and manage all
2386
+ // their apps in their grid" — an invitation that presumes the app is live. A
2387
+ // still-building app is not, and it sits two lines under "Do NOT tell the user it
2388
+ // is live", so relaying a manage-your-apps prompt there contradicts the honesty
2389
+ // gate. For an async runtime build the console line arrives once liveness is
2390
+ // confirmed, in runCheckDeploy's success branch.
2390
2391
  if (data.entity_id && !isBuilding) {
2391
2392
  const consoleUrl = consoleGridUrl(data.grid ?? grid ?? null);
2392
2393
  structured.console_url = consoleUrl;
2393
- lines.push(`You can view it live in your grid along with all the apps you build here: ${consoleUrl}`);
2394
+ lines.push(`Give the user this link so they can see and manage all their apps in their grid: ${consoleUrl}`);
2394
2395
  }
2395
2396
 
2396
2397
  // Visibility is the user's choice — never set silently. On a NEW deploy,
@@ -2881,9 +2882,10 @@ export async function runCheckDeploy(ctx, { poll_url, grid } = {}) {
2881
2882
  return {
2882
2883
  // The "now live" moment for an async runtime build lands here, not in
2883
2884
  // runPlug (which returned "building"). Point the user at their grid too
2884
- // (#321) — this is where liveness is confirmed, so "view it live" is true.
2885
+ // (#321) — this is where liveness is confirmed, so the manage-your-apps
2886
+ // invitation is honest to make.
2885
2887
  text: `Live${url ? `: ${url}` : ""} — the build finished. Give the user the URL.\n` +
2886
- `You can view it live in your grid along with all the apps you build here: ${consoleGridUrl(gridSlug)}`,
2888
+ `Give the user this link so they can see and manage all their apps in their grid: ${consoleGridUrl(gridSlug)}`,
2887
2889
  structured: { status: "success", live: true, ...(url ? { url } : {}), console_url: consoleGridUrl(gridSlug) },
2888
2890
  };
2889
2891
  }
@@ -133,9 +133,10 @@ export function registerTools(server, ctx) {
133
133
 
134
134
  // ── Widget resources (web edition, ChatGPT Apps SDK) ──────────────────────
135
135
  if (ctx.edition === "web") {
136
- // ChatGPT Apps-SDK widgets. mimeType MUST be text/html+skybridge
137
- // (CHATGPT_WIDGET_MIME) — NOT the MCP-Apps `text/html;profile=mcp-app` the
138
- // Claude card uses; that mismatch was the black frame (#308). widgetAccessible
136
+ // ChatGPT Apps-SDK widgets. mimeType is CHATGPT_WIDGET_MIME, which since
137
+ // SEP-1865 (2026-01-26) IS `text/html;profile=mcp-app` — the standard MCP-Apps
138
+ // type. The old `text/html+skybridge` was retired and was the black frame
139
+ // (#308); the hosts stay separated by binding, not MIME. widgetAccessible
139
140
  // lets the widget call tools back over window.openai.callTool (the visibility
140
141
  // pills call grid_visibility). These make no direct network requests, so no
141
142
  // openai/widgetCSP is declared — the default deny is correct.
@@ -538,11 +539,12 @@ export function registerTools(server, ctx) {
538
539
  // live at a public URL in place. Versions + grid rollback exist, but the
539
540
  // honest MCP annotation for overwrite-live-state is destructive.
540
541
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
541
- // ChatGPT-only binding: openai/outputTemplate points at the skybridge
542
- // widget. Deliberately NOT the MCP-Apps `ui.resourceUri` key — that is
543
- // Claude's, and the live-result resource is now text/html+skybridge, which
544
- // Claude cannot render; carrying it here would regress the Claude path when
545
- // this flag is flipped. The two mechanisms stay independent (#308/#303).
542
+ // Bound via openai/outputTemplate, which ChatGPT honors as a compatibility
543
+ // alias for `_meta.ui.resourceUri` and which Claude ignores — so this
544
+ // widget renders in ChatGPT only. Deliberately NOT `_meta.ui.resourceUri`:
545
+ // since SEP-1865 both hosts read THAT key, so binding there would render the
546
+ // widget in Claude too. The two mechanisms stay independent because they
547
+ // bind on different keys, not different MIMEs (#308/#303).
546
548
  ...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
547
549
  _meta: { "openai/outputTemplate": LIVE_RESULT_URI },
548
550
  } : {}),
@@ -717,10 +719,11 @@ export function registerTools(server, ctx) {
717
719
  login_url: z.string().describe("URL to open in a browser to complete sign-in."),
718
720
  },
719
721
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
720
- // Bind the SEP-1865 sign-in card on the web edition (Claude web). This is
721
- // the SEP-1865 key (_meta.ui.resourceUri); ChatGPT reads openai/outputTemplate
722
- // and ignores it, and a client without the UI extension ignores it too and
723
- // gets the text-first result below. NOT gated behind MCP_APPS_WIDGETS — a
722
+ // Bind the SEP-1865 sign-in card on the web edition (Claude web) via the
723
+ // standard `_meta.ui.resourceUri` key. It carries NO openai/outputTemplate
724
+ // and is not gated by MCP_APPS_WIDGETS, so it is the Claude sign-in card;
725
+ // a client without the UI extension ignores _meta.ui and gets the
726
+ // text-first result below. NOT gated behind MCP_APPS_WIDGETS — a
724
727
  // second, independent mechanism (see constants.js). The result shape is
725
728
  // unchanged: content[0] stays the sign-in URL text so the link is never
726
729
  // unreachable if the card does not render.
@@ -865,7 +868,7 @@ export function registerTools(server, ctx) {
865
868
  status: z.string().describe("The build status: success | failed | building | queued | unknown."),
866
869
  live: z.boolean().describe("True only when the build finished successfully and the URL serves."),
867
870
  url: z.string().optional().describe("The live URL, when known."),
868
- console_url: z.string().optional().describe("On success: URL to view the app live in the grid alongside all the user's apps."),
871
+ console_url: z.string().optional().describe("On success: a link to see and manage all the apps in the grid, in the console."),
869
872
  error: z.string().optional().describe("User-language failure reason, when the build failed."),
870
873
  build_log_tail: z.string().optional().describe("Sanitized tail of the Cloud Build log (the real error), when the build failed."),
871
874
  suggested_fix: z.string().optional().describe("A suggested fix for the failure, when the platform provides one."),
@@ -1194,6 +1197,13 @@ export function registerTools(server, ctx) {
1194
1197
  error: z.boolean().optional().describe("True when the grid listing failed (network/auth error). The text carries a human-readable message. Do NOT treat this as needs_grid_create."),
1195
1198
  },
1196
1199
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1200
+ // ChatGPT Apps-SDK card, same binding as grid_plug: grid_hello returns a
1201
+ // runPlug result, so it deserves the same live-result widget. Without this a
1202
+ // grid_hello success renders no card even when the MIME is correct (#308).
1203
+ // openai/outputTemplate is ChatGPT-only; Claude ignores it, so #303 is safe.
1204
+ ...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
1205
+ _meta: { "openai/outputTemplate": LIVE_RESULT_URI },
1206
+ } : {}),
1197
1207
  },
1198
1208
  async (input) => {
1199
1209
  try {