@noodleseed/one 0.138.0 → 0.139.1
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/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/import.d.ts +4 -0
- package/dist/commands/import.d.ts.map +1 -0
- package/dist/commands/import.js +198 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/project-setup.d.ts +1 -1
- package/dist/commands/project-setup.d.ts.map +1 -1
- package/dist/commands/project-setup.js +2 -48
- package/dist/commands/project-setup.js.map +1 -1
- package/dist/commands/service-ops.d.ts.map +1 -1
- package/dist/commands/service-ops.js +44 -51
- package/dist/commands/service-ops.js.map +1 -1
- package/dist/mcp-import.d.ts +22 -0
- package/dist/mcp-import.d.ts.map +1 -0
- package/dist/mcp-import.js +91 -0
- package/dist/mcp-import.js.map +1 -0
- package/dist/self-host-init.d.ts +23 -0
- package/dist/self-host-init.d.ts.map +1 -0
- package/dist/self-host-init.js +315 -0
- package/dist/self-host-init.js.map +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.js +11 -2
- package/node_modules/@noodle-borg/agent-kit/dist/skill-connect-refs.js +31 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-content.js +3 -2
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.d.ts +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.js +35 -1
- package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
- package/node_modules/@noodle-borg/authoring/dist/connections.js +1 -0
- package/node_modules/@noodle-borg/authoring/dist/connectors.d.ts +25 -3
- package/node_modules/@noodle-borg/authoring/dist/connectors.js +82 -7
- package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/authoring/dist/model-tool-visibility.js +33 -0
- package/node_modules/@noodle-borg/authoring/dist/server.d.ts +5 -0
- package/node_modules/@noodle-borg/authoring/dist/server.js +7 -2
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-account.js +13 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-bootstrap-authoring.js +30 -1
- package/node_modules/@noodle-borg/connector-defs/dist/call-graph.js +1 -1
- package/node_modules/@noodle-borg/connector-defs/dist/compile-mcp.js +95 -0
- package/node_modules/@noodle-borg/connector-defs/dist/compile.js +4 -0
- package/node_modules/@noodle-borg/connector-defs/dist/customer-routing.js +1 -1
- package/node_modules/@noodle-borg/connector-defs/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/connector-defs/dist/schema.d.ts +642 -3
- package/node_modules/@noodle-borg/connector-defs/dist/schema.js +143 -1
- package/node_modules/@noodle-borg/connector-http/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/connector-http/dist/index.js +1 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/config.js +76 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/egress.js +125 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/failure.js +48 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/index.d.ts +3 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/index.js +2 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/mcp-connector.d.ts +12 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/mcp-connector.js +125 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/result.js +55 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/types.d.ts +52 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/types.js +10 -0
- package/node_modules/@noodle-borg/connector-http/package.json +4 -1
- package/node_modules/@noodle-borg/openapi-import/dist/index.js +1 -0
- package/node_modules/@noodle-borg/openapi-import/dist/mcp/index.js +4 -0
- package/node_modules/@noodle-borg/openapi-import/dist/mcp/probe.js +165 -0
- package/node_modules/@noodle-borg/openapi-import/dist/mcp/render.js +99 -0
- package/node_modules/@noodle-borg/openapi-import/dist/mcp/snapshot.js +290 -0
- package/node_modules/@noodle-borg/openapi-import/package.json +6 -2
- package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +2 -0
- package/node_modules/@noodle-borg/runtime/dist/operation-execution.js +1 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +8 -5
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodleseed/assistant/package.json +1 -1
- package/package.json +2 -2
|
@@ -57,18 +57,18 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
57
57
|
{ relPath: "examples/customer-auth/package.json", content: "{\n \"name\": \"customer-auth\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
58
58
|
{ relPath: "examples/customer-auth/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n customerAuth,\n customerEndpoint,\n embeddedAssistant,\n openAICompatible,\n secret,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\nconst customerApi = customerEndpoint('customer_api', {\n allowedHttpsHostSuffixes: ['api.noodleseed.dev'],\n});\nconst assistantOrigin = variable('ASSISTANT_ORIGIN');\n\nconst noodleseedApi = connector('noodleseed_app_api')\n .version('1.0.0')\n .http({\n baseUrl: customerApi,\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: variable('CUSTOMER_API_CLIENT_ID'),\n clientSecret: secret('CUSTOMER_API_CLIENT_SECRET'),\n scopes: ['organizations:read', 'org_apps:read', 'org_apps:write'],\n audience: 'noodleseed-customer-api',\n },\n operations: {\n list_org_apps: {\n type: 'read',\n method: 'GET',\n path: '/api/organizations/${args.org_id}/apps',\n query: ['skip', 'limit'],\n input: z.object({\n org_id: z.string(),\n skip: z.number().optional(),\n limit: z.number().optional(),\n }),\n output: z.object({ result: z.unknown().optional() }),\n response: {\n result: '${response}',\n },\n },\n list_organizations: {\n type: 'read',\n method: 'GET',\n path: '/api/organizations',\n output: z.object({ organizations: z.array(z.unknown()).optional() }),\n response: {\n organizations: '${response.organizations}',\n },\n },\n archive_org_app: {\n type: 'action',\n method: 'POST',\n path: '/api/organizations/${args.org_id}/apps/${args.app_id}/archive',\n input: z.object({\n org_id: z.string(),\n app_id: z.string(),\n }),\n output: z.object({ archived: z.boolean() }),\n response: {\n archived: '${response.archived}',\n },\n },\n },\n });\n\nconst CUSTOMER_AUTH_AGENT_GUIDE = {\n description:\n 'Use the signed-in customer context to discover organizations, review their Noodle Seed apps, and archive a selected app when authorized.',\n useWhen: [\n 'A signed-in customer asks which organizations or apps they can access.',\n 'An organization administrator asks to archive one selected app.',\n ],\n workflows: [\n {\n id: 'find_organizations',\n title: 'Find the customer organizations',\n intent: 'Ground later organization-scoped work in the verified customer membership.',\n steps: [\n {\n capability: { kind: 'tool', name: 'list_my_organizations' },\n guidance: 'Use an organization identifier returned by this read in later steps.',\n },\n ],\n },\n {\n id: 'review_organization_apps',\n title: 'Review apps in one organization',\n steps: [\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n {\n capability: { kind: 'tool', name: 'list_org_apps' },\n guidance: 'List apps only for an organization returned for the signed-in customer.',\n },\n ],\n },\n {\n id: 'archive_organization_app',\n title: 'Archive one organization app',\n steps: [\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n { capability: { kind: 'tool', name: 'list_org_apps' } },\n {\n capability: { kind: 'tool', name: 'archive_org_app' },\n guidance: 'Archive only the exact app the customer selected after confirmation.',\n },\n ],\n },\n ],\n boundaries: [\n 'Never infer an organization or app identifier that was not returned for the signed-in customer.',\n 'Never claim an app was archived until the confirmed action succeeds.',\n ],\n examples: [\n { prompt: 'Which organizations can I access?', workflow: 'find_organizations' },\n { prompt: 'Show me the apps in this organization.', workflow: 'review_organization_apps' },\n { prompt: 'Archive the app I selected.', workflow: 'archive_organization_app' },\n ],\n} as const;\n\nexport default server(\n 'noodleseed_customer_auth',\n {\n title: 'NoodleSeed.com Customer Auth',\n version: '1.0.0',\n branding: {\n name: 'Noodle Seed Assistant',\n accent: '#E85D24',\n surface: '#FFFFFF',\n surfaceDark: '#171310',\n colorScheme: 'auto',\n theme: {\n light: { accentText: '#FFFFFF', text: '#1C1714' },\n dark: { accent: '#FF8A4C', accentText: '#1C100A', text: '#FFF8F2' },\n },\n },\n use: { app_api: noodleseedApi },\n agentGuide: CUSTOMER_AUTH_AGENT_GUIDE,\n interactions: { confirmationFallback: 'host' },\n auth: customerAuth.oidc({\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n }),\n instructions:\n 'Direct/federated MCP OIDC demo. The customer IdP proves identity and privately selects the tenant API base URL, while the broker supplies delegated credentials and confirmed actions stay bound to the reviewed route.',\n assistant: embeddedAssistant({\n model: openAICompatible({\n baseUrl: variable('ASSISTANT_MODEL_BASE_URL'),\n model: variable('ASSISTANT_MODEL'),\n apiKey: secret('ASSISTANT_MODEL_API_KEY'),\n }),\n // Production origins are exact HTTPS; http://localhost:<port> is allowed for local development.\n access: authenticatedWebsite({\n origins: [assistantOrigin, 'https://dev.noodleseed.com', 'http://localhost:3000'],\n }),\n layout: { mode: 'floating', position: 'bottom-right', panelWidth: 420 },\n labels: {\n welcomeHeading: 'How can I help with Noodle Seed?',\n composerPlaceholder: 'Ask about your apps…',\n },\n presentation: {\n panel: { surface: 'glass', elevation: 'soft', border: 'subtle' },\n launcher: { icon: 'brand-mark', status: 'session', effect: 'pulse' },\n header: {\n mark: 'status',\n badge: { text: 'Workspace online', tone: 'success', indicator: true },\n },\n composer: { leadingIcon: 'brand-mark', shape: 'pill' },\n },\n suggestedPrompts: ['Explain how to connect this customer-authenticated MCP server'],\n }),\n },\n [\n tool('list_org_apps', {\n title: 'List organization apps',\n description: 'List NoodleSeed.com apps for an organization from its customer API.',\n authorization: {\n requiredScopes: ['org_apps:read'],\n allowedRoles: ['org_admin', 'org_member'],\n },\n input: z.object({\n org_id: z.string().meta({ title: 'Organization' }),\n skip: z.number().int().min(0).optional().meta({ title: 'Starting item' }),\n limit: z.number().int().min(1).max(100).optional().meta({ title: 'Maximum results' }),\n }),\n output: z.object({\n result: z.unknown(),\n }),\n annotations: annotations.readOnly(),\n fulfil({ input, connectors }) {\n const apps = connectors.app_api.listOrgApps({\n org_id: input.org_id,\n skip: input.skip,\n limit: input.limit,\n });\n return {\n result: apps.result,\n };\n },\n }),\n tool('list_my_organizations', {\n title: 'List my organizations',\n description: 'List the NoodleSeed.com organizations the signed-in customer belongs to.',\n contextProvider: true,\n input: z.object({}),\n // The customer API returns every organization for the signed-in customer in one response, with no\n // page parameter to pass through, so the bound is declared on the shape. A customer belongs to a\n // handful of organizations; `noodle check` reports an unbounded list as\n // `tool_design_output_bounds`.\n output: z.object({\n organizations: z.array(z.unknown()).max(100),\n }),\n annotations: annotations.readOnly(),\n fulfil({ connectors }) {\n const organizations = connectors.app_api.listOrganizations();\n return {\n organizations: organizations.organizations,\n };\n },\n }),\n tool('archive_org_app', {\n title: 'Archive organization app',\n description: 'Archive one NoodleSeed.com app through its customer API after confirmation.',\n authorization: {\n requiredScopes: ['org_apps:write'],\n allowedRoles: ['org_admin'],\n },\n input: z.object({\n org_id: z.string().meta({ title: 'Organization' }),\n app_id: z.string().meta({ title: 'App' }),\n }),\n output: z.object({\n archived: z.boolean(),\n }),\n annotations: annotations.openAction({ destructive: false, confirm: true }),\n fulfil({ input, connectors }) {\n const result = connectors.app_api.archiveOrgApp({\n org_id: input.org_id,\n app_id: input.app_id,\n });\n return {\n archived: result.archived,\n };\n },\n }),\n ],\n);\n" },
|
|
59
59
|
{ relPath: "examples/customer-auth/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('customer-auth example', () => {\n it('exports a customer-authenticated, customer-branded embedded assistant', async () => {\n expect(typeof app.toManifest).toBe('function');\n const manifest = await app.toManifest();\n expect(manifest.server.assistant).toMatchObject({\n model: { kind: 'openai-compatible', apiKey: 'ASSISTANT_MODEL_API_KEY' },\n layout: { mode: 'floating' },\n presentation: {\n panel: { surface: 'glass', elevation: 'soft', border: 'subtle' },\n launcher: { icon: 'brand-mark', status: 'session', effect: 'pulse' },\n header: { mark: 'status', badge: { text: 'Workspace online', tone: 'success' } },\n },\n });\n expect(manifest.server.assistant?.allowedOrigins).toEqual([\n '${env.ASSISTANT_ORIGIN}',\n 'https://dev.noodleseed.com',\n 'http://localhost:3000',\n ]);\n expect(manifest.server.branding).toMatchObject({\n name: 'Noodle Seed Assistant',\n colorScheme: 'auto',\n });\n expect(manifest.server.auth).toEqual({\n kind: 'oidc',\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n });\n expect(manifest.server.interactions).toEqual({ confirmationFallback: 'host' });\n expect(manifest.server.agentGuide?.workflows.map((workflow) => workflow.id)).toEqual([\n 'find_organizations',\n 'review_organization_apps',\n 'archive_organization_app',\n ]);\n expect(\n manifest.server.agentGuide?.workflows.find(\n (workflow) => workflow.id === 'archive_organization_app',\n )?.steps,\n ).toEqual([\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n { capability: { kind: 'tool', name: 'list_org_apps' } },\n {\n capability: { kind: 'tool', name: 'archive_org_app' },\n guidance: 'Archive only the exact app the customer selected after confirmation.',\n },\n ]);\n const catalog = app.toConnectorCatalog();\n expect(catalog?.connectors).toHaveLength(1);\n expect(catalog?.connectors[0]?.http).toMatchObject({\n baseUrl: {\n kind: 'customerEndpoint',\n name: 'customer_api',\n policy: { allowedHttpsHostSuffixes: ['api.noodleseed.dev'] },\n },\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: '${env.CUSTOMER_API_CLIENT_ID}',\n clientSecret: 'CUSTOMER_API_CLIENT_SECRET',\n },\n });\n expect(catalog?.connectors[0]?.operations).toMatchObject({\n list_org_apps: { type: 'read' },\n list_organizations: { type: 'read' },\n archive_org_app: { type: 'action', method: 'POST' },\n });\n expect(catalog?.connectors[0]?.http).not.toHaveProperty('allowedOrigins');\n expect(JSON.stringify({ manifest, catalog })).not.toContain('tenant-a.api.noodleseed.dev');\n expect(manifest.tools.find((tool) => tool.name === 'list_org_apps')?.authorization).toEqual({\n requiredScopes: ['org_apps:read'],\n allowedRoles: ['org_admin', 'org_member'],\n });\n expect(\n manifest.tools.find((tool) => tool.name === 'list_my_organizations')?.authorization,\n ).toBeUndefined();\n expect(manifest.tools.find((tool) => tool.name === 'archive_org_app')).toMatchObject({\n authorization: {\n requiredScopes: ['org_apps:write'],\n allowedRoles: ['org_admin'],\n },\n annotations: {\n readOnlyHint: false,\n destructiveHint: false,\n openWorldHint: true,\n confirm: true,\n },\n });\n });\n});\n" },
|
|
60
|
-
{ relPath: "examples/food-ordering/README.md", content: "# Food Ordering\n\n**Owns:** The flagship consumer ordering MCP App example: React view authoring, app-only helper tools,\ncaller-scoped cart state handles, invocation context, model-visible widget state/lifecycle, packaged image\nassets, portable structured elicitation, checkout handoff policy, host actions, CSP/permissions metadata,\nproduct-agent guidance, host-neutral distribution metadata, and widget preview coverage.\n\nFood Ordering is a generic, synthetic version of a live marketplace ordering app. It lets a user search\nstores, browse menus, customize an item, build a multi-line cart, review the order, and hand off checkout to\nan allowlisted example domain. It does not use real restaurant APIs, real checkout, customer credentials, or\nprivate customer data.\n\n## What It Shows\n\n| Capability | Example |\n| :--- | :--- |\n| Public entry tool | `open_ordering` returns structured fallback content and renders the React widget |\n| Product and distribution projections | `agentGuide` supplies grounded cross-capability guidance; `distribution` supplies listing, publisher, legal, image, and review facts separately from the runtime manifest |\n| App-only helper tools | `search_stores`, `load_menu`, `load_item`, `read_cart`, `sync_cart`, `prepare_checkout`; mutating widget-owned helpers use `confirm: false` (equivalent to omission) and execute directly because action hints alone never gate |\n| Durable cart state | `server(..., { state: { handles: { cart } }, use: { state } })` with caller scope and revision checks |\n| React app runtime kit | `@noodleseed/one/react` supplies app flow, shell/nav/view, async state, form, quantity, choice, and handoff primitives |\n| Multi-step widget flow | One React shell navigates stores, menu, item customization, cart, review, and handoff views through `useAppFlow` |\n| Invocation context | `server.context` sets locale/time-zone defaults, derives an ambient service area/date, and exposes optional host-supplied coordinates to tools and the reserved `noodle_context` MCP adapter; location is an untrusted convenience hint, never an authorization signal or a substitute for explicit input |\n| Structured missing input | `plan_order` uses `ctx.elicit` to collect a fulfilment method and date through embedded/headless forms, standard bidirectional elicitation, a linked MCP App form, or an exact structured conversational retry on stateless hosts |\n| Model-visible widget state | `useUpdateModelContext` publishes one cohesive replacement snapshot when supported; `useWidgetLifecycle` auto-publishes mounted/cancelled/dismissed and reports author-owned submitted milestones for future context (not host-presentation proof), while the user-triggered submit pairs `useSendFollowUpMessage` for an immediate reply |\n| Handoff | `handoff.allowedDomains` allows only `https://orders.example.com` checkout URLs |\n| Progressive enhancement | Non-Apps hosts still receive stores, featured items, and a readable fallback summary |\n| Fail-closed hydration | The React view treats only the unhydrated, pre-result `{}` envelope as pending; a hydrated empty success remains distinct. It surfaces `isError`, validates required records and identifiers, and withholds ordering actions from malformed results |\n\nThe example is intentionally richer than the generated starter, but each inline view still follows the\nsame default: one immediate purpose, one primary action, at most one subordinate action, and progressive\ndisclosure for the rest. Preview it at 280px before adding navigation or local CSS; loading, empty, stale,\nerror/retry, and success states must remain readable without nested vertical scrolling.\n\nLike the comprehensive default `noodle init my-app` scaffold, this flagship keeps the server feature-rich\nwhile making each individual widget view focused; server capability breadth and screen density are separate.\nThe compiled initial widget should normally remain under the 1 MiB performance recommendation; Noodle Seed's\nhard ceilings are 10 MiB per compiled widget and 20 MiB across one deployment. Run `noodle check` to see raw\nand gzip-estimated sizes. Deploy requests are gzip-compressed as one stream so repeated self-contained React\nruntime bytes deduplicate on the wire without a cross-tenant CDN. Keep menu images or large live datasets in assets/resources and app-only tools\nrather than embedding them into the initial HTML bundle.\n\n## Local Author Loop\n\n```sh\nnoodle validate\nnoodle test\nnoodle dev\n```\n\nThe same `server.ts` declares `distribution` metadata for host adapters. It references real packaged images\nand keeps listing copy, support/legal URLs, and positive/negative review scenarios outside the canonical App\nPackage and Runtime Artifact. Explicit OpenAI and Claude adapters project those facts with the generated\nproduct skill; installable plugin archives and directory-submission dossiers remain separate outputs.\n\nIn another terminal:\n\n```sh\nnoodle tools list\nnoodle tools call open_ordering --args '{\"customer\":\"Asha\",\"query\":\"noodles\"}'\nnoodle tools call summarize_ordering_options --args '{}'\n```\n\nWhen a developer finalizes visual feedback in the local Design experience, a coding agent can inspect the\nlatest project-local brief without a path or session id:\n\n```sh\nnoodle design inspect --latest --json\n```\n\nThe agent should locate the captured elements in this example's authored React source, preserve the listed\nbehavior and accessibility constraints, and verify every acceptance check before changing unrelated UI.\n\nFor Apps metadata conformance, start `noodle dev`, copy the loopback MCP endpoint, then run:\n\n```sh\nnpx @mcpjam/cli@latest apps conformance --url http://127.0.0.1:<port>/o/demo/food-ordering/mcp --quiet --format json\n```\n\n## Export an OpenAI plugin\n\nThis flagship includes the guided workflows, listing metadata, review cases, and image assets needed to test\nOpenAI export. See the public [product-agent guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#export-an-openai-package)\nfor the current package workflow and boundaries.\n\nAgainst its deployed MCP URL, generate the Food Ordering submission candidate with:\n\n```sh\nnoodle export plugin openai \\\n --state submission \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --output food-ordering-openai.zip\n```\n\nExtract `food-ordering-openai.zip` before using the portal. Upload\n`submission/chatgpt-app-submission.json` to the Codex-assisted import field and\n`submission/food-ordering-skill.zip` to **With MCP → Skills**. The outer ZIP is the complete review kit and\nis not itself a valid skill upload; `submission/README.md` repeats the portal steps.\n\nAfter registering that same URL in ChatGPT developer mode, substitute its real technical ID to generate the\nFood Ordering local test package:\n\n```sh\nnoodle export plugin openai \\\n --state local \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --registered-app-id plugin_asdk_app_0123456789abcdef0123456789abcdef \\\n --output food-ordering-openai-local.zip\n```\n\n## Export for Claude\n\nClaude Code plugin packaging and Anthropic Connector Directory review are separate outputs. Generate the\ninstallable plugin repository with:\n\n```sh\nnoodle export plugin claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --output food-ordering-claude.zip\n```\n\nGenerate the credential-free operator dossier for the remote Connector Directory with:\n\n```sh\nnoodle export connector claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --auth none \\\n --category \"Food & Drink\" \\\n --output food-ordering-anthropic-connector.zip\n```\n\nThe dossier is deliberately marked `portalUploadable: false`: it gathers the listing, tool annotations,\nuse cases, allowed-link candidates, test-account guidance, and MCP App screenshot evidence, but a human must\nverify ownership/compliance and enter the final answers in Anthropic's portal. The plugin ZIP does not\ncontain this dossier.\n\n## Client Setup\n\nUse the CLI to print the exact setup flow for your MCP client:\n\n```sh\nnoodle connect claude\nnoodle connect chatgpt\nnoodle connect inspector\n```\n\n## Deploy\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access owner-only\nnoodle open\n```\n\nThat one deploy command preflights the complete target, creates a missing app/environment, and verifies\nhosted readiness. If it is interrupted, rerun the same command to resume the unfinished operation without a\nduplicate deployment. Use `--access org-members` for an org-wide internal demo. This example has no\nconnector secrets and does not include tokens, caller-key mechanisms, or `.env.noodle` values.\n\n### Publish an immutable host archive\n\nOnly when this demo is intentionally being prepared for an external directory, deploy it with exact public\naccess and use the returned deployment ID to publish the matching local source:\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access public\nnoodle distributions publish <deployment-id> src/server.ts --target openai --category \"Food & Drink\"\nnoodle distributions list <deployment-id> --target openai\nnoodle distributions readiness <distribution-id> --status ready --note \"Archive and review evidence checked\"\nnoodle distributions release <distribution-id> --visibility private\nnoodle distributions grant <distribution-id> --expires-in 900\nnoodle distributions download <distribution-id> --output food-ordering-openai.zip\n```\n\nPublish fails if local `src/server.ts` no longer compiles to that deployment's package snapshot. Readiness is\nan explicit operator claim, the private release keeps anonymous discovery off, and the grant prints one\nsensitive exact-version reviewer URL. Record `noodle distributions review` only after a human observes the\nreal portal state. Public Noodle delivery, rollback, deprecation, and terminal revocation are separate explicit\nactions; none submits to a directory or claims acceptance.\n\n## Demo Assets\n\nThe packaged demo images live under `assets/`. The current app uses `assets/noodle-bowl.jpg` as the server\nbranding image. Its three distribution screenshots are real, response-only MCP App captures from Noodle\nDevtools at 2× device scale; each is 1640×970 PNG and has the producing user prompt next to its `asset(...)`\nreference in `server.ts`.\n\nImage sources:\n\n- `assets/noodle-bowl.jpg` — Unsplash photo\n [`IRv8V9Hb8gI`](https://unsplash.com/photos/IRv8V9Hb8gI), downloaded from Unsplash.\n- `assets/food-ordering-stores.png` — store-discovery state produced by “Help me build a noodle order for\n pickup.”\n- `assets/food-ordering-menu.png` — Harbor Noodles menu state produced by “Show me the Harbor Noodles\n menu.”\n- `assets/food-ordering-handoff.png` — checkout-handoff state produced by “Review my spicy miso bowl order\n before checkout.”\n\nThe Unsplash branding photo is free to use under the [Unsplash License](https://unsplash.com/license);\nattribution is not required, but the source note is kept here for provenance.\n" },
|
|
60
|
+
{ relPath: "examples/food-ordering/README.md", content: "# Food Ordering\n\n**Owns:** The flagship consumer ordering MCP App example: React view authoring, app-only helper tools,\ncaller-scoped cart state handles, invocation context, model-visible widget state/lifecycle, packaged image\nassets, portable structured elicitation, checkout handoff policy, host actions, CSP/permissions metadata,\nproduct-agent guidance, host-neutral distribution metadata, and widget preview coverage.\n\nFood Ordering is a generic, synthetic version of a live marketplace ordering app. It lets a user search\nstores, browse menus, customize an item, build a multi-line cart, review the order, and hand off checkout to\nan allowlisted example domain. It does not use real restaurant APIs, real checkout, customer credentials, or\nprivate customer data.\n\n## What It Shows\n\n| Capability | Example |\n| :--- | :--- |\n| Public entry tool | `open_ordering` returns structured fallback content and renders the React widget |\n| Product and distribution projections | `agentGuide` supplies grounded cross-capability guidance; `distribution` supplies listing, publisher, legal, image, and review facts separately from the runtime manifest |\n| App-only helper tools | `search_stores`, `load_menu`, `load_item`, `read_cart`, `sync_cart`, `prepare_checkout`; mutating widget-owned helpers use `confirm: false` (equivalent to omission) and execute directly because action hints alone never gate |\n| Durable cart state | `server(..., { state: { handles: { cart } }, use: { state } })` with caller scope and revision checks |\n| React app runtime kit | `@noodleseed/one/react` supplies app flow, shell/nav/view, async state, form, quantity, choice, and handoff primitives |\n| Multi-step widget flow | One React shell navigates stores, menu, item customization, cart, review, and handoff views through `useAppFlow` |\n| Invocation context | `server.context` sets locale/time-zone defaults, derives an ambient service area/date, and exposes optional host-supplied coordinates to tools and the reserved `noodle_context` MCP adapter; location is an untrusted convenience hint, never an authorization signal or a substitute for explicit input |\n| Structured missing input | `plan_order` uses `ctx.elicit` to collect a fulfilment method and date through embedded/headless forms, standard bidirectional elicitation, a linked MCP App form, or an exact structured conversational retry on stateless hosts |\n| Model-visible widget state | `useUpdateModelContext` publishes one cohesive replacement snapshot when supported; `useWidgetLifecycle` auto-publishes mounted/cancelled/dismissed and reports author-owned submitted milestones for future context (not host-presentation proof), while the user-triggered submit pairs `useSendFollowUpMessage` for an immediate reply |\n| Handoff | `handoff.allowedDomains` allows only `https://orders.example.com` checkout URLs |\n| Progressive enhancement | Non-Apps hosts still receive stores, featured items, and a readable fallback summary |\n| Fail-closed hydration | The React view treats only the unhydrated, pre-result `{}` envelope as pending; a hydrated empty success remains distinct. It surfaces `isError`, validates required records and identifiers, and withholds ordering actions from malformed results |\n| Upstream MCP composition | This synthetic example keeps its data local. For the canonical frozen-tool import, governed upstream invocation, response normalization, and Noodle-owned widget pattern, use the repository's `shopify-storefront` flagship rather than copying another composition surface here |\n\nThe example is intentionally richer than the generated starter, but each inline view still follows the\nsame default: one immediate purpose, one primary action, at most one subordinate action, and progressive\ndisclosure for the rest. Preview it at 280px before adding navigation or local CSS; loading, empty, stale,\nerror/retry, and success states must remain readable without nested vertical scrolling.\n\nLike the comprehensive default `noodle init my-app` scaffold, this flagship keeps the server feature-rich\nwhile making each individual widget view focused; server capability breadth and screen density are separate.\nThe compiled initial widget should normally remain under the 1 MiB performance recommendation; Noodle Seed's\nhard ceilings are 10 MiB per compiled widget and 20 MiB across one deployment. Run `noodle check` to see raw\nand gzip-estimated sizes. Deploy requests are gzip-compressed as one stream so repeated self-contained React\nruntime bytes deduplicate on the wire without a cross-tenant CDN. Keep menu images or large live datasets in assets/resources and app-only tools\nrather than embedding them into the initial HTML bundle.\n\n## Local Author Loop\n\n```sh\nnoodle validate\nnoodle test\nnoodle dev\n```\n\nThe same `server.ts` declares `distribution` metadata for host adapters. It references real packaged images\nand keeps listing copy, support/legal URLs, and positive/negative review scenarios outside the canonical App\nPackage and Runtime Artifact. Explicit OpenAI and Claude adapters project those facts with the generated\nproduct skill; installable plugin archives and directory-submission dossiers remain separate outputs.\n\nIn another terminal:\n\n```sh\nnoodle tools list\nnoodle tools call open_ordering --args '{\"customer\":\"Asha\",\"query\":\"noodles\"}'\nnoodle tools call summarize_ordering_options --args '{}'\n```\n\nWhen a developer finalizes visual feedback in the local Design experience, a coding agent can inspect the\nlatest project-local brief without a path or session id:\n\n```sh\nnoodle design inspect --latest --json\n```\n\nThe agent should locate the captured elements in this example's authored React source, preserve the listed\nbehavior and accessibility constraints, and verify every acceptance check before changing unrelated UI.\n\nFor Apps metadata conformance, start `noodle dev`, copy the loopback MCP endpoint, then run:\n\n```sh\nnpx @mcpjam/cli@latest apps conformance --url http://127.0.0.1:<port>/o/demo/food-ordering/mcp --quiet --format json\n```\n\n## Export an OpenAI plugin\n\nThis flagship includes the guided workflows, listing metadata, review cases, and image assets needed to test\nOpenAI export. See the public [product-agent guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#export-an-openai-package)\nfor the current package workflow and boundaries.\n\nAgainst its deployed MCP URL, generate the Food Ordering submission candidate with:\n\n```sh\nnoodle export plugin openai \\\n --state submission \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --output food-ordering-openai.zip\n```\n\nExtract `food-ordering-openai.zip` before using the portal. Upload\n`submission/chatgpt-app-submission.json` to the Codex-assisted import field and\n`submission/food-ordering-skill.zip` to **With MCP → Skills**. The outer ZIP is the complete review kit and\nis not itself a valid skill upload; `submission/README.md` repeats the portal steps.\n\nAfter registering that same URL in ChatGPT developer mode, substitute its real technical ID to generate the\nFood Ordering local test package:\n\n```sh\nnoodle export plugin openai \\\n --state local \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --registered-app-id plugin_asdk_app_0123456789abcdef0123456789abcdef \\\n --output food-ordering-openai-local.zip\n```\n\n## Export for Claude\n\nClaude Code plugin packaging and Anthropic Connector Directory review are separate outputs. Generate the\ninstallable plugin repository with:\n\n```sh\nnoodle export plugin claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --output food-ordering-claude.zip\n```\n\nGenerate the credential-free operator dossier for the remote Connector Directory with:\n\n```sh\nnoodle export connector claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --auth none \\\n --category \"Food & Drink\" \\\n --output food-ordering-anthropic-connector.zip\n```\n\nThe dossier is deliberately marked `portalUploadable: false`: it gathers the listing, tool annotations,\nuse cases, allowed-link candidates, test-account guidance, and MCP App screenshot evidence, but a human must\nverify ownership/compliance and enter the final answers in Anthropic's portal. The plugin ZIP does not\ncontain this dossier.\n\n## Client Setup\n\nUse the CLI to print the exact setup flow for your MCP client:\n\n```sh\nnoodle connect claude\nnoodle connect chatgpt\nnoodle connect inspector\n```\n\n## Deploy\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access owner-only\nnoodle open\n```\n\nThat one deploy command preflights the complete target, creates a missing app/environment, and verifies\nhosted readiness. If it is interrupted, rerun the same command to resume the unfinished operation without a\nduplicate deployment. Use `--access org-members` for an org-wide internal demo. This example has no\nconnector secrets and does not include tokens, caller-key mechanisms, or `.env.noodle` values.\n\n### Publish an immutable host archive\n\nOnly when this demo is intentionally being prepared for an external directory, deploy it with exact public\naccess and use the returned deployment ID to publish the matching local source:\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access public\nnoodle distributions publish <deployment-id> src/server.ts --target openai --category \"Food & Drink\"\nnoodle distributions list <deployment-id> --target openai\nnoodle distributions readiness <distribution-id> --status ready --note \"Archive and review evidence checked\"\nnoodle distributions release <distribution-id> --visibility private\nnoodle distributions grant <distribution-id> --expires-in 900\nnoodle distributions download <distribution-id> --output food-ordering-openai.zip\n```\n\nPublish fails if local `src/server.ts` no longer compiles to that deployment's package snapshot. Readiness is\nan explicit operator claim, the private release keeps anonymous discovery off, and the grant prints one\nsensitive exact-version reviewer URL. Record `noodle distributions review` only after a human observes the\nreal portal state. Public Noodle delivery, rollback, deprecation, and terminal revocation are separate explicit\nactions; none submits to a directory or claims acceptance.\n\n## Demo Assets\n\nThe packaged demo images live under `assets/`. The current app uses `assets/noodle-bowl.jpg` as the server\nbranding image. Its three distribution screenshots are real, response-only MCP App captures from Noodle\nDevtools at 2× device scale; each is 1640×970 PNG and has the producing user prompt next to its `asset(...)`\nreference in `server.ts`.\n\nImage sources:\n\n- `assets/noodle-bowl.jpg` — Unsplash photo\n [`IRv8V9Hb8gI`](https://unsplash.com/photos/IRv8V9Hb8gI), downloaded from Unsplash.\n- `assets/food-ordering-stores.png` — store-discovery state produced by “Help me build a noodle order for\n pickup.”\n- `assets/food-ordering-menu.png` — Harbor Noodles menu state produced by “Show me the Harbor Noodles\n menu.”\n- `assets/food-ordering-handoff.png` — checkout-handoff state produced by “Review my spicy miso bowl order\n before checkout.”\n\nThe Unsplash branding photo is free to use under the [Unsplash License](https://unsplash.com/license);\nattribution is not required, but the source note is kept here for provenance.\n" },
|
|
61
61
|
{ relPath: "examples/food-ordering/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"food-ordering\",\n \"template\": \"widget\"\n}\n" },
|
|
62
62
|
{ relPath: "examples/food-ordering/package.json", content: "{\n \"name\": \"food-ordering\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run test\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"latest\",\n \"@types/react\": \"latest\",\n \"@types/react-dom\": \"latest\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
63
63
|
{ relPath: "examples/food-ordering/src/agent-guide.ts", content: "import type { AgentGuideSource } from '@noodleseed/one';\n\n/** Product guidance for the model-visible ordering and planning workflows. */\nexport const FOOD_ORDERING_AGENT_GUIDE = {\n description:\n 'Use Food Ordering to browse synthetic local options, build a reviewable cart, and hand checkout to the user.',\n useWhen: [\n 'The user wants to browse nearby food or assemble an order.',\n 'The user wants a structured pickup or delivery plan before ordering.',\n ],\n workflows: [\n {\n id: 'build_order',\n title: 'Browse and build an order',\n steps: [\n {\n capability: { kind: 'tool', name: 'open_ordering' },\n guidance:\n 'Open the ordering app so the user can choose a store, review the cart, and control checkout handoff.',\n },\n ],\n },\n {\n id: 'summarize_options',\n title: 'Summarize available options',\n steps: [{ capability: { kind: 'tool', name: 'summarize_ordering_options' } }],\n },\n {\n id: 'plan_fulfilment',\n title: 'Plan pickup or delivery',\n steps: [\n {\n capability: { kind: 'tool', name: 'plan_order' },\n guidance: 'Collect the user’s fulfilment preference without claiming to place an order.',\n },\n ],\n },\n ],\n boundaries: [\n 'Treat every store, menu item, price, and service area in this example as synthetic.',\n 'Never claim checkout or payment completed; the final order happens only after the external handoff.',\n ],\n examples: [\n { prompt: 'Help me put together a noodle order.', workflow: 'build_order' },\n { prompt: 'What food options are available?', workflow: 'summarize_options' },\n { prompt: 'Plan a delivery for Friday.', workflow: 'plan_fulfilment' },\n ],\n} as const satisfies AgentGuideSource;\n" },
|
|
64
64
|
{ relPath: "examples/food-ordering/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\n\nexport {\n ActionBar,\n AppShell,\n AsyncBoundary,\n ChoiceGroup,\n createViewStore,\n DataCard,\n DataList,\n Feedback,\n Field,\n Form,\n HandoffButton,\n QuantityStepper,\n ShellNav,\n StatusBadge,\n SubmitButton,\n View,\n ViewStack,\n} from '@noodleseed/one/react';\n\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const {\n useCallTool,\n useAppFlow,\n useHandoff,\n useLayout,\n useOpenExternal,\n useSendFollowUpMessage,\n useToolInfo,\n useUpdateModelContext,\n useViewState,\n useWidgetLifecycle,\n useWidgetReady,\n} = generateHelpers<AppType>();\n" },
|
|
65
|
-
{ relPath: "examples/food-ordering/src/server.ts", content: "import { annotations, asset, connector, resource, server, tool, z } from '@noodleseed/one';\nimport { FOOD_ORDERING_AGENT_GUIDE } from './agent-guide.js';\n\nconst heroImage = asset('assets/noodle-bowl.jpg');\nconst storesScreenshot = asset('assets/food-ordering-stores.png');\nconst menuScreenshot = asset('assets/food-ordering-menu.png');\nconst handoffScreenshot = asset('assets/food-ordering-handoff.png');\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({\n handle: z.string(),\n key: z.string().optional(),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int(),\n value: z.record(z.string(), z.unknown()),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n });\n\nconst stores = [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n {\n id: 'garden-wraps',\n name: 'Garden Wraps',\n cuisine: 'Vegetarian',\n address: '44 Market Street',\n open: true,\n etaMinutes: 18,\n rating: 4.6,\n },\n {\n id: 'midnight-tacos',\n name: 'Midnight Tacos',\n cuisine: 'Mexican',\n address: '7 Station Road',\n open: false,\n etaMinutes: 35,\n rating: 4.7,\n },\n] as const;\n\nconst menu = [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth, wheat noodles, chili crisp, egg, and greens.',\n modifiers: ['extra_noodles', 'soft_egg', 'chili_crisp'],\n },\n {\n id: 'ginger_tofu',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Ginger Tofu Bowl',\n price: 15,\n description: 'Tofu, ginger broth, mushrooms, and scallions.',\n modifiers: ['extra_tofu', 'brown_rice', 'no_mushroom'],\n },\n {\n id: 'green_falafel',\n storeId: 'garden-wraps',\n category: 'Wraps',\n name: 'Green Falafel Wrap',\n price: 13,\n description: 'Falafel, herbs, pickles, tahini, and crisp vegetables.',\n modifiers: ['extra_tahini', 'add_fries', 'gluten_free_wrap'],\n },\n {\n id: 'sweet_potato',\n storeId: 'garden-wraps',\n category: 'Plates',\n name: 'Sweet Potato Plate',\n price: 14,\n description: 'Roasted sweet potato, grains, greens, and lemon yogurt.',\n modifiers: ['vegan_yogurt', 'extra_greens', 'hot_sauce'],\n },\n] as const;\n\nconst cartLine = z.object({\n itemId: z.string(),\n quantity: z.number().int().min(1),\n modifiers: z.array(z.string()).default([]),\n note: z.string().optional(),\n});\n\nconst cartInput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine).default([]),\n customer: z.string().default('Guest'),\n notes: z.string().optional(),\n subtotal: z.number().default(0),\n expectedRevision: z.number().int().min(0).default(0),\n});\n\nconst storeShape = z.object({\n id: z.string(),\n name: z.string(),\n cuisine: z.string(),\n address: z.string(),\n open: z.boolean(),\n etaMinutes: z.number(),\n rating: z.number(),\n});\n\nconst menuItemShape = z.object({\n id: z.string(),\n storeId: z.string(),\n category: z.string(),\n name: z.string(),\n price: z.number(),\n description: z.string(),\n // Nested lists count toward the output budget too: unbounded modifiers multiply by every item in a\n // menu payload, so the ceiling is declared here rather than only on the outer array.\n modifiers: z.array(z.string()).max(10),\n});\n\nconst cartOutput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n status: z.enum(['draft', 'review', 'handoff']),\n checkoutUrl: z.string().optional(),\n});\n\nconst cartStateSchema = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n // `.default()`/`.optional()` state fields are optional on write: a cart save that omits\n // `status` still validates, and a fresh cart starts in `draft`.\n status: z.enum(['draft', 'review', 'handoff']).default('draft'),\n checkoutUrl: z.string().optional(),\n});\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartInput = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly expectedRevision: number;\n};\n\nconst readOnly = annotations.readOnly();\n// These writes are app-only controls inside the cart widget. The widget already presents the\n// reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent\n// to omission because action/open-world hints alone never enable the confirmation gate.\nconst action = annotations.openAction({ destructive: false, confirm: false });\n\nfunction checkoutUrl(customer: string): string {\n return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;\n}\n\nfunction cartValue(input: CartInput, status: 'draft' | 'review' | 'handoff') {\n return {\n selectedStoreId: input.selectedStoreId,\n lines: input.lines,\n customer: input.customer,\n notes: input.notes,\n subtotal: input.subtotal,\n status,\n ...(status === 'handoff' ? { checkoutUrl: 'https://orders.example.com/checkout' } : {}),\n };\n}\n\nexport default server(\n 'food_ordering',\n {\n title: 'Food Ordering',\n version: '1.0.0',\n agentGuide: FOOD_ORDERING_AGENT_GUIDE,\n distribution: {\n listing: {\n summary: 'Build a pickup noodle order.',\n description:\n 'Food Ordering is a synthetic MCP App that demonstrates store discovery, menu browsing, a caller-scoped cart, fulfilment planning, and explicit checkout handoff.',\n keywords: ['food', 'ordering', 'delivery'],\n },\n publisher: {\n name: 'Noodle Seed Examples',\n websiteUrl: 'https://noodleseed.com',\n },\n support: {\n documentationUrl: 'https://docs.noodleseed.com/examples/food-ordering',\n supportUrl: 'https://noodleseed.com/support',\n },\n legal: {\n privacyPolicyUrl: 'https://noodleseed.com/privacy',\n termsOfServiceUrl: 'https://noodleseed.com/terms',\n },\n assets: {\n icon: { source: heroImage, alt: 'Food Ordering noodle bowl' },\n screenshots: [\n {\n source: storesScreenshot,\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n },\n {\n source: menuScreenshot,\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n },\n {\n source: handoffScreenshot,\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n },\n ],\n },\n review: {\n instructions:\n 'Use the synthetic menu and guest cart. No account or reviewer credential is required.',\n scenarios: [\n {\n id: 'build_order',\n prompt: 'Help me build a noodle order for pickup.',\n expected:\n 'The ordering app opens with stores and menu items; checkout remains a handoff.',\n shouldInvoke: true,\n tools: ['open_ordering'],\n },\n {\n id: 'browse_menu',\n prompt: 'Show me vegetarian menu options nearby.',\n expected: 'The app shows matching stores and bounded menu choices.',\n shouldInvoke: true,\n tools: ['search_stores', 'load_menu'],\n },\n {\n id: 'compare_options',\n prompt: 'Compare the quickest open food options for me.',\n expected: 'The app grounds its comparison in the synthetic store data.',\n shouldInvoke: true,\n tools: ['search_stores', 'summarize_ordering_options'],\n },\n {\n id: 'plan_pickup',\n prompt: 'Plan a pickup order for Friday.',\n expected: 'The app collects the missing fulfilment details before planning the order.',\n shouldInvoke: true,\n tools: ['plan_order'],\n },\n {\n id: 'review_checkout',\n prompt: 'Review my cart before I continue to checkout.',\n expected: 'The app shows the cart and keeps payment on the explicit external handoff.',\n shouldInvoke: true,\n tools: ['read_cart', 'prepare_checkout'],\n },\n // Negative scenarios are non-invocation cases, so they never declare expected tools.\n {\n id: 'unrelated_weather',\n prompt: 'Will it rain tomorrow?',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_email',\n prompt: 'Draft an email to my manager.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_travel',\n prompt: 'Book me a flight to Lisbon.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n ],\n },\n },\n use: { state },\n context: {\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),\n fulfil: ({ context }) => ({\n serviceArea: 'Harbor District',\n orderingDate: context.temporal.localDate,\n }),\n },\n },\n state: {\n handles: {\n cart: {\n kind: 'cart',\n version: 'v1',\n scope: 'caller',\n ttlSeconds: 7200,\n schema: cartStateSchema,\n },\n },\n },\n branding: {\n name: 'Food Ordering',\n accent: '#0F8F5F',\n surface: '#F7F7F5',\n surfaceDark: '#111820',\n logo: {\n uri: heroImage,\n alt: 'Food Ordering noodle bowl',\n },\n radius: 'lg',\n density: 'comfortable',\n },\n handoff: {\n allowedDomains: ['https://orders.example.com'],\n },\n },\n [\n tool('open_ordering', {\n title: 'Open food ordering',\n description:\n 'Open a complete food-ordering widget with store discovery, menu browsing, cart review, and checkout handoff.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n customer: z.string().default('Guest'),\n }),\n // List outputs declare a ceiling so a host and the model both know the payload is bounded.\n // A recorded `fulfil` cannot slice, so the cap belongs on the shape; connector-backed lists take\n // a pagination input instead. `noodle check` reports `tool_design_output_bounds` without one.\n output: z.object({\n status: z.string(),\n customer: z.string(),\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n localDate: z.string(),\n serviceArea: z.string(),\n location: z.object({\n latitude: z.number().optional(),\n longitude: z.number().optional(),\n }),\n fallback: z.string(),\n }),\n fulfil: ({ input, context }) => ({\n status: 'Ready to build a food order.',\n customer: input.customer,\n stores,\n featuredItems: menu,\n localDate: context.temporal.localDate,\n serviceArea: context.ambient.serviceArea,\n location: {\n latitude: context.location.latitude.optional(),\n longitude: context.location.longitude.optional(),\n },\n fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',\n }),\n viewTitle: 'Food ordering',\n domain: 'https://orders.example.com',\n view: {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n viewDescription:\n 'A complete consumer ordering surface with app-only helper tools, cart state, and checkout handoff.',\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n tool('search_stores', {\n title: 'Search stores',\n visibility: ['app'],\n description: 'Filter synthetic restaurants for the ordering widget.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n openOnly: z.boolean().default(false),\n }),\n output: z.object({ stores: z.array(storeShape) }),\n fulfil: () => ({ stores }),\n }),\n tool('load_menu', {\n title: 'Load store menu',\n visibility: ['app'],\n description: 'Load synthetic menu categories and items for one store.',\n annotations: readOnly,\n input: z.object({ storeId: z.string() }),\n output: z.object({\n storeId: z.string(),\n stores: z.array(storeShape),\n items: z.array(menuItemShape),\n }),\n fulfil: ({ input }) => ({ storeId: input.storeId, stores, items: menu }),\n }),\n tool('load_item', {\n title: 'Load menu item',\n visibility: ['app'],\n description: 'Load item details and modifier options for the ordering widget.',\n annotations: readOnly,\n input: z.object({ itemId: z.string() }),\n output: z.object({ itemId: z.string(), items: z.array(menuItemShape) }),\n fulfil: ({ input }) => ({ itemId: input.itemId, items: menu }),\n }),\n tool('read_cart', {\n title: 'Read ordering cart',\n visibility: ['app'],\n description: 'Read the caller-scoped ordering cart state.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n value: z.unknown(),\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ connectors }) => {\n const state = connectors.state.readState({ handle: 'cart' });\n return { value: state.value, revision: state.revision, status: state.status };\n },\n }),\n tool('sync_cart', {\n title: 'Update ordering cart',\n visibility: ['app'],\n description: 'Patch the caller-scoped ordering cart with the widget cart mirror.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'draft');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return { cart, revision: state.revision, status: state.status };\n },\n }),\n tool('prepare_checkout', {\n title: 'Prepare checkout handoff',\n visibility: ['app'],\n description: 'Prepare the caller-scoped cart for checkout handoff.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n checkoutUrl: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'handoff');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return {\n cart,\n revision: state.revision,\n checkoutUrl: cart.checkoutUrl ?? checkoutUrl(input.customer),\n };\n },\n }),\n tool('summarize_ordering_options', {\n title: 'Summarize ordering options',\n description: 'Summarize available stores and menu examples without opening the widget.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n }),\n fulfil: () => ({ stores, featuredItems: menu }),\n }),\n tool('plan_order', {\n title: 'Plan an order',\n description:\n 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n customer: z.string(),\n method: z.enum(['pickup', 'delivery']),\n requestedDate: z.string(),\n serviceArea: z.string(),\n }),\n fulfil: ({ input, context, elicit }) => {\n const preference = elicit({\n id: 'choose_fulfilment',\n message: 'How should we fulfil this order?',\n input: z.object({\n method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),\n requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),\n }),\n });\n return {\n customer: input.customer,\n method: preference.method,\n requestedDate: preference.requestedDate,\n serviceArea: context.ambient.serviceArea,\n };\n },\n }),\n tool('show_capabilities', {\n title: 'Show capabilities',\n description: 'Return a concise summary for the standalone widget capability preview.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({ status: z.string(), note: z.string() }),\n fulfil: () => ({\n status: 'Food Ordering widget capabilities are ready.',\n note: 'Standalone preview covers React views, helper tools, cart state, handoff, CSP, and permissions.',\n }),\n viewName: 'capabilities_card',\n viewTitle: 'Food Ordering capabilities',\n viewDescription: 'Standalone widget resource for previewing the ordering capability surface.',\n domain: 'https://orders.example.com',\n view: { component: 'capabilities-card', entry: './views/capabilities-card.tsx' },\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n resource('food_ordering_guide', {\n uri: 'docs://food-ordering',\n title: 'Food Ordering widget guide',\n description: 'Synthetic guide resource for the consumer ordering flagship.',\n mimeType: 'text/markdown',\n // Return the resource body directly; the runtime maps it into MCP `contents` using the\n // resource's own uri + mimeType. Do not return a `{ contents: [...] }` wrapper — that double-wraps.\n fulfil: () =>\n [\n '# Food Ordering Widget Guide',\n '',\n '- Demonstrates a multi-step ordering widget, app-only helper tools, typed cart state, and handoff.',\n '- Store, menu, and checkout data are synthetic and contain no customer credentials.',\n '- Checkout opens an allowlisted example URL; payment and final ordering remain out of scope.',\n ].join('\\n'),\n }),\n ],\n);\n" },
|
|
65
|
+
{ relPath: "examples/food-ordering/src/server.ts", content: "import { annotations, asset, connector, resource, server, tool, z } from '@noodleseed/one';\nimport { FOOD_ORDERING_AGENT_GUIDE } from './agent-guide.js';\n\nconst heroImage = asset('assets/noodle-bowl.jpg');\nconst storesScreenshot = asset('assets/food-ordering-stores.png');\nconst menuScreenshot = asset('assets/food-ordering-menu.png');\nconst handoffScreenshot = asset('assets/food-ordering-handoff.png');\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({\n handle: z.string(),\n key: z.string().optional(),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int(),\n value: z.record(z.string(), z.unknown()),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n });\n\nconst stores = [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n {\n id: 'garden-wraps',\n name: 'Garden Wraps',\n cuisine: 'Vegetarian',\n address: '44 Market Street',\n open: true,\n etaMinutes: 18,\n rating: 4.6,\n },\n {\n id: 'midnight-tacos',\n name: 'Midnight Tacos',\n cuisine: 'Mexican',\n address: '7 Station Road',\n open: false,\n etaMinutes: 35,\n rating: 4.7,\n },\n] as const;\n\nconst menu = [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth, wheat noodles, chili crisp, egg, and greens.',\n modifiers: ['extra_noodles', 'soft_egg', 'chili_crisp'],\n },\n {\n id: 'ginger_tofu',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Ginger Tofu Bowl',\n price: 15,\n description: 'Tofu, ginger broth, mushrooms, and scallions.',\n modifiers: ['extra_tofu', 'brown_rice', 'no_mushroom'],\n },\n {\n id: 'green_falafel',\n storeId: 'garden-wraps',\n category: 'Wraps',\n name: 'Green Falafel Wrap',\n price: 13,\n description: 'Falafel, herbs, pickles, tahini, and crisp vegetables.',\n modifiers: ['extra_tahini', 'add_fries', 'gluten_free_wrap'],\n },\n {\n id: 'sweet_potato',\n storeId: 'garden-wraps',\n category: 'Plates',\n name: 'Sweet Potato Plate',\n price: 14,\n description: 'Roasted sweet potato, grains, greens, and lemon yogurt.',\n modifiers: ['vegan_yogurt', 'extra_greens', 'hot_sauce'],\n },\n] as const;\n\nconst cartLine = z.object({\n itemId: z.string(),\n quantity: z.number().int().min(1),\n modifiers: z.array(z.string()).default([]),\n note: z.string().optional(),\n});\n\nconst cartInput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine).default([]),\n customer: z.string().default('Guest'),\n notes: z.string().optional(),\n subtotal: z.number().default(0),\n expectedRevision: z.number().int().min(0).default(0),\n});\n\nconst storeShape = z.object({\n id: z.string(),\n name: z.string(),\n cuisine: z.string(),\n address: z.string(),\n open: z.boolean(),\n etaMinutes: z.number(),\n rating: z.number(),\n});\n\nconst menuItemShape = z.object({\n id: z.string(),\n storeId: z.string(),\n category: z.string(),\n name: z.string(),\n price: z.number(),\n description: z.string(),\n // Nested lists count toward the output budget too: unbounded modifiers multiply by every item in a\n // menu payload, so the ceiling is declared here rather than only on the outer array.\n modifiers: z.array(z.string()).max(10),\n});\n\nconst cartOutput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n status: z.enum(['draft', 'review', 'handoff']),\n checkoutUrl: z.string().optional(),\n});\n\nconst cartStateSchema = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n // `.default()`/`.optional()` state fields are optional on write: a cart save that omits\n // `status` still validates, and a fresh cart starts in `draft`.\n status: z.enum(['draft', 'review', 'handoff']).default('draft'),\n checkoutUrl: z.string().optional(),\n});\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartInput = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly expectedRevision: number;\n};\n\nconst readOnly = annotations.readOnly();\n// These writes are app-only controls inside the cart widget. The widget already presents the\n// reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent\n// to omission because action/open-world hints alone never enable the confirmation gate.\nconst action = annotations.openAction({ destructive: false, confirm: false });\n\nfunction checkoutUrl(customer: string): string {\n return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;\n}\n\nfunction cartValue(input: CartInput, status: 'draft' | 'review' | 'handoff') {\n return {\n selectedStoreId: input.selectedStoreId,\n lines: input.lines,\n customer: input.customer,\n notes: input.notes,\n subtotal: input.subtotal,\n status,\n ...(status === 'handoff' ? { checkoutUrl: 'https://orders.example.com/checkout' } : {}),\n };\n}\n\nexport default server(\n 'food_ordering',\n {\n title: 'Food Ordering',\n version: '1.0.0',\n agentGuide: FOOD_ORDERING_AGENT_GUIDE,\n distribution: {\n listing: {\n summary: 'Build a pickup noodle order.',\n description:\n 'Food Ordering is a synthetic MCP App that demonstrates store discovery, menu browsing, a caller-scoped cart, fulfilment planning, and explicit checkout handoff.',\n keywords: ['food', 'ordering', 'delivery'],\n },\n publisher: {\n name: 'Noodle Seed Examples',\n websiteUrl: 'https://noodleseed.com',\n },\n support: {\n documentationUrl: 'https://docs.noodleseed.com/examples/food-ordering',\n supportUrl: 'https://noodleseed.com/support',\n },\n legal: {\n privacyPolicyUrl: 'https://noodleseed.com/privacy',\n termsOfServiceUrl: 'https://noodleseed.com/terms',\n },\n assets: {\n icon: { source: heroImage, alt: 'Food Ordering noodle bowl' },\n screenshots: [\n {\n source: storesScreenshot,\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n },\n {\n source: menuScreenshot,\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n },\n {\n source: handoffScreenshot,\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n },\n ],\n },\n review: {\n instructions:\n 'Use the synthetic menu and guest cart. No account or reviewer credential is required.',\n scenarios: [\n {\n id: 'build_order',\n prompt: 'Help me build a noodle order for pickup.',\n expected:\n 'The ordering app opens with stores and menu items; checkout remains a handoff.',\n shouldInvoke: true,\n tools: ['open_ordering'],\n },\n {\n id: 'browse_menu',\n prompt: 'Show me vegetarian menu options nearby.',\n expected: 'The app shows matching stores and bounded menu choices.',\n shouldInvoke: true,\n tools: ['search_stores', 'load_menu'],\n },\n {\n id: 'compare_options',\n prompt: 'Compare the quickest open food options for me.',\n expected: 'The app grounds its comparison in the synthetic store data.',\n shouldInvoke: true,\n tools: ['search_stores', 'summarize_ordering_options'],\n },\n {\n id: 'plan_pickup',\n prompt: 'Plan a pickup order for Friday.',\n expected: 'The app collects the missing fulfilment details before planning the order.',\n shouldInvoke: true,\n tools: ['plan_order'],\n },\n {\n id: 'review_checkout',\n prompt: 'Review my cart before I continue to checkout.',\n expected: 'The app shows the cart and keeps payment on the explicit external handoff.',\n shouldInvoke: true,\n tools: ['read_cart', 'prepare_checkout'],\n },\n // Negative scenarios are non-invocation cases, so they never declare expected tools.\n {\n id: 'unrelated_weather',\n prompt: 'Will it rain tomorrow?',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_email',\n prompt: 'Draft an email to my manager.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_travel',\n prompt: 'Book me a flight to Lisbon.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n ],\n },\n },\n use: { state },\n context: {\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),\n fulfil: ({ context }) => ({\n serviceArea: 'Harbor District',\n orderingDate: context.temporal.localDate,\n }),\n },\n },\n state: {\n handles: {\n cart: {\n kind: 'cart',\n version: 'v1',\n scope: 'caller',\n ttlSeconds: 7200,\n schema: cartStateSchema,\n },\n },\n },\n branding: {\n name: 'Food Ordering',\n accent: '#0F8F5F',\n surface: '#F7F7F5',\n surfaceDark: '#111820',\n logo: {\n uri: heroImage,\n alt: 'Food Ordering noodle bowl',\n },\n radius: 'lg',\n density: 'comfortable',\n },\n handoff: {\n allowedDomains: ['https://orders.example.com'],\n },\n },\n [\n tool('open_ordering', {\n title: 'Open food ordering',\n description:\n 'Open a complete food-ordering widget with store discovery, menu browsing, cart review, and checkout handoff.',\n annotations: readOnly,\n modelVisibility: {\n latestMessageIncludesAny: [\n 'order',\n 'food',\n 'menu',\n 'restaurant',\n 'cart',\n 'pickup',\n 'delivery',\n 'checkout',\n ],\n },\n input: z.object({\n query: z.string().optional(),\n customer: z.string().default('Guest'),\n }),\n // List outputs declare a ceiling so a host and the model both know the payload is bounded.\n // A recorded `fulfil` cannot slice, so the cap belongs on the shape; connector-backed lists take\n // a pagination input instead. `noodle check` reports `tool_design_output_bounds` without one.\n output: z.object({\n status: z.string(),\n customer: z.string(),\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n localDate: z.string(),\n serviceArea: z.string(),\n location: z.object({\n latitude: z.number().optional(),\n longitude: z.number().optional(),\n }),\n fallback: z.string(),\n }),\n fulfil: ({ input, context }) => ({\n status: 'Ready to build a food order.',\n customer: input.customer,\n stores,\n featuredItems: menu,\n localDate: context.temporal.localDate,\n serviceArea: context.ambient.serviceArea,\n location: {\n latitude: context.location.latitude.optional(),\n longitude: context.location.longitude.optional(),\n },\n fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',\n }),\n viewTitle: 'Food ordering',\n domain: 'https://orders.example.com',\n view: {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n viewDescription:\n 'A complete consumer ordering surface with app-only helper tools, cart state, and checkout handoff.',\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n tool('search_stores', {\n title: 'Search stores',\n visibility: ['app'],\n description: 'Filter synthetic restaurants for the ordering widget.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n openOnly: z.boolean().default(false),\n }),\n output: z.object({ stores: z.array(storeShape) }),\n fulfil: () => ({ stores }),\n }),\n tool('load_menu', {\n title: 'Load store menu',\n visibility: ['app'],\n description: 'Load synthetic menu categories and items for one store.',\n annotations: readOnly,\n input: z.object({ storeId: z.string() }),\n output: z.object({\n storeId: z.string(),\n stores: z.array(storeShape),\n items: z.array(menuItemShape),\n }),\n fulfil: ({ input }) => ({ storeId: input.storeId, stores, items: menu }),\n }),\n tool('load_item', {\n title: 'Load menu item',\n visibility: ['app'],\n description: 'Load item details and modifier options for the ordering widget.',\n annotations: readOnly,\n input: z.object({ itemId: z.string() }),\n output: z.object({ itemId: z.string(), items: z.array(menuItemShape) }),\n fulfil: ({ input }) => ({ itemId: input.itemId, items: menu }),\n }),\n tool('read_cart', {\n title: 'Read ordering cart',\n visibility: ['app'],\n description: 'Read the caller-scoped ordering cart state.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n value: z.unknown(),\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ connectors }) => {\n const state = connectors.state.readState({ handle: 'cart' });\n return { value: state.value, revision: state.revision, status: state.status };\n },\n }),\n tool('sync_cart', {\n title: 'Update ordering cart',\n visibility: ['app'],\n description: 'Patch the caller-scoped ordering cart with the widget cart mirror.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'draft');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return { cart, revision: state.revision, status: state.status };\n },\n }),\n tool('prepare_checkout', {\n title: 'Prepare checkout handoff',\n visibility: ['app'],\n description: 'Prepare the caller-scoped cart for checkout handoff.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n checkoutUrl: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'handoff');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return {\n cart,\n revision: state.revision,\n checkoutUrl: cart.checkoutUrl ?? checkoutUrl(input.customer),\n };\n },\n }),\n tool('summarize_ordering_options', {\n title: 'Summarize ordering options',\n description: 'Summarize available stores and menu examples without opening the widget.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n }),\n fulfil: () => ({ stores, featuredItems: menu }),\n }),\n tool('plan_order', {\n title: 'Plan an order',\n description:\n 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n customer: z.string(),\n method: z.enum(['pickup', 'delivery']),\n requestedDate: z.string(),\n serviceArea: z.string(),\n }),\n fulfil: ({ input, context, elicit }) => {\n const preference = elicit({\n id: 'choose_fulfilment',\n message: 'How should we fulfil this order?',\n input: z.object({\n method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),\n requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),\n }),\n });\n return {\n customer: input.customer,\n method: preference.method,\n requestedDate: preference.requestedDate,\n serviceArea: context.ambient.serviceArea,\n };\n },\n }),\n tool('show_capabilities', {\n title: 'Show capabilities',\n description: 'Return a concise summary for the standalone widget capability preview.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({ status: z.string(), note: z.string() }),\n fulfil: () => ({\n status: 'Food Ordering widget capabilities are ready.',\n note: 'Standalone preview covers React views, helper tools, cart state, handoff, CSP, and permissions.',\n }),\n viewName: 'capabilities_card',\n viewTitle: 'Food Ordering capabilities',\n viewDescription: 'Standalone widget resource for previewing the ordering capability surface.',\n domain: 'https://orders.example.com',\n view: { component: 'capabilities-card', entry: './views/capabilities-card.tsx' },\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n resource('food_ordering_guide', {\n uri: 'docs://food-ordering',\n title: 'Food Ordering widget guide',\n description: 'Synthetic guide resource for the consumer ordering flagship.',\n mimeType: 'text/markdown',\n // Return the resource body directly; the runtime maps it into MCP `contents` using the\n // resource's own uri + mimeType. Do not return a `{ contents: [...] }` wrapper — that double-wraps.\n fulfil: () =>\n [\n '# Food Ordering Widget Guide',\n '',\n '- Demonstrates a multi-step ordering widget, app-only helper tools, typed cart state, and handoff.',\n '- Store, menu, and checkout data are synthetic and contain no customer credentials.',\n '- Checkout opens an allowlisted example URL; payment and final ordering remain out of scope.',\n ].join('\\n'),\n }),\n ],\n);\n" },
|
|
66
66
|
{ relPath: "examples/food-ordering/src/views/capabilities-card.tsx", content: "import { useLayout, useOpenExternal, useSendFollowUpMessage, useToolInfo } from '../helpers.js';\nimport './widget-style.css';\n\nconst capabilities: readonly {\n readonly name: string;\n readonly description: string;\n}[] = [\n {\n name: 'React view resource',\n description: 'Compiled widget HTML with a hydrated React entrypoint.',\n },\n {\n name: 'Widget helper calls',\n description: 'App-only tools route through the same host-mediated path.',\n },\n {\n name: 'Durable cart state',\n description: 'Caller-scoped cart records use Noodle state handles with revisions.',\n },\n {\n name: 'External handoff',\n description: 'Checkout opens through declared allowlisted domains.',\n },\n {\n name: 'Model context',\n description: 'Relevant UI state is mirrored through data-llm.',\n },\n {\n name: 'CSP and permissions',\n description: 'Resource metadata declares network and clipboard needs.',\n },\n] as const;\n\nexport default function CapabilitiesCard() {\n const { theme } = useLayout();\n const toolInfo = useToolInfo('show_capabilities');\n const openExternal = useOpenExternal();\n const sendFollowUpMessage = useSendFollowUpMessage();\n const structured = toolInfo.structuredContent as\n | { readonly status?: string; readonly note?: string }\n | undefined;\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Food Ordering capabilities: ${capabilities.map((item) => item.name).join(', ')}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <PuzzleIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">MCP capabilities</h1>\n <p className=\"nw-subtitle\">\n {structured?.status ?? 'Food Ordering widget capabilities are ready.'}\n </p>\n </div>\n <span className=\"nw-chip\">Connected</span>\n </header>\n <div className=\"nw-body\">\n <p className=\"nw-section-title\">Available features</p>\n <ul className=\"nw-feature-list\">\n {capabilities.map((capability) => (\n <li className=\"nw-feature\" key={capability.name}>\n <span className=\"nw-check\" aria-hidden=\"true\">\n <CheckIcon />\n </span>\n <span>\n <span className=\"nw-feature-name\">{capability.name}</span>\n <span className=\"nw-feature-desc\">{capability.description}</span>\n </span>\n </li>\n ))}\n </ul>\n <div className=\"nw-actions\">\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n onClick={() =>\n sendFollowUpMessage({\n prompt: 'Summarize what the Food Ordering MCP App widget demonstrates.',\n })\n }\n >\n <SparkIcon />\n Ask assistant\n </button>\n <button\n className=\"nw-button\"\n type=\"button\"\n onClick={() => openExternal('https://example.com/noodle-widget-docs')}\n >\n <ExternalIcon />\n Open docs\n </button>\n </div>\n {structured?.note ? <p className=\"nw-note\">{structured.note}</p> : null}\n </div>\n <footer className=\"nw-footer\">\n <span className=\"nw-meta\">\n <ShieldIcon />\n Secure widget surface\n </span>\n <span>v1.0.0</span>\n </footer>\n </section>\n </main>\n );\n}\n\nfunction PuzzleIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M8 4h5v4h3a2 2 0 0 1 0 4h-3v3H9v-3H6a2 2 0 0 1 0-4h2V4Z\" />\n <path d=\"M13 15v5H4v-5\" />\n <path d=\"M13 20h7v-8\" />\n </svg>\n );\n}\n\nfunction CheckIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m6 12 4 4 8-8\" />\n </svg>\n );\n}\n\nfunction SparkIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n\nfunction ShieldIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M12 3 5 6v5c0 4.4 2.8 8.3 7 10 4.2-1.7 7-5.6 7-10V6l-7-3Z\" />\n <path d=\"m9 12 2 2 4-4\" />\n </svg>\n );\n}\n" },
|
|
67
67
|
{ relPath: "examples/food-ordering/src/views/ordering-flow.tsx", content: "import { useEffect, useMemo, useState } from 'react';\nimport {\n ActionBar,\n AppShell,\n AsyncBoundary,\n ChoiceGroup,\n createViewStore,\n DataCard,\n DataList,\n Feedback,\n Field,\n Form,\n HandoffButton,\n QuantityStepper,\n ShellNav,\n StatusBadge,\n SubmitButton,\n useAppFlow,\n useCallTool,\n useHandoff,\n useLayout,\n useSendFollowUpMessage,\n useToolInfo,\n useUpdateModelContext,\n useViewState,\n useWidgetLifecycle,\n useWidgetReady,\n View,\n ViewStack,\n} from '../helpers.js';\nimport { isOrderingEntryResult, type MenuItem, type Store } from './ordering-result.js';\nimport './widget-style.css';\n\ntype ViewName = 'stores' | 'menu' | 'item' | 'cart' | 'review' | 'handoff';\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartState = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly status: 'draft' | 'review' | 'handoff';\n readonly checkoutUrl?: string;\n};\n\nconst zeroCart: CartState = {\n lines: [],\n customer: 'Guest',\n subtotal: 0,\n status: 'draft',\n};\n\nconst useCartStore = createViewStore<CartState>('cart', zeroCart);\nconst useCartRevisionStore = createViewStore('cart_revision', { value: 0 });\n\nfunction structured<T>(value: unknown): T | undefined {\n return (value as { structuredContent?: T } | undefined)?.structuredContent;\n}\n\nfunction currency(value: number): string {\n return `$${value.toFixed(2)}`;\n}\n\nfunction modifierLabel(value: string): string {\n return value\n .split('_')\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ');\n}\n\nexport default function OrderingFlow() {\n const ready = useWidgetReady();\n const { displayMode, supports, theme } = useLayout();\n const toolInfo = useToolInfo('open_ordering');\n const isPending = !ready || Object.keys(toolInfo).length === 0;\n const entry = isOrderingEntryResult(toolInfo.structuredContent)\n ? toolInfo.structuredContent\n : undefined;\n const flow = useAppFlow<ViewName>({\n key: 'ordering_flow',\n initialView: 'stores',\n views: ['stores', 'menu', 'item', 'cart', 'review', 'handoff'],\n });\n const view = flow.activeView;\n const setView = flow.navigate;\n const handoff = useHandoff();\n const sendFollowUpMessage = useSendFollowUpMessage();\n const updateModelContext = useUpdateModelContext();\n const publishLifecycle = useWidgetLifecycle('ordering-flow');\n const searchStores = useCallTool('search_stores');\n const loadMenu = useCallTool('load_menu');\n const loadItem = useCallTool('load_item');\n const readCart = useCallTool('read_cart');\n const syncCart = useCallTool('sync_cart');\n const prepareCheckout = useCallTool('prepare_checkout');\n\n const [query, setQuery] = useViewState('query', '');\n const [customer, setCustomer] = useViewState('customer', entry?.customer ?? 'Guest');\n const [selectedStoreId, setSelectedStoreId] = useViewState<string | undefined>(\n 'selected_store',\n entry?.stores?.[0]?.id,\n );\n const [selectedItemId, setSelectedItemId] = useViewState<string | undefined>(\n 'selected_item',\n entry?.featuredItems?.[0]?.id,\n );\n const cartStore = useCartStore();\n const revisionStore = useCartRevisionStore((state) => state.value);\n const cart =\n cartStore.state.customer === 'Guest' && entry?.customer\n ? { ...cartStore.state, customer: entry.customer }\n : cartStore.state;\n const revision = revisionStore.selected;\n const setCart = cartStore.setState;\n const setRevision = (value: number) => revisionStore.setState({ value });\n const [quantity, setQuantity] = useState(1);\n const [selectedModifiers, setSelectedModifiers] = useState<readonly string[]>([]);\n const [lifecycle, setLifecycle] = useState<'active' | 'submitted'>('active');\n\n const storeData = structured<{ readonly stores?: readonly Store[] }>(searchStores.data);\n const menuData = structured<{\n readonly stores?: readonly Store[];\n readonly items?: readonly MenuItem[];\n }>(loadMenu.data);\n const itemData = structured<{ readonly items?: readonly MenuItem[] }>(loadItem.data);\n const checkoutData = structured<{ readonly checkoutUrl?: string; readonly cart?: CartState }>(\n prepareCheckout.data,\n );\n const stores = storeData?.stores ?? entry?.stores ?? [];\n const queryText = query.trim().toLowerCase();\n const displayedStores = stores.filter(\n (store) =>\n queryText.length === 0 ||\n store.name.toLowerCase().includes(queryText) ||\n store.cuisine.toLowerCase().includes(queryText),\n );\n const allItems = menuData?.items ?? itemData?.items ?? entry?.featuredItems ?? [];\n const items = allItems.filter((item) => item.storeId === selectedStoreId);\n const currentStore =\n stores.find((store) => store.id === selectedStoreId) ?? menuData?.stores?.[0] ?? stores[0];\n const currentItem = allItems.find((item) => item.id === selectedItemId) ?? items[0];\n const lineItems = useMemo(\n () =>\n cart.lines.map((line) => ({\n ...line,\n item: [...(entry?.featuredItems ?? []), ...allItems].find(\n (item) => item.id === line.itemId,\n ),\n })),\n [cart.lines, entry?.featuredItems, allItems],\n );\n const llmSummary = `${view} view for ${customer}; ${cart.lines.length} cart lines; subtotal ${currency(\n cart.subtotal,\n )}`;\n const canUpdateModelContext = supports?.modelContext === true;\n\n useEffect(() => {\n if (!entry || !canUpdateModelContext) return;\n // Each call replaces model context, so publish one cohesive snapshot of current surface state.\n void updateModelContext({\n content: [{ type: 'text', text: `Food ordering: ${llmSummary}; ${lifecycle}.` }],\n structuredContent: {\n widget: { name: 'ordering-flow', lifecycle },\n ordering: {\n view,\n customer,\n cartLines: cart.lines.length,\n subtotal: cart.subtotal,\n selectedStoreId: selectedStoreId ?? null,\n selectedItemId: selectedItemId ?? null,\n },\n },\n });\n }, [\n canUpdateModelContext,\n cart.lines.length,\n cart.subtotal,\n customer,\n entry,\n lifecycle,\n llmSummary,\n selectedItemId,\n selectedStoreId,\n updateModelContext,\n view,\n ]);\n\n async function chooseStore(store: Store) {\n setSelectedStoreId(store.id);\n setView('menu');\n const result = await loadMenu.callTool({ storeId: store.id });\n const loaded = structured<{ readonly items?: readonly MenuItem[] }>(result);\n setSelectedItemId(loaded?.items?.find((item) => item.storeId === store.id)?.id);\n }\n\n async function chooseItem(item: MenuItem) {\n setSelectedItemId(item.id);\n setSelectedModifiers([]);\n setQuantity(1);\n setView('item');\n await loadItem.callTool({ itemId: item.id });\n }\n\n async function persistCart(nextCart: CartState, nextView: ViewName) {\n const result = await syncCart.callTool({\n selectedStoreId: nextCart.selectedStoreId,\n lines: nextCart.lines,\n customer: nextCart.customer,\n notes: nextCart.notes,\n subtotal: nextCart.subtotal,\n expectedRevision: revision,\n });\n const synced = structured<{ readonly cart?: CartState; readonly revision?: number }>(result);\n setCart(synced?.cart ?? nextCart);\n setRevision(synced?.revision ?? revision + 1);\n setView(nextView);\n }\n\n async function addCurrentItem() {\n if (!currentItem) return;\n const nextLines = [\n ...cart.lines,\n {\n itemId: currentItem.id,\n quantity,\n modifiers: selectedModifiers,\n },\n ];\n await persistCart(\n {\n ...cart,\n selectedStoreId: currentStore?.id ?? selectedStoreId,\n lines: nextLines,\n customer,\n subtotal: nextLines.reduce(\n (sum, line) =>\n sum +\n ([...(entry?.featuredItems ?? []), ...allItems].find((item) => item.id === line.itemId)\n ?.price ?? 0) *\n line.quantity,\n 0,\n ),\n status: 'draft',\n },\n 'cart',\n );\n }\n\n async function prepareHandoff() {\n const result = await prepareCheckout.callTool({\n selectedStoreId: cart.selectedStoreId,\n lines: cart.lines,\n customer,\n notes: cart.notes,\n subtotal: cart.subtotal,\n expectedRevision: revision,\n });\n const prepared = structured<{\n readonly cart?: CartState;\n readonly revision?: number;\n readonly checkoutUrl?: string;\n }>(result);\n const preparedCart = prepared?.cart ?? cart;\n setCart(preparedCart);\n setRevision(prepared?.revision ?? revision + 1);\n setLifecycle('submitted');\n setView('handoff');\n if (canUpdateModelContext) {\n await publishLifecycle('submitted', {\n view: 'handoff',\n customer,\n cartLines: preparedCart.lines.length,\n subtotal: preparedCart.subtotal,\n selectedStoreId: preparedCart.selectedStoreId ?? null,\n selectedItemId: selectedItemId ?? null,\n });\n }\n if (supports?.followUpMessage) {\n await sendFollowUpMessage({\n prompt: 'Checkout is prepared. Confirm the cart summary and explain the final handoff.',\n });\n }\n }\n\n if (isPending) {\n return (\n <Feedback status=\"loading\" title=\"Loading\">\n Loading food ordering…\n </Feedback>\n );\n }\n if (toolInfo.isError) {\n return (\n <Feedback status=\"error\" title=\"Ordering unavailable\">\n Could not load food ordering.\n </Feedback>\n );\n }\n if (!entry) {\n return (\n <Feedback status=\"error\" title=\"Invalid ordering result\">\n Food ordering result was incomplete.\n </Feedback>\n );\n }\n\n return (\n <AppShell\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={llmSummary}\n title=\"Food Ordering\"\n subtitle={entry?.status ?? 'Find a store, build a cart, and hand off checkout.'}\n icon={<BowlIcon />}\n badge={displayMode === 'fullscreen' ? 'Fullscreen' : view}\n footer={\n <>\n <span className=\"nw-meta\">\n <BagIcon />\n {cart.lines.length} items\n </span>\n <span className=\"nw-meta\">Revision {revision}</span>\n </>\n }\n >\n <ShellNav\n activeView={view}\n aria-label=\"Ordering steps\"\n items={(['stores', 'menu', 'cart', 'review', 'handoff'] as const).map((step) => ({\n view: step,\n label: step,\n }))}\n onNavigate={setView}\n />\n <div className=\"nw-body\">\n <ViewStack flow={flow}>\n <View name=\"stores\">\n <Form onSubmit={() => void searchStores.callTool({ query, openOnly: false })}>\n <div className=\"nw-field-grid\">\n <Field className=\"nw-field\" label=\"Customer\">\n <input\n className=\"nw-input\"\n value={customer}\n onChange={(event) => setCustomer(event.currentTarget.value)}\n />\n </Field>\n <Field className=\"nw-field\" label=\"Search\">\n <input\n className=\"nw-input\"\n value={query}\n placeholder=\"Noodles\"\n onChange={(event) => setQuery(event.currentTarget.value)}\n />\n </Field>\n </div>\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"submit\"\n className=\"nw-button nw-button-primary\"\n disabled={!ready}\n pending={searchStores.isPending}\n pendingLabel=\"Searching...\"\n >\n <SearchIcon />\n Search stores\n </SubmitButton>\n <SubmitButton\n type=\"button\"\n className=\"nw-button\"\n pending={readCart.isPending}\n pendingLabel=\"Loading...\"\n onClick={async () => {\n const result = await readCart.callTool({});\n const stored = structured<{\n readonly value?: CartState;\n readonly revision?: number;\n }>(result);\n if (stored?.value?.lines) setCart(stored.value);\n setRevision(stored?.revision ?? revision);\n }}\n >\n <RefreshIcon />\n Load cart\n </SubmitButton>\n </ActionBar>\n </Form>\n <AsyncBoundary\n state={searchStores}\n isEmpty={displayedStores.length === 0}\n empty=\"No matching stores\"\n >\n <StoreList stores={displayedStores} onChoose={chooseStore} />\n </AsyncBoundary>\n </View>\n\n <View name=\"menu\">\n <SectionHeader title={currentStore?.name ?? 'Menu'} detail={currentStore?.address} />\n <AsyncBoundary\n state={loadMenu}\n isEmpty={items.length === 0}\n empty=\"No menu items loaded\"\n >\n <ItemList items={items} onChoose={chooseItem} />\n </AsyncBoundary>\n </View>\n\n <View name=\"item\">\n {currentItem ? (\n <>\n <SectionHeader title={currentItem.name} detail={currentItem.description} />\n <div className=\"nw-field-grid\">\n <Field className=\"nw-field\" label=\"Quantity\">\n <QuantityStepper value={quantity} min={1} onChange={setQuantity} />\n </Field>\n <div className=\"nw-summary nw-summary-compact\">\n <div className=\"nw-summary-row\">\n <dt>Price</dt>\n <dd>{currency(currentItem.price)}</dd>\n </div>\n </div>\n </div>\n <ChoiceGroup\n className=\"nw-modifier-grid\"\n values={currentItem.modifiers}\n selected={selectedModifiers}\n onChange={setSelectedModifiers}\n labelFor={modifierLabel}\n />\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n pending={syncCart.isPending}\n pendingLabel=\"Adding...\"\n onClick={addCurrentItem}\n >\n <BagIcon />\n Add to cart\n </SubmitButton>\n <button className=\"nw-button\" type=\"button\" onClick={() => setView('menu')}>\n Back to menu\n </button>\n </ActionBar>\n </>\n ) : null}\n </View>\n\n <View name=\"cart\">\n <SectionHeader\n title=\"Cart\"\n detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <Field className=\"nw-field\" label=\"Notes\">\n <input\n className=\"nw-input\"\n value={cart.notes ?? ''}\n placeholder=\"Utensils, pickup name, or allergies\"\n onChange={(event) => setCart({ ...cart, notes: event.currentTarget.value })}\n />\n </Field>\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"button\"\n className=\"nw-button\"\n pending={syncCart.isPending}\n pendingLabel=\"Saving...\"\n onClick={() => persistCart({ ...cart, customer, status: 'draft' }, 'review')}\n >\n <SaveIcon />\n Save cart\n </SubmitButton>\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n disabled={cart.lines.length === 0}\n pending={prepareCheckout.isPending}\n pendingLabel=\"Preparing...\"\n onClick={prepareHandoff}\n >\n <ExternalIcon />\n Prepare checkout\n </SubmitButton>\n </ActionBar>\n </View>\n\n <View name=\"review\">\n <SectionHeader\n title=\"Review order\"\n detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <ActionBar className=\"nw-actions\">\n <button className=\"nw-button\" type=\"button\" onClick={() => setView('cart')}>\n Edit cart\n </button>\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n disabled={cart.lines.length === 0}\n pending={prepareCheckout.isPending}\n pendingLabel=\"Preparing...\"\n onClick={prepareHandoff}\n >\n <ExternalIcon />\n Prepare checkout\n </SubmitButton>\n </ActionBar>\n </View>\n\n <View name=\"handoff\">\n <SectionHeader\n title=\"Checkout handoff\"\n detail=\"Payment stays with the ordering site.\"\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <p className=\"nw-note\">\n The MCP App prepared a synthetic checkout URL and will open only the allowlisted\n example domain.\n </p>\n <ActionBar className=\"nw-actions\">\n <HandoffButton\n handoff={handoff}\n target={checkoutData?.checkoutUrl ?? cart.checkoutUrl ?? ''}\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n pendingLabel=\"Opening...\"\n >\n <ExternalIcon />\n Continue checkout\n </HandoffButton>\n <button\n className=\"nw-button\"\n type=\"button\"\n onClick={() =>\n sendFollowUpMessage({\n prompt: `Summarize my food order for ${customer} before checkout.`,\n })\n }\n >\n <SparkIcon />\n Ask assistant\n </button>\n </ActionBar>\n </View>\n </ViewStack>\n </div>\n </AppShell>\n );\n}\n\nfunction SectionHeader({ title, detail }: { readonly title: string; readonly detail?: string }) {\n return (\n <div className=\"nw-section-head\">\n <p className=\"nw-section-title\">{title}</p>\n {detail ? <p className=\"nw-section-detail\">{detail}</p> : null}\n </div>\n );\n}\n\nfunction StoreList({\n stores,\n onChoose,\n}: {\n readonly stores: readonly Store[];\n readonly onChoose: (store: Store) => void;\n}) {\n return (\n <DataList className=\"nw-menu-list\">\n {stores.map((store) => (\n <DataCard\n as=\"button\"\n className=\"nw-menu-item\"\n key={store.id}\n type=\"button\"\n onClick={() => onChoose(store)}\n >\n <span>\n <span className=\"nw-menu-name\">{store.name}</span>\n <span className=\"nw-menu-desc\">\n {store.cuisine} · {store.address}\n </span>\n </span>\n <StatusBadge className=\"nw-status\" tone={store.open ? 'success' : 'neutral'}>\n {store.open ? `${store.etaMinutes}m` : 'Closed'}\n </StatusBadge>\n </DataCard>\n ))}\n </DataList>\n );\n}\n\nfunction ItemList({\n items,\n onChoose,\n}: {\n readonly items: readonly MenuItem[];\n readonly onChoose: (item: MenuItem) => void;\n}) {\n return (\n <DataList className=\"nw-menu-list\">\n {items.map((item) => (\n <DataCard\n as=\"button\"\n className=\"nw-menu-item\"\n key={item.id}\n type=\"button\"\n onClick={() => onChoose(item)}\n >\n <span>\n <span className=\"nw-menu-name\">{item.name}</span>\n <span className=\"nw-menu-desc\">{item.description}</span>\n </span>\n <span className=\"nw-price\">{currency(item.price)}</span>\n </DataCard>\n ))}\n </DataList>\n );\n}\n\nfunction CartSummary({\n lineItems,\n subtotal,\n}: {\n readonly lineItems: readonly (CartLine & { readonly item?: MenuItem })[];\n readonly subtotal: number;\n}) {\n return (\n <dl className=\"nw-summary\">\n {lineItems.length === 0 ? (\n <div className=\"nw-summary-row\">\n <dt>No items yet</dt>\n <dd>{currency(0)}</dd>\n </div>\n ) : (\n lineItems.map((line, index) => (\n <div className=\"nw-summary-row\" key={`${line.itemId}-${index}`}>\n <dt>\n {line.quantity} x {line.item?.name ?? line.itemId}\n {line.modifiers.length > 0 ? (\n <span className=\"nw-line-note\">{line.modifiers.map(modifierLabel).join(', ')}</span>\n ) : null}\n </dt>\n <dd>{currency((line.item?.price ?? 0) * line.quantity)}</dd>\n </div>\n ))\n )}\n <div className=\"nw-summary-row nw-total\">\n <dt>Subtotal</dt>\n <dd>{currency(subtotal)}</dd>\n </div>\n </dl>\n );\n}\n\nfunction BowlIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M4 11h16a7 7 0 0 1-7 7h-2a7 7 0 0 1-7-7Z\" />\n <path d=\"M7 21h10\" />\n <path d=\"M8 6c0-1 1-1 1-2\" />\n <path d=\"M12 6c0-1 1-1 1-2\" />\n <path d=\"M16 6c0-1 1-1 1-2\" />\n </svg>\n );\n}\n\nfunction SearchIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m20 20-4-4\" />\n </svg>\n );\n}\n\nfunction RefreshIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M20 12a8 8 0 1 1-2.3-5.6\" />\n <path d=\"M20 4v5h-5\" />\n </svg>\n );\n}\n\nfunction BagIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M6 8h12l-1 12H7L6 8Z\" />\n <path d=\"M9 8a3 3 0 0 1 6 0\" />\n </svg>\n );\n}\n\nfunction SaveIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M5 4h12l2 2v14H5V4Z\" />\n <path d=\"M8 4v6h8\" />\n <path d=\"M8 20v-5h8v5\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n\nfunction SparkIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z\" />\n </svg>\n );\n}\n" },
|
|
68
68
|
{ relPath: "examples/food-ordering/src/views/ordering-result.ts", content: "export type Store = {\n readonly id: string;\n readonly name: string;\n readonly cuisine: string;\n readonly address: string;\n readonly open: boolean;\n readonly etaMinutes: number;\n readonly rating: number;\n};\n\nexport type MenuItem = {\n readonly id: string;\n readonly storeId: string;\n readonly category: string;\n readonly name: string;\n readonly price: number;\n readonly description: string;\n readonly modifiers: readonly string[];\n};\n\nexport type OrderingEntryResult = {\n readonly customer: string;\n readonly stores: readonly Store[];\n readonly featuredItems: readonly MenuItem[];\n readonly status: string;\n};\n\nfunction nonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.trim().length > 0;\n}\n\nfunction isStore(value: unknown): value is Store {\n if (value === null || typeof value !== 'object') return false;\n const store = value as Partial<Store>;\n return (\n nonEmptyString(store.id) &&\n nonEmptyString(store.name) &&\n nonEmptyString(store.cuisine) &&\n nonEmptyString(store.address) &&\n typeof store.open === 'boolean' &&\n typeof store.etaMinutes === 'number' &&\n Number.isFinite(store.etaMinutes) &&\n typeof store.rating === 'number' &&\n Number.isFinite(store.rating)\n );\n}\n\nfunction isMenuItem(value: unknown): value is MenuItem {\n if (value === null || typeof value !== 'object') return false;\n const item = value as Partial<MenuItem>;\n return (\n nonEmptyString(item.id) &&\n nonEmptyString(item.storeId) &&\n nonEmptyString(item.category) &&\n nonEmptyString(item.name) &&\n typeof item.price === 'number' &&\n Number.isFinite(item.price) &&\n nonEmptyString(item.description) &&\n Array.isArray(item.modifiers) &&\n item.modifiers.every(nonEmptyString)\n );\n}\n\nexport function isOrderingEntryResult(value: unknown): value is OrderingEntryResult {\n if (value === null || typeof value !== 'object') return false;\n const entry = value as Partial<OrderingEntryResult>;\n if (\n !nonEmptyString(entry.customer) ||\n !nonEmptyString(entry.status) ||\n !Array.isArray(entry.stores) ||\n !entry.stores.every(isStore) ||\n !Array.isArray(entry.featuredItems) ||\n !entry.featuredItems.every(isMenuItem)\n ) {\n return false;\n }\n const storeIds = new Set(entry.stores.map((store) => store.id));\n const itemIds = new Set(entry.featuredItems.map((item) => item.id));\n return (\n storeIds.size === entry.stores.length &&\n itemIds.size === entry.featuredItems.length &&\n entry.featuredItems.every((item) => storeIds.has(item.storeId))\n );\n}\n" },
|
|
69
69
|
{ relPath: "examples/food-ordering/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f7f7f5;\n --nw-surface-strong: #ffffff;\n --nw-text: #1f2328;\n --nw-muted: #677079;\n --nw-border: #d9dee3;\n --nw-accent: #0f8f5f;\n --nw-accent-strong: #047857;\n --nw-accent-soft: #e8f7ef;\n --nw-info: #1d6feb;\n --nw-info-soft: #eaf2ff;\n --nw-warn: #b7791f;\n --nw-danger: #c2410c;\n --nw-shadow: 0 18px 50px rgb(15 23 42 / 12%);\n --nw-radius: 8px;\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #111820;\n --nw-surface: #16212a;\n --nw-surface-strong: #101820;\n --nw-text: #f4f7f8;\n --nw-muted: #a4b0ba;\n --nw-border: #33414c;\n --nw-accent: #25c385;\n --nw-accent-strong: #19a974;\n --nw-accent-soft: #123a2b;\n --nw-info: #4c9aff;\n --nw-info-soft: #132b4d;\n --nw-warn: #f0b35b;\n --nw-danger: #fb7b54;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 28%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton,\ninput,\nselect {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n width: min(100%, 560px);\n margin: 0 auto;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface-strong);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-card-wide {\n width: min(100%, 720px);\n}\n\n.nsr-card {\n width: min(100%, 560px);\n margin: 0 auto;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface-strong);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nsr-card-wide {\n width: min(100%, 720px);\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 14px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nsr-header {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 14px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon {\n display: inline-grid;\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n place-items: center;\n border-radius: 8px;\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nsr-icon {\n display: inline-grid;\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n place-items: center;\n border-radius: 8px;\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nw-icon svg,\n.nsr-icon svg,\n.nw-button svg,\n.nw-meta svg {\n width: 16px;\n height: 16px;\n stroke: currentColor;\n stroke-width: 2;\n fill: none;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n min-width: 0;\n flex: 1;\n}\n\n.nsr-title-block {\n min-width: 0;\n flex: 1;\n}\n\n.nw-title {\n margin: 0;\n font-size: 16px;\n line-height: 1.2;\n font-weight: 700;\n letter-spacing: 0;\n}\n\n.nsr-title {\n margin: 0;\n font-size: 16px;\n line-height: 1.2;\n font-weight: 700;\n letter-spacing: 0;\n}\n\n.nw-subtitle {\n margin: 4px 0 0;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nsr-subtitle {\n margin: 4px 0 0;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nw-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-height: 24px;\n padding: 3px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 999px;\n background: var(--nw-surface);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n}\n\n.nsr-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-height: 24px;\n padding: 3px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 999px;\n background: var(--nw-surface);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n}\n\n.nw-chip::before {\n width: 7px;\n height: 7px;\n border-radius: 99px;\n background: var(--nw-accent);\n content: \"\";\n}\n\n.nsr-chip::before {\n width: 7px;\n height: 7px;\n border-radius: 99px;\n background: var(--nw-accent);\n content: \"\";\n}\n\n.nw-tabs {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 1px;\n border-bottom: 1px solid var(--nw-border);\n background: var(--nw-border);\n}\n\n.nsr-tabs {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 1px;\n border-bottom: 1px solid var(--nw-border);\n background: var(--nw-border);\n}\n\n.nw-tab {\n min-width: 0;\n min-height: 34px;\n border: 0;\n background: var(--nw-surface-strong);\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 700;\n text-transform: capitalize;\n cursor: pointer;\n}\n\n.nsr-tab {\n min-width: 0;\n min-height: 34px;\n border: 0;\n background: var(--nw-surface-strong);\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 700;\n text-transform: capitalize;\n cursor: pointer;\n}\n\n.nw-tab[aria-current=\"step\"] {\n color: var(--nw-text);\n background: var(--nw-accent-soft);\n}\n\n.nsr-tab[aria-current=\"step\"] {\n color: var(--nw-text);\n background: var(--nw-accent-soft);\n}\n\n.nw-body {\n display: grid;\n gap: 14px;\n padding: 14px;\n}\n\n.nw-grid {\n display: grid;\n grid-template-columns: minmax(0, 1fr) minmax(180px, 0.75fr);\n gap: 14px;\n}\n\n.nw-section-title {\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 700;\n}\n\n.nw-section-head {\n display: grid;\n gap: 2px;\n}\n\n.nw-section-detail {\n margin: 0;\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-menu-list,\n.nw-summary-list,\n.nw-feature-list {\n display: grid;\n gap: 8px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.nw-menu-item {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n gap: 10px;\n width: 100%;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-bg);\n color: var(--nw-text);\n text-align: left;\n cursor: pointer;\n}\n\n.nw-menu-item[aria-pressed=\"true\"] {\n border-color: var(--nw-accent);\n background: var(--nw-accent-soft);\n}\n\n.nw-menu-name,\n.nw-feature-name {\n display: block;\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-menu-desc,\n.nw-feature-desc {\n display: block;\n margin-top: 3px;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nw-price {\n align-self: center;\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-status {\n align-self: center;\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 800;\n white-space: nowrap;\n}\n\n.nw-status-ok {\n color: var(--nw-accent-strong);\n}\n\n.nw-field-grid {\n display: grid;\n grid-template-columns: 1fr 120px;\n gap: 10px;\n}\n\n.nw-field {\n display: grid;\n gap: 5px;\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nsr-field-label {\n color: var(--nw-muted);\n}\n\n.nsr-stepper {\n display: grid;\n grid-template-columns: 34px minmax(42px, 1fr) 34px;\n width: 100%;\n min-height: 34px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n overflow: hidden;\n background: var(--nw-bg);\n}\n\n.nsr-stepper button {\n border: 0;\n background: var(--nw-surface);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nsr-stepper button:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.nsr-stepper-value {\n display: grid;\n place-items: center;\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 800;\n}\n\n.nw-input,\n.nw-select {\n min-width: 0;\n width: 100%;\n min-height: 34px;\n padding: 7px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n outline: none;\n}\n\n.nw-input:focus,\n.nw-select:focus,\n.nw-button:focus-visible,\n.nw-menu-item:focus-visible {\n border-color: var(--nw-info);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--nw-info) 24%, transparent);\n}\n\n.nw-modifier-grid {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 8px;\n}\n\n.nw-option {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nsr-choice {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nw-summary {\n display: grid;\n gap: 8px;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface);\n}\n\n.nw-summary-compact {\n align-self: end;\n}\n\n.nw-summary-row {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding-bottom: 8px;\n border-bottom: 1px solid color-mix(in srgb, var(--nw-border) 70%, transparent);\n}\n\n.nw-summary-row:last-child {\n padding-bottom: 0;\n border-bottom: 0;\n}\n\n.nw-summary-row dt {\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-line-note {\n display: block;\n margin-top: 2px;\n font-size: 11px;\n font-weight: 500;\n}\n\n.nw-summary-row dd {\n margin: 0;\n text-align: right;\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-total dd {\n font-size: 18px;\n}\n\n.nw-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.nsr-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n min-height: 36px;\n padding: 8px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 700;\n cursor: pointer;\n}\n\n.nw-button-primary {\n border-color: var(--nw-accent-strong);\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nw-button-info {\n border-color: var(--nw-info);\n background: var(--nw-info);\n color: white;\n}\n\n.nw-button:disabled {\n cursor: wait;\n opacity: 0.65;\n}\n\n.nw-note {\n margin: 0;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface);\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.45;\n}\n\n.nw-footer {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid var(--nw-border);\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nsr-footer {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid var(--nw-border);\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-meta {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.nw-feature {\n display: grid;\n grid-template-columns: 24px minmax(0, 1fr);\n gap: 10px;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-bg);\n}\n\n.nw-check {\n display: inline-grid;\n width: 22px;\n height: 22px;\n place-items: center;\n border-radius: 6px;\n background: var(--nw-accent);\n color: white;\n font-size: 13px;\n font-weight: 800;\n}\n\n.nw-check svg {\n width: 14px;\n height: 14px;\n stroke: currentColor;\n stroke-width: 3;\n fill: none;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n@media (max-width: 560px) {\n .nw-shell {\n padding: 10px;\n }\n\n .nw-grid,\n .nw-field-grid,\n .nw-modifier-grid {\n grid-template-columns: 1fr;\n }\n\n .nw-tabs {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n\n .nsr-tabs {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n\n .nw-header {\n align-items: flex-start;\n }\n\n .nsr-header {\n align-items: flex-start;\n }\n\n .nw-chip {\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .nsr-chip {\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .nw-button {\n flex: 1 1 140px;\n }\n}\n" },
|
|
70
70
|
{ relPath: "examples/food-ordering/test/ordering-flow.test.ts", content: "// @vitest-environment happy-dom\n/// <reference lib=\"dom\" />\nimport { act, createElement as h } from 'react';\nimport { createRoot, type Root } from 'react-dom/client';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport OrderingFlow from '../src/views/ordering-flow.js';\n\nvi.mock('../src/helpers.js', async () => {\n const { createElement } = await import('react');\n const container = ({ children }: { readonly children?: unknown }) =>\n createElement('div', null, children);\n const button = ({ children }: { readonly children?: unknown }) =>\n createElement('button', { type: 'button' }, children);\n return {\n ActionBar: container,\n AppShell: ({\n children,\n footer,\n subtitle,\n title,\n }: {\n readonly children?: unknown;\n readonly footer?: unknown;\n readonly subtitle?: string;\n readonly title?: string;\n }) => createElement('main', null, title, subtitle, children, footer),\n AsyncBoundary: container,\n ChoiceGroup: container,\n createViewStore:\n <T>(_key: string, initial: T) =>\n (selector?: (state: T) => unknown) => ({\n state: initial,\n selected: selector?.(initial),\n setState: () => undefined,\n }),\n DataCard: button,\n DataList: container,\n Feedback: ({ children, status }: { readonly children?: unknown; readonly status?: string }) =>\n createElement('div', { 'data-status': status }, children),\n Field: container,\n Form: ({ children }: { readonly children?: unknown }) => createElement('form', null, children),\n HandoffButton: button,\n QuantityStepper: container,\n ShellNav: container,\n StatusBadge: container,\n SubmitButton: button,\n useAppFlow: () => ({\n activeView: 'stores',\n navigate: () => undefined,\n back: () => undefined,\n canBack: false,\n params: {},\n }),\n useCallTool: () => ({\n status: 'idle',\n isIdle: true,\n isPending: false,\n isSuccess: false,\n isError: false,\n callTool: () => Promise.resolve({ structuredContent: {} }),\n callToolAsync: () => Promise.resolve({ structuredContent: {} }),\n reset: () => undefined,\n }),\n useHandoff: () => ({ status: 'idle', open: () => Promise.resolve() }),\n useLayout: () => ({\n theme: 'light',\n displayMode: 'inline',\n supports: { modelContext: false, followUpMessage: false },\n }),\n useSendFollowUpMessage: () => () => Promise.resolve(),\n useToolInfo: () => toolResult,\n useUpdateModelContext: () => () => Promise.resolve(),\n useViewState: <T>(_key: string, initial: T) => [initial, () => undefined] as const,\n useWidgetLifecycle: () => () => Promise.resolve(),\n useWidgetReady: () => true,\n View: container,\n ViewStack: container,\n };\n});\n\ntype ToolResult = {\n readonly content?: unknown;\n readonly structuredContent?: unknown;\n readonly _meta?: unknown;\n readonly isError?: boolean;\n};\n\nlet toolResult: ToolResult;\nlet root: Root | undefined;\n\nbeforeEach(() => {\n (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n toolResult = {};\n document.body.innerHTML = '<div id=\"root\"></div>';\n (globalThis as { __noodleReactVersion?: number }).__noodleReactVersion = 0;\n (globalThis as { __noodleReactBridge?: unknown }).__noodleReactBridge = {\n getToolResult: () => toolResult,\n getViewState: () => ({}),\n setWidgetState: () => undefined,\n getLayout: () => ({\n theme: 'light',\n displayMode: 'inline',\n supports: { modelContext: false, followUpMessage: false },\n }),\n callServerTool: () => Promise.resolve({ structuredContent: {} }),\n openExternal: () => Promise.resolve(),\n sendFollowUpMessage: () => Promise.resolve(),\n updateModelContext: () => Promise.resolve(),\n };\n});\n\nafterEach(() => {\n if (root) act(() => root?.unmount());\n root = undefined;\n delete (globalThis as { __noodleReactBridge?: unknown }).__noodleReactBridge;\n});\n\nfunction render(result: ToolResult): string {\n toolResult = result;\n root = createRoot(document.querySelector('#root') as HTMLElement);\n act(() => root?.render(h(OrderingFlow)));\n return document.body.textContent ?? '';\n}\n\ndescribe('food-ordering invoking result states', () => {\n it('renders pending without identifier-dependent actions before hydration', () => {\n const text = render({});\n expect(text).toContain('Loading food ordering');\n expect(text).not.toContain('Search stores');\n expect(document.querySelector('button')).toBeNull();\n });\n\n it('renders an explicit tool error without ordering actions', () => {\n const text = render({\n content: [{ type: 'text', text: 'Ordering is unavailable' }],\n isError: true,\n });\n expect(text).toContain('Could not load food ordering');\n expect(text).not.toContain('Search stores');\n expect(document.querySelector('button')).toBeNull();\n });\n\n it('rejects malformed success data before rendering identifier-dependent actions', () => {\n const text = render({\n structuredContent: {\n status: 'Ready',\n customer: 'Asha',\n stores: [{ name: 'Missing identifier' }],\n featuredItems: [],\n },\n });\n expect(text).toContain('Food ordering result was incomplete');\n expect(text).not.toContain('Search stores');\n expect(document.querySelector('button')).toBeNull();\n });\n\n it('preserves the ordering flow after valid hydration', () => {\n const text = render({\n structuredContent: {\n status: 'Ready to build a food order.',\n customer: 'Asha',\n stores: [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n ],\n featuredItems: [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth and noodles.',\n modifiers: ['extra_noodles'],\n },\n ],\n },\n });\n expect(text).toContain('Search stores');\n expect(text).toContain('Harbor Noodles');\n expect(document.querySelector('form')).not.toBeNull();\n });\n});\n" },
|
|
71
|
-
{ relPath: "examples/food-ordering/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('food-ordering example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('emits a complete ordering app manifest with cart state and app-only helpers', async () => {\n const manifest = (await app.toManifest()) as {\n server: {\n name: string;\n agentGuide?: unknown;\n context?: {\n defaults?: { locale?: string; timeZone?: string };\n ambient?: {\n outputSchema?: unknown;\n fulfilment?: { output?: unknown };\n };\n };\n };\n handoff?: { allowedDomains?: string[] };\n state?: { handles?: Record<string, { kind: string; scope: string }> };\n connectors?: Record<string, { id: string; version: string }>;\n tools: Array<{\n name: string;\n title?: string;\n visibility?: string[];\n annotations?: Record<string, unknown>;\n output?: unknown;\n fulfilment?: { steps?: unknown[]; output?: unknown };\n }>;\n widgets?: Array<{\n name: string;\n tool: string;\n view?: { component?: string; entry?: string };\n }>;\n };\n\n expect(manifest.server.name).toBe('food_ordering');\n expect(manifest.server.agentGuide).toBeDefined();\n expect(manifest.server).not.toHaveProperty('distribution');\n expect(manifest.server.context).toMatchObject({\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n fulfilment: {\n output: {\n serviceArea: 'Harbor District',\n orderingDate: '${context.temporal.localDate}',\n },\n },\n },\n });\n expect(manifest.state?.handles?.cart).toMatchObject({\n kind: 'cart',\n scope: 'caller',\n });\n expect(manifest.handoff?.allowedDomains).toEqual(['https://orders.example.com']);\n expect(manifest.connectors?.state).toEqual({ id: 'noodle_state', version: '1.0.0' });\n\n const tools = new Map(manifest.tools.map((tool) => [tool.name, tool]));\n expect(manifest.widgets?.find((widget) => widget.tool === 'open_ordering')?.view).toMatchObject(\n {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n );\n for (const helper of [\n 'search_stores',\n 'load_menu',\n 'load_item',\n 'read_cart',\n 'sync_cart',\n 'prepare_checkout',\n ]) {\n expect(tools.get(helper)?.visibility).toEqual(['app']);\n }\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('featuredItems');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.temporal.localDate}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.ambient.serviceArea}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.location.latitude}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.location.longitude}');\n expect(JSON.stringify(tools.get('sync_cart'))).toContain('revision');\n expect(tools.get('sync_cart')?.annotations?.confirm).toBe(false);\n expect(tools.get('prepare_checkout')?.annotations?.confirm).toBe(false);\n expect(tools.get('plan_order')?.fulfilment).toMatchObject({\n steps: [\n {\n id: 'choose_fulfilment',\n elicit: {\n message: 'How should we fulfil this order?',\n requestedSchema: {\n type: 'object',\n properties: {\n method: { type: 'string', enum: ['pickup', 'delivery'] },\n requestedDate: { type: 'string', format: 'date' },\n },\n required: ['method', 'requestedDate'],\n },\n },\n },\n ],\n output: {\n method: '${steps.choose_fulfilment.method}',\n requestedDate: '${steps.choose_fulfilment.requestedDate}',\n },\n });\n expect(manifest.widgets?.map((widget) => widget.name)).toContain('capabilities_card');\n expect(manifest.tools.every((tool) => typeof tool.title === 'string')).toBe(true);\n });\n\n it('projects host distribution metadata separately from the runtime manifest', () => {\n const distribution = app.toDistributionMetadata();\n expect(distribution).toMatchObject({\n schemaVersion: 1,\n listing: { summary: 'Build a pickup noodle order.' },\n assets: {\n icon: { alt: 'Food Ordering noodle bowl' },\n screenshots: [\n expect.objectContaining({\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n }),\n expect.objectContaining({\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n }),\n expect.objectContaining({\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n }),\n ],\n },\n });\n const scenarios = distribution?.review.scenarios ?? [];\n expect(scenarios.filter(({ shouldInvoke }) => shouldInvoke).map(({ id }) => id)).toEqual([\n 'build_order',\n 'browse_menu',\n 'compare_options',\n 'plan_pickup',\n 'review_checkout',\n ]);\n expect(scenarios.filter(({ shouldInvoke }) => !shouldInvoke).map(({ id }) => id)).toEqual([\n 'unrelated_weather',\n 'unrelated_email',\n 'unrelated_travel',\n ]);\n expect(\n scenarios\n .filter(({ shouldInvoke }) => !shouldInvoke)\n .every((scenario) => !('tools' in scenario)),\n ).toBe(true);\n });\n});\n" },
|
|
71
|
+
{ relPath: "examples/food-ordering/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('food-ordering example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('emits a complete ordering app manifest with cart state and app-only helpers', async () => {\n const manifest = (await app.toManifest()) as {\n server: {\n name: string;\n agentGuide?: unknown;\n context?: {\n defaults?: { locale?: string; timeZone?: string };\n ambient?: {\n outputSchema?: unknown;\n fulfilment?: { output?: unknown };\n };\n };\n };\n handoff?: { allowedDomains?: string[] };\n state?: { handles?: Record<string, { kind: string; scope: string }> };\n connectors?: Record<string, { id: string; version: string }>;\n tools: Array<{\n name: string;\n title?: string;\n visibility?: string[];\n annotations?: Record<string, unknown>;\n output?: unknown;\n fulfilment?: { steps?: unknown[]; output?: unknown };\n }>;\n widgets?: Array<{\n name: string;\n tool: string;\n view?: { component?: string; entry?: string };\n }>;\n };\n\n expect(manifest.server.name).toBe('food_ordering');\n expect(manifest.server.agentGuide).toBeDefined();\n expect(manifest.server).not.toHaveProperty('distribution');\n expect(manifest.server.context).toMatchObject({\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n fulfilment: {\n output: {\n serviceArea: 'Harbor District',\n orderingDate: '${context.temporal.localDate}',\n },\n },\n },\n });\n expect(manifest.state?.handles?.cart).toMatchObject({\n kind: 'cart',\n scope: 'caller',\n });\n expect(manifest.handoff?.allowedDomains).toEqual(['https://orders.example.com']);\n expect(manifest.connectors?.state).toEqual({ id: 'noodle_state', version: '1.0.0' });\n\n const tools = new Map(manifest.tools.map((tool) => [tool.name, tool]));\n expect(manifest.widgets?.find((widget) => widget.tool === 'open_ordering')?.view).toMatchObject(\n {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n );\n for (const helper of [\n 'search_stores',\n 'load_menu',\n 'load_item',\n 'read_cart',\n 'sync_cart',\n 'prepare_checkout',\n ]) {\n expect(tools.get(helper)?.visibility).toEqual(['app']);\n }\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('featuredItems');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.temporal.localDate}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.ambient.serviceArea}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.location.latitude}');\n expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.location.longitude}');\n expect(tools.get('open_ordering')?.annotations).toMatchObject({\n 'x-noodleseed-model-latest-message-includes-any': expect.arrayContaining([\n 'order',\n 'menu',\n 'checkout',\n ]),\n });\n expect(JSON.stringify(tools.get('sync_cart'))).toContain('revision');\n expect(tools.get('sync_cart')?.annotations?.confirm).toBe(false);\n expect(tools.get('prepare_checkout')?.annotations?.confirm).toBe(false);\n expect(tools.get('plan_order')?.fulfilment).toMatchObject({\n steps: [\n {\n id: 'choose_fulfilment',\n elicit: {\n message: 'How should we fulfil this order?',\n requestedSchema: {\n type: 'object',\n properties: {\n method: { type: 'string', enum: ['pickup', 'delivery'] },\n requestedDate: { type: 'string', format: 'date' },\n },\n required: ['method', 'requestedDate'],\n },\n },\n },\n ],\n output: {\n method: '${steps.choose_fulfilment.method}',\n requestedDate: '${steps.choose_fulfilment.requestedDate}',\n },\n });\n expect(manifest.widgets?.map((widget) => widget.name)).toContain('capabilities_card');\n expect(manifest.tools.every((tool) => typeof tool.title === 'string')).toBe(true);\n });\n\n it('projects host distribution metadata separately from the runtime manifest', () => {\n const distribution = app.toDistributionMetadata();\n expect(distribution).toMatchObject({\n schemaVersion: 1,\n listing: { summary: 'Build a pickup noodle order.' },\n assets: {\n icon: { alt: 'Food Ordering noodle bowl' },\n screenshots: [\n expect.objectContaining({\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n }),\n expect.objectContaining({\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n }),\n expect.objectContaining({\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n }),\n ],\n },\n });\n const scenarios = distribution?.review.scenarios ?? [];\n expect(scenarios.filter(({ shouldInvoke }) => shouldInvoke).map(({ id }) => id)).toEqual([\n 'build_order',\n 'browse_menu',\n 'compare_options',\n 'plan_pickup',\n 'review_checkout',\n ]);\n expect(scenarios.filter(({ shouldInvoke }) => !shouldInvoke).map(({ id }) => id)).toEqual([\n 'unrelated_weather',\n 'unrelated_email',\n 'unrelated_travel',\n ]);\n expect(\n scenarios\n .filter(({ shouldInvoke }) => !shouldInvoke)\n .every((scenario) => !('tools' in scenario)),\n ).toBe(true);\n });\n});\n" },
|
|
72
72
|
{ relPath: "examples/food-ordering/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n resolve: {\n alias: {\n '@noodleseed/one': new URL('../../packages/authoring/src/index.ts', import.meta.url).pathname,\n '@noodle-borg/capabilities': new URL(\n '../../packages/capabilities/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/compiler': new URL('../../packages/compiler/src/index.ts', import.meta.url)\n .pathname,\n '@noodle-borg/compute': new URL('../../packages/compute/src/index.ts', import.meta.url)\n .pathname,\n '@noodle-borg/connector-defs': new URL(\n '../../packages/connector-defs/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/connector-http': new URL(\n '../../packages/connector-http/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/runtime': new URL('../../packages/runtime/src/index.ts', import.meta.url)\n .pathname,\n },\n },\n test: {\n include: ['test/**/*.test.ts'],\n },\n});\n" },
|
|
73
73
|
{ relPath: "examples/gmail-multi-account/README.md", content: "# Gmail multi-account automation\n\n**Owns:** The flagship proof that one reusable connector can be bound to multiple independently\nauthenticated accounts inside one MCP server.\n\nThis fictional example binds `gmailConnector()` twice through separate `externalExchange()` logical\nconnections. Public tools always accept `accounts: [...]`; reads accept either account or the canonical\npersonal-then-work pair, while mutations accept exactly one account and require runtime confirmation.\n\nCapability slot: **reusable connector + independently authenticated multi-account bindings**. It is distinct\nfrom `customer-auth`, which owns authentication of the MCP caller rather than downstream connector accounts.\n\nThe labels `personal@example.com` and `work@example.com` are static display labels, not provider identities.\nThe deployment-owned credential provider maps each logical connection to its real Google authorization.\nNo Google client, provider account id, token, or real email address belongs in this project.\n\n## Safety and API boundary\n\n- Search, message/thread reads, draft reads, and vacation-setting reads may target one or both accounts.\n- Draft creation/update/send, label changes, archive, raw send, trash, and vacation updates target one account.\n- Every mutation is prepared against the exact selected binding and must be confirmed before dispatch.\n- `send_message.raw` and draft `raw` are RFC 2822 MIME bytes encoded with base64url. This example does not\n pretend that `to`/`subject`/`body` strings are sufficient to encode Unicode MIME correctly.\n- Vacation `startTime`/`endTime` schemas enforce only digit-shaped 1–19 character epoch-millisecond strings.\n When both are supplied, Gmail's backend remains authoritative for the required `startTime < endTime`\n relationship; this example does not claim cross-field JSON Schema validation.\n- Trash is reversible. Permanent message/thread/draft deletion, delegation, forwarding/sharing settings,\n and unrestricted raw HTTP requests are intentionally absent.\n\n## Local checks\n\n```sh\nnoodle validate\nnoodle test\n```\n\nThe committed tests compile hermetic fake connector responses. They never contact Gmail or load OAuth\ncredentials. A real deployment additionally needs an operator-provided external credential exchange\nendpoint for each logical connection.\n\n## Personal automation skill\n\nThe source skill is [`skills/personal-email-automation/SKILL.md`](skills/personal-email-automation/SKILL.md).\nValidate it with the standard skill validator before distribution. The source skill is shipped as part of\nthis example; canonical export of an app and its skill as an installable Codex plugin remains roadmap work\nand is not currently provided by Noodle Seed.\n" },
|
|
74
74
|
{ relPath: "examples/gmail-multi-account/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"gmail-multi-account\"\n}\n" },
|
|
@@ -40,7 +40,8 @@ export function renderAuthoringWorkflowReference() {
|
|
|
40
40
|
'',
|
|
41
41
|
'1. **Website scrape** — if the user gives a URL, scrape it for surface hints (products, services, hours, contact, pricing). Stop there: the URL does not reveal CRM, booking systems, custom APIs, auth model, eligibility rules, quoting logic, or approval flows. Those live in the business systems and the owner’s head — ask.',
|
|
42
42
|
'2. **OpenAPI import** — `noodle import openapi <file>` emits a starter `server.ts` from a spec. Use it when the user provides an OpenAPI document.',
|
|
43
|
-
'3. **
|
|
43
|
+
'3. **Upstream MCP import** — `noodle import mcp <url> --name <slug> --output <dir>` discovers `tools/list` once, validates and freezes tool schemas into TypeScript, and writes a secret-free drift snapshot. Upstream annotations are untrusted, so generated tools remain destructive confirmed actions until an author verifies and narrows them. Use `--header-env <header>=<ENV_NAME>` for import-only auth and `--check` for classified, non-mutating drift detection. Runtime never performs discovery.',
|
|
44
|
+
'4. **User interview** — Noodle does not interview; you do. Cover custom APIs/integrations, eligibility rules, quoting/approval logic, and private schemas (SQL DDL or JSON samples for custom `connector` declarations). Ask for concrete examples and sample payloads; do not guess a schema from a URL or invent endpoints.',
|
|
44
45
|
'',
|
|
45
46
|
'## Fit check',
|
|
46
47
|
'',
|
|
@@ -59,10 +60,18 @@ export function renderAuthoringWorkflowReference() {
|
|
|
59
60
|
'Declare connectors as data, not imperative code:',
|
|
60
61
|
'',
|
|
61
62
|
'- **HTTP**: `connector("id").version("1.0.0").http({ baseUrl, allowedOrigins, auth, operations })` with per-operation `request`/`response` mapping using `${args...}` / `${response...}` expressions.',
|
|
63
|
+
'- **MCP**: `connector("id").version("1.0.0").mcp({ endpoint, allowedOrigins, auth?, operations })` where each operation freezes the separate upstream `tool` wire name plus input/output schema. Import with `noodle import mcp`; do not hand-copy a live surface or call `tools/list` at runtime.',
|
|
62
64
|
'- **Compute**: `connector("id").version("1.0.0").compute(name, { input, output, calls?, run })` — a self-contained, sandboxed function (no imports/closure capture) that may call allowlisted operations via `callOperation`.',
|
|
63
65
|
'',
|
|
64
66
|
'Tools record connector calls into a flow; recording is not execution. Do not branch on runtime outputs with native `if` — use declarative `when(...)` conditions.',
|
|
65
67
|
'',
|
|
68
|
+
'MCP connectors are curated backing operations, never transparent proxies. Publish ordinary Noodle tools',
|
|
69
|
+
'with stable intent-shaped names, descriptions, annotations, authored schemas, confirmation, visibility,',
|
|
70
|
+
'and optional React `view`. Normalize a text-only upstream result through compute before rendering when the',
|
|
71
|
+
'view needs a richer stable output. Upstream resources, prompts, `_meta`, annotations, widgets, and CSP are',
|
|
72
|
+
'not imported or forwarded. The runtime opens one guarded session for one operation, uses only a broker-minted',
|
|
73
|
+
'credential, and closes it; it does not act as an agent for upstream sampling, roots, or elicitation.',
|
|
74
|
+
'',
|
|
66
75
|
'HTTP connector auth variants: `bearer` (`{ kind: "bearer", secret: secret("API_TOKEN") }`), `apiKey` (`{ kind: "apiKey", header: "X-API-Key", secret: secret("API_KEY") }`), `clientCredentials`, `delegatedOAuth`, `delegatedSessionCookie`, and `delegatedTokenExchange` (per-user calls to your own API — see "Delegated downstream auth" below). Use managed `secret(...)` / `variable(...)` refs for all values that differ by org/app/env.',
|
|
67
76
|
'',
|
|
68
77
|
'When one connector needs independently selectable accounts, declare catalog `credentialProfiles` plus each operation’s accepted `credentials.profiles`, then bind each `server.use` alias with `bind(connector, { profile, connection: connection("logical_id", managedSecret(secret("NAME"), { scopes, audience })) })`. The alias is the stable account boundary; never put provider account ids, labels, or credential values in it. `gmailConnector()` is the curated Gmail catalog helper; reuse it under independent aliases and accept canonical `accounts` arrays in tools (one account for writes, or an explicitly ordered supported combination for reads). See the bundled `gmail-multi-account` flagship. Bound managed secrets are supported by hosted execution. For deployed-server access to Google APIs, use `googleWorkloadIdentity({ provider: variable("GOOGLE_WIF_PROVIDER"), access: { kind: "direct" } })`, or add `serviceAccountImpersonation` with a managed service-account email. This is keyless Google Workload Identity Federation: exact Google scopes/audience come from the catalog operation, while `noodle auth google prepare|status|doctor|revoke` owns operator lifecycle. See the bundled `google-bigquery` flagship. `externalExchange()` is runnable only when the deployment operator injects an exact HTTPS provider endpoint/origin/audience and durable shared subject-pin store through service ports; Noodle sends a short-lived platform-signed deployment workload assertion and accepts only a bounded bearer response. Provider implementations must consume assertion replay ids through durable shared atomic storage across instances and restarts. There is intentionally no hosted enrollment or provider CRUD surface yet. The provider wire contract is public, but its conformance kit is workspace/source-only and is not an installable npm package. Bound `clientCredentials(...)` remains fail-closed until its provider slice lands.',
|
|
@@ -271,7 +280,7 @@ export function renderWidgetsAndAppsReference() {
|
|
|
271
280
|
'',
|
|
272
281
|
'## Tools and views',
|
|
273
282
|
'',
|
|
274
|
-
'Use `tool(name, { description, input, output, fulfil, view })` for a model-visible tool that renders a widget, and the same `tool(name, { ..., visibility: ["app"] })` for an app-only helper hidden from the model. A `view` is `{ component: "name", entry: "./views/name.tsx" }` — a React component the compiler bundles at validate/deploy time.',
|
|
283
|
+
'Use `tool(name, { description, input, output, fulfil, view })` for a model-visible tool that renders a widget, and the same `tool(name, { ..., visibility: ["app"] })` for an app-only helper hidden from the model. When a view is valid only for narrow explicit intent, add `modelVisibility: { latestMessageIncludesAny: ["open the form", ...] }`; Noodle normalizes and matches those literal phrases against the latest user message before model discovery and fails closed on malformed data. This is relevance filtering, never authorization. A `view` is `{ component: "name", entry: "./views/name.tsx" }` — a React component the compiler bundles at validate/deploy time.',
|
|
275
284
|
'',
|
|
276
285
|
'## Noodle Design default',
|
|
277
286
|
'',
|
|
@@ -16,6 +16,7 @@ export function renderConnectAnApiReference() {
|
|
|
16
16
|
'- Secure the key first',
|
|
17
17
|
'- Probe the live API',
|
|
18
18
|
'- Model the connector from the observed shape',
|
|
19
|
+
'- Import and curate an upstream MCP server',
|
|
19
20
|
'- Return a list',
|
|
20
21
|
'- Create, update, delete',
|
|
21
22
|
'- Design intent tools',
|
|
@@ -27,7 +28,7 @@ export function renderConnectAnApiReference() {
|
|
|
27
28
|
'',
|
|
28
29
|
'## Use when',
|
|
29
30
|
'',
|
|
30
|
-
'- The user provides credentials, a reachable API,
|
|
31
|
+
'- The user provides credentials, a reachable API, an OpenAPI document, or an upstream MCP endpoint and wants real MCP behavior backed by it.',
|
|
31
32
|
'- Existing connector behavior compiles but still needs proof against the actual service and data shape.',
|
|
32
33
|
'',
|
|
33
34
|
'## Do not use when',
|
|
@@ -85,6 +86,35 @@ export function renderConnectAnApiReference() {
|
|
|
85
86
|
'The full connector shape, every `auth.kind`, and compute connectors are in',
|
|
86
87
|
'`references/authoring-workflow.md`.',
|
|
87
88
|
'',
|
|
89
|
+
'### Import and curate an upstream MCP server',
|
|
90
|
+
'',
|
|
91
|
+
'Use an explicit import to discover the upstream tool surface once and generate TypeScript:',
|
|
92
|
+
'',
|
|
93
|
+
'```sh',
|
|
94
|
+
'noodle import mcp https://store.example/api/mcp --name store --output store-app',
|
|
95
|
+
'# When import discovery needs a credential, read it only from the environment:',
|
|
96
|
+
'noodle import mcp https://store.example/api/mcp --name store \\',
|
|
97
|
+
' --header-env Authorization=STORE_MCP_TOKEN',
|
|
98
|
+
'```',
|
|
99
|
+
'',
|
|
100
|
+
'The importer freezes upstream tool names and normalized schemas into `.mcp({ operations })`; it never',
|
|
101
|
+
'persists the import credential. Runtime calls only those declared operations and never runs `tools/list`.',
|
|
102
|
+
'MCP annotations are untrusted hints, so every generated tool starts as a destructive confirmed action.',
|
|
103
|
+
'Verify real behavior and deliberately narrow proven reads before accepting the generated source. Use',
|
|
104
|
+
'`noodle import mcp <url> --output <dir> --check` in CI to detect upstream additions, removals, or schema',
|
|
105
|
+
'changes without mutating source; its review labels drift additive, breaking, or metadata-only.',
|
|
106
|
+
'',
|
|
107
|
+
'Treat the imported connector operation as a backing-system contract, not the public tool design. Publish',
|
|
108
|
+
'ordinary intent-shaped `tool(...)` capabilities with stable names and narrower schemas, normalize text or',
|
|
109
|
+
'large upstream output through compute, and attach a normal Noodle React `view` when UI helps. Upstream',
|
|
110
|
+
'`_meta`, resources, prompts, annotations, widgets, and CSP are never forwarded. A Noodle-owned widget may',
|
|
111
|
+
'therefore enrich a headless upstream tool without trusting upstream executable UI.',
|
|
112
|
+
'',
|
|
113
|
+
'Managed endpoints require an exact managed origin allowlist. Configure each deployment with',
|
|
114
|
+
'`variable(...)` and `secret(...)`; inbound MCP/OAuth bearer tokens are never reusable upstream credentials.',
|
|
115
|
+
'The first release supports no auth, bearer, API key, and client credentials through the broker. Do not',
|
|
116
|
+
'invent a delegated-user OAuth shortcut for an upstream MCP server.',
|
|
117
|
+
'',
|
|
88
118
|
'### Return a list',
|
|
89
119
|
'',
|
|
90
120
|
'Most real tools return a variable-length list (search results, a user’s tasks). Bind the **whole array** — a single `${response.path}` returns the referenced value verbatim, arrays included:',
|
|
@@ -129,7 +129,7 @@ const SDK_RECIPES = [
|
|
|
129
129
|
'',
|
|
130
130
|
'### Non-trivial tool: ctx connectors, annotations, visibility, async',
|
|
131
131
|
'',
|
|
132
|
-
"`ctx` is `{ input, user, connectors }`. Bind connectors with `use` on the server, then call one inside `fulfil` to record a step. `annotations.readOnly()` declares a closed-world safe read. TypeScript action helpers enforce confirmation only with `{ confirm: true }`; omitted or `false` executes directly, and action/destructive/open-world hints alone never enable the gate. For stateless hosts that cannot present Noodle confirmation, set `interactions: { confirmationFallback: 'host' }` in the `server` options to explicitly trust native host write approval; omission remains fail-closed and the fallback never supplies missing `ctx.elicit` input. `visibility` defaults to `['model', 'app']` — set `['app']` to hide a helper from the model. `fulfil` may be `async` (the compiler awaits it while recording).",
|
|
132
|
+
"`ctx` is `{ input, user, connectors }`. Bind connectors with `use` on the server, then call one inside `fulfil` to record a step. `annotations.readOnly()` declares a closed-world safe read. TypeScript action helpers enforce confirmation only with `{ confirm: true }`; omitted or `false` executes directly, and action/destructive/open-world hints alone never enable the gate. For stateless hosts that cannot present Noodle confirmation, set `interactions: { confirmationFallback: 'host' }` in the `server` options to explicitly trust native host write approval; omission remains fail-closed and the fallback never supplies missing `ctx.elicit` input. `visibility` defaults to `['model', 'app']` — set `['app']` to hide a helper from the model. For a narrow explicit-intent tool, `modelVisibility: { latestMessageIncludesAny: [...] }` deterministically limits model discovery to a latest user message containing one of the normalized literal phrases; it is not authorization. `fulfil` may be `async` (the compiler awaits it while recording).",
|
|
133
133
|
'',
|
|
134
134
|
'```ts',
|
|
135
135
|
"import { annotations, connector, server, tool, z } from '@noodleseed/one';",
|
|
@@ -160,6 +160,7 @@ const SDK_RECIPES = [
|
|
|
160
160
|
' output: z.object({ subject: z.string(), status: z.string() }),',
|
|
161
161
|
' annotations: annotations.readOnly(), // read-only hint for hosts',
|
|
162
162
|
" visibility: ['model', 'app'], // default; use ['app'] to hide the tool from the model",
|
|
163
|
+
" modelVisibility: { latestMessageIncludesAny: ['show ticket', 'open ticket'] },",
|
|
163
164
|
' // ctx is { input, user, connectors }. A connector call records one flow step (a Ref) —',
|
|
164
165
|
' // recording is not execution, so never branch on the result with native if (use when).',
|
|
165
166
|
' fulfil: ({ input, connectors }) => {',
|
|
@@ -243,7 +244,7 @@ const SDK_AUTHORING_SIGNATURES = [
|
|
|
243
244
|
'## Authoring signatures',
|
|
244
245
|
'',
|
|
245
246
|
'- `server(name, options, definitions)` — `options` commonly includes `title`, `version`, `instructions`, `agentGuide`, `distribution`, `branding`, `auth`, `use`, `provides`, `state`, and `handoff`; `definitions` is the array of tools/resources/prompts.',
|
|
246
|
-
'- `tool(name, { description, input, output, annotations?, visibility?, view?, fulfil })` — `input`/`output` are Zod schemas; `fulfil({ input, connectors, user })` returns data matching `output`. Add `view: { component, entry }` for a React widget; use `visibility: ["app"]` for an app-only helper.',
|
|
247
|
+
'- `tool(name, { description, input, output, annotations?, visibility?, modelVisibility?, view?, fulfil })` — `input`/`output` are Zod schemas; `fulfil({ input, connectors, user })` returns data matching `output`. Add `view: { component, entry }` for a React widget; use `visibility: ["app"]` for an app-only helper. Use `modelVisibility.latestMessageIncludesAny` only for normalized literal explicit-intent discovery; it does not authorize execution.',
|
|
247
248
|
'- Keep tool input names application-owned and meaningful; `__noodleIntent` is reserved for an optional serve-time operator analytics adapter and never reaches `fulfil`.',
|
|
248
249
|
'- `resource(name, { uri, description?, mimeType?, fulfil })` and `prompt(name, { description?, arguments?, fulfil })` expose MCP resources/prompts.',
|
|
249
250
|
'- View metadata (`viewTitle`, `viewDescription`, `csp`, `domain`, `permissions`) belongs on the tool that renders it; `asset("./path")` packages local files.',
|
|
@@ -27,6 +27,7 @@ export declare function selectAssistantModelTools(artifact: RuntimeArtifact, cal
|
|
|
27
27
|
readonly identityKind?: string;
|
|
28
28
|
}) | undefined, options?: {
|
|
29
29
|
readonly anonymousSignInOfferSurface?: readonly SurfaceCapabilityRef[];
|
|
30
|
+
readonly latestMessage?: string;
|
|
30
31
|
}): readonly ArtifactTool[];
|
|
31
32
|
/**
|
|
32
33
|
* Build bounded product guidance from structured App Package data, never from rendered host files.
|
|
@@ -3,6 +3,9 @@ import { filterAuthorizedTools } from '@noodle-borg/protocol';
|
|
|
3
3
|
import { projectArtifactForSurface } from './artifact-projection.js';
|
|
4
4
|
/** Model-context budget for the embedded assistant's compact product workflow projection. */
|
|
5
5
|
export const ASSISTANT_GUIDE_MAX_BYTES = 16 * 1024;
|
|
6
|
+
const LATEST_MESSAGE_INCLUDES_ANY = 'x-noodleseed-model-latest-message-includes-any';
|
|
7
|
+
const MAX_VISIBILITY_PHRASES = 32;
|
|
8
|
+
const MAX_VISIBILITY_PHRASE_CHARS = 128;
|
|
6
9
|
/**
|
|
7
10
|
* Select the exact deployment tools an embedded model may see for this turn.
|
|
8
11
|
*
|
|
@@ -16,7 +19,38 @@ export function selectAssistantModelTools(artifact, caller, options = {}) {
|
|
|
16
19
|
? projectArtifactForSurface(artifact, options.anonymousSignInOfferSurface)
|
|
17
20
|
: undefined;
|
|
18
21
|
const authorized = signInOfferArtifact?.tools ?? filterAuthorizedTools(artifact.tools, caller);
|
|
19
|
-
return authorized.filter((tool) => tool._meta?.ui?.visibility?.includes('model') !== false
|
|
22
|
+
return authorized.filter((tool) => tool._meta?.ui?.visibility?.includes('model') !== false &&
|
|
23
|
+
matchesLatestMessageConstraint(tool, options.latestMessage));
|
|
24
|
+
}
|
|
25
|
+
function matchesLatestMessageConstraint(tool, latestMessage) {
|
|
26
|
+
if (!Object.hasOwn(tool.annotations ?? {}, LATEST_MESSAGE_INCLUDES_ANY)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const phrases = tool.annotations?.[LATEST_MESSAGE_INCLUDES_ANY];
|
|
30
|
+
if (!Array.isArray(phrases) ||
|
|
31
|
+
phrases.length < 1 ||
|
|
32
|
+
phrases.length > MAX_VISIBILITY_PHRASES ||
|
|
33
|
+
phrases.some((phrase) => typeof phrase !== 'string' ||
|
|
34
|
+
phrase.length > MAX_VISIBILITY_PHRASE_CHARS ||
|
|
35
|
+
normalizeVisibilityText(phrase).length === 0)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const normalizedMessage = normalizeVisibilityText(latestMessage ?? '');
|
|
39
|
+
if (normalizedMessage.length === 0)
|
|
40
|
+
return false;
|
|
41
|
+
const paddedMessage = ` ${normalizedMessage} `;
|
|
42
|
+
return phrases.some((phrase) => {
|
|
43
|
+
const normalizedPhrase = normalizeVisibilityText(phrase);
|
|
44
|
+
return paddedMessage.includes(` ${normalizedPhrase} `);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function normalizeVisibilityText(value) {
|
|
48
|
+
return value
|
|
49
|
+
.normalize('NFKC')
|
|
50
|
+
.toLocaleLowerCase('en-US')
|
|
51
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
52
|
+
.trim()
|
|
53
|
+
.replace(/\s+/gu, ' ');
|
|
20
54
|
}
|
|
21
55
|
/**
|
|
22
56
|
* Build bounded product guidance from structured App Package data, never from rendered host files.
|