@noodleseed/one 0.160.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,7 +104,7 @@ export const BUNDLED_EXAMPLE_FILES = [
104
104
  { relPath: "examples/stateful-draft/test/draft-card.test.tsx", content: "// @vitest-environment happy-dom\nimport { act } from 'react';\nimport { createRoot } from 'react-dom/client';\nimport { afterEach, beforeEach, expect, it, vi } from 'vitest';\n\nconst callTool = vi.fn();\nconst followUp = vi.fn();\nconst initial = {\n value: { title: 'Team launch', audience: 'New teammates', goal: 'Complete their first project' },\n revision: 4,\n status: 'active',\n};\nlet entry: unknown = initial;\nvi.mock('../src/helpers.js', () => ({\n useToolInfo: () => ({ structuredContent: entry }),\n useCallTool: () => ({ callTool }),\n useSendFollowUpMessage: () => followUp,\n}));\n\nimport DraftCard from '../src/views/draft-card.js';\n\nlet host: HTMLDivElement;\nlet root: ReturnType<typeof createRoot>;\nbeforeEach(async () => {\n vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);\n callTool.mockReset();\n followUp.mockReset();\n entry = initial;\n host = document.createElement('div');\n document.body.append(host);\n root = createRoot(host);\n await act(async () => root.render(<DraftCard />));\n});\nafterEach(() => {\n act(() => root.unmount());\n host.remove();\n});\nfunction button(label: string) {\n const found = [...host.querySelectorAll('button')].find((entry) => entry.textContent === label);\n if (!found) throw new Error(`Missing button: ${label}`);\n return found;\n}\n\nit('saves using the server revision and displays only the returned result', async () => {\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 8 } });\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenCalledWith({ ...initial.value, expectedRevision: 4 });\n expect(host.textContent).toContain('Your brief is saved.');\n expect(button('Continue with an account').disabled).toBe(false);\n});\n\nit('shows a proposed brief without pretending it is already saved', async () => {\n entry = { value: {}, revision: 0, status: 'active', proposal: initial.value };\n await act(async () => root.render(<DraftCard />));\n expect(host.querySelector('input')?.value).toBe('Team launch');\n expect(button('Continue with an account').disabled).toBe(true);\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 1 } });\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenCalledWith({ ...initial.value, expectedRevision: 0 });\n});\n\nit('does not invent a save when confirmation is pending or the response is missing', async () => {\n callTool.mockResolvedValue({});\n await act(async () => button('Save brief').click());\n expect(host.textContent).not.toContain('Your brief is saved.');\n expect(host.textContent).toContain('No save was confirmed.');\n});\n\nit('retains edits on a stale write and requires a reload before another save', async () => {\n callTool.mockResolvedValue({ isError: true });\n await act(async () => button('Save brief').click());\n expect(host.querySelector('input')?.value).toBe('Team launch');\n expect(button('Save brief').disabled).toBe(true);\n expect(host.textContent).toContain('Reload saved');\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 7 } });\n await act(async () => button('Reload saved').click());\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenLastCalledWith({ ...initial.value, expectedRevision: 7 });\n});\n\nit('keeps continuing separate from saving and makes no project-creation claim', async () => {\n await act(async () => button('Continue with an account').click());\n expect(callTool).not.toHaveBeenCalled();\n expect(followUp).toHaveBeenCalledWith({\n prompt: 'I would like to continue with my saved brief in an account.',\n });\n expect(host.textContent).not.toContain('Project created');\n});\n" },
105
105
  { relPath: "examples/stateful-draft/test/server.test.ts", content: "import { fileURLToPath } from 'node:url';\nimport { validate } from '@noodleseed/one';\nimport { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('stateful draft onboarding reference', () => {\n it('reads and saves authoritative state instead of a widget-only copy', async () => {\n const manifest = await app.toManifest();\n expect(manifest.tools.find((entry) => entry.name === 'open_draft')?.fulfilment.steps).toEqual([\n expect.objectContaining({ use: 'state.read_state', args: { handle: 'draft' } }),\n ]);\n expect(manifest.tools.find((entry) => entry.name === 'save_draft')).toMatchObject({\n annotations: { readOnlyHint: false, confirm: true },\n fulfilment: {\n steps: [\n expect.objectContaining({\n use: 'state.patch_state',\n args: {\n handle: 'draft',\n expectedRevision: '${input.expectedRevision}',\n value: {\n title: '${input.title}',\n audience: '${input.audience}',\n goal: '${input.goal}',\n },\n },\n }),\n ],\n },\n });\n });\n\n it('limits anonymous access and transfers only an expiring draft after verified login', async () => {\n const manifest = await app.toManifest();\n expect(manifest.state?.handles.draft).toMatchObject({\n scope: 'caller',\n ttlSeconds: 86400,\n claimOnAuthentication: true,\n });\n expect(manifest.server.assistant?.surfaces?.map((surface) => surface.mode)).toEqual([\n 'mixed',\n 'authenticated',\n ]);\n const continued = manifest.tools.find((entry) => entry.name === 'continue_draft');\n expect(continued?.annotations?.readOnlyHint).toBe(true);\n expect(continued?.fulfilment.output).toMatchObject({ accountId: '${user.id}' });\n expect(continued?.fulfilment.steps).toEqual([\n expect.objectContaining({ use: 'state.read_state', args: { handle: 'draft' } }),\n ]);\n });\n\n it('compiles through the public validator, including anonymous action confirmation', async () => {\n const result = await validate({\n manifestPath: fileURLToPath(new URL('../src/server.ts', import.meta.url)),\n });\n expect(result.ok, JSON.stringify(result.ok ? [] : result.errors)).toBe(true);\n });\n});\n" },
106
106
  { relPath: "examples/stateful-draft/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n oxc: { jsx: { runtime: 'automatic' } },\n test: { include: ['test/**/*.test.{ts,tsx}'], testTimeout: 30_000, maxWorkers: 2 },\n});\n" },
107
- { relPath: "examples/weather/README.md", content: "# Weather Briefing\n\nTwo declarative tools that show the runtime's breadth working together, with **no auth and no API\nkeys**. The `weather_briefing` tool takes a city name and runs a **three-step flow**:\n\nCapability slots: HTTP connector authoring, ordered fulfilment flows, query/response mapping,\n**list-returning connector output** (a connector that returns a live, variable-length array), and\nsandboxed compute, including an explicit least-privilege per-operation response-size bound.\n\nFor a different API with an OpenAPI document, start with `noodle import openapi <file>` in a separate\ndirectory. It preserves supported typed JSON bodies and scalar parameters; unsupported input encodings\nstop import instead of dropping fields. Its offline test establishes the contract, not live behavior; follow the\n[connector guide](https://docs.noodleseed.dev/docs/guides/connectors) before replacing this curated flow.\n\n1. **`geo.search`** → geocode the city to coordinates (Open-Meteo Geocoding API)\n2. **`forecast.current`** → fetch current weather for those coordinates (Open-Meteo Forecast API)\n3. **`brief.summarize`** → derive a human-readable briefing in a **WASM/QuickJS compute sandbox**\n\nThe second tool, `search_places`, shows a connector returning a **live, variable-length list**: it binds\nthe whole Open-Meteo geocoding `results` array with `${response.results}`, then narrows each match to\n`{ id, label }` in a compute connector — the \"search → a list of options the model can pick from\"\npattern. Narrowing lives in compute because a `${...}` response mapping cannot iterate an array and a\ntool's Zod output does not strip fields at runtime.\n\nIt exercises, in one TypeScript-authored app:\n\n- **Server-level branding** with semantic tokens carried through the runtime artifact for any generated\n app surface.\n- **Ordered flow execution** with outputs threaded between steps (`${steps.geo.latitude}` → next step).\n- **Two HTTP connectors on two different hosts**, each with its own egress allowlist.\n- **Query parameters** (`query: [...]`) and a constant query baked into the path (`?current_weather=true`).\n- **Deep response mapping** with the `${...}` language — single-element indexing\n (`${response.results[0].latitude}`, `${response.current_weather.temperature}`) **and** whole-array\n binding (`${response.results}` returns the entire list verbatim).\n- **A list-returning connector + compute narrowing** — `geo.search_list` binds the whole `results`\n array; `places.narrow` reduces each element to `{ id, label }` and normalizes the no-results case\n to `[]`.\n- **A per-operation transport bound** — `search_list` sets\n `limits: { maxResponseBytes: 256 * 1024 }`, tightening this known-small endpoint below the 1 MiB default.\n The authoring ceiling is 6 MiB, but grant only the bytes representative evidence proves this operation\n needs.\n- **Sandboxed compute** (no network/fs/env/clock) turning raw numbers into conditions + advice.\n- **Typed input/output schemas** emitted as JSON Schema 2020-12.\n\n## APIs that require form-urlencoded search bodies\n\nThe live Open-Meteo calls above are GET requests. For APIs whose search endpoint is a POST expecting\n`application/x-www-form-urlencoded`, keep authoring a request object and select the encoding explicitly:\n\n```ts\nsearch_quotes: {\n type: 'read',\n method: 'POST',\n path: '/quotes/search',\n requestEncoding: 'form-urlencoded',\n input: z.object({\n fromAirportId: z.string(),\n categories: z.array(z.string()),\n }),\n request: {\n 'from airport id': '${args.fromAirportId}',\n 'aircraft[categories]': '${args.categories}',\n },\n // output and response mapping omitted\n},\n```\n\nNoodle builds a `URLSearchParams` body: spaces and punctuation in field names are encoded normally, while\neach array or nested object is JSON-stringified into its individual form field. Do not pre-encode the body\nor set `Content-Type` manually; the connector owns both.\n\n## Run it locally\n\nBefore an authorized hosted deployment, inspect this project's inputs with your project-local CLI:\n`noodle deploy preflight --org <org> --app weather --env staging --version 1 --json`.\nThis requires existing hosted access but does not publish or call the weather backend. Its readiness result\ndoes not replace the local and hosted representative-call checks below.\n\nFrom the repo root, with the workspace built (`pnpm build`):\n\n```bash\n: # 1. boot the local loopback dev server\nnode packages/cli/dist/cli.js dev examples/weather/src/server.ts --app weather\n\n: # 2. in another shell, call the printed local endpoint\nURL=http://127.0.0.1:<port>/o/local/weather/dev/mcp\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"weather_briefing\",\"arguments\":{\"city\":\"Paris\"}}}'\n```\n\nExample result (live data, abbreviated):\n\n```json\n{\n \"place\": \"Paris\", \"country\": \"France\",\n \"temperature_c\": 25.1, \"windspeed_kmh\": 8.3,\n \"conditions\": \"overcast\",\n \"headline\": \"Paris, France: 25°C, overcast.\",\n \"advice\": \"Comfortable conditions — no special prep needed.\"\n}\n```\n\nTry other cities (`Reykjavik`, `Singapore`, `Denver`) to see the conditions and advice change.\n\nCall `search_places` to see the **list-returning** tool — one query, many matches:\n\n```bash\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_places\",\"arguments\":{\"query\":\"Springfield\"}}}'\n```\n\n```json\n{\n \"places\": [\n { \"id\": \"4951788\", \"label\": \"Springfield, Massachusetts, United States\" },\n { \"id\": \"4250542\", \"label\": \"Springfield, Illinois, United States\" },\n { \"id\": \"4508722\", \"label\": \"Springfield, Ohio, United States\" }\n ]\n}\n```\n" },
107
+ { relPath: "examples/weather/README.md", content: "# Weather Briefing\n\nTwo declarative tools that show the runtime's breadth working together, with **no auth and no API\nkeys**. The `weather_briefing` tool takes a city name and runs a **three-step flow**:\n\nCapability slots: HTTP connector authoring, ordered fulfilment flows, query/response mapping,\n**list-returning connector output** (a connector that returns a live, variable-length array), and\nsandboxed compute, including an explicit least-privilege per-operation response-size bound.\n\nFor a different API with an OpenAPI document, start with `noodle import openapi <file>` in a separate\ndirectory. It preserves supported typed JSON bodies and scalar parameters; unsupported input encodings\nstop import instead of dropping fields. Its offline test establishes the contract, not live behavior; follow the\n[connector guide](https://docs.noodleseed.dev/docs/guides/connectors) before replacing this curated flow.\n\n1. **`geo.search`** → geocode the city to coordinates (Open-Meteo Geocoding API)\n2. **`forecast.current`** → fetch current weather for those coordinates (Open-Meteo Forecast API)\n3. **`brief.summarize`** → derive a human-readable briefing in a **WASM/QuickJS compute sandbox**\n\nThe second tool, `search_places`, shows a connector returning a **live, variable-length list**: it binds\nthe whole Open-Meteo geocoding `results` array with `${response.results}`, then narrows each match to\n`{ id, label }` in a compute connector — the \"search → a list of options the model can pick from\"\npattern. Narrowing lives in compute because a `${...}` response mapping cannot iterate an array and a\ntool's Zod output does not strip fields at runtime.\n\nIt exercises, in one TypeScript-authored app:\n\n- **Server-level branding** with semantic tokens carried through the runtime artifact for any generated\n app surface.\n- **Ordered flow execution** with outputs threaded between steps (`${steps.geo.latitude}` → next step).\n- **Two HTTP connectors on two different hosts**, each with its own egress allowlist.\n- **Query parameters** (`query: [...]`) and a constant query baked into the path (`?current_weather=true`).\n- **Deep response mapping** with the `${...}` language — single-element indexing\n (`${response.results[0].latitude}`, `${response.current_weather.temperature}`) **and** whole-array\n binding (`${response.results}` returns the entire list verbatim).\n- **A list-returning connector + compute narrowing** — `geo.search_list` binds the whole `results`\n array; `places.narrow` reduces each element to `{ id, label }` and normalizes the no-results case\n to `[]`.\n- **A per-operation transport bound** — `search_list` sets\n `limits: { maxResponseBytes: 256 * 1024 }`, tightening this known-small endpoint below the 1 MiB default.\n The authoring ceiling is 6 MiB, but grant only the bytes representative evidence proves this operation\n needs.\n- **Sandboxed compute** (no network/fs/env/clock) turning raw numbers into conditions + advice.\n- **Typed input/output schemas** emitted as JSON Schema 2020-12.\n\n## APIs that require form-urlencoded search bodies\n\nThe live Open-Meteo calls above are GET requests. For APIs whose search endpoint is a POST expecting\n`application/x-www-form-urlencoded`, keep authoring a request object and select the encoding explicitly:\n\n```ts\nsearch_quotes: {\n type: 'read',\n method: 'POST',\n path: '/quotes/search',\n requestEncoding: 'form-urlencoded',\n input: z.object({\n fromAirportId: z.string(),\n categories: z.array(z.string()),\n }),\n request: {\n 'from airport id': '${args.fromAirportId}',\n 'aircraft[categories]': '${args.categories}',\n },\n // output and response mapping omitted\n},\n```\n\nNoodle builds a `URLSearchParams` body: spaces and punctuation in field names are encoded normally, while\neach array or nested object is JSON-stringified into its individual form field. Do not pre-encode the body\nor set `Content-Type` manually; the connector owns both.\n\n## Run it locally\n\nBefore an authorized hosted deployment, inspect this project's inputs with your project-local CLI:\n`noodle deploy preflight --org <org> --app weather --env staging --version 1 --json`.\nThis requires existing hosted access but does not publish or call the weather backend. Its readiness result\ndoes not replace the local and hosted representative-call checks below. Review independent missing bindings,\norigin and target-capability findings together before an authorized repair and recheck.\n\nFrom the repo root, with the workspace built (`pnpm build`):\n\n```bash\n: # 1. boot the local loopback dev server\nnode packages/cli/dist/cli.js dev examples/weather/src/server.ts --app weather\n\n: # 2. in another shell, call the printed local endpoint\nURL=http://127.0.0.1:<port>/o/local/weather/dev/mcp\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"weather_briefing\",\"arguments\":{\"city\":\"Paris\"}}}'\n```\n\nExample result (live data, abbreviated):\n\n```json\n{\n \"place\": \"Paris\", \"country\": \"France\",\n \"temperature_c\": 25.1, \"windspeed_kmh\": 8.3,\n \"conditions\": \"overcast\",\n \"headline\": \"Paris, France: 25°C, overcast.\",\n \"advice\": \"Comfortable conditions — no special prep needed.\"\n}\n```\n\nTry other cities (`Reykjavik`, `Singapore`, `Denver`) to see the conditions and advice change.\n\nCall `search_places` to see the **list-returning** tool — one query, many matches:\n\n```bash\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_places\",\"arguments\":{\"query\":\"Springfield\"}}}'\n```\n\n```json\n{\n \"places\": [\n { \"id\": \"4951788\", \"label\": \"Springfield, Massachusetts, United States\" },\n { \"id\": \"4250542\", \"label\": \"Springfield, Illinois, United States\" },\n { \"id\": \"4508722\", \"label\": \"Springfield, Ohio, United States\" }\n ]\n}\n```\n" },
108
108
  { relPath: "examples/weather/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"weather\"\n}\n" },
109
109
  { relPath: "examples/weather/package.json", content: "{\n \"name\": \"weather\",\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" },
110
110
  { relPath: "examples/weather/src/server.ts", content: "import { connector, server, tool, z } from '@noodleseed/one';\n\n// The same Weather Briefing server, authored in TypeScript with the Noodle authoring SDK.\n//\n// The SDK owns the *manifest*: the tool, its Zod-typed input/output schemas, and the flow — which you\n// write as ordinary code in `fulfil` and the SDK records symbolically into ordered steps.\n//\n// HTTP and compute connectors are authored here too, so the public developer entrypoint is one\n// self-contained server.ts. The SDK still compiles this to internal manifest/catalog data for the runtime.\n\nconst geocoding = connector('open_meteo_geocoding')\n .version('1.0.0')\n .http({\n baseUrl: 'https://geocoding-api.open-meteo.com',\n allowedOrigins: ['https://geocoding-api.open-meteo.com'],\n operations: {\n search: {\n type: 'read',\n method: 'GET',\n path: '/v1/search',\n query: ['name'],\n input: z.object({ name: z.string() }),\n output: z.object({\n latitude: z.number(),\n longitude: z.number(),\n place: z.string().optional(),\n country: z.string().optional(),\n }),\n response: {\n latitude: '${response.results[0].latitude}',\n longitude: '${response.results[0].longitude}',\n place: '${response.results[0].name}',\n country: '${response.results[0].country}',\n },\n },\n // A LIST-returning read. `${response.results}` binds the WHOLE array verbatim — a\n // variable-length list of place objects — with no pagination (Open-Meteo returns every match in\n // one page). Contrast the `search` op above, which indexes a single element (`results[0]`). To\n // reduce each element to a few fields, narrow it in the `geo_places` compute connector below: a\n // response mapping cannot iterate an array, and a tool's Zod output does not strip fields at\n // runtime.\n search_list: {\n type: 'read',\n method: 'GET',\n path: '/v1/search',\n query: ['name', 'count'],\n // This endpoint is intentionally small; tighten its allowance below the 1 MiB default.\n limits: { maxResponseBytes: 256 * 1024 },\n input: z.object({ name: z.string(), count: z.number().optional() }),\n output: z.object({ results: z.array(z.unknown()).optional() }),\n response: {\n results: '${response.results}',\n },\n },\n },\n });\n\nconst forecast = connector('open_meteo_forecast')\n .version('1.0.0')\n .http({\n baseUrl: 'https://api.open-meteo.com',\n allowedOrigins: ['https://api.open-meteo.com'],\n operations: {\n current: {\n type: 'read',\n method: 'GET',\n path: '/v1/forecast?current_weather=true',\n query: ['latitude', 'longitude'],\n input: z.object({ latitude: z.number(), longitude: z.number() }),\n output: z.object({\n temperature: z.number().optional(),\n windspeed: z.number().optional(),\n weathercode: z.number().optional(),\n }),\n response: {\n temperature: '${response.current_weather.temperature}',\n windspeed: '${response.current_weather.windspeed}',\n weathercode: '${response.current_weather.weathercode}',\n },\n },\n },\n });\n\nconst brief = connector('weather_brief')\n .version('1.0.0')\n .compute('summarize', {\n type: 'read',\n input: z.object({\n place: z.string(),\n country: z.string().optional(),\n temperature: z.number(),\n windspeed: z.number(),\n weathercode: z.number(),\n }),\n output: z.object({\n conditions: z.string(),\n headline: z.string(),\n advice: z.string(),\n }),\n // A real function — type-checked here, serialized to source and run in the sandbox. It must be\n // self-contained: no imports, no closure over outer variables, synchronous.\n run: (input) => {\n const codes: Record<number, string> = {\n 0: 'clear sky',\n 1: 'mainly clear',\n 2: 'partly cloudy',\n 3: 'overcast',\n 45: 'fog',\n 48: 'depositing rime fog',\n 51: 'light drizzle',\n 53: 'moderate drizzle',\n 55: 'dense drizzle',\n 61: 'slight rain',\n 63: 'moderate rain',\n 65: 'heavy rain',\n 71: 'slight snow',\n 73: 'moderate snow',\n 75: 'heavy snow',\n 77: 'snow grains',\n 80: 'slight rain showers',\n 81: 'moderate rain showers',\n 82: 'violent rain showers',\n 85: 'slight snow showers',\n 86: 'heavy snow showers',\n 95: 'thunderstorm',\n 96: 'thunderstorm with hail',\n 99: 'thunderstorm with heavy hail',\n };\n const code = Number(input.weathercode);\n const conditions = codes[code] || 'unknown conditions';\n const temp = Math.round(Number(input.temperature));\n const wind = Math.round(Number(input.windspeed));\n const where = input.country ? `${input.place}, ${input.country}` : input.place;\n const headline = `${where}: ${temp}°C, ${conditions}.`;\n const tips: string[] = [];\n if (temp <= 0) tips.push(\"bundle up, it's freezing\");\n else if (temp <= 10) tips.push('wear a warm coat');\n else if (temp >= 28) tips.push(\"stay hydrated, it's hot\");\n if (code >= 95) tips.push('thunderstorms expected — seek shelter');\n else if (code >= 71 && code <= 86 && code !== 80 && code !== 81 && code !== 82)\n tips.push('snow — dress warm and tread carefully');\n else if (code >= 51 && code <= 82) tips.push('bring an umbrella');\n if (wind >= 30) tips.push('expect strong winds');\n const advice = tips.length\n ? `${tips.join('; ')}.`\n : 'Comfortable conditions — no special prep needed.';\n return { conditions, headline, advice };\n },\n });\n\n// Narrowing a live list to `{ id, label }` summaries is the ONE reshape a response mapping cannot do\n// (the `${...}` language has no per-item iteration) and a tool's Zod output does not enforce at runtime\n// — so it happens here, in a sandboxed compute connector (a connector is HTTP or compute, not both).\n// This also normalizes the no-results case (Open-Meteo omits `results` when nothing matches) to `[]`.\nconst placeNarrow = connector('geo_places')\n .version('1.0.0')\n .compute('narrow', {\n type: 'read',\n input: z.object({ results: z.unknown().optional() }),\n output: z.object({ places: z.array(z.unknown()) }),\n // Self-contained: no imports, no closure over outer variables, synchronous.\n run: (input) => {\n const raw = input.results;\n const list = Array.isArray(raw) ? raw : [];\n const places = list.map((entry) => {\n const parts = [entry.name, entry.admin1, entry.country].filter(\n (part) => typeof part === 'string' && part.length > 0,\n );\n const id =\n entry.id !== undefined && entry.id !== null\n ? String(entry.id)\n : `${entry.latitude},${entry.longitude}`;\n return { id, label: parts.join(', ') };\n });\n return { places };\n },\n });\n\nexport default server(\n 'weather_briefing',\n {\n title: 'Weather Briefing',\n version: '1.0.0',\n use: { geo: geocoding, forecast, brief, places: placeNarrow },\n branding: {\n name: 'Weather Briefing',\n accent: '#0284C7',\n radius: 'md',\n density: 'comfortable',\n },\n },\n [\n tool('weather_briefing', {\n title: 'Weather briefing',\n description:\n 'Look up a city, fetch its current weather, and return a human-readable briefing. Runs a ' +\n 'three-step flow: geocode the city, fetch the forecast, then derive the briefing in a sandboxed compute step.',\n input: z.object({\n city: z.string(),\n }),\n output: z.object({\n place: z.string(),\n country: z.string(),\n temperature_c: z.number(),\n windspeed_kmh: z.number(),\n conditions: z.string(),\n headline: z.string(),\n advice: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const located = connectors.geo.search({ name: input.city });\n const weather = connectors.forecast.current({\n latitude: located.latitude,\n longitude: located.longitude,\n });\n const briefing = connectors.brief.summarize({\n place: located.place,\n country: located.country,\n temperature: weather.temperature,\n windspeed: weather.windspeed,\n weathercode: weather.weathercode,\n });\n return {\n place: located.place,\n country: located.country,\n temperature_c: weather.temperature,\n windspeed_kmh: weather.windspeed,\n conditions: briefing.conditions,\n headline: briefing.headline,\n advice: briefing.advice,\n };\n },\n }),\n // A connector that returns a live, variable-length LIST: search a place name, get back the\n // matching locations as `{ id, label }` options the model can resolve against. The HTTP op binds\n // the whole array; the compute connector narrows each element to the two fields the model speaks\n // from. Append new tools AFTER existing ones so `tools[0]` stays stable for host harnesses.\n tool('search_places', {\n title: 'Search places',\n description:\n 'Search a place name and return the matching locations as a list of { id, label } options.',\n // Bound the list at the source: `limit` is capped in the schema and passed through to the\n // upstream `count` parameter, so the model can never pull an unbounded page into its context.\n // `noodle check` reports an unbounded array output as `tool_design_output_bounds`.\n input: z.object({\n query: z.string(),\n limit: z.number().int().min(1).max(10).default(5),\n }),\n output: z.object({\n places: z.array(z.object({ id: z.string(), label: z.string() })),\n }),\n fulfil: ({ input, connectors }) => {\n const found = connectors.geo.search_list({ name: input.query, count: input.limit });\n const narrowed = connectors.places.narrow({ results: found.results });\n return { places: narrowed.places };\n },\n }),\n ],\n);\n" },
@@ -62,7 +62,7 @@ export function renderVerifyAndRecoverReference() {
62
62
  '- Real API: distinguish authentication, reachability, legitimate empty results, and broken response mappings before changing code.',
63
63
  '- App: repair the cited contract or state in `noodle check --json`, then confirm it in devtools before attempting a host.',
64
64
  '- Host/deployment/production: confirm revision, target, identity, and configuration independently; do not infer one from another.',
65
- '- Authored deploy readiness: with existing access and the intended target, inspect `noodle deploy preflight --json` through the installed execution transport (`noodle-readiness.preflight_build` for plugin users). It never configures or publishes. Use all returned findings; proposed configuration commands require separate authorization. Readiness is not a backend call or hosted journey.',
65
+ '- Authored deploy readiness: with existing access and the intended target, inspect `noodle deploy preflight --json` through the installed execution transport (`noodle-readiness.preflight_build` for plugin users). It never configures or publishes. Review independent config, origin, capability, auth and rendering blockers together; denied or invalid inputs can prevent dependent checks. Proposed configuration commands require separate authorization. Readiness is not a backend call or hosted journey.',
66
66
  '- Repeated external failure: preserve passing evidence and report the sanitized failure, required authority or external state, owner, and exact next action.',
67
67
  '',
68
68
  '## Stop conditions',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodle-borg/agent-kit",
3
- "version": "0.97.0",
3
+ "version": "0.98.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,11 +1,16 @@
1
1
  import type { CompileError, RuntimeArtifact } from '@noodle-borg/compiler';
2
+ interface ManagedOriginError extends CompileError {
3
+ readonly variableName: string;
4
+ readonly reason: 'missing' | 'invalid';
5
+ }
2
6
  export type ManagedOriginResolutionResult = {
3
7
  readonly ok: true;
4
8
  readonly artifact: RuntimeArtifact;
5
9
  } | {
6
10
  readonly ok: false;
7
- readonly errors: readonly CompileError[];
11
+ readonly errors: readonly ManagedOriginError[];
8
12
  };
9
13
  /** Bind operator-owned exact origins into every runtime authority and host projection. */
10
14
  export declare function resolveManagedOrigins(artifact: RuntimeArtifact, variables: Readonly<Record<string, string>>): ManagedOriginResolutionResult;
15
+ export {};
11
16
  //# sourceMappingURL=managed-origins.d.ts.map
@@ -53,6 +53,8 @@ function resolveManagedOrigin(value, variables, path, allowLoopback, replacement
53
53
  if (resolved === undefined) {
54
54
  errors.push({
55
55
  code: 'invalid_shape',
56
+ variableName: match[1],
57
+ reason: 'missing',
56
58
  path,
57
59
  message: `managed origin variable "${match[1]}" is not configured`,
58
60
  });
@@ -61,6 +63,8 @@ function resolveManagedOrigin(value, variables, path, allowLoopback, replacement
61
63
  if (!isCanonicalOrigin(resolved, allowLoopback)) {
62
64
  errors.push({
63
65
  code: 'invalid_shape',
66
+ variableName: match[1],
67
+ reason: 'invalid',
64
68
  path,
65
69
  message: allowLoopback
66
70
  ? `managed origin variable "${match[1]}" must resolve to a canonical bare HTTPS origin (loopback HTTP is allowed for development)`
@@ -45,14 +45,10 @@ export async function compileRegistryTarget(context, input) {
45
45
  : { localDevtoolsCustomerIdentity: true }),
46
46
  })
47
47
  : [];
48
- if (identityErrors.length > 0)
49
- return { ok: false, errors: identityErrors };
50
48
  // A delegated-auth tool on a pure public surface inevitably fails; reject it while the author can fix it.
51
49
  const projectionErrors = compiled.ok
52
50
  ? publicSurfaceDelegatedAuthErrors(compiled.artifact, secretBindings)
53
51
  : [];
54
- if (projectionErrors.length > 0)
55
- return { ok: false, errors: projectionErrors };
56
52
  const [resolvedSecrets, resolvedVariables] = await Promise.all([
57
53
  context.configStore.resolveConfigValues('secret', scope),
58
54
  context.configStore.resolveConfigValues('variable', scope),
@@ -65,13 +61,20 @@ export async function compileRegistryTarget(context, input) {
65
61
  return { ok: false, errors: [...connectorConfigErrors, ...compiled.errors] };
66
62
  }
67
63
  const missingServerConfig = missingServerConfigErrors(compiled.artifact, resolvedSecrets, resolvedVariables);
68
- const configErrors = [...connectorConfigErrors, ...missingServerConfig];
69
- if (configErrors.length > 0)
70
- return { ok: false, errors: configErrors };
64
+ const errors = [
65
+ ...identityErrors,
66
+ ...projectionErrors,
67
+ ...connectorConfigErrors,
68
+ ...missingServerConfig,
69
+ ];
71
70
  const originResolution = resolveManagedOrigins(compiled.artifact, resolvedVariables);
72
- if (!originResolution.ok)
73
- return { ok: false, errors: originResolution.errors };
74
- const artifact = originResolution.artifact;
71
+ if (!originResolution.ok) {
72
+ const missingVariables = new Set(errors.filter((error) => error.code === 'missing_variable').map((error) => error.path));
73
+ errors.push(...originResolution.errors
74
+ // An unset binding is already actionable config, not an independent invalid-origin fault.
75
+ .filter((error) => error.reason !== 'missing' || !missingVariables.has(`variables.${error.variableName}`))
76
+ .map(({ code, path, message }) => ({ code, path, message })));
77
+ }
75
78
  let appPackageSnapshot;
76
79
  if (input.renderAppPackage && compiled.appPackage !== undefined) {
77
80
  try {
@@ -79,11 +82,17 @@ export async function compileRegistryTarget(context, input) {
79
82
  }
80
83
  catch (error) {
81
84
  if (error instanceof AppPackageSnapshotError) {
82
- return { ok: false, errors: [error.deployError] };
85
+ errors.push(error.deployError);
86
+ }
87
+ else {
88
+ throw error;
83
89
  }
84
- throw error;
85
90
  }
86
91
  }
92
+ if (errors.length > 0 || !originResolution.ok) {
93
+ return { ok: false, errors, compiledArtifact: compiled.artifact };
94
+ }
95
+ const artifact = originResolution.artifact;
87
96
  const localAuthority = context.delegatedExchange === undefined &&
88
97
  secretBindings.some((binding) => binding.authKind === 'delegatedTokenExchange')
89
98
  ? await context.localDevtoolsDelegatedExchange?.resolve()
@@ -10,20 +10,24 @@ export async function preflightRegistryDeploy(input) {
10
10
  return deployerRequiredError(accessMode);
11
11
  }
12
12
  const built = await input.compile();
13
- if (!built.ok)
14
- return built;
15
- const missingCapabilities = missingCapabilityErrors(built.served.artifact.requirements?.capabilities ?? [], input.serviceCapabilities);
16
- if (missingCapabilities.length > 0)
17
- return { ok: false, errors: missingCapabilities };
18
- if (accessMode === 'customers' && built.served.artifact.server.auth === undefined) {
19
- return serverAuthRequiredError();
13
+ const errors = built.ok ? [] : [...built.errors];
14
+ const artifact = built.ok ? built.served.artifact : built.compiledArtifact;
15
+ if (artifact !== undefined) {
16
+ errors.push(...missingCapabilityErrors(artifact.requirements?.capabilities ?? [], input.serviceCapabilities));
17
+ if (accessMode === 'customers' && artifact.server.auth === undefined) {
18
+ errors.push(...serverAuthRequiredError().errors);
19
+ }
20
20
  }
21
21
  if (orgMembershipSources !== undefined) {
22
22
  if (orgMembershipSources.length === 0)
23
- return emptyMembershipSourcesError();
24
- if (accessMode !== 'org-members')
25
- return membershipSourcesRequireOrgMembersError();
23
+ errors.push(...emptyMembershipSourcesError().errors);
24
+ if (accessMode !== 'org-members') {
25
+ errors.push(...membershipSourcesRequireOrgMembersError().errors);
26
+ }
26
27
  }
28
+ // Only diagnostics cross the registry/API boundary; failed compilation cannot publish metadata.
29
+ if (!built.ok || errors.length > 0)
30
+ return { ok: false, errors };
27
31
  return {
28
32
  ok: true,
29
33
  serverName: built.served.artifact.server.name,
@@ -100,17 +100,19 @@ export async function handleDeployPreflight(req, res, registry, options, maxBody
100
100
  const ownerSelection = await authorizeDeploymentOwnerSelection(res, controlPlane, tenant.org, accessMode, identity, parsed.ownerSubject);
101
101
  if (!ownerSelection.ok)
102
102
  return;
103
+ const requestErrors = [];
103
104
  if (accessMode === 'public' && manifestUsesUserRoot(parsed.manifest)) {
104
- return sendJson(res, 400, {
105
+ requestErrors.push({
105
106
  code: 'public_user_context_conflict',
106
- error: 'public access mode cannot reference ${user}; use mixed for optional identity',
107
+ path: 'accessMode',
108
+ message: 'public access mode cannot reference ${user}; use mixed for optional identity',
107
109
  });
108
110
  }
109
- const [cspFault] = cspFaultsInManifest(parsed.manifest);
110
- if (cspFault !== undefined) {
111
- return sendJson(res, 400, {
111
+ for (const cspFault of cspFaultsInManifest(parsed.manifest)) {
112
+ requestErrors.push({
112
113
  code: 'invalid_widget_csp',
113
- error: `widget "${cspFault.widget}" CSP ${cspFault.list} origin "${cspFault.value}" is not an ` +
114
+ path: `widgets.${cspFault.widgetIndex}.csp.${cspFault.list}.${cspFault.index}`,
115
+ message: `widget "${cspFault.widget}" CSP ${cspFault.list} origin "${cspFault.value}" is not an ` +
114
116
  `absolute https:// origin and would be dropped by the host renderer` +
115
117
  (cspFault.suggestion !== undefined ? `; use "${cspFault.suggestion}"` : ''),
116
118
  });
@@ -149,7 +151,7 @@ export async function handleDeployPreflight(req, res, registry, options, maxBody
149
151
  })
150
152
  .finally(() => clearTimeout(timer));
151
153
  const [application, environment, preflight] = checked;
152
- const errors = preflight.ok ? [] : preflight.errors;
154
+ const errors = [...requestErrors, ...(preflight.ok ? [] : preflight.errors)];
153
155
  const missingSecrets = configNames(errors, 'missing_secret', 'secrets.');
154
156
  const missingVariables = configNames(errors, 'missing_variable', 'variables.');
155
157
  const appState = application === undefined ? 'will-create' : 'existing';
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "^1.29.0",
41
41
  "@noodle-borg/admission-limits": "0.0.0",
42
- "@noodle-borg/agent-kit": "0.97.0",
42
+ "@noodle-borg/agent-kit": "0.98.0",
43
43
  "@noodle-borg/app-package": "0.0.0",
44
44
  "@noodle-borg/assistant-gateway": "0.0.0",
45
45
  "@noodle-borg/auth": "0.0.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodleseed/one",
3
- "version": "0.160.0",
3
+ "version": "0.161.0",
4
4
  "private": false,
5
5
  "description": "Noodle CLI by Noodle Seed — author, run, and deploy declarative MCP servers. Embedding the assistant in your own web app is @noodleseed/assistant.",
6
6
  "license": "Apache-2.0",
@@ -235,7 +235,7 @@
235
235
  "@modelcontextprotocol/client": "2.0.0",
236
236
  "@modelcontextprotocol/server": "2.0.0",
237
237
  "@noodle-borg/admission-limits": "0.0.0",
238
- "@noodle-borg/agent-kit": "0.97.0",
238
+ "@noodle-borg/agent-kit": "0.98.0",
239
239
  "@noodle-borg/app-audit": "0.0.0",
240
240
  "@noodle-borg/app-package": "0.0.0",
241
241
  "@noodle-borg/assistant-gateway": "0.0.0",