@openmobilehub/credentagent-storefront 0.2.0
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/LICENSE +201 -0
- package/README.md +186 -0
- package/dist/generated-images.d.ts +2 -0
- package/dist/generated-images.js +23 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.js +159 -0
- package/dist/redis.d.ts +31 -0
- package/dist/redis.js +140 -0
- package/dist/server.d.ts +124 -0
- package/dist/server.js +506 -0
- package/dist/state.d.ts +25 -0
- package/dist/state.js +25 -0
- package/dist/tool-meta.d.ts +27 -0
- package/dist/tool-meta.js +24 -0
- package/dist/ui/mcp-app.html +175 -0
- package/package.json +90 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// createStorefront() — a runnable storefront in one line.
|
|
2
|
+
//
|
|
3
|
+
// Stands up the real MCP storefront — the nine shopping tools (six UI-linked to the
|
|
4
|
+
// React widget, three plain) + the single-file widget resource + a checkout page —
|
|
5
|
+
// over HTTP at /mcp, around an injected catalog. The checkout tool is UNGATED by
|
|
6
|
+
// default; call `store.gate(resolve)` to have it surface a `requires` manifest,
|
|
7
|
+
// which is exactly where @openmobilehub/credentagent-gate mounts on:
|
|
8
|
+
//
|
|
9
|
+
// const store = createStorefront();
|
|
10
|
+
// const credentagent = new CredentAgent();
|
|
11
|
+
// credentagent.mount(store.app);
|
|
12
|
+
// store.gate((order) => credentagent.requirements(order, [ required(age.over(21).when(hasAlcohol)) ]));
|
|
13
|
+
// const { url } = await store.listen(3005); // → add http://localhost:3005/mcp to Claude / ChatGPT
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
21
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
22
|
+
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
23
|
+
import { randomBytes } from "node:crypto";
|
|
24
|
+
import express from "express";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { CART_META_KEY, CATALOG_META_KEY, createOrder, getProduct, getReviews, isCatalogSource, priceCart, SAMPLE_CATALOG, staticCatalog, } from "./index.js";
|
|
27
|
+
import { appToolMeta } from "./tool-meta.js";
|
|
28
|
+
import { MemoryCartStore, MemoryOrderStore } from "./state.js";
|
|
29
|
+
// Composition with @openmobilehub/credentagent-gate (Context 2): the storefront pre-binds
|
|
30
|
+
// the gate's shared `completeOrder` over ITS OWN stores + catalog and publishes the
|
|
31
|
+
// ceremony seams on `app.locals.credentagent`, so `new CredentAgent().mount(store.app)` wires
|
|
32
|
+
// the `/credentagent/*` rails with zero explicit args (the quickstart). The gate stays an
|
|
33
|
+
// optional pairing — only this server module imports it; the pure pricing core
|
|
34
|
+
// (`./index.js`) does not.
|
|
35
|
+
import { completeOrder, issueCartMandate, verifyCartMandate, decodeCartMandateParam, renderRequirements, MemoryVerificationStore, } from "@openmobilehub/credentagent-gate";
|
|
36
|
+
// ── widget bundle (single-file html, built by vite into dist/ui/) ───────────
|
|
37
|
+
const SKYBRIDGE_MIME = "text/html+skybridge";
|
|
38
|
+
// Product images are self-contained `data:` URIs (added to the CSP below); picsum
|
|
39
|
+
// stays allowlisted in case a custom catalog uses remote images.
|
|
40
|
+
const IMAGE_DOMAINS = ["https://picsum.photos", "https://fastly.picsum.photos"];
|
|
41
|
+
function bundleCandidates() {
|
|
42
|
+
return [join(import.meta.dirname, "ui", "mcp-app.html"), join(process.cwd(), "dist", "ui", "mcp-app.html")];
|
|
43
|
+
}
|
|
44
|
+
// Stamp the resource URI with a short hash of the bundle so hosts re-fetch exactly
|
|
45
|
+
// when the widget changes (they cache by URI). "dev" until the bundle is on disk.
|
|
46
|
+
function bundleVersion() {
|
|
47
|
+
for (const c of bundleCandidates()) {
|
|
48
|
+
try {
|
|
49
|
+
return createHash("sha256").update(readFileSync(c)).digest("hex").slice(0, 8);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* try next */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return "dev";
|
|
56
|
+
}
|
|
57
|
+
async function loadBundle() {
|
|
58
|
+
for (const c of bundleCandidates()) {
|
|
59
|
+
try {
|
|
60
|
+
return await readFile(c, "utf-8");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* try next */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`credentagent-storefront: widget bundle not found (looked in: ${bundleCandidates().join(", ")})`);
|
|
67
|
+
}
|
|
68
|
+
// Derive this server's public origin from the incoming request. Proxies (Vercel,
|
|
69
|
+
// tunnels) set x-forwarded-*; fall back to the Host header. Lets the storefront
|
|
70
|
+
// build absolute checkout URLs at any origin when baseUrl wasn't configured.
|
|
71
|
+
export function originFromRequest(req) {
|
|
72
|
+
const fwd = (name) => req.headers[name]?.split(",")[0]?.trim();
|
|
73
|
+
const proto = fwd("x-forwarded-proto") ?? req.protocol ?? "http";
|
|
74
|
+
const host = fwd("x-forwarded-host") ?? req.headers.host;
|
|
75
|
+
return host ? `${proto}://${host}`.replace(/\/+$/, "") : "";
|
|
76
|
+
}
|
|
77
|
+
// Re-home a mounted-ceremony approve link (`/credentagent/*`) onto THIS server's origin —
|
|
78
|
+
// the same base the checkout link uses — so the gate links and the checkout link
|
|
79
|
+
// share an origin (the rails are registered on this same app). Links to any other
|
|
80
|
+
// path (e.g. a developer's external wallet origin) pass through untouched.
|
|
81
|
+
function homeApproveUrl(approveUrl, base) {
|
|
82
|
+
try {
|
|
83
|
+
const u = new URL(approveUrl, "http://re-home.invalid");
|
|
84
|
+
if (u.pathname.startsWith("/credentagent/"))
|
|
85
|
+
return `${base}${u.pathname}${u.search}`;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* not URL-shaped — leave as-is */
|
|
89
|
+
}
|
|
90
|
+
return approveUrl;
|
|
91
|
+
}
|
|
92
|
+
// Re-home every `/credentagent/*` approveUrl in a `requires` manifest onto `base`, and
|
|
93
|
+
// (statelessOrders) append the `cart` param so each gate rail page can reconstruct the
|
|
94
|
+
// order from the signed mandate rather than a store read.
|
|
95
|
+
function homeRequires(requires, base, cart) {
|
|
96
|
+
return requires.map((e) => {
|
|
97
|
+
const entry = e;
|
|
98
|
+
if (typeof entry.approveUrl !== "string")
|
|
99
|
+
return e;
|
|
100
|
+
let approveUrl = homeApproveUrl(entry.approveUrl, base);
|
|
101
|
+
if (cart)
|
|
102
|
+
approveUrl += `${approveUrl.includes("?") ? "&" : "?"}cart=${cart}`;
|
|
103
|
+
return { ...entry, approveUrl };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
export function createStorefront(opts = {}) {
|
|
107
|
+
// Normalize the catalog into a CatalogSource: a plain array (or the default) is wrapped
|
|
108
|
+
// in a static source; a dynamic source (e.g. `firestoreCatalog(...)`) is used as-is. Every
|
|
109
|
+
// catalog read below goes through `source.current()` — the last-known-good snapshot the
|
|
110
|
+
// prime middleware keeps warm — so the SYNCHRONOUS re-price paths (incl. the gate's
|
|
111
|
+
// ceremony `createOrder`) re-derive prices/ages server-side (invariant 2) with no gate change.
|
|
112
|
+
const source = isCatalogSource(opts.catalog)
|
|
113
|
+
? opts.catalog
|
|
114
|
+
: staticCatalog(opts.catalog ?? SAMPLE_CATALOG);
|
|
115
|
+
const reviews = opts.reviews;
|
|
116
|
+
// Per-slot store resolution: an explicit store wins, else the `storage` provider's
|
|
117
|
+
// store for that slot (e.g. `redisStorage(...)`), else the in-memory default. Keeping
|
|
118
|
+
// the in-memory fallback last means zero-config stays unchanged (no `storage` → memory).
|
|
119
|
+
const cartStore = opts.cartStore ?? opts.storage?.cartStore ?? new MemoryCartStore();
|
|
120
|
+
// orderId → sessionId, recorded at checkout so the completion path (browser / place-order,
|
|
121
|
+
// which has no MCP session) can clear the RIGHT session's cart. In-memory, so on
|
|
122
|
+
// multi-instance serverless it shares the stateful-session limitation (needs sticky
|
|
123
|
+
// sessions); elsewhere it's best-effort and the cart simply isn't cleared.
|
|
124
|
+
const orderSessions = new Map();
|
|
125
|
+
const orderStore = opts.orderStore ?? opts.storage?.orderStore ?? new MemoryOrderStore();
|
|
126
|
+
// Created-but-not-completed orders, for the checkout page + place-order. A store
|
|
127
|
+
// (not a process Map) so it can be shared across serverless instances.
|
|
128
|
+
const createdOrderStore = opts.createdOrderStore ?? opts.storage?.createdOrderStore ?? new MemoryOrderStore();
|
|
129
|
+
// Per-order verification state shared with the mounted ceremony (the rails write
|
|
130
|
+
// it; the completion seam below reads it back to re-price + enforce the age gate).
|
|
131
|
+
const verificationStore = opts.verificationStore ?? opts.storage?.verificationStore ?? new MemoryVerificationStore();
|
|
132
|
+
let resolveGate;
|
|
133
|
+
let baseUrl = opts.baseUrl?.replace(/\/+$/, "") ?? "";
|
|
134
|
+
// statelessOrders (gate FR-007): the signed Cart Mandate is the created-order transport.
|
|
135
|
+
// The storefront must OWN a concrete signing key (not the gate's ephemeral one) so the
|
|
136
|
+
// mandate the checkout tool issues is the one the gate rails verify.
|
|
137
|
+
//
|
|
138
|
+
// That key MUST be STABLE across instances — statelessOrders exists to survive an
|
|
139
|
+
// instance split, and a per-process random key would make a mandate minted on instance A
|
|
140
|
+
// fail to verify on instance B (defeating the whole feature). So fail fast unless the host
|
|
141
|
+
// provides a `signingKey`, OR explicitly opts into an ephemeral per-process key
|
|
142
|
+
// (single-process dev / tests) — mirroring the gate's `allowEphemeralKey` escape hatch.
|
|
143
|
+
const statelessOrders = opts.statelessOrders ?? false;
|
|
144
|
+
if (statelessOrders && !opts.signingKey && !opts.allowEphemeralKey) {
|
|
145
|
+
throw new Error("[credentagent-storefront] statelessOrders requires a stable `signingKey` so a cart mandate minted on " +
|
|
146
|
+
"one instance verifies on another. Pass { signingKey } (e.g. process.env.GATE_SECRET), or " +
|
|
147
|
+
"{ allowEphemeralKey: true } for a single-process dev server / tests.");
|
|
148
|
+
}
|
|
149
|
+
const signingKey = opts.signingKey ?? (statelessOrders ? randomBytes(32).toString("hex") : undefined);
|
|
150
|
+
// Issue + base64url-encode a Cart Mandate for a priced order (the checkout link's `cart`).
|
|
151
|
+
const cartParamFor = (order) => {
|
|
152
|
+
const mandate = issueCartMandate({ orderId: order.id, lines: order.lines.map((l) => ({ id: l.id, quantity: l.quantity, unitPrice: l.unitPrice, lineTotal: l.lineTotal })), currency: order.currency, total: order.total }, signingKey);
|
|
153
|
+
return Buffer.from(JSON.stringify(mandate)).toString("base64url");
|
|
154
|
+
};
|
|
155
|
+
const withCart = (url, cart) => cart ? `${url}${url.includes("?") ? "&" : "?"}cart=${cart}` : url;
|
|
156
|
+
// Resolve a created order by id: from a VERIFIED cart mandate (statelessOrders, no store
|
|
157
|
+
// read) or the createdOrderStore. Fails closed — a forged/tampered/expired mandate → null.
|
|
158
|
+
const resolveCreated = async (orderId, cartRaw) => {
|
|
159
|
+
if (statelessOrders && cartRaw !== undefined) {
|
|
160
|
+
const verdict = verifyCartMandate(decodeCartMandateParam(cartRaw), orderId, signingKey);
|
|
161
|
+
if (!verdict.ok)
|
|
162
|
+
return null;
|
|
163
|
+
return createOrder(verdict.mandate.lines.map((l) => ({ productId: l.id, quantity: l.quantity })), orderId, source.current());
|
|
164
|
+
}
|
|
165
|
+
return (await createdOrderStore.read(orderId)) ?? null;
|
|
166
|
+
};
|
|
167
|
+
const BUNDLE_VERSION = bundleVersion();
|
|
168
|
+
const RESOURCE_URI = `ui://product-picker/mcp-app-${BUNDLE_VERSION}.html`;
|
|
169
|
+
const SKYBRIDGE_URI = `ui://product-picker/mcp-app-${BUNDLE_VERSION}.skybridge.html`;
|
|
170
|
+
// One canonical tool-meta for every UI-linked tool — both host surfaces, with
|
|
171
|
+
// openai/widgetAccessible always on (FR-014).
|
|
172
|
+
const UI_META = appToolMeta({ resourceUri: RESOURCE_URI, skybridgeUri: SKYBRIDGE_URI });
|
|
173
|
+
const app = createMcpExpressApp({ host: "0.0.0.0" });
|
|
174
|
+
// place-order accepts the order id from either a JSON fetch (the shared checkout
|
|
175
|
+
// page's instant-demo method) or an x-www-form-urlencoded form post; the SDK app
|
|
176
|
+
// only parses JSON, so add a urlencoded parser too or a form post's `req.body.order`
|
|
177
|
+
// is undefined and completion is never recorded.
|
|
178
|
+
app.use(express.urlencoded({ extended: false }));
|
|
179
|
+
// Prime the catalog before EVERY route runs — including the `/credentagent/*` ceremony
|
|
180
|
+
// rails a consumer mounts later (registered after this middleware, so this runs first).
|
|
181
|
+
// Awaiting the TTL-cached load means every synchronous `source.current()` below reads a
|
|
182
|
+
// warm, server-side re-derived snapshot. A cold/unreachable load FAILS CLOSED (503, no
|
|
183
|
+
// handler runs) rather than serving an empty catalog (Security invariant 2). The static
|
|
184
|
+
// default resolves instantly and never fails, so the zero-config path is unchanged.
|
|
185
|
+
app.use((_req, res, next) => {
|
|
186
|
+
source.load().then(() => next(), () => res.status(503).type("text").send("Catalog temporarily unavailable."));
|
|
187
|
+
});
|
|
188
|
+
// ── CredentAgent ceremony seams (Context 2) ──────────────────────────────────────
|
|
189
|
+
// Pre-bound so `new CredentAgent().mount(store.app)` wires the `/credentagent/*` rails with
|
|
190
|
+
// ZERO explicit args — it reads these off `app.locals.credentagent` (see the quickstart).
|
|
191
|
+
// The catalog re-prices server-side (the amount source of truth — invariant 2); the
|
|
192
|
+
// completion seam is the gate's shared `completeOrder` bound over THIS server's
|
|
193
|
+
// stores, so a finished ceremony records the order + clears the cart the SAME way
|
|
194
|
+
// get-order-status / the order-status poll read.
|
|
195
|
+
const ceremonyCatalog = {
|
|
196
|
+
createOrder: (items, orderId, repriceOpts) => createOrder(items, orderId, source.current(), { ageVerified: repriceOpts?.ageVerified, loyaltyApplied: repriceOpts?.loyaltyApplied }),
|
|
197
|
+
};
|
|
198
|
+
const ceremonyOrderStore = {
|
|
199
|
+
// A storefront Order is structurally a CeremonyOrder; resolveOrder re-prices it
|
|
200
|
+
// from the catalog regardless, recovering only the line items + id (CT3).
|
|
201
|
+
read: (orderId) => createdOrderStore.read(orderId),
|
|
202
|
+
};
|
|
203
|
+
const completion = (input) => completeOrder(input, {
|
|
204
|
+
catalog: ceremonyCatalog,
|
|
205
|
+
verificationStore,
|
|
206
|
+
records: {
|
|
207
|
+
read: async (orderId) => ((await orderStore.read(orderId)) ?? undefined),
|
|
208
|
+
write: async (record) => { await orderStore.write(record.orderId, record); },
|
|
209
|
+
},
|
|
210
|
+
cart: { clear: async () => { const sid = orderSessions.get(input.order.id); if (sid)
|
|
211
|
+
await cartStore.write(sid, new Map()); } },
|
|
212
|
+
...(opts.settle ? { settle: opts.settle } : {}),
|
|
213
|
+
});
|
|
214
|
+
app.locals.credentagent = {
|
|
215
|
+
orderStore: ceremonyOrderStore,
|
|
216
|
+
verificationStore,
|
|
217
|
+
catalog: ceremonyCatalog,
|
|
218
|
+
completion,
|
|
219
|
+
// signingKey survives an instance split; default to an ephemeral per-process key
|
|
220
|
+
// for a single-process dev server / tests when none is configured (but statelessOrders
|
|
221
|
+
// forces a concrete, storefront-owned key so it can sign the mandate).
|
|
222
|
+
...(signingKey ? { signingKey } : {}),
|
|
223
|
+
allowEphemeralKey: opts.allowEphemeralKey ?? !signingKey,
|
|
224
|
+
statelessOrders,
|
|
225
|
+
};
|
|
226
|
+
// ── cart logic (per-session over the catalog source + the cart store) ─────
|
|
227
|
+
// Each MCP session gets its own working cart: `sessionId` is the MCP session
|
|
228
|
+
// (extra.sessionId); a fallback key covers non-session transports (e.g. the in-memory
|
|
229
|
+
// transport used in tests) so single-connection flows work.
|
|
230
|
+
// `priceFrom` reads the warm catalog snapshot synchronously; every async entry below
|
|
231
|
+
// first `await source.load()` so the snapshot is fresh regardless of transport — the
|
|
232
|
+
// HTTP `/mcp` route is already primed by the middleware, but `mcpServer()` over a raw
|
|
233
|
+
// transport (e.g. stdio) is not, so the tool handlers warm the catalog themselves.
|
|
234
|
+
const DEFAULT_SESSION = "default";
|
|
235
|
+
const sessionOf = (extra) => extra.sessionId ?? DEFAULT_SESSION;
|
|
236
|
+
const priceFrom = (cart) => priceCart([...cart.entries()].map(([productId, quantity]) => ({ productId, quantity })), source.current());
|
|
237
|
+
const readPriced = async (sessionId) => {
|
|
238
|
+
await source.load();
|
|
239
|
+
return priceFrom(await cartStore.read(sessionId));
|
|
240
|
+
};
|
|
241
|
+
const addToCart = async (sessionId, items) => {
|
|
242
|
+
await source.load();
|
|
243
|
+
const cart = await cartStore.read(sessionId);
|
|
244
|
+
for (const { productId, quantity } of items) {
|
|
245
|
+
if (quantity <= 0)
|
|
246
|
+
continue;
|
|
247
|
+
cart.set(productId, (cart.get(productId) ?? 0) + quantity);
|
|
248
|
+
}
|
|
249
|
+
await cartStore.write(sessionId, cart);
|
|
250
|
+
return priceFrom(cart);
|
|
251
|
+
};
|
|
252
|
+
const setQuantity = async (sessionId, productId, quantity) => {
|
|
253
|
+
await source.load();
|
|
254
|
+
const cart = await cartStore.read(sessionId);
|
|
255
|
+
if (quantity <= 0)
|
|
256
|
+
cart.delete(productId);
|
|
257
|
+
else
|
|
258
|
+
cart.set(productId, quantity);
|
|
259
|
+
await cartStore.write(sessionId, cart);
|
|
260
|
+
return priceFrom(cart);
|
|
261
|
+
};
|
|
262
|
+
const removeFromCart = async (sessionId, productId) => {
|
|
263
|
+
await source.load();
|
|
264
|
+
const cart = await cartStore.read(sessionId);
|
|
265
|
+
cart.delete(productId);
|
|
266
|
+
await cartStore.write(sessionId, cart);
|
|
267
|
+
return priceFrom(cart);
|
|
268
|
+
};
|
|
269
|
+
// Cart-bearing result, emitted three ways so either host reads it: structuredContent
|
|
270
|
+
// (ChatGPT widget + model), a JSON text block, and _meta (Claude's out-of-band channel).
|
|
271
|
+
const cartResult = (priced) => ({
|
|
272
|
+
structuredContent: { products: source.current(), cart: priced },
|
|
273
|
+
content: [{ type: "text", text: JSON.stringify(priced) }],
|
|
274
|
+
_meta: { [CART_META_KEY]: priced },
|
|
275
|
+
});
|
|
276
|
+
function buildServer() {
|
|
277
|
+
const server = new McpServer({ name: "credentagent-storefront", version: "0.1.0" });
|
|
278
|
+
// ── UI-linked tools (6) — registerAppTool + the canonical UI_META ───────
|
|
279
|
+
registerAppTool(server, "browse-products", {
|
|
280
|
+
title: "Browse Products",
|
|
281
|
+
description: "Show the storefront catalog as an interactive visual product picker (a grid with images). " +
|
|
282
|
+
"Call this whenever the user asks what you sell, what's available, to see/show/browse products, or " +
|
|
283
|
+
"to shop — it renders the grid for them. Prefer it over describing the catalog in text.",
|
|
284
|
+
inputSchema: {},
|
|
285
|
+
annotations: { readOnlyHint: true },
|
|
286
|
+
_meta: UI_META,
|
|
287
|
+
}, async (_args, extra) => {
|
|
288
|
+
await source.load();
|
|
289
|
+
const catalog = source.current();
|
|
290
|
+
const priced = await readPriced(sessionOf(extra));
|
|
291
|
+
return {
|
|
292
|
+
content: [
|
|
293
|
+
{
|
|
294
|
+
type: "text",
|
|
295
|
+
text: `The product picker is now showing the catalog visually to the user (${catalog.length} products in a grid with images). ` +
|
|
296
|
+
`Do NOT re-list the products as text — they can see them. Briefly invite them to pick items or tell you what to add. ` +
|
|
297
|
+
`Adjust the cart by id with add-to-cart / set-quantity / remove-from-cart; check out with checkout.`,
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
structuredContent: { products: catalog, cart: priced },
|
|
301
|
+
_meta: { [CATALOG_META_KEY]: { products: catalog }, [CART_META_KEY]: priced },
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
registerAppTool(server, "add-to-cart", { title: "Add to Cart", description: "Add products to the cart by id (quantities add on top).", inputSchema: { items: z.array(z.object({ productId: z.string(), quantity: z.number().int().min(1) })) }, annotations: { readOnlyHint: false }, _meta: UI_META }, async ({ items }, extra) => cartResult(await addToCart(sessionOf(extra), items)));
|
|
305
|
+
registerAppTool(server, "set-quantity", { title: "Set Quantity", description: "Set the exact quantity of a product by id (0 removes).", inputSchema: { productId: z.string(), quantity: z.number().int().min(0) }, annotations: { readOnlyHint: false }, _meta: UI_META }, async ({ productId, quantity }, extra) => cartResult(await setQuantity(sessionOf(extra), productId, quantity)));
|
|
306
|
+
registerAppTool(server, "remove-from-cart", { title: "Remove from Cart", description: "Remove a product from the cart by id.", inputSchema: { productId: z.string() }, annotations: { readOnlyHint: false }, _meta: UI_META }, async ({ productId }, extra) => cartResult(await removeFromCart(sessionOf(extra), productId)));
|
|
307
|
+
registerAppTool(server, "get-cart", { title: "Get Cart", description: "Return the current cart: line items, quantities, total.", inputSchema: {}, annotations: { readOnlyHint: true }, _meta: UI_META }, async (_args, extra) => cartResult(await readPriced(sessionOf(extra))));
|
|
308
|
+
registerAppTool(server, "checkout", { title: "Checkout", description: "Snapshot the cart into an order and return a checkout link; if gated, also a `requires` manifest of what the buyer must prove on the page.", inputSchema: { items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })).optional() }, annotations: { readOnlyHint: false }, _meta: UI_META }, async ({ items }, extra) => {
|
|
309
|
+
await source.load();
|
|
310
|
+
const catalog = source.current();
|
|
311
|
+
const sessionId = sessionOf(extra);
|
|
312
|
+
const entries = items?.length ? items : [...(await cartStore.read(sessionId)).entries()].map(([productId, quantity]) => ({ productId, quantity }));
|
|
313
|
+
if (entries.length === 0)
|
|
314
|
+
return { content: [{ type: "text", text: "The cart is empty — add items before checking out." }], isError: true };
|
|
315
|
+
// Random id (not a per-instance counter): two serverless instances must
|
|
316
|
+
// not both mint "ORD-1" for different carts.
|
|
317
|
+
const order = createOrder(entries, `ORD-${Math.random().toString(36).slice(2, 8)}`, catalog);
|
|
318
|
+
// statelessOrders: carry the order in a signed Cart Mandate on the link instead of
|
|
319
|
+
// a store write — the checkout page + gate rails reconstruct + verify it (FR-007).
|
|
320
|
+
const cart = statelessOrders ? cartParamFor(order) : null;
|
|
321
|
+
if (!statelessOrders)
|
|
322
|
+
await createdOrderStore.write(order.id, order);
|
|
323
|
+
orderSessions.set(order.id, sessionId); // so completion clears THIS session's cart
|
|
324
|
+
const checkoutUrl = withCart(`${baseUrl}/checkout?order=${order.id}`, cart);
|
|
325
|
+
// ← where CredentAgent mounts on. Re-home any /credentagent/* approve link onto this
|
|
326
|
+
// server's origin (and propagate the cart param), so the gate links share the base.
|
|
327
|
+
const rawRequires = resolveGate?.(order);
|
|
328
|
+
const requires = rawRequires ? homeRequires(rawRequires, baseUrl, cart) : undefined;
|
|
329
|
+
const priced = priceFrom(new Map(entries.map((e) => [e.productId, e.quantity])));
|
|
330
|
+
// Cart-bearing structuredContent (FR-014): a fresh ChatGPT widget instance
|
|
331
|
+
// hydrates the real cart instead of an empty one.
|
|
332
|
+
const payload = { orderId: order.id, checkoutUrl, ...(requires?.length ? { requires } : {}), products: catalog, cart: priced };
|
|
333
|
+
return { structuredContent: payload, content: [{ type: "text", text: JSON.stringify({ orderId: order.id, checkoutUrl, requires: requires ?? [] }) }], _meta: { [CART_META_KEY]: priced } };
|
|
334
|
+
});
|
|
335
|
+
// ── plain tools (3) — registerTool, no widget ───────────────────────────
|
|
336
|
+
server.registerTool("get-product-details", { title: "Get Product Details", description: "Return full details for a single product by id.", inputSchema: { productId: z.string() }, annotations: { readOnlyHint: true } }, async ({ productId }) => {
|
|
337
|
+
await source.load();
|
|
338
|
+
const product = getProduct(source.current(), productId);
|
|
339
|
+
return product
|
|
340
|
+
? { content: [{ type: "text", text: JSON.stringify(product) }], structuredContent: { product } }
|
|
341
|
+
: { content: [{ type: "text", text: `No product found with id "${productId}".` }], isError: true };
|
|
342
|
+
});
|
|
343
|
+
server.registerTool("get-product-reviews", { title: "Get Product Reviews", description: "Return customer reviews for a single product by id.", inputSchema: { productId: z.string() }, annotations: { readOnlyHint: true } }, async ({ productId }) => {
|
|
344
|
+
const r = getReviews(reviews, productId);
|
|
345
|
+
return { content: [{ type: "text", text: JSON.stringify(r) }], structuredContent: { reviews: r } };
|
|
346
|
+
});
|
|
347
|
+
server.registerTool("get-order-status", { title: "Get Order Status", description: "Read-only status of a completed purchase (the buyer completes checkout on the page; this only reports).", inputSchema: { orderId: z.string() }, annotations: { readOnlyHint: true } }, async ({ orderId }) => {
|
|
348
|
+
const order = await orderStore.read(orderId);
|
|
349
|
+
if (!order)
|
|
350
|
+
return { content: [{ type: "text", text: `Order ${orderId}: pending — the buyer hasn't finished on the checkout page yet.` }], structuredContent: { orderId, status: "pending" } };
|
|
351
|
+
return { content: [{ type: "text", text: JSON.stringify(order) }], structuredContent: { orderId, status: "completed", order } };
|
|
352
|
+
});
|
|
353
|
+
// ── widget resource — two registrations from one bundle ─────────────────
|
|
354
|
+
// Claude / MCP-Apps hosts read RESOURCE_URI; ChatGPT reads the skybridge URI.
|
|
355
|
+
// `data:` in the CSP so the widget's inline SVG image placeholder renders (FR-014).
|
|
356
|
+
registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
|
|
357
|
+
contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: await loadBundle(), _meta: { ui: { csp: { resourceDomains: [...IMAGE_DOMAINS, "data:"], connectDomains: baseUrl ? [baseUrl] : [] } } } }],
|
|
358
|
+
}));
|
|
359
|
+
server.registerResource("product-picker-skybridge", SKYBRIDGE_URI, { mimeType: SKYBRIDGE_MIME }, async () => ({
|
|
360
|
+
contents: [{ uri: SKYBRIDGE_URI, mimeType: SKYBRIDGE_MIME, text: await loadBundle(), _meta: { "openai/widgetCSP": { connect_domains: baseUrl ? [baseUrl] : [], resource_domains: [...IMAGE_DOMAINS, "data:"] } } }],
|
|
361
|
+
}));
|
|
362
|
+
return server;
|
|
363
|
+
}
|
|
364
|
+
// MCP over streamable HTTP, STATEFUL: the server issues an mcp-session-id on
|
|
365
|
+
// `initialize` and reuses that session's transport for its later requests, so each
|
|
366
|
+
// client gets a stable session id (→ its own cart, keyed by session in the tool
|
|
367
|
+
// handlers). NOTE: the transport map is per-instance memory — multi-instance serverless
|
|
368
|
+
// needs sticky sessions (session affinity) for per-session carts to hold.
|
|
369
|
+
const transports = new Map();
|
|
370
|
+
app.all("/mcp", async (req, res) => {
|
|
371
|
+
// Self-derive the public origin from the first request so checkout URLs are
|
|
372
|
+
// absolute behind any proxy (Vercel, a tunnel) with zero config — without it,
|
|
373
|
+
// `${baseUrl}/checkout` would be relative and the widget's `new URL()` throws.
|
|
374
|
+
if (!baseUrl)
|
|
375
|
+
baseUrl = originFromRequest(req);
|
|
376
|
+
const sid = req.headers["mcp-session-id"];
|
|
377
|
+
let transport = sid ? transports.get(sid) : undefined;
|
|
378
|
+
if (!transport) {
|
|
379
|
+
// A new session must arrive as an `initialize` with no session id; anything else
|
|
380
|
+
// (unknown id, or a non-init without a session) is rejected.
|
|
381
|
+
if (sid || !isInitializeRequest(req.body)) {
|
|
382
|
+
res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "No valid session" }, id: null });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const created = new StreamableHTTPServerTransport({
|
|
386
|
+
sessionIdGenerator: () => randomUUID(),
|
|
387
|
+
onsessioninitialized: (id) => { transports.set(id, created); },
|
|
388
|
+
});
|
|
389
|
+
created.onclose = () => { if (created.sessionId)
|
|
390
|
+
transports.delete(created.sessionId); };
|
|
391
|
+
await buildServer().connect(created);
|
|
392
|
+
transport = created;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
await transport.handleRequest(req, res, req.body);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
if (!res.headersSent)
|
|
399
|
+
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "error" }, id: null });
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
// The checkout page: the ONE shared three-gate page (renderRequirements), so the
|
|
403
|
+
// storefront and the committed demo render the same polished checkout (T030). This
|
|
404
|
+
// page LINKS to the ceremony routes credentagent.mount() registered (re-homed onto this
|
|
405
|
+
// origin, in policy order, payment last); it does NOT run the ceremony — completion
|
|
406
|
+
// happens on the mounted /credentagent/* rails, which enforce the gates fail-closed.
|
|
407
|
+
app.get("/checkout", async (req, res) => {
|
|
408
|
+
// statelessOrders: reconstruct + VERIFY the order from the `cart` mandate (no store
|
|
409
|
+
// read); else read the createdOrderStore. `cart` is propagated onto the gate links below.
|
|
410
|
+
const cartRaw = typeof req.query.cart === "string" ? req.query.cart : undefined;
|
|
411
|
+
const created = await resolveCreated(String(req.query.order ?? ""), cartRaw);
|
|
412
|
+
if (!created)
|
|
413
|
+
return res.status(404).type("html").send("<h1>Unknown order</h1>");
|
|
414
|
+
// Read THIS order's verification (per order id — never global; Security
|
|
415
|
+
// invariant 4) so the page reflects what the buyer has proven so far, and
|
|
416
|
+
// re-price from the catalog with it (the discount opts in only once membership
|
|
417
|
+
// is presented — never trust the token's total; invariant 2/3).
|
|
418
|
+
const v = ((await verificationStore.read(created.id)) ?? {});
|
|
419
|
+
const ageVerified = v.ageVerified === true;
|
|
420
|
+
const loyaltyApplied = v.loyalty?.applied === true;
|
|
421
|
+
const order = ceremonyCatalog.createOrder(created.lines.map((l) => ({ productId: l.id, quantity: l.quantity })), created.id, { ageVerified, loyaltyApplied });
|
|
422
|
+
// A revisit of an already-completed order shows the paid state instead of the
|
|
423
|
+
// payment methods.
|
|
424
|
+
const done = (await orderStore.read(created.id)) ?? null;
|
|
425
|
+
// Resolve + re-home the manifest onto this server's mounted routes (each gate
|
|
426
|
+
// carries its OWN approveUrl — the renderer is route-agnostic). Resolve against
|
|
427
|
+
// the created order (the policy reads line ids + minimumAge — the re-priced
|
|
428
|
+
// `order` carries the same lines; the discounted total shows via `order` below).
|
|
429
|
+
const requires = homeRequires(resolveGate?.(created) ?? [], baseUrl, statelessOrders ? cartRaw : null);
|
|
430
|
+
const verification = { ageVerified, loyaltyApplied };
|
|
431
|
+
const paid = done ? { amount: done.amount, currency: done.currency, method: done.method } : null;
|
|
432
|
+
// An UNGATED storefront has no payment gate, so the manifest carries no
|
|
433
|
+
// `authorize` entry the renderer could derive a Pay CTA from — keep a simple
|
|
434
|
+
// instant-demo complete path (POST the order id to /checkout/place-order). A
|
|
435
|
+
// GATED order has NO such bypass: completion goes through the fail-closed payment
|
|
436
|
+
// gate (the manifest's `authorize` approveUrl → the renderer's single Pay CTA).
|
|
437
|
+
const ungated = requires.length === 0;
|
|
438
|
+
const orderQ = encodeURIComponent(order.id);
|
|
439
|
+
const payment = ungated
|
|
440
|
+
? {
|
|
441
|
+
methods: [
|
|
442
|
+
{ value: "demo", name: `Complete purchase (demo) — ${order.total} ${order.currency}`, desc: "No real charge — records the order and clears the cart.", placeOrder: true },
|
|
443
|
+
],
|
|
444
|
+
placeOrderPath: "/checkout/place-order",
|
|
445
|
+
orderToken: order.id,
|
|
446
|
+
}
|
|
447
|
+
: // A GATED order: offer the same payment methods the demo does — the headline
|
|
448
|
+
// passkey rail (authorize on-device; settles on-chain via x402 on Hedera) and
|
|
449
|
+
// the cross-device wallet rail — both mounted by credentagent.mount(), both completing
|
|
450
|
+
// through the fail-closed gate (no bypass). Without this the renderer falls back
|
|
451
|
+
// to a single Pay CTA from the manifest and the x402/Hedera passkey option never shows.
|
|
452
|
+
{
|
|
453
|
+
methods: [
|
|
454
|
+
{ value: "passkey", name: "Pay with x402 Hedera · Passkey", desc: "Authorize with this device's passkey — payment settles on-chain via the x402 protocol (test network).", href: withCart(`/credentagent/passkey?order=${orderQ}`, statelessOrders ? cartRaw : null), checked: true },
|
|
455
|
+
{ value: "dc-payment", name: "Cross-device wallet", desc: "Scan a QR and approve with your phone's passkey or wallet — also x402 on Hedera.", href: withCart(`/credentagent/dc-payment?order=${orderQ}`, statelessOrders ? cartRaw : null) },
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
res.type("html").send(renderRequirements(order, requires, verification, { ...(payment ? { payment } : {}), paid }));
|
|
459
|
+
});
|
|
460
|
+
app.post("/checkout/place-order", async (req, res) => {
|
|
461
|
+
// statelessOrders: reconstruct + verify from the body's `cart` mandate; else the store.
|
|
462
|
+
const order = await resolveCreated(String(req.body?.order ?? ""), req.body?.cart);
|
|
463
|
+
if (order) {
|
|
464
|
+
// Security invariant 1 — enforce gates on EVERY completion path, not just the
|
|
465
|
+
// rendered page. This instant-demo path completes WITHOUT a device ceremony, so
|
|
466
|
+
// it is only ever valid for an UNGATED order. A gated order (age / payment
|
|
467
|
+
// requirements) MUST complete through the fail-closed payment gate; refuse it
|
|
468
|
+
// here server-side. The checkout page only offers this button for ungated
|
|
469
|
+
// orders, but a DIRECT POST of a gated order id would otherwise bypass the gate
|
|
470
|
+
// entirely — e.g. an age-restricted order completing with no age proof. Hiding
|
|
471
|
+
// the button is not enforcement.
|
|
472
|
+
if ((resolveGate?.(order) ?? []).length > 0) {
|
|
473
|
+
res.status(403).type("html").send(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;max-width:32rem;margin:3rem auto"><h1>Verification required</h1><p>This order has age / payment requirements — complete it through checkout. It can't be placed from the instant-demo path.</p></body>`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
await orderStore.write(order.id, { orderId: order.id, amount: order.total, currency: order.currency, method: "demo", completedAt: new Date().toISOString() });
|
|
477
|
+
const sid = orderSessions.get(order.id); // completion empties THIS session's cart
|
|
478
|
+
if (sid)
|
|
479
|
+
await cartStore.write(sid, new Map());
|
|
480
|
+
}
|
|
481
|
+
res.type("html").send(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;max-width:32rem;margin:3rem auto"><h1>✓ Order placed (demo)</h1><p>You can close this tab — the storefront will update.</p></body>`);
|
|
482
|
+
});
|
|
483
|
+
// The widget polls this after checkout to learn when the buyer finished on the page
|
|
484
|
+
// (MCP has no server→client push). It then shows the confirmation + clears its cart.
|
|
485
|
+
app.get("/checkout/order-status", async (req, res) => {
|
|
486
|
+
// The widget iframe polls this cross-origin; allow it (simple GET → no preflight).
|
|
487
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
488
|
+
const orderId = typeof req.query.orderId === "string" ? req.query.orderId : "";
|
|
489
|
+
const order = orderId ? await orderStore.read(orderId) : null;
|
|
490
|
+
res.json({ completed: !!order, order });
|
|
491
|
+
});
|
|
492
|
+
return {
|
|
493
|
+
app,
|
|
494
|
+
// Static source: the injected array. Dynamic source: the last-known-good snapshot
|
|
495
|
+
// (throws if read before the first successful load — the server primes it per request).
|
|
496
|
+
get catalog() { return source.current(); },
|
|
497
|
+
mcpServer: buildServer,
|
|
498
|
+
gate(resolve) { resolveGate = resolve; },
|
|
499
|
+
async listen(port = 3005) {
|
|
500
|
+
if (!baseUrl)
|
|
501
|
+
baseUrl = `http://localhost:${port}`;
|
|
502
|
+
await new Promise((resolve) => { app.listen(port, () => resolve()); });
|
|
503
|
+
return { url: `${baseUrl}/mcp`, port };
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The working cart, keyed per session (productId → quantity). Each MCP session gets its
|
|
2
|
+
* own cart, so concurrent buyers never share one (Security invariant 4). */
|
|
3
|
+
export interface CartStore {
|
|
4
|
+
read(sessionId: string): Promise<Map<string, number>>;
|
|
5
|
+
write(sessionId: string, cart: Map<string, number>): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare class MemoryCartStore implements CartStore {
|
|
8
|
+
private carts;
|
|
9
|
+
read(sessionId: string): Promise<Map<string, number>>;
|
|
10
|
+
write(sessionId: string, cart: Map<string, number>): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/** Completed-order records, keyed by order id (what `get-order-status` reads). The
|
|
13
|
+
* payload is opaque to the storefront — the demo's ceremony writes its rich
|
|
14
|
+
* completed-order shape (with settlement); a standalone storefront leaves it empty. */
|
|
15
|
+
export interface OrderStore<T = unknown> {
|
|
16
|
+
read(orderId: string): Promise<T | null>;
|
|
17
|
+
write(orderId: string, order: T): Promise<void>;
|
|
18
|
+
clear(orderId: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export declare class MemoryOrderStore<T = unknown> implements OrderStore<T> {
|
|
21
|
+
private orders;
|
|
22
|
+
read(orderId: string): Promise<T | null>;
|
|
23
|
+
write(orderId: string, order: T): Promise<void>;
|
|
24
|
+
clear(orderId: string): Promise<void>;
|
|
25
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Storefront state — cart + completed orders. In-memory by default (zero deps);
|
|
2
|
+
// inject a custom store for a persistent / multi-session backend (e.g. Redis on a
|
|
3
|
+
// serverless deployment). Keys are per session / per order — never process-global
|
|
4
|
+
// beyond this in-process default (Security invariant 4).
|
|
5
|
+
export class MemoryCartStore {
|
|
6
|
+
carts = new Map();
|
|
7
|
+
async read(sessionId) {
|
|
8
|
+
return new Map(this.carts.get(sessionId) ?? []);
|
|
9
|
+
}
|
|
10
|
+
async write(sessionId, cart) {
|
|
11
|
+
this.carts.set(sessionId, new Map(cart));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class MemoryOrderStore {
|
|
15
|
+
orders = new Map();
|
|
16
|
+
async read(orderId) {
|
|
17
|
+
return this.orders.get(orderId) ?? null;
|
|
18
|
+
}
|
|
19
|
+
async write(orderId, order) {
|
|
20
|
+
this.orders.set(orderId, order);
|
|
21
|
+
}
|
|
22
|
+
async clear(orderId) {
|
|
23
|
+
this.orders.delete(orderId);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface AppToolMeta {
|
|
2
|
+
[key: string]: unknown;
|
|
3
|
+
/** MCP-Apps (Claude) — the widget resource to render. */
|
|
4
|
+
ui: {
|
|
5
|
+
resourceUri: string;
|
|
6
|
+
};
|
|
7
|
+
/** ChatGPT — the skybridge template (the registered `text/html+skybridge` resource). */
|
|
8
|
+
"openai/outputTemplate": string;
|
|
9
|
+
/** ChatGPT — authorizes `window.openai.callTool` from inside the widget. */
|
|
10
|
+
"openai/widgetAccessible": true;
|
|
11
|
+
/** ChatGPT — the invoking/invoked status strings shown while a tool runs. */
|
|
12
|
+
"openai/toolInvocation": {
|
|
13
|
+
invoking: string;
|
|
14
|
+
invoked: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface AppToolMetaUris {
|
|
18
|
+
/** The MCP-Apps `ui://` resource (Claude). */
|
|
19
|
+
resourceUri: string;
|
|
20
|
+
/** The ChatGPT skybridge `ui://` resource; defaults to `resourceUri` if a single resource serves both. */
|
|
21
|
+
skybridgeUri?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Build the canonical UI-linked tool `_meta` (both host surfaces, `widgetAccessible` always on). */
|
|
24
|
+
export declare function appToolMeta(uris: AppToolMetaUris, status?: {
|
|
25
|
+
invoking?: string;
|
|
26
|
+
invoked?: string;
|
|
27
|
+
}): AppToolMeta;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The canonical `_meta` for a UI-linked storefront tool — emitting BOTH host
|
|
2
|
+
// surfaces from ONE place so neither can be forgotten.
|
|
3
|
+
//
|
|
4
|
+
// • MCP-Apps hosts (Claude native app / claude.ai) read `ui.resourceUri`.
|
|
5
|
+
// • ChatGPT (skybridge) reads the `openai/*` keys.
|
|
6
|
+
//
|
|
7
|
+
// `openai/widgetAccessible: true` is what authorizes `window.openai.callTool`.
|
|
8
|
+
// The reference demo set `openai/outputTemplate` but NOT `widgetAccessible`
|
|
9
|
+
// (an inline `_meta` that forgot a key), which is exactly why its widget rendered
|
|
10
|
+
// in ChatGPT but was interactively dead — the steppers and Checkout button
|
|
11
|
+
// silently no-op'd. Routing every UI-linked tool through this builder makes that
|
|
12
|
+
// omission impossible (FR-014).
|
|
13
|
+
/** Build the canonical UI-linked tool `_meta` (both host surfaces, `widgetAccessible` always on). */
|
|
14
|
+
export function appToolMeta(uris, status) {
|
|
15
|
+
return {
|
|
16
|
+
ui: { resourceUri: uris.resourceUri },
|
|
17
|
+
"openai/outputTemplate": uris.skybridgeUri ?? uris.resourceUri,
|
|
18
|
+
"openai/widgetAccessible": true,
|
|
19
|
+
"openai/toolInvocation": {
|
|
20
|
+
invoking: status?.invoking ?? "Working…",
|
|
21
|
+
invoked: status?.invoked ?? "Done",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|