@cyanheads/pixoo-mcp-server 1.2.1 → 1.3.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.
Files changed (67) hide show
  1. package/AGENTS.md +17 -10
  2. package/CLAUDE.md +17 -10
  3. package/Dockerfile +63 -46
  4. package/README.md +16 -12
  5. package/changelog/1.2.x/1.2.2.md +40 -0
  6. package/changelog/1.3.x/1.3.0.md +35 -0
  7. package/dist/config/server-config.d.ts +1 -1
  8. package/dist/config/server-config.d.ts.map +1 -1
  9. package/dist/config/server-config.js +6 -1
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.d.ts.map +1 -1
  12. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.js +4 -1
  13. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.js.map +1 -1
  14. package/dist/mcp-server/tools/asset-source-schema.d.ts +13 -0
  15. package/dist/mcp-server/tools/asset-source-schema.d.ts.map +1 -0
  16. package/dist/mcp-server/tools/asset-source-schema.js +25 -0
  17. package/dist/mcp-server/tools/asset-source-schema.js.map +1 -0
  18. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.d.ts +139 -55
  19. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.js +182 -79
  21. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.js +14 -6
  24. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/pixoo-discover-devices.tool.d.ts +1 -1
  26. package/dist/mcp-server/tools/definitions/pixoo-discover-devices.tool.js +1 -1
  27. package/dist/mcp-server/tools/definitions/pixoo-discover-devices.tool.js.map +1 -1
  28. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.d.ts +8 -9
  29. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.js +50 -29
  31. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/pixoo-overlay-text.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/pixoo-overlay-text.tool.js +1 -3
  34. package/dist/mcp-server/tools/definitions/pixoo-overlay-text.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.d.ts +30 -1
  36. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.js +106 -28
  38. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.js.map +1 -1
  39. package/dist/mcp-server/tools/finish-schema.d.ts +28 -0
  40. package/dist/mcp-server/tools/finish-schema.d.ts.map +1 -0
  41. package/dist/mcp-server/tools/finish-schema.js +42 -0
  42. package/dist/mcp-server/tools/finish-schema.js.map +1 -0
  43. package/dist/renderer/finish.d.ts +34 -0
  44. package/dist/renderer/finish.d.ts.map +1 -0
  45. package/dist/renderer/finish.js +62 -0
  46. package/dist/renderer/finish.js.map +1 -0
  47. package/dist/renderer/keyframes.d.ts +5 -1
  48. package/dist/renderer/keyframes.d.ts.map +1 -1
  49. package/dist/renderer/keyframes.js +16 -2
  50. package/dist/renderer/keyframes.js.map +1 -1
  51. package/dist/renderer/remote-image.d.ts +9 -7
  52. package/dist/renderer/remote-image.d.ts.map +1 -1
  53. package/dist/renderer/remote-image.js +15 -23
  54. package/dist/renderer/remote-image.js.map +1 -1
  55. package/dist/renderer/scene-renderer.d.ts +59 -14
  56. package/dist/renderer/scene-renderer.d.ts.map +1 -1
  57. package/dist/renderer/scene-renderer.js +204 -66
  58. package/dist/renderer/scene-renderer.js.map +1 -1
  59. package/dist/renderer/text-engine.d.ts +40 -11
  60. package/dist/renderer/text-engine.d.ts.map +1 -1
  61. package/dist/renderer/text-engine.js +75 -40
  62. package/dist/renderer/text-engine.js.map +1 -1
  63. package/dist/services/pixoo/pixoo-service.d.ts.map +1 -1
  64. package/dist/services/pixoo/pixoo-service.js +9 -33
  65. package/dist/services/pixoo/pixoo-service.js.map +1 -1
  66. package/package.json +3 -3
  67. package/server.json +3 -3
package/AGENTS.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** pixoo-mcp-server
4
- **Version:** 1.2.1
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.9`
4
+ **Version:** 1.3.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.10`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revision 2026-07-28 alongside the 2025 era)
8
8
  **Zod:** ^4.6.5
@@ -118,6 +118,7 @@ export const pixooDeviceStatusResource = resource('pixoo://device/status', {
118
118
 
119
119
  ```ts
120
120
  // src/config/server-config.ts
121
+ import * as path from 'node:path';
121
122
  import { z } from '@cyanheads/mcp-ts-core';
122
123
  import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
123
124
 
@@ -126,7 +127,8 @@ const ServerConfigSchema = z.object({
126
127
  pixooSize: z.coerce.number()
127
128
  .refine((v) => v === 16 || v === 32 || v === 64)
128
129
  .default(64).describe('Display size in pixels (16, 32, or 64).'),
129
- pixooOutputDir: z.string().optional().describe('Auto-save directory for preview PNG/GIF files.'),
130
+ pixooOutputDir: z.string().transform((dir) => path.resolve(dir)).optional()
131
+ .describe('Auto-save directory for preview PNG/GIF files; a relative path resolves against the launch directory.'),
130
132
  pixooPushMinIntervalMs: z.coerce.number().int().min(0).default(1000)
131
133
  .describe('Pacing floor between device pushes in milliseconds.'),
132
134
  });
@@ -164,12 +166,11 @@ Handlers receive a unified `ctx` object. Key properties used by this server:
164
166
  |:---------|:------------|
165
167
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
166
168
  | `ctx.enrich` | Success-path agent context — `.notice()` / `.total()` / `.echo()` / `.truncated()`. Lands only when the definition declares an `enrichment` block. |
167
- | `ctx.fail` | Typed throw against the definition's `errors[]` reason union — auto-populates `data.reason`. |
168
- | `ctx.recoveryFor` | `{ recovery: { hint } }` for a declared reason, resolved from the contract. Pass it as `ctx.fail`'s data argument (or spread it in) to put the declared hint on the wire. |
169
+ | `ctx.fail` | Typed throw against the definition's `errors[]` reason union — auto-populates `data.reason`; the framework fills in the entry's `recovery` as `data.recovery.hint`. |
169
170
  | `ctx.state` | Tenant-scoped KV — `.get`, `.set(key, value, { ttl? })`, `.delete`, `.getMany`, `.list`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). Keys are validated; colons are not legal separators. |
170
171
  | `ctx.requestInput` / `ctx.inputs` | Multi-round-trip input. `return ctx.requestInput(...)`; read the answers with `ctx.inputs.accepted(key, schema)` on re-entry. Unused by this server. |
171
172
  | `ctx.signal` | `AbortSignal` for cancellation. Pass it to any long-running I/O. |
172
- | `ctx.requestId` | Unique request ID. |
173
+ | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. |
173
174
  | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
174
175
 
175
176
  ---
@@ -187,11 +188,12 @@ Pixoo-specific error reasons declared on tools:
187
188
  | `device_rejected` | `ServiceUnavailable` | Firmware returned non-zero `error_code` | — |
188
189
  | `no_device_configured` | `InvalidParams` | Device tool called without `PIXOO_IP` | — |
189
190
  | `asset_not_found` | `NotFound` | Image/sprite path or URL unreadable | — |
191
+ | `invalid_image` | `InvalidParams` | Image source or sprite path read but not decodable | — |
190
192
  | `invalid_color` | `InvalidParams` | `resolveColor` throw — invalid color name or format | — |
191
193
  | `unknown_icon` | `InvalidParams` | Icon name not in registry | — |
192
194
  | `discovery_failed` | `ServiceUnavailable` | Divoom cloud unreachable | `true` |
193
195
 
194
- `PixooService` and `src/renderer/` raise the device, configuration, and asset reasons themselves (a factory error carrying `data.reason`), so those entries carry `thrownBy: 'service'` — lint-only metadata that keeps `error-contract-unthrown` from reading them as dead. A reason the handler throws with `ctx.fail` stays unmarked, and every such site forwards its declared recovery with `ctx.recoveryFor`. A computed reason forwards the same way (`ctx.recoveryFor(reason)`); `lint:mcp` skips a definition whose `ctx.fail` reason is non-literal, so a clean lint says nothing about those sites.
196
+ `PixooService` and `src/renderer/` raise the device, configuration, and asset reasons themselves (a factory error carrying `data.reason`), so those entries carry `thrownBy: 'service'` — lint-only metadata that keeps `error-contract-unthrown` from reading them as dead. A reason the handler throws with `ctx.fail` stays unmarked. Either way, the framework puts the entry's `recovery` on the wire when a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason', message)` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` unless the message already contains it. Pass an explicit `{ recovery: { hint } }` only to override it for one occurrence — a throw-site hint wins over the contract, so wording that holds for every occurrence of a reason lives in its `errors[]` entry, not at the throw. Every error envelope also carries `data.requestId`, and `content[]` closes with `(reason … · request <id>)`.
195
197
 
196
198
  A contract's `retryable` reaches the wire only through `ctx.fail`; a service throw carries it only when the service writes `data.retryable` itself. `classifyDeviceFailure` (in `pixoo-service.ts`) is the one place a failed device call becomes a reason and a retryability — `PixooService` writes both on its push-path throws, and `pixoo_overlay_text` passes the retryability into `ctx.fail` so it overrides the contract default per occurrence.
197
199
 
@@ -211,7 +213,7 @@ errors: [
211
213
  ],
212
214
 
213
215
  // in the handler
214
- throw ctx.fail('invalid_color', `Invalid color "${input.color}"`, ctx.recoveryFor('invalid_color'));
216
+ throw ctx.fail('invalid_color', `Invalid color "${input.color}"`);
215
217
  ```
216
218
 
217
219
  Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring. A tool argument that fails the input schema reaches the client as `InvalidParams` (-32602) with `structuredContent.error` — assert that code, not `ValidationError`, in tests.
@@ -234,11 +236,14 @@ src/
234
236
  text-engine.ts # Gradient ramp + shadow + outline text engine, overflow handling
235
237
  scene-renderer.ts # Element vocabulary, layout resolver, frame rendering
236
238
  keyframes.ts # Keyframe interpolation + animation preset compiler
239
+ finish.ts # Palette finish (quantize + dither) for one frame, or frames sharing one palette
237
240
  preview.ts # PNG/contact-sheet/GIF encoding
238
- remote-image.ts # https image fetch to a temp file for the toolkit loader; stops on ctx.signal
241
+ remote-image.ts # https image fetch into memory for the toolkit loader; stops on ctx.signal
239
242
  mcp-server/
240
243
  tools/
244
+ asset-source-schema.ts # Shared image `source` / sprite `path` schemas: absolute path or URL only
241
245
  device-push.ts # Shared post-render push: preview kept on failure, visibility notice
246
+ finish-schema.ts # Shared `finish` input schema (colors | palette, plus dither)
242
247
  tools/definitions/
243
248
  pixoo-display-text.tool.ts
244
249
  pixoo-compose-scene.tool.ts
@@ -254,6 +259,8 @@ src/
254
259
  pixoo-design-guide.resource.ts
255
260
  tests/
256
261
  index.session-mode.test.ts # Boots the entry point over HTTP, pins the declared session mode
262
+ config/ # Server config parsed from env vars
263
+ helpers/ # Shared fixtures and assertions (image sources, canvas ink, device failures)
257
264
  renderer/ # Pure renderer unit tests (no device)
258
265
  resources/ # Resource handler tests
259
266
  services/pixoo/ # PixooService tests with a fake client
@@ -375,7 +382,7 @@ Each per-version file opens with YAML frontmatter: `summary` (required, ≤350 c
375
382
  ## Checklist
376
383
 
377
384
  - [ ] Zod schemas: all fields have `.describe()`, only JSON-Schema-serializable types (no `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`)
378
- - [ ] Tool inputs are strict at the root — an undeclared argument key is rejected by name. Add `.passthrough()` / `.catchall()` only where an open object is genuinely required
385
+ - [ ] Tool inputs are closed at every level — the framework closes the root `z.object`, and every nested input object is a `z.strictObject`, so an undeclared key fails by its full path (`Unknown key style.colour. style accepts: …`) instead of being dropped. A tool with nested input objects gets a schema test that walks its `input` and fails on any open object below the root (`tests/helpers/zod-object-nodes.ts`). Use `z.looseObject()` / `.catchall()` only where an open object is genuinely required
379
386
  - [ ] JSDoc `@fileoverview` + `@module` on every file
380
387
  - [ ] `ctx.log` for logging, `ctx.state` for storage
381
388
  - [ ] Handlers throw on failure — error factories or plain `Error`, no try/catch
package/CLAUDE.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** pixoo-mcp-server
4
- **Version:** 1.2.1
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.9`
4
+ **Version:** 1.3.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.10`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revision 2026-07-28 alongside the 2025 era)
8
8
  **Zod:** ^4.6.5
@@ -118,6 +118,7 @@ export const pixooDeviceStatusResource = resource('pixoo://device/status', {
118
118
 
119
119
  ```ts
120
120
  // src/config/server-config.ts
121
+ import * as path from 'node:path';
121
122
  import { z } from '@cyanheads/mcp-ts-core';
122
123
  import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
123
124
 
@@ -126,7 +127,8 @@ const ServerConfigSchema = z.object({
126
127
  pixooSize: z.coerce.number()
127
128
  .refine((v) => v === 16 || v === 32 || v === 64)
128
129
  .default(64).describe('Display size in pixels (16, 32, or 64).'),
129
- pixooOutputDir: z.string().optional().describe('Auto-save directory for preview PNG/GIF files.'),
130
+ pixooOutputDir: z.string().transform((dir) => path.resolve(dir)).optional()
131
+ .describe('Auto-save directory for preview PNG/GIF files; a relative path resolves against the launch directory.'),
130
132
  pixooPushMinIntervalMs: z.coerce.number().int().min(0).default(1000)
131
133
  .describe('Pacing floor between device pushes in milliseconds.'),
132
134
  });
@@ -164,12 +166,11 @@ Handlers receive a unified `ctx` object. Key properties used by this server:
164
166
  |:---------|:------------|
165
167
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
166
168
  | `ctx.enrich` | Success-path agent context — `.notice()` / `.total()` / `.echo()` / `.truncated()`. Lands only when the definition declares an `enrichment` block. |
167
- | `ctx.fail` | Typed throw against the definition's `errors[]` reason union — auto-populates `data.reason`. |
168
- | `ctx.recoveryFor` | `{ recovery: { hint } }` for a declared reason, resolved from the contract. Pass it as `ctx.fail`'s data argument (or spread it in) to put the declared hint on the wire. |
169
+ | `ctx.fail` | Typed throw against the definition's `errors[]` reason union — auto-populates `data.reason`; the framework fills in the entry's `recovery` as `data.recovery.hint`. |
169
170
  | `ctx.state` | Tenant-scoped KV — `.get`, `.set(key, value, { ttl? })`, `.delete`, `.getMany`, `.list`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). Keys are validated; colons are not legal separators. |
170
171
  | `ctx.requestInput` / `ctx.inputs` | Multi-round-trip input. `return ctx.requestInput(...)`; read the answers with `ctx.inputs.accepted(key, schema)` on re-entry. Unused by this server. |
171
172
  | `ctx.signal` | `AbortSignal` for cancellation. Pass it to any long-running I/O. |
172
- | `ctx.requestId` | Unique request ID. |
173
+ | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. |
173
174
  | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
174
175
 
175
176
  ---
@@ -187,11 +188,12 @@ Pixoo-specific error reasons declared on tools:
187
188
  | `device_rejected` | `ServiceUnavailable` | Firmware returned non-zero `error_code` | — |
188
189
  | `no_device_configured` | `InvalidParams` | Device tool called without `PIXOO_IP` | — |
189
190
  | `asset_not_found` | `NotFound` | Image/sprite path or URL unreadable | — |
191
+ | `invalid_image` | `InvalidParams` | Image source or sprite path read but not decodable | — |
190
192
  | `invalid_color` | `InvalidParams` | `resolveColor` throw — invalid color name or format | — |
191
193
  | `unknown_icon` | `InvalidParams` | Icon name not in registry | — |
192
194
  | `discovery_failed` | `ServiceUnavailable` | Divoom cloud unreachable | `true` |
193
195
 
194
- `PixooService` and `src/renderer/` raise the device, configuration, and asset reasons themselves (a factory error carrying `data.reason`), so those entries carry `thrownBy: 'service'` — lint-only metadata that keeps `error-contract-unthrown` from reading them as dead. A reason the handler throws with `ctx.fail` stays unmarked, and every such site forwards its declared recovery with `ctx.recoveryFor`. A computed reason forwards the same way (`ctx.recoveryFor(reason)`); `lint:mcp` skips a definition whose `ctx.fail` reason is non-literal, so a clean lint says nothing about those sites.
196
+ `PixooService` and `src/renderer/` raise the device, configuration, and asset reasons themselves (a factory error carrying `data.reason`), so those entries carry `thrownBy: 'service'` — lint-only metadata that keeps `error-contract-unthrown` from reading them as dead. A reason the handler throws with `ctx.fail` stays unmarked. Either way, the framework puts the entry's `recovery` on the wire when a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason', message)` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` unless the message already contains it. Pass an explicit `{ recovery: { hint } }` only to override it for one occurrence — a throw-site hint wins over the contract, so wording that holds for every occurrence of a reason lives in its `errors[]` entry, not at the throw. Every error envelope also carries `data.requestId`, and `content[]` closes with `(reason … · request <id>)`.
195
197
 
196
198
  A contract's `retryable` reaches the wire only through `ctx.fail`; a service throw carries it only when the service writes `data.retryable` itself. `classifyDeviceFailure` (in `pixoo-service.ts`) is the one place a failed device call becomes a reason and a retryability — `PixooService` writes both on its push-path throws, and `pixoo_overlay_text` passes the retryability into `ctx.fail` so it overrides the contract default per occurrence.
197
199
 
@@ -211,7 +213,7 @@ errors: [
211
213
  ],
212
214
 
213
215
  // in the handler
214
- throw ctx.fail('invalid_color', `Invalid color "${input.color}"`, ctx.recoveryFor('invalid_color'));
216
+ throw ctx.fail('invalid_color', `Invalid color "${input.color}"`);
215
217
  ```
216
218
 
217
219
  Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring. A tool argument that fails the input schema reaches the client as `InvalidParams` (-32602) with `structuredContent.error` — assert that code, not `ValidationError`, in tests.
@@ -234,11 +236,14 @@ src/
234
236
  text-engine.ts # Gradient ramp + shadow + outline text engine, overflow handling
235
237
  scene-renderer.ts # Element vocabulary, layout resolver, frame rendering
236
238
  keyframes.ts # Keyframe interpolation + animation preset compiler
239
+ finish.ts # Palette finish (quantize + dither) for one frame, or frames sharing one palette
237
240
  preview.ts # PNG/contact-sheet/GIF encoding
238
- remote-image.ts # https image fetch to a temp file for the toolkit loader; stops on ctx.signal
241
+ remote-image.ts # https image fetch into memory for the toolkit loader; stops on ctx.signal
239
242
  mcp-server/
240
243
  tools/
244
+ asset-source-schema.ts # Shared image `source` / sprite `path` schemas: absolute path or URL only
241
245
  device-push.ts # Shared post-render push: preview kept on failure, visibility notice
246
+ finish-schema.ts # Shared `finish` input schema (colors | palette, plus dither)
242
247
  tools/definitions/
243
248
  pixoo-display-text.tool.ts
244
249
  pixoo-compose-scene.tool.ts
@@ -254,6 +259,8 @@ src/
254
259
  pixoo-design-guide.resource.ts
255
260
  tests/
256
261
  index.session-mode.test.ts # Boots the entry point over HTTP, pins the declared session mode
262
+ config/ # Server config parsed from env vars
263
+ helpers/ # Shared fixtures and assertions (image sources, canvas ink, device failures)
257
264
  renderer/ # Pure renderer unit tests (no device)
258
265
  resources/ # Resource handler tests
259
266
  services/pixoo/ # PixooService tests with a fake client
@@ -375,7 +382,7 @@ Each per-version file opens with YAML frontmatter: `summary` (required, ≤350 c
375
382
  ## Checklist
376
383
 
377
384
  - [ ] Zod schemas: all fields have `.describe()`, only JSON-Schema-serializable types (no `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`)
378
- - [ ] Tool inputs are strict at the root — an undeclared argument key is rejected by name. Add `.passthrough()` / `.catchall()` only where an open object is genuinely required
385
+ - [ ] Tool inputs are closed at every level — the framework closes the root `z.object`, and every nested input object is a `z.strictObject`, so an undeclared key fails by its full path (`Unknown key style.colour. style accepts: …`) instead of being dropped. A tool with nested input objects gets a schema test that walks its `input` and fails on any open object below the root (`tests/helpers/zod-object-nodes.ts`). Use `z.looseObject()` / `.catchall()` only where an open object is genuinely required
379
386
  - [ ] JSDoc `@fileoverview` + `@module` on every file
380
387
  - [ ] `ctx.log` for logging, `ctx.state` for storage
381
388
  - [ ] Handlers throw on failure — error factories or plain `Error`, no try/catch
package/Dockerfile CHANGED
@@ -29,80 +29,97 @@ RUN bun run build
29
29
 
30
30
 
31
31
  # ==============================================================================
32
- # Production Stage
32
+ # Production Dependencies Stage
33
33
  #
34
- # This stage creates a minimal, optimized, and secure image for running the
35
- # application. It uses a slim base image and only includes production
36
- # dependencies and build artifacts.
34
+ # Installs the production dependency tree for the target platform. Every step
35
+ # here can run JavaScript — bunfig.toml's security scanner runs as a Bun
36
+ # program, and so does the OTel script — so the stage runs on $BUILDPLATFORM
37
+ # and cross-installs with `--os`/`--cpu`, which pick each platform-specific
38
+ # optional dependency (sharp's native `@img/*` bindings) for the target. Only
39
+ # `node_modules` leaves this stage.
40
+ #
41
+ # A clean image rather than `FROM build`: the build stage's node_modules holds
42
+ # devDependencies.
37
43
  # ==============================================================================
38
- FROM oven/bun:1.4.2-slim AS production
44
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.2 AS deps
39
45
 
40
46
  WORKDIR /usr/src/app
41
47
 
42
- # Set the environment to production for performance and to ensure only
43
- # production dependencies are installed.
44
- ENV NODE_ENV=production
45
-
46
- # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
47
- ARG APP_VERSION
48
- LABEL org.opencontainers.image.title="pixoo-mcp-server"
49
- LABEL org.opencontainers.image.description="Render and push styled pixel art, text, dashboards, and animations to Divoom Pixoo LED displays."
50
- LABEL org.opencontainers.image.licenses="Apache-2.0"
51
- LABEL org.opencontainers.image.version="${APP_VERSION}"
52
- LABEL org.opencontainers.image.source="https://github.com/cyanheads/pixoo-mcp-server"
53
-
54
48
  # Copy dependency manifests. `bunfig.toml` rides along so every install below
55
49
  # passes its release-age gate and security scanner, as a local install does.
56
50
  COPY package.json bun.lock bunfig.toml ./
57
51
 
58
52
  # The scanner bunfig.toml names is a devDependency, and Bun installs a missing
59
53
  # scanner through the same production-filtered install, which omits it and
60
- # aborts. Seed it from the build stage's full install instead. Remove this line
61
- # together with the scanner if bunfig.toml stops naming one.
54
+ # aborts. Seed it from the build stage's full install instead. Remove this line,
55
+ # and the `rm` at the end of this stage, if bunfig.toml stops naming a scanner.
62
56
  COPY --from=build /usr/src/app/node_modules/@socketsecurity/bun-security-scanner ./node_modules/@socketsecurity/bun-security-scanner
63
57
 
58
+ # Docker names the target architecture `amd64`/`arm64`; Bun's `--cpu` takes
59
+ # `x64`/`arm64`. Mapped once here, read by both installs below. `oven/bun`
60
+ # publishes only these two architectures, so any other target fails here.
61
+ ARG TARGETOS
62
+ ARG TARGETARCH
63
+ RUN case "$TARGETARCH" in \
64
+ amd64) echo x64 ;; \
65
+ arm64) echo arm64 ;; \
66
+ *) echo "Unsupported TARGETARCH '$TARGETARCH': expected amd64 or arm64" >&2; exit 1 ;; \
67
+ esac > .bun-cpu
68
+
64
69
  # Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
65
70
  # that are not needed in the final production image.
66
71
  # `--omit=peer` drops the framework's optional peer tiers (test runner, service
67
72
  # SDKs, parsers) that Bun would otherwise auto-install. Anything this server
68
73
  # actually imports belongs in its own `dependencies`, so nothing needed at
69
- # runtime is lost. The OTEL step below carries the same flag — without it, that
70
- # install re-resolves the graph and pulls every optional peer back in.
74
+ # runtime is lost.
71
75
  RUN --mount=type=cache,target=/root/.bun/install/cache \
72
- bun install --production --omit=peer --frozen-lockfile --ignore-scripts
76
+ bun install --production --omit=peer --frozen-lockfile --ignore-scripts \
77
+ --os="$TARGETOS" --cpu="$(cat .bun-cpu)"
73
78
 
74
79
  # Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
75
80
  # Installed by default. Omit them for a leaner image at build time
76
81
  # with: docker build --build-arg OTEL_ENABLED=false
77
- # Each package is requested at the range the installed framework declares in
78
- # `peerDependencies`, so the resolution stays inside the framework's tested
79
- # peer range; a name with no declared range fails the build.
82
+ # The script reads the list and each range from the installed framework's
83
+ # `peerDependencies` and passes the target flags on to its `bun install`.
84
+ COPY scripts/install-otel.ts ./scripts/
80
85
  ARG OTEL_ENABLED=true
81
86
  RUN --mount=type=cache,target=/root/.bun/install/cache \
82
87
  if [ "$OTEL_ENABLED" = "true" ]; then \
83
- specs=$(bun -e ' \
84
- const { peerDependencies: peers } = await Bun.file("node_modules/@cyanheads/mcp-ts-core/package.json").json(); \
85
- const names = process.argv.slice(1); \
86
- const missing = names.filter((name) => !peers?.[name]); \
87
- if (missing.length > 0) throw new Error(`no peerDependencies range for ${missing.join(", ")}`); \
88
- console.log(names.map((name) => `${name}@${peers[name]}`).join(" ")); \
89
- ' \
90
- @hono/otel \
91
- @opentelemetry/api-logs \
92
- @opentelemetry/exporter-logs-otlp-http \
93
- @opentelemetry/exporter-metrics-otlp-http \
94
- @opentelemetry/exporter-trace-otlp-http \
95
- @opentelemetry/instrumentation-http \
96
- @opentelemetry/instrumentation-pino \
97
- @opentelemetry/resources \
98
- @opentelemetry/sdk-logs \
99
- @opentelemetry/sdk-metrics \
100
- @opentelemetry/sdk-node \
101
- @opentelemetry/sdk-trace-node \
102
- @opentelemetry/semantic-conventions) \
103
- && bun add --omit=dev --omit=peer --ignore-scripts $specs; \
88
+ bun scripts/install-otel.ts --os="$TARGETOS" --cpu="$(cat .bun-cpu)"; \
104
89
  fi
105
90
 
91
+ # The seeded scanner served only the installs above; keep it out of the image.
92
+ RUN rm -rf node_modules/@socketsecurity/bun-security-scanner
93
+
94
+
95
+ # ==============================================================================
96
+ # Production Stage
97
+ #
98
+ # This stage creates a minimal, optimized, and secure image for running the
99
+ # application. It uses a slim base image and only includes production
100
+ # dependencies and build artifacts. Its only Bun invocations are HEALTHCHECK
101
+ # and CMD, which run on the real target at container start.
102
+ # ==============================================================================
103
+ FROM oven/bun:1.4.2-slim AS production
104
+
105
+ WORKDIR /usr/src/app
106
+
107
+ # Set the environment to production for performance.
108
+ ENV NODE_ENV=production
109
+
110
+ # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
111
+ ARG APP_VERSION
112
+ LABEL org.opencontainers.image.title="pixoo-mcp-server"
113
+ LABEL org.opencontainers.image.description="Render and push styled pixel art, text, dashboards, and animations to Divoom Pixoo LED displays."
114
+ LABEL org.opencontainers.image.licenses="Apache-2.0"
115
+ LABEL org.opencontainers.image.version="${APP_VERSION}"
116
+ LABEL org.opencontainers.image.source="https://github.com/cyanheads/pixoo-mcp-server"
117
+
118
+ # The manifest comes from the build context: the deps stage's copy was rewritten
119
+ # by the OTel install, and the runtime reads only its name, version, and type.
120
+ COPY package.json ./
121
+ COPY --from=deps /usr/src/app/node_modules ./node_modules
122
+
106
123
  # Copy the compiled application code from the build stage
107
124
  COPY --from=build /usr/src/app/dist ./dist
108
125
 
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-1.2.1-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pixoo-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.1.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pixoo-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pixoo-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-1.3.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pixoo-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.1.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pixoo-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pixoo-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -31,7 +31,7 @@ Divoom Pixoo LED matrix displays on the local network, with the Pixoo-64 as the
31
31
  |:---|:---|
32
32
  | `pixoo_display_text` | Render styled text with themes, gradients, shadows, and auto-fit, static or animated, and push it |
33
33
  | `pixoo_compose_scene` | Compose layered scenes of text, icons, widgets, shapes, bitmaps, images, and sprites, static or animated |
34
- | `pixoo_push_image` | Resize a local or https image to the LED grid and push it |
34
+ | `pixoo_push_image` | Resize a local or https image to the LED grid and push it, an animated GIF or WebP as an animation |
35
35
  | `pixoo_overlay_text` | Set or clear a device-rendered scrolling text overlay |
36
36
  | `pixoo_control_device` | Read or change brightness, screen state, channel, or clock face |
37
37
  | `pixoo_discover_devices` | Find Pixoo devices and their LAN IPs through Divoom's cloud discovery |
@@ -53,23 +53,27 @@ Tools cover the same ground for tool-only clients: `pixoo_control_device` reads
53
53
  ### `pixoo_display_text` <sub>tool</sub>
54
54
 
55
55
  - `text` as a string or an array of lines; `theme` (`midnight`, `ember`, `claude`, `ice`, `neon`, `forest`, `mono`) sets the background and default palette. `style` takes a `palette` ramp (`ember`, `ice`, `neon`, `fire`, `lavender`, `claude`, `mono`) or a custom `{ from, to }`, plus `shadow`, `outline`, and `scale` 1–8; `position` is semantic or in pixels, and `align` lines up multi-line text
56
- - `layout[]` reports every fit decision as an `action` (`shrunk-to-compact`, `scrolling`, `truncated`, …). Single-line text falls back from the standard to the compact font unless `font` is set, and text still too wide only scrolls under `effect: "auto"` or `"scroll"`
56
+ - `font`: `standard` (5×7) and `compact` (3×5) draw printable ASCII plus `° ← ↑ → ↓ ▲ ▼ ♥ · …`, and any other character (`€`, `’`, a newline inside one string) as `?`, named with its code point and line index in the response `notice`; `numerals` is an 11×18 digit face for clocks and readouts that draws 0–9, space, and `: . - + / % ° ?`, and text holding any other character fails validation, naming those characters
57
+ - `layout[]` reports every fit decision as an `action` (`shrunk-to-compact` when the text fits only in the compact font, `scrolling` when the returned frames scroll, `none` otherwise), the `font` used, and whether each line's box `fits` on the panel; a single line too wide for the panel starts at x 0, cut at the right edge when it does not scroll. Single-line text falls back from the standard to the compact font unless `font` is set — never to `numerals` — and text still too wide only scrolls under `effect: "auto"` or `"scroll"`
57
58
  - `effect`: `scroll` makes one pass in up to 40 frames, `auto` scrolls only on overflow, and `float` and `pulse` loop over 20 frames; `frames` reports the count, and the animation pushes as one device animation
58
59
 
59
60
  ---
60
61
 
61
62
  ### `pixoo_compose_scene` <sub>tool</sub>
62
63
 
63
- - Up to 50 `elements` drawn back-to-front: `text`, `icon`, `rect`, `circle`, `line`, `progress`, `sparkline`, `bitmap`, `pixels`, `image` (absolute path or https URL), `sprite` (absolute path). The `background` is a solid color, a `v` / `h` / `r` gradient, or a `theme`
64
- - Returns `layout[]`: each element's placed box and whether it `fits` on the panel. An absolute `output` path saves the first frame as a PNG in place of the `PIXOO_OUTPUT_DIR` auto-save. Typed failures: `asset_not_found`, `invalid_color`, `unknown_icon`, `invalid_output_path`
65
- - Animation through per-element `effect` presets (`float`, `scroll-left`, `scroll-right`, `pulse`, `blink`, `twinkle`, `drift`, `fade-in`, `fade-out`) or raw `animate` keyframes over `dx`, `dy`, `opacity` (numbers or numeric strings), `visible` (`true`/`false`), and `color` (interpolated through RGB on any element with a `color`), each track holding at least one keyframe; `frames` 1–40, `speed` 10–2000 ms per frame (default 150). An effect's `amplitude` sets the movement of `float`, `scroll-*`, and `drift`, and the 0–1 depth of the `pulse` and `twinkle` opacity dip
64
+ - Up to 50 `elements` drawn back-to-front: `text` (in the same three fonts as `pixoo_display_text`), `icon`, `rect`, `circle`, `line`, `progress`, `sparkline`, `bitmap`, `pixels`, `image` (absolute path or https URL, with the same `finish` as `pixoo_push_image`), `sprite` (absolute path). The `background` is a solid color, a `v` / `h` / `r` gradient, or a `theme`
65
+ - Every element takes `opacity` (each pixel lands at its own alpha × `opacity`, so soft edges fade evenly) and `blend`: `normal`, `add` (glows and light beams), `screen`, or `multiply`. `line` and outline `circle` take `strokeWidth` and `antialias`, and a `rect` border takes `strokeWidth`, growing inward; either field on a shape that draws no stroke fails validation, naming it
66
+ - Returns `layout[]`: each element's placed box — for a wide or anti-aliased stroke, every pixel it draws — and whether it `fits` on the panel. Elements are placed as given and never refit, so `action` is always `none`. An absolute `output` path saves the first frame as a PNG in place of the `PIXOO_OUTPUT_DIR` auto-save. Typed failures: `asset_not_found`, `invalid_image` (an image or sprite that was read but does not decode), `invalid_color`, `unknown_icon`, `invalid_output_path`
67
+ - Animation through per-element `effect` presets (`float`, `scroll-left`, `scroll-right`, `pulse`, `blink`, `twinkle`, `drift`, `fade-in`, `fade-out`) or raw `animate` keyframes over `dx`, `dy`, `opacity` (numbers or numeric strings), `visible` (`true`/`false`), and `color` (interpolated through RGB on any element with a `color`), each track holding at least one keyframe; `frames` 1–40, `speed` 10–2000 ms per frame (default 150). An element takes `effect` or `animate`, not both. An effect's `amplitude` sets the movement of `float`, `scroll-*`, and `drift`, and the 0–1 depth of the `pulse` and `twinkle` opacity dip
66
68
 
67
69
  ---
68
70
 
69
71
  ### `pixoo_push_image` <sub>tool</sub>
70
72
 
71
73
  - `source` is an absolute local path or an https URL, with downloads capped at 10 MB; `fit` is `contain` (default), `cover`, or `fill`, and `kernel` is `nearest` (default, for pixel art), `lanczos3` (photos), or `mitchell`
72
- - An unreadable path or URL fails as `asset_not_found`; the preview is the exact resized frame the device receives
74
+ - A source that decodes as an animated GIF or WebP, whatever its file name, pushes as an animation of up to 40 frames, sampled evenly from a longer source. It plays at the source's total duration over the pushed frame count (150 ms when the source records no delays), or at `speed` (10–2000 ms per frame); `frames`, `sourceFrames`, and `speed` report what was pushed
75
+ - `finish` reduces the image to a palette before the push: exactly one of `colors` (2–256, built from the image) or `palette` (1–256 hex or named colors), plus `dither` (`none`, `bayer4`, `floyd-steinberg`). Transparent pixels stay unlit, and an animation's `colors` palette is shared by every frame
76
+ - An unreadable path or URL fails as `asset_not_found`, a source that is read but does not decode (a text file, an HTML page, a truncated download) fails as `invalid_image`, and an unresolvable `finish` palette entry fails as `invalid_color`; the preview is the exact frame the device receives, or a grid of every frame for an animation
73
77
 
74
78
  ---
75
79
 
@@ -135,12 +139,12 @@ Pixoo-specific:
135
139
 
136
140
  - All composition happens on the host in an RGBA canvas pipeline (`@cyanheads/pixoo-toolkit`); the device receives finished RGB frames
137
141
  - Pushes switch the device to the custom channel and run one at a time, spaced by `PIXOO_PUSH_MIN_INTERVAL_MS` (default 1000) so rapid pushes don't freeze the device
138
- - Animations cap at 40 frames, past which the device becomes unstable
142
+ - Animations cap at 40 frames, past which the device becomes unstable; `pixoo_push_image` samples a longer GIF or WebP down to 40
139
143
 
140
144
  Agent-friendly output:
141
145
 
142
146
  - Preview on every render: `pixoo_display_text`, `pixoo_compose_scene`, and `pixoo_push_image` return the frame as an 8× upscaled PNG image block, pushed or not, so `push: false` checks a design with no device attached. Animations preview as a grid of every frame, since GIF display varies across MCP clients; the GIF itself is saved to `PIXOO_OUTPUT_DIR` when set
143
- - Layout transparency: `layout[]` reports every renderer decision (font fallback, truncation, scrolling, clipping) so agents can refine a design
147
+ - Layout transparency: `layout[]` reports every renderer decision (font fallback, scrolling, and whether each box fits on the panel) so agents can refine a design
144
148
  - Device truth: `pushed` reflects the device ACK, and the `deviceState` read back after a push comes with a notice naming the fix when the render won't be visible (screen off, brightness ≤ 10, off the custom channel)
145
149
  - Renders survive failed pushes: the typed error (`device_unreachable`, `device_http_error`, `device_rejected`, `no_device_configured`) carries `outputFiles` pointing at the saved preview (the `PIXOO_OUTPUT_DIR` copy, or a temp file when that is unset)
146
150
 
@@ -248,7 +252,7 @@ cp .env.example .env
248
252
  |:---|:---|:---|
249
253
  | `PIXOO_IP` | Device IP on the local network. **Required** for pushes, overlays, and device control; discovery, design briefs, and `push: false` renders work without it. | — |
250
254
  | `PIXOO_SIZE` | Display size in pixels: `16`, `32`, or `64`. | `64` |
251
- | `PIXOO_OUTPUT_DIR` | Directory where render tools save preview PNG and GIF files. Unset, previews are returned only in the response. | — |
255
+ | `PIXOO_OUTPUT_DIR` | Directory where render tools save preview PNG and GIF files. A relative path resolves against the directory the server was launched from, so the saved paths it reports are absolute. Unset, previews are returned only in the response. | — |
252
256
  | `PIXOO_PUSH_MIN_INTERVAL_MS` | Minimum gap between device pushes, in ms. | `1000` |
253
257
  | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
254
258
  | `MCP_HTTP_PORT` | HTTP server port. | `3010` |
@@ -300,10 +304,10 @@ The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `
300
304
  |:---|:---|
301
305
  | `src/index.ts` | `createApp()` entry point: registers tools and resources and initializes the Pixoo service. |
302
306
  | `src/config/` | Server-specific environment variable parsing and validation with Zod. |
303
- | `src/mcp-server/tools/` | Tool definitions (`*.tool.ts`) and the shared post-render push path. |
307
+ | `src/mcp-server/tools/` | Tool definitions (`*.tool.ts`), the shared post-render push path, and the shared `finish` input schema. |
304
308
  | `src/mcp-server/resources/` | Resource definitions (`*.resource.ts`). |
305
309
  | `src/services/pixoo/` | `PixooService`: wraps `@cyanheads/pixoo-toolkit` with push pacing, result mapping, and device state reads. |
306
- | `src/renderer/` | Pure rendering pipeline with no device dependency: element renderers, styled-text engine, themes, icons, effect compiler, preview encoding, remote image fetch. |
310
+ | `src/renderer/` | Pure rendering pipeline with no device dependency: element renderers, styled-text engine, themes, icons, effect compiler, palette finishing, preview encoding, remote image fetch. |
307
311
  | `tests/` | Unit and integration tests mirroring `src/`. |
308
312
 
309
313
  ## Development guide
@@ -0,0 +1,40 @@
1
+ ---
2
+ summary: "Adopts @cyanheads/pixoo-toolkit 0.10.0: animated GIF and WebP push as animations, images take a palette finish, scene elements take blend modes and stroke widths, and text gains a numerals face. Undecodable images fail as invalid_image, and filled circles and JPEG/WebP images render differently."
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 1.2.2 — 2026-09-26
8
+
9
+ ## Added
10
+
11
+ - **`finish` on `pixoo_push_image` and scene `image` elements** reduces the image to a palette: `colors` (2–256) or `palette` (1–256 colors), plus `dither` (`none`, `bayer4`, `floyd-steinberg`) ([#27](https://github.com/cyanheads/pixoo-mcp-server/issues/27)). An animation's `colors` palette is shared across frames; a bad palette entry fails as `invalid_color`.
12
+ - **`blend` on every `pixoo_compose_scene` element** — `normal` (default), `add`, `screen`, or `multiply` ([#29](https://github.com/cyanheads/pixoo-mcp-server/issues/29)).
13
+ - **`strokeWidth` and `antialias` on `line` and outline `circle`; `strokeWidth` on a `rect` border**, growing inward (#29). Either on a shape that draws no stroke fails `-32602` naming it; a wide or anti-aliased stroke's layout box covers every pixel drawn.
14
+ - **`font: "numerals"`** on `pixoo_display_text` and scene `text` — an 11×18 digit face drawing 0–9, space, and `: . - + / % ° ?`; any other character fails `-32602` naming it, and auto-fit never picks it ([#30](https://github.com/cyanheads/pixoo-mcp-server/issues/30)).
15
+ - **`pixoo_push_image` `speed` input and `frames`, `sourceFrames`, `speed` output fields** for animated sources ([#28](https://github.com/cyanheads/pixoo-mcp-server/issues/28)).
16
+ - **`pixoo_output_dir` and `pixoo_push_min_interval_ms` options in `manifest.json`**, so a Claude Desktop bundle install can set `PIXOO_OUTPUT_DIR` and `PIXOO_PUSH_MIN_INTERVAL_MS`.
17
+
18
+ ## Changed
19
+
20
+ - **An animated GIF or WebP given to `pixoo_push_image` pushes as an animation** instead of its first frame, whatever its file extension (#28). Up to 40 frames, sampled evenly from a longer source, play at the source's total duration over the pushed frame count.
21
+ - **Filled `circle` elements draw a rounder disc** (`dx² + dy² ≤ r² + r`, from the toolkit), so every filled circle's pixels change.
22
+ - **JPEG and WebP images decode at full resolution before resizing** (from the toolkit), so their pushed pixels change.
23
+ - **An image source that is read but does not decode fails as `-32602` `invalid_image`** naming the source and the decoder's reason, instead of an unclassified `-32603`, in `pixoo_push_image` and compose `image` and `sprite` elements ([#34](https://github.com/cyanheads/pixoo-mcp-server/issues/34)).
24
+ - **`asset_not_found` carries its tool's declared recovery hint, reworded, on both image tools**, and a sprite `path` given as a URL says sprite sheets take a local path ([#51](https://github.com/cyanheads/pixoo-mcp-server/issues/51)).
25
+ - **https image sources load from memory** — they render when the temp dir is unwritable, and an https SVG renders like the same local file instead of rasterizing at its intrinsic size ([#23](https://github.com/cyanheads/pixoo-mcp-server/issues/23)).
26
+ - **A partially transparent pixel under `opacity` below 100 fades at its own alpha × opacity**, so soft-edged images no longer brighten as they fade (#29).
27
+ - **`twinkle` renders the same frames for the same input**; its jitter comes from the frame index and element position ([#50](https://github.com/cyanheads/pixoo-mcp-server/issues/50)).
28
+ - **`pixoo_display_text` reports `fits: false` for text leaving the panel on any edge** ([#49](https://github.com/cyanheads/pixoo-mcp-server/issues/49)), and a scrolling line reports `box.x: 0`, where its frame draws it ([#55](https://github.com/cyanheads/pixoo-mcp-server/issues/55)).
29
+ - **`standard` and `compact` draw `° ← ↑ → ↓ ▲ ▼ ♥ · …` as glyphs** instead of `?` (#30).
30
+ - **`Object.prototype` names such as `constructor` fail as `invalid_color`** on every color input of `pixoo_compose_scene`, `pixoo_display_text`, and `pixoo_overlay_text`, which now rejects them before any device call ([#32](https://github.com/cyanheads/pixoo-mcp-server/issues/32)).
31
+ - **`pixoo_design_brief` and the design guide** describe dithering, anti-aliasing, the new glyphs and numerals face, a glow recipe, and the Pixoo-64's measured dark and mid-level response.
32
+
33
+ ## Fixed
34
+
35
+ - **`pixoo_compose_scene` loads each distinct asset once per call** — elements sharing a sprite sheet, or an image source and placement, share one decode, and a URL is fetched once ([#52](https://github.com/cyanheads/pixoo-mcp-server/issues/52)).
36
+ - **Standard text at scale 2 is 14 px tall** in the design brief and guide, which said 10 px.
37
+
38
+ ## Dependencies
39
+
40
+ - `@cyanheads/pixoo-toolkit` ^0.8.2 → ^0.10.0, exempt from the `bunfig.toml` release-age hold beside the framework
@@ -0,0 +1,35 @@
1
+ ---
2
+ summary: "Stricter tool inputs: unknown nested keys, relative image paths, and effect with animate now fail -32602 instead of being dropped or resolved silently. layout[].action reports only what the frames do, standard and compact text names the characters it draws as ?, and errors carry the request ID."
3
+ breaking: true
4
+ security: false
5
+ ---
6
+
7
+ # 1.3.0 — 2026-09-26
8
+
9
+ ## Added
10
+
11
+ - **Fallback-glyph `notice` on `pixoo_display_text` and `pixoo_compose_scene`** ([#57](https://github.com/cyanheads/pixoo-mcp-server/issues/57)). A `standard` or `compact` line, scene `text` element, or `progress` `label` holding a character those fonts draw as `?` names it by element and code point (`element 1 "×" (U+00D7)`); the render is unchanged.
12
+
13
+ ## Changed
14
+
15
+ - **Image `source` and sprite `path` must be an absolute local path or a URL** ([#53](https://github.com/cyanheads/pixoo-mcp-server/issues/53)). A relative or `~` path, a `file://` URL, or `""` now fails `-32602` `invalid_arguments`: `source: Must be an absolute local path or an https URL, not a relative path, a ~ path, or a file:// URL.` (in a scene, `elements.<i>.source` / `elements.<i>.path`).
16
+ - **Every nested `pixoo_compose_scene` input object rejects unknown keys** ([#54](https://github.com/cyanheads/pixoo-mcp-server/issues/54)) — elements, `style` and its palette stop, `effect`, `animate`, rect `gradient`, `pixels` points, `background` — failing `-32602` by full path: `Unknown key elements.0.colour. elements.0 accepts: type, x, y, w, h, color, …`.
17
+ - **`animate` takes only the tracks an element drives** (#54): an unknown track fails as `Unknown key elements.0.animate.y. …`, and `bitmap`, `progress`, `image`, and `sprite` reject a `color` track.
18
+ - **A compose `background` object holds exactly one of `gradient` or `theme`** (#54). Both or neither fails `background: Set exactly one of gradient or theme.` instead of the gradient winning.
19
+ - **A scene element setting both `effect` and `animate` fails** ([#59](https://github.com/cyanheads/pixoo-mcp-server/issues/59)) with `elements.<i>.effect: Set either effect or animate on an element, not both: …`, instead of dropping the effect.
20
+ - **`pixoo_display_text` rejects unknown keys in `style`, its palette stop, `position`, and `background`** ([#60](https://github.com/cyanheads/pixoo-mcp-server/issues/60)): `Unknown key style.colour. style accepts: palette, shadow, outline, scale, color.`
21
+ - **`layout[].action` reports what the returned frames do** ([#56](https://github.com/cyanheads/pixoo-mcp-server/issues/56)). A `pixoo_display_text` line too wide to fit that does not scroll reports `none` with `fits: false`, not `scrolling`; the output schemas advertise `none`/`shrunk-to-compact`/`scrolling` (display text) and `none` (compose), and `element` is always a number.
22
+ - **Error results carry the request ID** (framework): `data.requestId`, with the text closing `(reason … · request <id>)`.
23
+ - **Input-validation rejections log at `notice`** instead of `error` (framework), tagged `mcp.error.severity: "notice"` on `mcp.errors.classified`.
24
+
25
+ ## Fixed
26
+
27
+ - **A relative `PIXOO_OUTPUT_DIR` reports absolute `outputFiles`** ([#61](https://github.com/cyanheads/pixoo-mcp-server/issues/61)); it resolves against the launch directory when the config is read, so files land where they did.
28
+ - **Image URL schemes are read case-insensitively** (#53), so an `HTTPS://…` source is fetched rather than read as a local path.
29
+ - **`pixoo_discover_devices` declares the `discovery_failed` recovery hint callers receive**; the wording on the wire is unchanged.
30
+ - **Multi-arch Docker builds no longer run Bun under emulation**: production dependencies cross-install in a build-platform `deps` stage, and `lint:packaging` fails a target-platform stage that runs JavaScript.
31
+
32
+ ## Dependencies
33
+
34
+ - `@cyanheads/mcp-ts-core` `^0.13.9` → `^0.13.10`
35
+ - `hono` `4.13.8` → `4.13.9` (transitive, via the framework)
@@ -6,7 +6,7 @@ import { z } from '@cyanheads/mcp-ts-core';
6
6
  declare const ServerConfigSchema: z.ZodObject<{
7
7
  pixooIp: z.ZodOptional<z.ZodString>;
8
8
  pixooSize: z.ZodDefault<z.ZodCoercedNumber<unknown> & z.ZodType<16 | 32 | 64, unknown, z.core.$ZodTypeInternals<16 | 32 | 64, unknown>>>;
9
- pixooOutputDir: z.ZodOptional<z.ZodString>;
9
+ pixooOutputDir: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
10
10
  pixooPushMinIntervalMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
11
11
  }, z.core.$strip>;
12
12
  export type ServerConfig = z.infer<typeof ServerConfigSchema>;
@@ -1 +1 @@
1
- {"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAG3C,QAAA,MAAM,kBAAkB;;;;;iBAgBtB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wBAAgB,eAAe,IAAI,YAAY,CAQ9C;AAED,yCAAyC;AACzC,wBAAgB,iBAAiB,IAAI,IAAI,CAExC"}
1
+ {"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAG3C,QAAA,MAAM,kBAAkB;;;;;iBAsBtB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wBAAgB,eAAe,IAAI,YAAY,CAQ9C;AAED,yCAAyC;AACzC,wBAAgB,iBAAiB,IAAI,IAAI,CAExC"}
@@ -2,6 +2,7 @@
2
2
  * @fileoverview Server-specific environment variable configuration for pixoo-mcp-server.
3
3
  * @module config/server-config
4
4
  */
5
+ import * as path from 'node:path';
5
6
  import { z } from '@cyanheads/mcp-ts-core';
6
7
  import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
7
8
  const ServerConfigSchema = z.object({
@@ -13,7 +14,11 @@ const ServerConfigSchema = z.object({
13
14
  })
14
15
  .default(64)
15
16
  .describe('Display size in pixels (16, 32, or 64).'),
16
- pixooOutputDir: z.string().optional().describe('Auto-save directory for preview PNG/GIF files.'),
17
+ pixooOutputDir: z
18
+ .string()
19
+ .transform((dir) => path.resolve(dir))
20
+ .optional()
21
+ .describe('Auto-save directory for preview PNG/GIF files. A relative path resolves against the working directory the server was launched from, so every saved path it reports is absolute.'),
17
22
  pixooPushMinIntervalMs: z.coerce
18
23
  .number()
19
24
  .int()
@@ -1 +1 @@
1
- {"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iCAAiC,CAAC;IAC1E,SAAS,EAAE,CAAC,CAAC,MAAM;SAChB,MAAM,EAAE;SACR,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,EAAE;QAC/C,OAAO,EAAE,uBAAuB;KACjC,CAAC;SACD,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,yCAAyC,CAAC;IACtD,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gDAAgD,CAAC;IAChG,sBAAsB,EAAE,CAAC,CAAC,MAAM;SAC7B,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,IAAI,CAAC;SACb,QAAQ,CAAC,qDAAqD,CAAC;CACnE,CAAC,CAAC;AAIH,IAAI,OAAiC,CAAC;AAEtC,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,cAAc,CAAC,kBAAkB,EAAE;QAC7C,OAAO,EAAE,UAAU;QACnB,SAAS,EAAE,YAAY;QACvB,cAAc,EAAE,kBAAkB;QAClC,sBAAsB,EAAE,4BAA4B;KACrD,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,yCAAyC;AACzC,MAAM,UAAU,iBAAiB;IAC/B,OAAO,GAAG,SAAS,CAAC;AACtB,CAAC"}
1
+ {"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,iCAAiC,CAAC;IAC1E,SAAS,EAAE,CAAC,CAAC,MAAM;SAChB,MAAM,EAAE;SACR,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,EAAE;QAC/C,OAAO,EAAE,uBAAuB;KACjC,CAAC;SACD,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,yCAAyC,CAAC;IACtD,cAAc,EAAE,CAAC;SACd,MAAM,EAAE;SACR,SAAS,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;SACrC,QAAQ,EAAE;SACV,QAAQ,CACP,iLAAiL,CAClL;IACH,sBAAsB,EAAE,CAAC,CAAC,MAAM;SAC7B,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,IAAI,CAAC;SACb,QAAQ,CAAC,qDAAqD,CAAC;CACnE,CAAC,CAAC;AAIH,IAAI,OAAiC,CAAC;AAEtC,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,cAAc,CAAC,kBAAkB,EAAE;QAC7C,OAAO,EAAE,UAAU;QACnB,SAAS,EAAE,YAAY;QACvB,cAAc,EAAE,kBAAkB;QAClC,sBAAsB,EAAE,4BAA4B;KACrD,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,yCAAyC;AACzC,MAAM,UAAU,iBAAiB;IAC/B,OAAO,GAAG,SAAS,CAAC;AACtB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"pixoo-design-guide.resource.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/resources/definitions/pixoo-design-guide.resource.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAY,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAwGrD,eAAO,MAAM,wBAAwB,2GAyBnC,CAAC"}
1
+ {"version":3,"file":"pixoo-design-guide.resource.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/resources/definitions/pixoo-design-guide.resource.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAY,CAAC,EAAE,MAAM,wBAAwB,CAAC;AA2GrD,eAAO,MAAM,wBAAwB,2GAyBnC,CAAC"}