diff --git a/bun.lock b/bun.lock index b3128d2532..d0730d1a70 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "apps/admin": { "name": "packrat-admin-app", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@elysiajs/eden": "catalog:", "@packrat/api-client": "workspace:*", @@ -71,7 +71,7 @@ }, "apps/expo": { "name": "packrat-expo-app", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@ai-sdk/react": "^3.0.170", "@better-auth/expo": "^1.6.9", @@ -210,7 +210,7 @@ }, "apps/guides": { "name": "packrat-guides-app", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@ai-sdk/openai": "catalog:", "@elysiajs/eden": "catalog:", @@ -299,7 +299,7 @@ }, "apps/landing": { "name": "packrat-landing-app", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@emotion/is-prop-valid": "^1.3.1", "@hookform/resolvers": "catalog:", @@ -369,7 +369,7 @@ }, "apps/trails": { "name": "packrat-trails-app", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/api-client": "workspace:*", "@packrat/app": "workspace:*", @@ -442,7 +442,7 @@ }, "packages/analytics": { "name": "@packrat/analytics", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@duckdb/node-api": "catalog:", "@packrat/env": "workspace:*", @@ -459,7 +459,7 @@ }, "packages/api": { "name": "@packrat/api", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@ai-sdk/google": "^3.0.64", "@ai-sdk/openai": "catalog:", @@ -523,7 +523,7 @@ }, "packages/api-client": { "name": "@packrat/api-client", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@elysiajs/eden": "catalog:", "@packrat/guards": "workspace:*", @@ -541,7 +541,7 @@ }, "packages/app": { "name": "@packrat/app", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/api-client": "workspace:*", "@packrat/schemas": "workspace:*", @@ -562,11 +562,11 @@ }, "packages/checks": { "name": "@packrat/checks", - "version": "2.0.27", + "version": "2.0.28", }, "packages/cli": { "name": "@packrat/cli", - "version": "2.0.27", + "version": "2.0.28", "bin": { "packrat": "./src/index.ts", }, @@ -590,21 +590,21 @@ }, "packages/config": { "name": "@packrat/config", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/guards": "workspace:*", }, }, "packages/constants": { "name": "@packrat/constants", - "version": "2.0.27", + "version": "2.0.28", "devDependencies": { "typescript": "catalog:", }, }, "packages/db": { "name": "@packrat/db", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/constants": "workspace:*", "drizzle-orm": "catalog:", @@ -616,14 +616,14 @@ }, "packages/env": { "name": "@packrat/env", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "zod": "catalog:", }, }, "packages/guards": { "name": "@packrat/guards", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "radash": "catalog:", "ts-extras": "catalog:", @@ -632,9 +632,10 @@ }, "packages/mcp": { "name": "@packrat/mcp", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@cloudflare/workers-oauth-provider": "^0.4.0", + "@modelcontextprotocol/ext-apps": "^1.7.4", "@modelcontextprotocol/sdk": "^1.11.0", "@packrat/api-client": "workspace:*", "agents": "^0.11.0", @@ -652,7 +653,7 @@ }, "packages/osm-db": { "name": "@packrat/osm-db", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@neondatabase/serverless": "catalog:", "drizzle-orm": "catalog:", @@ -666,7 +667,7 @@ }, "packages/osm-import": { "name": "@packrat/osm-import", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/env": "workspace:*", "pg": "catalog:", @@ -674,7 +675,7 @@ }, "packages/overpass": { "name": "@packrat/overpass", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/guards": "workspace:*", "zod": "catalog:", @@ -686,7 +687,7 @@ }, "packages/schemas": { "name": "@packrat/schemas", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/constants": "workspace:*", "@packrat/db": "workspace:*", @@ -699,7 +700,7 @@ }, "packages/types": { "name": "@packrat/types", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/constants": "workspace:*", "@packrat/schemas": "workspace:*", @@ -710,18 +711,18 @@ }, "packages/typescript-config": { "name": "@packrat/typescript-config", - "version": "0.0.0", + "version": "2.0.28", }, "packages/ui": { "name": "@packrat/ui", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat-ai/nativewindui": "2.0.3-2", }, }, "packages/units": { "name": "@packrat/units", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/constants": "workspace:*", "@packrat/guards": "workspace:*", @@ -733,7 +734,7 @@ }, "packages/web-ui": { "name": "@packrat/web-ui", - "version": "2.0.27", + "version": "2.0.28", "dependencies": { "@packrat/guards": "workspace:*", "@radix-ui/react-accordion": "catalog:", @@ -1578,6 +1579,8 @@ "@manypkg/tools": ["@manypkg/tools@2.1.1", "", { "dependencies": { "jju": "^1.4.0", "js-yaml": "^4.1.0", "tinyglobby": "^0.2.13" } }, "sha512-CEFCOGzhFdx5sIehISBRS9Ev5D1Zp+24YT1uyOkaEcY8uAKeK+kA58NChYfUwXmAFerm3zWZWYhQViUf8XhQcg=="], + "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="], @@ -2114,7 +2117,7 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -4920,8 +4923,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@appium/base-driver/@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], "@appium/base-driver/asyncbox": ["asyncbox@6.2.0", "", { "dependencies": { "p-limit": "^7.2.0" } }, "sha512-z1XpHkoT3y+1aXfazEY5d7HN2eOi50fLq7ZTxG0H4WegLxrtEAI5Vsc6OR9dOwoC3SJQLXyV0ZVnPEh6GIgMKQ=="], @@ -4984,8 +4985,6 @@ "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -5132,8 +5131,6 @@ "@react-navigation/routers/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - "@reduxjs/toolkit/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], "@sentry-internal/browser-utils/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], @@ -5520,6 +5517,8 @@ "npm-package-arg/validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], + "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], @@ -5838,10 +5837,6 @@ "@manypkg/tools/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@react-native-ai/apple/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@react-native-ai/llama/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "@react-native/dev-middleware/serve-static/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], diff --git a/docs/plans/2026-07-17-001-feat-chatgpt-pack-app-plan.md b/docs/plans/2026-07-17-001-feat-chatgpt-pack-app-plan.md new file mode 100644 index 0000000000..4b47a8d319 --- /dev/null +++ b/docs/plans/2026-07-17-001-feat-chatgpt-pack-app-plan.md @@ -0,0 +1,209 @@ +--- +title: "feat: Add ChatGPT pack workspace" +date: 2026-07-17 +type: feat +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# Add ChatGPT Pack Workspace + +## Goal Capsule + +- **Objective:** Turn Packrat's existing remote MCP server into a ChatGPT App by adding a portable MCP Apps widget for viewing an authenticated pack, while preserving Eden Treaty as the only application-data boundary. +- **Authority:** The Product Contract and session-settled KTDs in this plan override inferred implementation preferences. Current OpenAI Apps SDK documentation and existing Packrat MCP/API conventions constrain protocol details. +- **Execution profile:** Implement the smallest production-shaped `vanilla-widget` archetype in the existing `packages/mcp` Cloudflare Worker, then verify generic MCP compatibility and Apps UI behavior. +- **Stop conditions:** Stop if ChatGPT requires an incompatible auth topology, if `@modelcontextprotocol/ext-apps` cannot run in the Worker target, or if the widget would need to receive credentials or bypass the MCP tool boundary. +- **Tail ownership:** The implementing agent owns tests, documentation, dependency updates, and PR verification. Live ChatGPT Developer Mode validation may be recorded as an explicit external verification gap when credentials or deployed bindings are unavailable. + +--- + +## Product Contract + +### Summary + +Packrat already exposes more than 60 outdoor-planning tools through an OAuth 2.1 remote MCP Worker. ChatGPT Apps are not a competing transport: current OpenAI guidance defines an app as an MCP server plus optional UI resources and the MCP Apps bridge. The first Packrat app will therefore enhance the existing server rather than build a second backend. + +The MVP adds a polished, read-only pack workspace when ChatGPT calls `get_pack`. The model still receives concise structured pack data and can use all existing tools; ChatGPT additionally renders the pack as an iframe widget. Other MCP clients continue to receive a valid tool result and may ignore the UI metadata. + +### Problem Frame + +The current MCP surface is capable but text-only. Rebuilding the same domain access in a ChatGPT-specific server would duplicate tool contracts, authentication, and error handling. Packrat needs a visible Apps SDK foothold that proves the UI integration without moving business logic out of the API or widening the first release to every tool. + +### Actors + +- A1. An authenticated Packrat user asking ChatGPT to inspect or reason about one of their packs. +- A2. ChatGPT, which selects Packrat MCP tools and renders a linked UI resource. +- A3. A generic MCP client, which must remain compatible with the enhanced tool result. + +### Requirements + +- R1. The existing `/mcp` endpoint remains the single remote tool endpoint for generic MCP clients, ChatGPT, and Claude. +- R2. `get_pack` continues to fetch authoritative data through `@packrat/api-client` and Eden Treaty; the widget must not call Packrat API routes directly. +- R3. A successful `get_pack` result includes concise `structuredContent` suitable for both the model and widget, the complete Eden API result serialized as formatted JSON in the text `content` fallback, and no credentials or sensitive widget-only payloads. +- R4. The `get_pack` descriptor links to a versioned `ui://` resource using MCP Apps standard metadata and accurate read-only annotations. +- R5. The widget renders the pack name, aggregate weight/count information, and an accessible item/category breakdown from the tool result, including empty and failure states. +- R6. The widget uses the MCP Apps bridge as the portable integration surface and treats `window.openai` only as an optional compatibility/host enhancement. +- R7. The resource declares the narrowest practical CSP and avoids remote scripts, direct API access, and bearer/admin token exposure. +- R8. Existing non-App MCP tool behavior, OAuth 2.1/PKCE handling, admin-tool visibility, resources, and prompts remain unchanged. +- R9. Automated tests protect the tool descriptor, UI resource, structured result, error fallback, and generic-client compatibility. +- R10. Developer documentation explains local Worker startup, MCP Inspector validation, HTTPS tunneling, ChatGPT Developer Mode connection, Claude Team connection, and refresh behavior after metadata changes. +- R11. User-controlled pack and item text renders only through safe DOM text APIs after runtime snapshot validation; API data is never interpolated through `innerHTML`. +- R12. The structured model/widget snapshot is deterministically bounded by field character length, item-row count, and category count, and reports total/truncated counts when the API result exceeds those limits. The complete text fallback remains intentionally unbounded by this snapshot contract. + +### Key Flow + +- F1. View a pack in ChatGPT + - **Trigger:** A1 asks to inspect a known pack or selects one after `list_packs`. + - **Actors:** A1, A2. + - **Steps:** ChatGPT calls `get_pack`; the Worker calls the Packrat API through Eden Treaty; the tool returns structured pack data and its output-template link; ChatGPT loads the versioned widget resource and delivers the result over the MCP Apps bridge; the widget renders the same durable Packrat record the model is discussing. + - **Outcome:** The user receives a visual pack workspace without creating a separate data path. + - **Covered by:** R1-R7. + +### Acceptance Examples + +- AE1. Given an authenticated user owns a populated pack, when ChatGPT calls `get_pack`, then the model receives concise structured data and the widget renders the pack totals and items without making a direct Packrat API request. +- AE2. Given an authenticated user owns an empty pack, when ChatGPT calls `get_pack`, then the widget renders a useful empty state rather than failing or inventing items. +- AE3. Given the API returns 401, 403, or 404, when `get_pack` runs, then the existing actionable MCP error remains available and ChatGPT does not render stale or credential-bearing data. +- AE4. Given a generic MCP client that does not implement MCP Apps, when it calls `get_pack`, then it can still consume the text/structured tool result and safely ignore the linked UI resource. + +### Scope Boundaries + +This release is read-only and pack-focused. It does not add widget-initiated mutations, expose admin/auth-management tools in UI, redesign all MCP tool contracts, add a second ChatGPT deployment, or prepare a public directory submission. Trip workspaces, pack editing, persistent widget state, file upload, and richer ChatGPT-only APIs are deferred until the single-pack flow is validated in Developer Mode. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Use the existing MCP Worker as the ChatGPT App server** `(session-settled: user-directed — chosen over duplicating domain/business logic in separate MCP and ChatGPT layers: Eden Treaty keeps both interfaces thin and prevents drift)`. Current OpenAI documentation confirms an MCP server is required for a ChatGPT App and that MCP Apps metadata/UI can be layered onto ordinary tools. +- KTD2. **Adopt the MCP Apps standard first.** Add `@modelcontextprotocol/ext-apps` beside the existing TypeScript MCP SDK, register a `text/html;profile=mcp-app` resource, and use the `ui/*` bridge. Use OpenAI compatibility metadata only where ChatGPT still requires an alias. +- KTD3. **Enhance `get_pack` instead of adding a parallel ChatGPT-only data tool.** The existing read operation already has the right user intent and Eden call. UI metadata is ignorable by other MCP clients, so one descriptor preserves tool-name and authorization parity. +- KTD4. **Ship a self-contained vanilla widget.** Keep the first widget in `packages/mcp` with no remote scripts or separate frontend deployment. This matches the official quickstart shape, avoids adding a build/deploy surface before the protocol seam is proven, and keeps CSP empty by default. +- KTD5. **Return model-visible data intentionally.** Extend the MCP result helper so successful pack reads can expose a schema-validated, bounded `structuredContent` snapshot while preserving a text fallback. Do not dump the full API response into `_meta`; omit sensitive fields, cap strings and item rows with explicit truncation metadata, and let the API remain authoritative. + +### High-Level Technical Design + +```mermaid +flowchart TB + U[User in ChatGPT] --> C[ChatGPT model] + C -->|get_pack| M[Existing PackRat MCP Worker] + M -->|typed Eden Treaty call| A[PackRat API] + A --> M + M -->|structuredContent + content + UI metadata| C + M -->|versioned ui resource| W[Pack widget iframe] + C -->|MCP Apps tool-result notification| W + G[Generic MCP client] -->|same get_pack tool| M + M -->|compatible tool result; UI metadata ignored| G +``` + +### Assumptions + +- The current OAuth provider's protected `/mcp` flow is accepted by ChatGPT Developer Mode; implementation must verify discovery and challenge behavior before claiming live compatibility. +- `@modelcontextprotocol/ext-apps` is compatible with the repo's MCP SDK and Cloudflare Worker bundle. If not, use the standard SDK resource APIs with the documented MIME type and metadata rather than creating another server. +- The API's current pack response contains the aggregate and item fields needed by the widget; implementation may normalize the returned subset but must not add business calculations that belong in the API. + +### System-Wide Impact and Risks + +- **MCP compatibility:** Changing the result envelope can affect existing clients. Preserve `content`, add standard fields, and test that unknown UI metadata remains optional. +- **Type-check pressure:** `packages/mcp` has disabled strict `tsc` because deep MCP/Eden types exhaust memory. Keep new helper types structural and verify through focused tests plus the Wrangler production bundle. +- **Authentication:** Browser-style cookie behavior and server bearer behavior differ. The widget does not own auth and must route any future data action through MCP tools. Existing OAuth tokens remain server-side. +- **UI security:** A self-contained resource minimizes CSP domains and supply-chain/runtime fetches. Any later external asset or API domain must be explicitly allowlisted. +- **Untrusted content and payload size:** Pack/item text is user-controlled. The widget must use `textContent`/safe DOM construction, never data-bearing `innerHTML`, and the server must bound the snapshot so hostile or accidental oversized packs cannot inflate model context or iframe work without limit. +- **Protocol churn:** MCP Apps standard fields are preferred over OpenAI-only aliases; tests should assert the intentional compatibility metadata so upgrades are visible. + +### Sources and Patterns + +- `packages/mcp/src/index.ts` — existing Worker, OAuth provider, and tool registration lifecycle. +- `packages/mcp/src/client.ts` — Eden result normalization and actionable auth/ACL errors. +- `packages/mcp/src/tools/packs.ts` — `get_pack` registration and typed Treaty call. +- `packages/mcp/src/resources.ts` — existing MCP resource conventions. +- `packages/api-client/src/index.ts` — shared Treaty client and adapter-owned auth hooks. +- `docs/solutions/integration-issues/web-auth-cross-origin-cors-credentials-secure-store-stub-2026-06-01.md` — keep auth transport interface-aware and never treat a browser's unreadable HttpOnly cookie as absent authentication. +- [OpenAI Apps SDK quickstart](https://developers.openai.com/apps-sdk/quickstart) — Apps require MCP and may add an iframe component. +- [Build your MCP server](https://developers.openai.com/apps-sdk/build/mcp-server) — resource, metadata, result-envelope, and local validation contract. +- [Build your ChatGPT UI](https://developers.openai.com/apps-sdk/build/chatgpt-ui) — MCP Apps bridge-first widget guidance. +- [Apps SDK reference](https://developers.openai.com/apps-sdk/reference) — standard fields first; `window.openai` remains optional compatibility/extensions. + +--- + +## Implementation Units + +### U1. Apps-capable MCP result and resource contract + +- **Goal:** Add the protocol primitives needed for a UI-bearing tool without changing Packrat business behavior. +- **Requirements:** R1-R4, R7-R9. +- **Dependencies:** None. +- **Files:** `packages/mcp/package.json`, `bun.lock`, `packages/mcp/src/client.ts`, `packages/mcp/src/apps/pack-widget.ts`, `packages/mcp/src/__tests__/apps.test.ts`. +- **Approach:** Add the version-matched Apps helper dependency; define and contract-test a registration helper for one versioned, self-contained pack-widget resource with the documented Apps MIME type and resource metadata; extend the successful result shape to support an explicitly supplied, concise `structuredContent` while retaining text `content` and existing error semantics. U2 owns calling the helper during server initialization. +- **Patterns to follow:** Structural types in `packages/mcp/src/client.ts`; resource registration lifecycle in `packages/mcp/src/resources.ts`; no direct API/service imports beyond `@packrat/api-client`. +- **Test scenarios:** + 1. The resource registration helper uses the versioned `ui://` URI and Apps MIME type. + 2. The resource HTML is self-contained, declares its intended CSP metadata, and contains no credential, API URL, or remote script. + 3. A structured success returns both concise `structuredContent` and the existing text fallback. + 4. Existing 401/403/404/error normalization is unchanged and does not emit stale structured data. +- **Verification:** Focused MCP tests pass and Wrangler can bundle the Worker with the Apps dependency. + +### U2. Visualize `get_pack` through the shared Eden path + +- **Goal:** Make the existing pack-detail intent render as a ChatGPT/MCP Apps widget while preserving generic MCP behavior. +- **Requirements:** R2-R9, R11-R12; F1; AE1-AE4. +- **Dependencies:** U1. +- **Files:** `packages/mcp/src/index.ts`, `packages/mcp/src/tools/packs.ts`, `packages/mcp/src/apps/pack-widget.ts`, `packages/mcp/src/__tests__/apps.test.ts`, `packages/mcp/src/__tests__/client.test.ts`. +- **Approach:** Register the U1 widget helper during server initialization; attach the resource URI and read-only annotations to `get_pack`; normalize and runtime-validate the successful Treaty result into a bounded pack snapshot shared by model and widget; render totals, categories, and item rows from MCP Apps tool-result notifications using safe DOM construction and `textContent` for every API-derived string. Include accessible semantics, responsive layout, light/dark host styling, loading/empty/error states, explicit truncation indicators, and a text fallback for hosts that ignore the resource. +- **Execution note:** Characterize the current `get_pack` text result first, then add the standard fields and prove the old fallback remains consumable. +- **Patterns to follow:** The typed Treaty call in `packages/mcp/src/tools/packs.ts`; `call()` error behavior in `packages/mcp/src/client.ts`; bridge-first guidance from the official ChatGPT UI documentation. +- **Test scenarios:** + 1. Covers F1 / AE1. A populated API response yields the expected concise pack snapshot, text fallback, output-template metadata, and widget sections. + 2. Covers AE2. An empty item list produces zero-safe totals and a useful empty state. + 3. Covers AE3. API auth/ACL/not-found failures remain MCP errors and contain no structured success snapshot. + 4. Covers AE4. The result retains ordinary `content`, and no widget-specific requirement is imposed on the caller. + 5. A tool-result notification re-renders the widget from `structuredContent`; messages from non-parent sources or malformed envelopes are ignored. + 6. The descriptor marks the tool read-only, non-destructive, idempotent, and closed-world accurately. + 7. Pack/item strings containing HTML, script, and event-handler payloads render as inert text; no API-derived value reaches `innerHTML`. + 8. Oversized strings and item lists produce a structurally bounded snapshot with deterministic character truncation and accurate total/truncated counts. +- **Verification:** Unit/contract tests prove descriptor and result behavior; a rendered widget fixture remains usable at narrow and wide widths and under light/dark color schemes. + +### U3. Document and smoke-test the ChatGPT development loop + +- **Goal:** Make the app reproducible for maintainers and capture the distinction between automated protocol checks and live ChatGPT validation. +- **Requirements:** R8-R10. +- **Dependencies:** U1, U2. +- **Files:** `packages/mcp/README.md`, `package.json`, `packages/mcp/package.json`. +- **Approach:** Add focused local commands and documentation for Worker startup, MCP Inspector, an HTTPS tunnel, ChatGPT Developer Mode app creation using the tunneled `/mcp` URL, OAuth sign-in, metadata refresh, and production deployment through the existing Worker. Do not add an OpenAI API key requirement; ChatGPT connects to the MCP endpoint. +- **Patterns to follow:** Root script naming in `package.json`; existing `packages/mcp/wrangler.jsonc` environments and bindings; official Apps SDK connection steps. +- **Test scenarios:** + 1. A new maintainer can identify required local env/bindings and start the Worker from the documented commands. + 2. Inspector can list `get_pack`, read the linked resource, and observe the structured result. + 3. The live-validation checklist covers HTTPS, Developer Mode connection, OAuth, populated/empty/error packs, widget rendering, and app refresh after metadata changes. + 4. Documentation clearly distinguishes generic MCP support, MCP Apps portability, and optional ChatGPT-specific extensions. +- **Verification:** Commands are checked against package scripts, the local MCP endpoint is smoke-tested where bindings permit, and any unavailable ChatGPT account/deployment step is recorded precisely rather than claimed. + +--- + +## Verification Contract + +| Gate | Applies to | Command or evidence | Done signal | +|---|---|---|---| +| MCP unit and contract tests | U1, U2 | `bun test:mcp` | Existing and new tests pass. | +| Coverage threshold | U1, U2 | `bun run --cwd packages/mcp test --coverage` | MCP coverage remains above configured thresholds. | +| Repository lint | U1-U3 | `bun run check` | No new Biome errors. | +| Worker production bundle | U1-U3 | `bun run --cwd packages/mcp deploy --dry-run` or the supported Wrangler dry-run equivalent | Worker and embedded widget bundle successfully without publishing. | +| MCP Inspector smoke | U2, U3 | Inspect the local `/mcp` endpoint, call `get_pack`, and open its resource | Descriptor, result envelope, and widget resource are coherent. | +| ChatGPT Developer Mode | U3 | Connect a tunneled/deployed `/mcp`, complete OAuth, call `get_pack`, and inspect the widget | Live pass recorded, or a specific external credential/binding gap is documented. | + +--- + +## Definition of Done + +- `get_pack` remains a thin Eden Treaty-backed MCP operation and now advertises one versioned MCP Apps widget. +- The widget renders populated and empty packs from `structuredContent`, has accessible fallbacks, and contains no direct API/auth path. +- User-controlled text is schema-validated and rendered inertly, and oversized packs cannot create an unbounded structured widget snapshot. The complete generic-client text fallback remains a separate intentional contract. +- Generic MCP clients retain a usable text result and all unrelated tools/resources/prompts behave as before. +- Descriptor annotations, UI metadata, resource MIME/CSP, and result partitioning match current official Apps SDK guidance. +- Focused tests, MCP coverage, lint, and a production-faithful Worker bundle pass. +- Maintainer docs explain MCP Inspector and ChatGPT Developer Mode setup without introducing an OpenAI API-key dependency. +- Live-only verification gaps, if any, are explicitly recorded; no unrun integration is reported as successful. diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000000..ff7f55f5d2 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,221 @@ +# PackRat MCP App for ChatGPT and Claude + +This Cloudflare Worker is the single remote MCP endpoint for PackRat. Ordinary MCP clients, +ChatGPT, and Claude all connect to `/mcp`; the `get_pack` tool additionally links the portable MCP +Apps resource `ui://packrat/pack-workspace-v1.html`. Pack data still comes through +`@packrat/api-client` and Eden Treaty. The embedded widget does not call the PackRat API or handle +credentials itself. + +No OpenAI or Anthropic API key is needed. ChatGPT and Claude connect to this Worker as remote MCP +clients and complete the Worker's OAuth flow. + +## Prerequisites and bindings + +- Bun and the repository dependencies installed. +- A reachable PackRat API with a user account suitable for testing. +- `PACKRAT_API_URL`, the origin of that API (for example, `https://packrat.world`). This is the only + required text variable for normal local startup. +- `OAUTH_KV`, the KV binding used for short-lived OAuth state and sessions. For local Wrangler it + is backed by local persistence. Before remote dev or deployment, replace the placeholder IDs in + `wrangler.jsonc` with real KV namespace IDs. Create them from this directory with + `bunx wrangler kv namespace create OAUTH_KV` and, for the `dev` environment, + `bunx wrangler kv namespace create OAUTH_KV --env dev`. +- `PackRatMCP`, the Durable Object binding, and its SQLite migration. Both are already declared in + `wrangler.jsonc`; no separate local service is required. + +`MCP_FEATURE_FLAGS` is an optional, comma-separated list of gated tool flags. +`MCP_INITIAL_ACCESS_TOKEN` exists in the Worker environment type as a reserved optional binding but +is not currently consumed by the OAuth provider configuration; do not treat it as a security +control. Neither is needed for the pack widget itself. OAuth access and refresh tokens, Better Auth +session tokens, and admin tokens stay in the Worker flow and must never be placed in widget data. + +For local development, create an uncommitted `packages/mcp/.dev.vars.dev`: + +```dotenv +PACKRAT_API_URL=https://your-packrat-api.example +``` + +The `dev` suffix matches the `-e dev` used by the package script. Use Wrangler secrets or +Cloudflare dashboard variables for deployed environments rather than committing secrets. + +## Start the Worker + +From the repository root: + +```sh +bun mcp +``` + +This runs `bun run --cwd packages/mcp dev`, which in turn runs `wrangler dev -e dev`. Wrangler +prints the local origin, normally `http://localhost:8787`. Check the public health handler first: + +```sh +curl --fail http://localhost:8787/health +``` + +`POST /mcp` is OAuth-protected, so an unauthenticated request should be rejected; a rejection is +not evidence that the server failed to start. If the health check works but tool calls fail, check +that `PACKRAT_API_URL` is reachable from the Worker and that the test user can sign in there. + +## Inspect the MCP contract + +With the Worker running, launch the official MCP Inspector in another terminal: + +```sh +bunx @modelcontextprotocol/inspector +``` + +In Inspector, select Streamable HTTP and enter `http://localhost:8787/mcp`. Connect and complete +the PackRat OAuth sign-in when prompted. Then verify: + +1. `tools/list` contains `get_pack`, with read-only annotations and both `ui.resourceUri` and the + ChatGPT compatibility `openai/outputTemplate` pointing to + `ui://packrat/pack-workspace-v1.html`. +2. `resources/read` for that URI returns `text/html;profile=mcp-app` HTML. +3. Calling `get_pack` with an owned pack ID returns the complete Eden API result as formatted JSON + text `content` plus bounded `structuredContent`; it must not return tokens or a direct API URL. +4. A generic MCP client can consume the text or structured result and ignore the UI metadata. + +Inspector versions can rename controls, but the protocol operations above are the source of +truth. Inspector's browser UI and proxy bind to local ports printed by the command; do not expose +those ports through the public tunnel. + +## Connect from ChatGPT Developer Mode + +ChatGPT requires a public HTTPS MCP URL. For local iteration, expose only the Worker port with a +tunnel such as Cloudflare Quick Tunnels: + +```sh +cloudflared tunnel --url http://localhost:8787 +``` + +Keep that process running and copy the generated `https://...trycloudflare.com` origin. Confirm +`https:///health` works, then use this MCP URL in ChatGPT: + +```text +https:///mcp +``` + +In ChatGPT web settings, enable Developer Mode under the Apps/Connectors advanced settings, create +a new app/connector, and supply the tunneled `/mcp` URL. The exact labels can change as Developer +Mode evolves. During connection, ChatGPT discovers the Worker's OAuth endpoints, opens the PackRat +sign-in page, and uses OAuth 2.1 authorization code flow with S256 PKCE. Sign in with a PackRat +user; do not paste a bearer token or an OpenAI key into the app configuration. + +Quick Tunnel hostnames change whenever the tunnel restarts. Create a new ChatGPT development app +or update its MCP URL after a restart. For stable shared testing, use the deployed Worker URL. + +### Refresh after metadata or widget changes + +ChatGPT caches tool descriptors and UI resources. After changing a tool description, annotations, +`_meta`, resource metadata, MIME type, or widget HTML: + +1. Restart the local Worker if Wrangler did not reload cleanly. +2. In ChatGPT's Developer Mode app/connector settings, use the refresh action for the app. If no + refresh action is available, remove and recreate the development app with the same `/mcp` URL. +3. Start a new chat before retesting; an existing conversation may retain an older tool snapshot. +4. Re-run `tools/list` and `resources/read` in Inspector to distinguish a Worker problem from a + ChatGPT cache problem. + +## Connect from a Claude Team workspace + +Claude Team supports [remote custom connectors](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) +and [cross-platform MCP Apps](https://claude.com/docs/connectors/building/mcp-apps/cross-compatibility). +It uses the same public HTTPS +`https:///mcp` URL, tool descriptors, OAuth flow, and embedded resource as ChatGPT. Do not +create a Claude-specific API adapter or put an Anthropic API key in the Worker. + +An Owner or Primary Owner must add the connector for the organization: + +1. Open **Organization settings → Connectors**. +2. Select **Add**, hover over **Custom**, and choose **Web**. +3. Enter the Worker's public `https:///mcp` URL. +4. Leave the advanced OAuth client credentials empty so Claude uses the Worker's dynamic client + registration. Only provide a client ID and secret if PackRat later disables dynamic + registration and provisions Claude as a fixed OAuth client. +5. Add the connector. Each member then opens **Customize → Connectors**, selects the PackRat custom + connector, and clicks **Connect** to complete PackRat sign-in. +6. In a conversation, enable PackRat from the **+ → Connectors** menu and ask Claude to show an + owned pack. + +Claude connects from Anthropic's cloud, including when the user is in Claude Desktop. The Worker +must therefore be publicly reachable; a localhost URL, private VPN hostname, or local Desktop MCP +configuration is not a substitute for the Team connector. A development tunnel is suitable for a +short test, while a stable deployed Worker URL is required for shared use. + +The widget uses the portable MCP Apps `ui/*` JSON-RPC bridge and self-contained HTML, so Claude can +render the same inline workspace. The `openai/*` fields are additive ChatGPT compatibility metadata +and may be ignored by Claude. If an organization owner disables the interactive `get_pack` tool, +the other text-based MCP tools remain available; generic clients can also consume `get_pack`'s +ordinary text fallback. + +After changing the connector URL, Claude currently requires removing and re-adding the custom +connector. After metadata or widget changes at the same URL, start a new conversation when +validating so an older host snapshot does not mask the update. + +## Deploy the existing Worker + +There is no separate ChatGPT or Claude frontend deployment. After configuring the real `OAUTH_KV` +namespace IDs and `PACKRAT_API_URL` for the target environment, use the existing scripts: + +```sh +# Development Worker (packrat-mcp-dev) +bun run --cwd packages/mcp deploy:dev + +# Production Worker (packrat-mcp) +bun mcp:deploy +``` + +The production command maps to `wrangler deploy --minify`; the development command adds `-e dev`. +Set deployed variables with Wrangler or the Cloudflare dashboard and validate the printed Worker +origin plus `/mcp`. Publishing updates the same endpoint used by generic MCP clients, ChatGPT, +and Claude. + +For a production-faithful bundle without publishing, run: + +```sh +bunx wrangler deploy --config packages/mcp/wrangler.jsonc --env="" --minify --dry-run +``` + +## Validation checklist + +Automated/local checks: + +- [ ] `bun test:mcp` passes. +- [ ] `bun run check` reports no new issues. +- [ ] The Wrangler dry run bundles the Worker and embedded widget without publishing. +- [ ] `/health` succeeds on the local HTTP origin or the tunneled/deployed HTTPS origin. +- [ ] Inspector completes OAuth, lists `get_pack`, reads the linked UI resource, and observes both + text `content` and `structuredContent`. +- [ ] An unauthenticated or unauthorized pack call remains an MCP error and exposes no stale + structured pack data. + +Live ChatGPT checks (require a ChatGPT account with Developer Mode and a reachable HTTPS Worker): + +- [ ] ChatGPT connects to the exact `https:///mcp` URL and completes PackRat OAuth. +- [ ] A populated owned pack renders its name, totals, categories, and item rows in the widget. +- [ ] An empty owned pack renders the intentional empty state. +- [ ] Missing, forbidden, and expired-auth cases show actionable errors rather than stale UI. +- [ ] Long packs report truncation while keeping total counts accurate. +- [ ] Pack/item strings containing markup render as inert text. +- [ ] After a metadata or widget change, refreshing the development app and starting a new chat + loads the new descriptor/resource. + +The repository tests and Inspector establish generic MCP and MCP Apps protocol compatibility. +Only the live checklist establishes ChatGPT host behavior; do not report those items as passed +without running them in an eligible ChatGPT account. + +Live Claude Team checks (require an organization Owner, a Team member, and a reachable HTTPS +Worker): + +- [ ] The Owner adds the exact `https:///mcp` URL under **Organization settings → + Connectors**. +- [ ] A member connects, completes PackRat OAuth, and enables PackRat for a new conversation. +- [ ] `get_pack` renders the same populated and empty pack states as the Inspector fixture. +- [ ] Disabling the interactive `get_pack` tool leaves ordinary text-based PackRat tools usable. +- [ ] Claude renders long packs from the bounded structured snapshot without crossing its host + limit. The repository's character-length test is a structural regression guard, not proof of + an external host byte limit. + +Only the live Claude checklist establishes Claude host and Team-admin behavior. Protocol tests do +not prove that the Team workspace policy, public network path, or user OAuth grants are configured. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 75db978ced..73128cc9db 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@cloudflare/workers-oauth-provider": "^0.4.0", + "@modelcontextprotocol/ext-apps": "^1.7.4", "@modelcontextprotocol/sdk": "^1.11.0", "@packrat/api-client": "workspace:*", "agents": "^0.11.0", diff --git a/packages/mcp/src/__tests__/apps.test.ts b/packages/mcp/src/__tests__/apps.test.ts new file mode 100644 index 0000000000..e5517e6bb9 --- /dev/null +++ b/packages/mcp/src/__tests__/apps.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + normalizePackSnapshot, + PACK_WIDGET_MIME_TYPE, + PACK_WIDGET_URI, + registerPackWidget, +} from '../apps/pack-widget'; +import { okStructured } from '../client'; +import { registerPackTools } from '../tools/packs'; + +describe('pack widget app contract', () => { + it('registers one versioned, self-contained MCP Apps resource', async () => { + const registerResource = vi.fn(); + registerPackWidget({ registerResource } as never); + + expect(registerResource).toHaveBeenCalledOnce(); + const [name, uri, metadata, read] = registerResource.mock.calls[0]; + expect(name).toBe('pack_workspace'); + expect(uri).toBe(PACK_WIDGET_URI); + expect(PACK_WIDGET_URI).toMatch(/^ui:\/\/packrat\/pack-workspace-v\d+\.html$/); + expect(metadata.mimeType).toBe(PACK_WIDGET_MIME_TYPE); + expect(metadata._meta.ui.csp).toEqual({ connectDomains: [], resourceDomains: [] }); + expect(metadata._meta['openai/widgetDescription']).toContain('PackRat workspace'); + + const result = await read(new URL(PACK_WIDGET_URI)); + expect(result.contents).toHaveLength(1); + expect(result.contents[0]).toMatchObject({ + uri: PACK_WIDGET_URI, + mimeType: PACK_WIDGET_MIME_TYPE, + _meta: { + 'openai/widgetDescription': expect.stringContaining('PackRat workspace'), + }, + }); + expect(result.contents[0].text).toContain(''); + expect(result.contents[0].text).not.toMatch(/]+src=/i); + expect(result.contents[0].text).not.toMatch(/authorization|bearer|api[_-]?key/i); + expect(result.contents[0].text).not.toMatch(/https?:\/\//i); + }); + + it('returns structured content alongside the ordinary text fallback', () => { + const snapshot = { pack: { id: 'pack-1', name: 'Weekender' }, itemCount: 3 }; + const result = okStructured({ data: snapshot, structuredContent: snapshot }); + + expect(result.structuredContent).toEqual(snapshot); + expect(result.content[0]).toEqual({ + type: 'text', + text: JSON.stringify(snapshot, null, 2), + }); + }); + + it('enhances get_pack while retaining a generic-client text fallback', async () => { + const registerTool = vi.fn(); + const get = vi.fn().mockResolvedValue({ + data: { + id: 'pack-1', + name: 'Weekender', + totalWeight: 1250, + baseWeight: 900, + items: Array.from({ length: 51 }, (_, index) => ({ + id: `item-${index}`, + name: index === 50 ? 'Generic fallback only item' : `Item ${index}`, + category: 'shelter', + weight: 1, + weightUnit: 'g', + quantity: 1, + consumable: false, + worn: false, + })), + }, + error: null, + status: 200, + }); + const agent = { + server: { registerTool }, + api: { user: { packs: Object.assign(() => ({ get }), { get: vi.fn() }) } }, + }; + registerPackTools(agent as never); + const getPackCall = registerTool.mock.calls.find(([name]) => name === 'get_pack'); + expect(getPackCall).toBeDefined(); + const [, descriptor, handler] = getPackCall; + expect(descriptor.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); + expect(descriptor._meta).toMatchObject({ + ui: { resourceUri: PACK_WIDGET_URI }, + 'openai/outputTemplate': PACK_WIDGET_URI, + }); + + const result = await handler({ pack_id: 'pack-1' }); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toMatchObject({ + pack: { id: 'pack-1', name: 'Weekender' }, + totals: { itemCount: 51, displayedItemCount: 50, truncatedItemCount: 1 }, + }); + expect(result.structuredContent.items).toHaveLength(50); + expect(result.content[0].text).toContain('Weekender'); + expect(result.content[0].text).toContain('Generic fallback only item'); + }); + + it.each([401, 403, 404])('keeps %s API failures as ordinary MCP errors', async (status) => { + const registerTool = vi.fn(); + const get = vi.fn().mockResolvedValue({ data: null, error: { status, value: null }, status }); + const agent = { + server: { registerTool }, + api: { user: { packs: Object.assign(() => ({ get }), { get: vi.fn() }) } }, + }; + registerPackTools(agent as never); + const handler = registerTool.mock.calls.find(([name]) => name === 'get_pack')?.[2]; + const result = await handler({ pack_id: 'missing' }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + expect(result.content[0].text.toLowerCase()).toContain( + status === 401 ? 'authentication' : status === 403 ? 'forbidden' : '404', + ); + }); + + it('turns malformed successful Eden data into an error without a stale snapshot', async () => { + const registerTool = vi.fn(); + const get = vi + .fn() + .mockResolvedValue({ data: { id: 7, items: null }, error: null, status: 200 }); + const agent = { + server: { registerTool }, + api: { user: { packs: Object.assign(() => ({ get }), { get: vi.fn() }) } }, + }; + registerPackTools(agent as never); + const handler = registerTool.mock.calls.find(([name]) => name === 'get_pack')?.[2]; + const result = await handler({ pack_id: 'bad' }); + expect(result).toMatchObject({ isError: true }); + expect(result.structuredContent).toBeUndefined(); + expect(result.content[0].text).toContain('malformed data'); + }); + + it('normalizes empty packs and rejects malformed API success data', () => { + expect( + normalizePackSnapshot({ + id: 'empty', + name: 'Empty pack', + totalWeight: 0, + baseWeight: 0, + items: [], + }), + ).toMatchObject({ + totals: { + itemCount: 0, + displayedItemCount: 0, + truncatedItemCount: 0, + categoryCount: 0, + displayedCategoryCount: 0, + truncatedCategoryCount: 0, + }, + }); + expect(normalizePackSnapshot({ id: 42, name: null, items: 'nope' })).toBeNull(); + }); + + it('normalizes item display weights to grams and reports category truncation', () => { + const snapshot = normalizePackSnapshot({ + id: 'mixed-units', + name: 'Mixed units', + items: Array.from({ length: 30 }, (_, index) => ({ + id: `item-${index}`, + name: `Item ${index}`, + category: `category-${index}`, + weight: index === 0 ? 2 : 1, + weightUnit: index === 0 ? 'oz' : 'g', + quantity: 1, + consumable: false, + worn: false, + })), + }); + expect(snapshot?.items.find((item) => item.id === 'item-0')).toMatchObject({ + weight: 56.7, + weightUnit: 'g', + }); + expect(snapshot?.categories).toHaveLength(24); + expect(snapshot?.totals).toMatchObject({ + categoryCount: 30, + displayedCategoryCount: 24, + truncatedCategoryCount: 6, + }); + }); + + it('rejects values whose derived weights could exceed the bounded snapshot contract', () => { + expect( + normalizePackSnapshot({ + id: 'overflow', + name: 'Overflow', + items: [ + { + id: 'item-1', + name: 'Impossible item', + category: 'other', + weight: Number.MAX_VALUE, + weightUnit: 'kg', + quantity: 100_001, + consumable: false, + worn: false, + }, + ], + }), + ).toBeNull(); + }); + + it('converts supported units and calculates totals when the API omits them', () => { + const snapshot = normalizePackSnapshot({ + id: 'unit-pack', + name: 'Unit pack', + items: [ + { + id: 'kg', + name: 'Kilogram item', + category: null, + weight: 1, + weightUnit: 'kg', + quantity: 1, + consumable: false, + worn: false, + }, + { + id: 'lb', + name: 'Pound item', + category: 'Clothing', + weight: 1, + weightUnit: 'lb', + quantity: 1, + consumable: false, + worn: true, + }, + { + id: 'lbs', + name: 'Pounds item', + category: 'Clothing', + weight: 1, + weightUnit: 'lbs', + quantity: 1, + consumable: true, + worn: false, + }, + ], + }); + expect(snapshot?.items.map((item) => item.weight)).toEqual([1000, 453.59, 453.59]); + expect(snapshot?.totals).toMatchObject({ totalWeight: 1907.18, baseWeight: 1000 }); + expect(snapshot?.categories.find((category) => category.name === 'Clothing')).toMatchObject({ + itemCount: 2, + weight: 907.18, + }); + expect(snapshot?.categories.some((category) => category.name === 'Uncategorized')).toBe(true); + }); + + it('rejects a pack whose individually valid items exceed the aggregate weight bound', () => { + expect( + normalizePackSnapshot({ + id: 'aggregate-overflow', + name: 'Aggregate overflow', + items: Array.from({ length: 2 }, (_, index) => ({ + id: `item-${index}`, + name: 'Very heavy item', + category: 'other', + weight: 10_000_000, + weightUnit: 'kg', + quantity: 60, + consumable: false, + worn: false, + })), + }), + ).toBeNull(); + }); + + it('rejects unsupported weight units', () => { + expect( + normalizePackSnapshot({ + id: 'unsupported-unit', + name: 'Unsupported unit', + items: [ + { + id: 'item-1', + name: 'Stone item', + category: 'other', + weight: 1, + weightUnit: 'stone', + quantity: 1, + consumable: false, + worn: false, + }, + ], + }), + ).toBeNull(); + }); + + it('rejects source arrays above the pre-parse aggregation limit', () => { + const item = { + id: 'item', + name: 'Item', + category: 'other', + weight: 1, + weightUnit: 'g', + quantity: 1, + consumable: false, + worn: false, + }; + expect( + normalizePackSnapshot({ + id: 'too-many-items', + name: 'Too many items', + items: Array.from({ length: 10_001 }, () => item), + }), + ).toBeNull(); + }); + + it('turns rejected Eden requests into ordinary MCP errors', async () => { + const registerTool = vi.fn(); + const get = vi.fn().mockRejectedValue(new Error('network unavailable')); + const agent = { + server: { registerTool }, + api: { user: { packs: Object.assign(() => ({ get }), { get: vi.fn() }) } }, + }; + registerPackTools(agent as never); + const handler = registerTool.mock.calls.find(([name]) => name === 'get_pack')?.[2]; + const result = await handler({ pack_id: 'pack-1' }); + expect(result).toMatchObject({ isError: true }); + expect(result.content[0].text).toContain('network unavailable'); + }); + + it('bounds hostile and oversized API text deterministically', () => { + const payload = ''; + const snapshot = normalizePackSnapshot({ + id: payload.repeat(20), + name: payload.repeat(20), + totalWeight: 100, + baseWeight: 100, + items: Array.from({ length: 80 }, (_, index) => ({ + id: `${payload}-${index}`.repeat(20), + name: `${payload}-${index}`.repeat(20), + category: `${payload}-category-${index % 30}`.repeat(5), + weight: 1, + weightUnit: 'g', + quantity: 1, + consumable: false, + worn: false, + })), + }); + expect(snapshot).not.toBeNull(); + expect(snapshot?.pack.name.length).toBeLessThanOrEqual(160); + expect(snapshot?.items).toHaveLength(50); + expect(snapshot?.categories).toHaveLength(24); + expect(snapshot?.totals).toMatchObject({ + itemCount: 80, + displayedItemCount: 50, + truncatedItemCount: 30, + categoryCount: 30, + displayedCategoryCount: 24, + truncatedCategoryCount: 6, + }); + expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(32_000); + }); + + it('uses parent-only bridge messages and safe text DOM APIs for API-derived values', async () => { + const registerResource = vi.fn(); + registerPackWidget({ registerResource } as never); + const read = registerResource.mock.calls[0][3]; + const html = (await read(new URL(PACK_WIDGET_URI))).contents[0].text as string; + expect(html).toContain('event.source !== window.parent'); + expect(html).toContain("message.method === 'ui/notifications/tool-result'"); + expect(html).toContain("method: 'ui/initialize'"); + expect(html).toContain("method: 'ui/notifications/initialized'"); + expect(html).toContain("method: 'ui/notifications/size-changed'"); + expect(html).toContain('Pack workspace could not connect to the host.'); + expect(html).toContain("protocolVersion: '2026-01-26'"); + expect(html).toContain('textContent'); + expect(html).not.toContain('.innerHTML'); + expect(html).toContain('structuredContent'); + expect(html).toContain('No items in this pack yet'); + expect(html).toContain('Some items are not shown'); + }); +}); diff --git a/packages/mcp/src/apps/pack-widget.ts b/packages/mcp/src/apps/pack-widget.ts new file mode 100644 index 0000000000..6cdf81f158 --- /dev/null +++ b/packages/mcp/src/apps/pack-widget.ts @@ -0,0 +1,302 @@ +import { RESOURCE_MIME_TYPE, registerAppResource } from '@modelcontextprotocol/ext-apps/server'; +import { isObject } from '@packrat/guards'; +import { z } from 'zod'; + +export const PACK_WIDGET_URI = 'ui://packrat/pack-workspace-v1.html'; +export const PACK_WIDGET_MIME_TYPE = RESOURCE_MIME_TYPE; + +const PACK_WIDGET_CSP = { + connectDomains: [], + resourceDomains: [], +}; + +const MAX_NAME_LENGTH = 160; +const MAX_ITEM_ROWS = 50; +const MAX_CATEGORIES = 24; +const MAX_SOURCE_ITEMS = 10_000; +const MAX_WEIGHT_GRAMS = 1_000_000_000_000; + +const PackItemInput = z + .object({ + id: z.string(), + name: z.string(), + category: z.string().nullable().optional(), + weight: z.number().finite().nonnegative().max(10_000_000), + weightUnit: z.string(), + quantity: z.number().int().positive().max(100_000), + consumable: z.boolean(), + worn: z.boolean(), + }) + .passthrough(); + +const PackInput = z + .object({ + id: z.string(), + name: z.string(), + totalWeight: z.number().finite().nonnegative().max(MAX_WEIGHT_GRAMS).optional(), + baseWeight: z.number().finite().nonnegative().max(MAX_WEIGHT_GRAMS).optional(), + items: z.array(PackItemInput), + }) + .passthrough(); + +export type PackSnapshot = { + pack: { id: string; name: string }; + totals: { + itemCount: number; + displayedItemCount: number; + truncatedItemCount: number; + categoryCount: number; + displayedCategoryCount: number; + truncatedCategoryCount: number; + totalWeight: number; + baseWeight: number; + }; + categories: Array<{ name: string; itemCount: number; weight: number }>; + items: Array<{ + id: string; + name: string; + category: string; + weight: number; + weightUnit: string; + quantity: number; + consumable: boolean; + worn: boolean; + }>; +}; + +function bounded({ value, length = MAX_NAME_LENGTH }: { value: string; length?: number }): string { + return value.slice(0, length); +} + +function grams({ weight, unit }: { weight: number; unit: string }): number | null { + const normalizedUnit = unit.toLowerCase(); + let factor: number; + switch (normalizedUnit) { + case 'g': + factor = 1; + break; + case 'kg': + factor = 1000; + break; + case 'oz': + factor = 28.3495; + break; + case 'lb': + case 'lbs': + factor = 453.592; + break; + default: + return null; + } + return Math.round(weight * factor * 100) / 100; +} + +/** Validate and reduce an API pack into the bounded model/widget contract. */ +export function normalizePackSnapshot(value: unknown): PackSnapshot | null { + if (isObject(value)) { + const sourceItems = Reflect.get(value, 'items'); + if (Array.isArray(sourceItems) && sourceItems.length > MAX_SOURCE_ITEMS) return null; + } + const parsed = PackInput.safeParse(value); + if (!parsed.success) return null; + const pack = parsed.data; + const visible: PackSnapshot['items'] = []; + const categoryMap = new Map(); + let calculatedTotal = 0; + let calculatedBase = 0; + for (const [index, item] of pack.items.entries()) { + const name = bounded({ value: item.category || 'Uncategorized', length: 80 }); + const category = categoryMap.get(name) ?? { name, itemCount: 0, weight: 0 }; + const unitGrams = grams({ weight: item.weight, unit: item.weightUnit }); + if (unitGrams == null) return null; + const itemGrams = unitGrams * item.quantity; + if (itemGrams > MAX_WEIGHT_GRAMS) return null; + category.itemCount += 1; + category.weight = Math.round((category.weight + itemGrams) * 100) / 100; + categoryMap.set(name, category); + calculatedTotal += itemGrams; + if (!item.consumable && !item.worn) calculatedBase += itemGrams; + if (calculatedTotal > MAX_WEIGHT_GRAMS) return null; + if (index < MAX_ITEM_ROWS) { + visible.push({ + id: bounded({ value: item.id, length: 80 }), + name: bounded({ value: item.name }), + category: name, + weight: unitGrams, + weightUnit: 'g', + quantity: item.quantity, + consumable: item.consumable, + worn: item.worn, + }); + } + } + const categories = [...categoryMap.values()].sort( + (a, b) => b.weight - a.weight || a.name.localeCompare(b.name), + ); + const displayedCategories = categories.slice(0, MAX_CATEGORIES); + return { + pack: { + id: bounded({ value: pack.id, length: 80 }), + name: bounded({ value: pack.name }), + }, + totals: { + itemCount: pack.items.length, + displayedItemCount: visible.length, + truncatedItemCount: pack.items.length - visible.length, + categoryCount: categories.length, + displayedCategoryCount: displayedCategories.length, + truncatedCategoryCount: categories.length - displayedCategories.length, + totalWeight: pack.totalWeight ?? Math.round(calculatedTotal * 100) / 100, + baseWeight: pack.baseWeight ?? Math.round(calculatedBase * 100) / 100, + }, + categories: displayedCategories, + items: visible, + }; +} + +const PACK_WIDGET_HTML = ` + + + + + Pack workspace + + + +

Loading pack…

+ + +`; + +/** Register the portable, versioned MCP Apps resource used by the pack workspace. */ +export function registerPackWidget(server: Parameters[0]): void { + const ui = { csp: PACK_WIDGET_CSP, prefersBorder: true }; + const resourceMeta = { + ui, + 'openai/widgetDescription': + 'Read-only PackRat workspace showing pack weights, categories, and item rows.', + }; + + registerAppResource( + server, + 'pack_workspace', + PACK_WIDGET_URI, + { + description: 'Read-only PackRat pack workspace', + mimeType: PACK_WIDGET_MIME_TYPE, + _meta: resourceMeta, + }, + async () => ({ + contents: [ + { + uri: PACK_WIDGET_URI, + mimeType: PACK_WIDGET_MIME_TYPE, + text: PACK_WIDGET_HTML, + _meta: resourceMeta, + }, + ], + }), + ); +} diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 4ab69eaa6e..0d83b94462 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -62,11 +62,24 @@ function noopHooks(getToken: TokenProvider) { export type McpToolResult = { content: [{ type: 'text'; text: string }]; + structuredContent?: Record; isError?: true; }; export function ok(data: unknown): McpToolResult { - return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }; +} + +export function okStructured({ + data, + structuredContent, +}: { + data: unknown; + structuredContent: Record; +}): McpToolResult { + return { ...ok(data), structuredContent }; } export function errMessage(message: string): McpToolResult { @@ -97,15 +110,18 @@ export type CallOptions = { * Thrown errors and `{ error: ... }` responses both surface as `isError: true`. */ export async function call( - args: { promise: Promise> } & CallOptions, + args: { + promise: Promise>; + onSuccess?: (data: T) => McpToolResult; + } & CallOptions, ): Promise { - const { promise, ...options } = args; + const { promise, onSuccess, ...options } = args; try { const result = await promise; if (result.error || result.data == null) { return formatError({ status: result.status, body: result.error?.value, opts: options }); } - return ok(result.data); + return onSuccess?.(result.data) ?? ok(result.data); } catch (e) { const message = e instanceof Error ? e.message : String(e); return errMessage(`${options.action ?? 'request'} failed: ${message}`); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 2c4feb4c46..37fba9d978 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -33,6 +33,7 @@ import { OAuthProvider } from '@cloudflare/workers-oauth-provider'; import { McpServer, type RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpAgent } from 'agents/mcp'; import { z } from 'zod'; +import { registerPackWidget } from './apps/pack-widget'; import { PackRatAuthHandler } from './auth'; import { createMcpClients, type McpClients } from './client'; import { registerPrompts } from './prompts'; @@ -203,6 +204,7 @@ export class PackRatMCP extends McpAgent> { registerAdminTools(this); // ── Resources + prompts ──────────────────────────────────────────────── + registerPackWidget(this.server); registerResources(this); registerPrompts(this); } diff --git a/packages/mcp/src/tools/packs.ts b/packages/mcp/src/tools/packs.ts index 5a10c86efc..1e603b6d93 100644 --- a/packages/mcp/src/tools/packs.ts +++ b/packages/mcp/src/tools/packs.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; -import { call, nowIso } from '../client'; +import { normalizePackSnapshot, PACK_WIDGET_URI } from '../apps/pack-widget'; +import { call, errMessage, nowIso, okStructured } from '../client'; import { ItemCategory, PackCategory } from '../enums'; import type { AgentContext } from '../types'; @@ -35,12 +36,29 @@ export function registerPackTools(agent: AgentContext): void { inputSchema: { pack_id: z.string().describe('The unique pack ID (e.g. "p_abc123")'), }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { + ui: { resourceUri: PACK_WIDGET_URI }, + 'openai/outputTemplate': PACK_WIDGET_URI, + 'openai/toolInvocation/invoking': 'Loading pack…', + 'openai/toolInvocation/invoked': 'Pack loaded', + }, }, async ({ pack_id }) => call({ promise: agent.api.user.packs({ packId: pack_id }).get(), action: 'get pack', resourceHint: `pack ${pack_id}`, + onSuccess: (data) => { + const snapshot = normalizePackSnapshot(data); + if (!snapshot) return errMessage('get pack returned malformed data'); + return okStructured({ data, structuredContent: snapshot }); + }, }), );