@particlr/mcp
Advanced tools
+20
-5
@@ -11,3 +11,4 @@ // patch.ts — a restricted RFC 6902 JSON Patch apply (MCP_M2.5_PLAN fact 5). | ||
| // keys/indices, "-" appends to an array (add only), and "~1"/"~0" unescape to | ||
| // "/"/"~". Leading zeros in an array index are rejected (RFC 6901). | ||
| // "/"/"~". Leading zeros in an array index are rejected (RFC 6901), and the | ||
| // prototype-bearing tokens "__proto__"/"prototype"/"constructor" are refused. | ||
| /** A patch failure that names the offending op by its index in the ops array. */ | ||
@@ -26,2 +27,7 @@ export class PatchError extends Error { | ||
| } | ||
| /** Reference tokens that would let a pointer walk or write onto the prototype | ||
| * chain (prototype pollution). Ops come from agent-supplied, prompt-injectable | ||
| * input, so these are denied outright — no legitimate .prt key uses them, and | ||
| * array indices are always digits. */ | ||
| const RESERVED_TOKENS = new Set(["__proto__", "prototype", "constructor"]); | ||
| /** Split a JSON Pointer into its (unescaped) reference tokens. "" -> []. */ | ||
@@ -34,3 +40,12 @@ function parsePointer(path, opIndex) { | ||
| } | ||
| return path.slice(1).split("/").map(unescapeToken); | ||
| return path | ||
| .slice(1) | ||
| .split("/") | ||
| .map((raw) => { | ||
| const token = unescapeToken(raw); | ||
| if (RESERVED_TOKENS.has(token)) { | ||
| throw new PatchError(opIndex, `refusing to walk reserved property name ${JSON.stringify(token)} in "${path}"`); | ||
| } | ||
| return token; | ||
| }); | ||
| } | ||
@@ -74,3 +89,3 @@ /** Parse an array reference token into an index. Rejects leading zeros and, unless | ||
| else if (parent !== null && typeof parent === "object") { | ||
| if (!(token in parent)) { | ||
| if (!Object.prototype.hasOwnProperty.call(parent, token)) { | ||
| throw new PatchError(i, `path segment ${JSON.stringify(token)} does not exist in "${op.path}"`); | ||
@@ -108,3 +123,3 @@ } | ||
| else if (op.op === "remove") { | ||
| if (!(last in obj)) | ||
| if (!Object.prototype.hasOwnProperty.call(obj, last)) | ||
| throw new PatchError(i, `remove target ${JSON.stringify(last)} does not exist in "${op.path}"`); | ||
@@ -114,3 +129,3 @@ delete obj[last]; | ||
| else { | ||
| if (!(last in obj)) | ||
| if (!Object.prototype.hasOwnProperty.call(obj, last)) | ||
| throw new PatchError(i, `replace target ${JSON.stringify(last)} does not exist in "${op.path}"`); | ||
@@ -117,0 +132,0 @@ obj[last] = op.value; |
+34
-1
@@ -9,2 +9,14 @@ // Minimal PNG decoder (MCP_M1_PLAN fact 5): 8-bit, non-interlaced, colorType 6 | ||
| const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; | ||
| /** Per-edge cap on a decoded texture (R-7). 4096 is the max texture size every | ||
| * WebGL2/WebGPU baseline guarantees, so no texture this renderer could pair with | ||
| * the GPU adapters is excluded — while IHDR can otherwise claim 2^31-1. */ | ||
| export const MAX_PNG_EDGE = 4096; | ||
| /** Total pixels, i.e. the square of the edge cap (~67 MB of RGBA, ~268 MB once | ||
| * premultiplied to Float32). Checked separately so a future edge bump can't | ||
| * silently authorize a gigapixel decode. */ | ||
| export const MAX_PNG_PIXELS = MAX_PNG_EDGE * MAX_PNG_EDGE; | ||
| /** Slack over the exact filtered-scanline size when inflating: a conforming | ||
| * stream produces exactly `expected` bytes; anything materially larger is a | ||
| * bomb, not a texture. */ | ||
| const INFLATE_SLACK = 4096; | ||
| function readU32(b, o) { | ||
@@ -87,2 +99,14 @@ return ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0; | ||
| const channels = colorType === 6 ? 4 : 3; | ||
| // R-7: IHDR is attacker-controlled — bound it BEFORE inflating or allocating. | ||
| // A ~1 MB IDAT declaring 20000×20000 otherwise inflates to gigabytes and | ||
| // OOM-kills the server (taking every in-memory session handle with it). | ||
| if (width < 1 || height < 1) { | ||
| throw new Error(`PNG declares ${width}x${height}px; width and height must be >= 1`); | ||
| } | ||
| if (width > MAX_PNG_EDGE || height > MAX_PNG_EDGE) { | ||
| throw new Error(`PNG is ${width}x${height}px; embedded textures are capped at ${MAX_PNG_EDGE}px per edge`); | ||
| } | ||
| if (width * height > MAX_PNG_PIXELS) { | ||
| throw new Error(`PNG is ${width}x${height} = ${width * height}px; embedded textures are capped at ${MAX_PNG_PIXELS}px`); | ||
| } | ||
| // Concatenate IDAT payloads and inflate. | ||
@@ -100,5 +124,14 @@ let total = 0; | ||
| } | ||
| const raw = new Uint8Array(inflateSync(zdata)); | ||
| const stride = width * channels; | ||
| const expected = height * (stride + 1); | ||
| // Bound the inflate itself too (R-7): the dimension caps above bound `expected`, | ||
| // and zlib aborts past maxOutputLength instead of growing the heap. | ||
| let raw; | ||
| try { | ||
| raw = new Uint8Array(inflateSync(zdata, { maxOutputLength: expected + INFLATE_SLACK })); | ||
| } | ||
| catch (e) { | ||
| throw new Error(`PNG IDAT did not inflate within the ${expected} bytes its ${width}x${height} header declares ` + | ||
| `(corrupt, or a decompression bomb): ${e instanceof Error ? e.message : String(e)}`); | ||
| } | ||
| if (raw.length < expected) | ||
@@ -105,0 +138,0 @@ throw new Error("PNG data underflow after inflate (corrupt)"); |
+81
-12
@@ -27,4 +27,22 @@ // render.ts — CPU rasterizer orchestration (MCP_M1_PLAN fact 6/7/8/9). Pure: | ||
| const MARGIN = 0.12; // 12% each side (fact 7) | ||
| const MAX_DURATION = 60; // completion-probe ceiling, seconds | ||
| export const MAX_DURATION = 60; // completion-probe ceiling AND explicit-time ceiling, seconds | ||
| const FALLBACK_VIEW = 100; // world-unit box when nothing ever spawns | ||
| /** fps ceiling (R-4). 240 covers every high-refresh capture rate; anything above | ||
| * it can only ever blow the MAX_FRAMES cap anyway. */ | ||
| export const MAX_FPS = 240; | ||
| /** Ceiling on the frames/fps time RANGE (R-5). `end` comes from the doc | ||
| * (completion probe or `duration`, which the validator only floors at 0.05s), so | ||
| * a doc claiming duration 1e9 would step ~6e10 times. 10 minutes is far past any | ||
| * effect worth contact-sheeting; explicit `times` stay the escape hatch. */ | ||
| const MAX_RANGE_SECONDS = 600; | ||
| /** Total emitted pixels across every returned frame (R-8). 16 Mpx = 16 frames at | ||
| * the 1024px edge cap, or the full 64-frame set at 512², i.e. ≤64 MB of RGBA | ||
| * before PNG+base64 — ~7× the largest possible contact sheet (1536² = 2.4 Mpx), | ||
| * and well under the audit's 64×1024² = 67 Mpx / ~536 MB separate-layout case. */ | ||
| export const MAX_OUTPUT_PIXELS = 16 * 1024 * 1024; | ||
| /** Pixels in ONE supersampled framebuffer (Float32 ×4 ⇒ 16 B/px). Exactly the | ||
| * documented worst case (1024² at 4×, ~268 MB) so no request valid today is | ||
| * rejected; it exists so a MAX_EDGE or supersample change can't silently | ||
| * multiply the transient peak. */ | ||
| export const MAX_FRAME_PIXELS = 16 * 1024 * 1024; | ||
| function buildLayerCtxs(doc, warnings) { | ||
@@ -36,2 +54,11 @@ return doc.layers.map((layer) => { | ||
| const fb = layer.texture.frames; | ||
| // R-3: a flipbook grid that doesn't evenly divide the sheet leaves fractional | ||
| // cell rects. The CPU rasterizer clamps taps to whole texels (so no NaN/holes), | ||
| // but edge cells get cropped and won't match the GPU adapters' fractional-UV | ||
| // sampling. Warn ONCE per offending layer/texture so the author can re-slice. | ||
| if (fb !== null && fb.cols * fb.rows > 1 && (resolved.tex.width % fb.cols !== 0 || resolved.tex.height % fb.rows !== 0)) { | ||
| warnings.push(`Texture "${layer.texture.ref}" is ${resolved.tex.width}×${resolved.tex.height}px but its flipbook grid is ` + | ||
| `${fb.cols}×${fb.rows}, which does not divide evenly; the CPU renderer clamps frame samples to whole ` + | ||
| `texels and edge cells may be cropped. Use sheet dimensions divisible by the grid.`); | ||
| } | ||
| const max = Math.max(1, layer.emission.maxParticles); | ||
@@ -74,21 +101,47 @@ // Trail geometry sizing mirrors the pixi adapter (renderer.ts ~L269): | ||
| } | ||
| /** Resolve the requested times, snap each to a 1/60 step index (fact 8). */ | ||
| const tooManyFrames = (n) => new RenderError(`requested ${n} frames exceeds the cap of ${MAX_FRAMES}; reduce times/fps/frames`); | ||
| /** Resolve the requested times, snap each to a 1/60 step index (fact 8). | ||
| * Every path checks its COUNT before materializing the array (R-4) and its | ||
| * per-element magnitude before anything steps an Effect (R-5) — the zod schema | ||
| * bounds the same values, this is the belt for library callers. */ | ||
| function resolveTimes(opts, end) { | ||
| let requested; | ||
| if (opts.times !== undefined && opts.times.length > 0) { | ||
| if (opts.times.length > MAX_FRAMES) | ||
| throw tooManyFrames(opts.times.length); | ||
| for (const t of opts.times) { | ||
| if (!Number.isFinite(t) || t < 0 || t > MAX_DURATION) { | ||
| throw new RenderError(`render time ${t}s is outside the supported range [0, ${MAX_DURATION}]s; each entry in \`times\` is stepped at 1/60s from t=0`); | ||
| } | ||
| } | ||
| requested = opts.times; | ||
| } | ||
| else if (opts.fps !== undefined && opts.fps > 0) { | ||
| requested = []; | ||
| for (let t = 0; t <= end + 1e-9; t += 1 / opts.fps) | ||
| requested.push(t); | ||
| if (requested.length === 0) | ||
| requested = [0]; | ||
| } | ||
| else { | ||
| const n = Math.max(1, Math.floor(opts.frames ?? DEFAULT_FRAMES)); | ||
| requested = n === 1 ? [0] : Array.from({ length: n }, (_, i) => (end * i) / (n - 1)); | ||
| if (!Number.isFinite(end) || end > MAX_RANGE_SECONDS) { | ||
| throw new RenderError(`the frames/fps range is ${end}s, past the ${MAX_RANGE_SECONDS}s ceiling (the doc's duration/completion time); ` + | ||
| `shorten the doc's duration or pass explicit \`times\``); | ||
| } | ||
| if (opts.fps !== undefined && opts.fps > 0) { | ||
| // Count FIRST: the loop below would otherwise materialize gigabytes for an | ||
| // absurd fps before the MAX_FRAMES check could fire (R-4). The +1 slack | ||
| // leaves the exact count to the loop, whose float accumulation decides | ||
| // whether the last boundary lands inside the range. | ||
| const expected = Math.floor((end + 1e-9) * opts.fps) + 1; | ||
| if (expected > MAX_FRAMES + 1) | ||
| throw tooManyFrames(expected); | ||
| requested = []; | ||
| for (let t = 0; t <= end + 1e-9; t += 1 / opts.fps) | ||
| requested.push(t); | ||
| if (requested.length === 0) | ||
| requested = [0]; | ||
| } | ||
| else { | ||
| const n = Math.max(1, Math.floor(opts.frames ?? DEFAULT_FRAMES)); | ||
| if (n > MAX_FRAMES) | ||
| throw tooManyFrames(n); | ||
| requested = n === 1 ? [0] : Array.from({ length: n }, (_, i) => (end * i) / (n - 1)); | ||
| } | ||
| } | ||
| if (requested.length > MAX_FRAMES) { | ||
| throw new RenderError(`requested ${requested.length} frames exceeds the cap of ${MAX_FRAMES}; reduce times/fps/frames`); | ||
| throw tooManyFrames(requested.length); | ||
| } | ||
@@ -346,2 +399,18 @@ const indices = requested.map((t) => Math.max(0, Math.round(t / DT))); | ||
| } | ||
| // Output budget (R-8): layout "separate" skips the sheet shrink above, so N | ||
| // full-size frames are all held in memory and base64'd into ONE JSON-RPC | ||
| // result — every value inside its own documented cap can still add up to | ||
| // hundreds of MB. Reject BEFORE rendering, naming the combination. | ||
| // (Streaming or shrinking separate frames like sheet mode is the deeper fix | ||
| // and is out of scope here.) | ||
| const outputPixels = N * frameW * frameH; | ||
| if (outputPixels > MAX_OUTPUT_PIXELS) { | ||
| throw new RenderError(`${N} frames at ${frameW}x${frameH}px is ${outputPixels} output pixels, over the ${MAX_OUTPUT_PIXELS}-pixel budget ` + | ||
| `for one render; reduce frames, width/height, or use layout "sheet" (which shrinks frames to fit)`); | ||
| } | ||
| const framePixels = frameW * ss * frameH * ss; | ||
| if (framePixels > MAX_FRAME_PIXELS) { | ||
| throw new RenderError(`a ${frameW}x${frameH}px frame at ${ss}x supersample rasterizes ${framePixels} pixels, over the ` + | ||
| `${MAX_FRAME_PIXELS}-pixel per-frame budget; reduce width/height or supersample`); | ||
| } | ||
| const { vp, rect } = computeViewport(opts, doc, seed, ctxs, indices, frameW, frameH); | ||
@@ -348,0 +417,0 @@ // Supersample: render at ss× then box-downsample. Viewport origin unchanged; |
@@ -7,6 +7,21 @@ /** Bilinear sample of the premultiplied texture at sheet-pixel (sx, sy), taps | ||
| export function sampleBilinearPM(tex, cell, sx, sy, out) { | ||
| const minX = cell.cx; | ||
| const maxX = cell.cx + cell.cw - 1; | ||
| const minY = cell.cy; | ||
| const maxY = cell.cy + cell.ch - 1; | ||
| // Clamp taps in INTEGER texel space so every tap index is a whole texel inside | ||
| // the cell (R-3). When cols/rows don't divide the sheet, cell.cx/cw are | ||
| // fractional; using them raw as clamp bounds made a tap index fractional → | ||
| // pmx[fractional] is undefined → undefined*w is NaN, which then poisons the | ||
| // premultiplied framebuffer through every blend. ceil/floor on already-integer | ||
| // bounds (the common evenly-dividing case) are identities, so byte-identical. | ||
| const minX = Math.ceil(cell.cx); | ||
| const maxX = Math.floor(cell.cx + cell.cw) - 1; | ||
| const minY = Math.ceil(cell.cy); | ||
| const maxY = Math.floor(cell.cy + cell.ch) - 1; | ||
| // Degenerate sub-texel cell (no whole texel to sample): emit transparent black | ||
| // rather than read out of bounds / NaN. | ||
| if (maxX < minX || maxY < minY) { | ||
| out[0] = 0; | ||
| out[1] = 0; | ||
| out[2] = 0; | ||
| out[3] = 0; | ||
| return; | ||
| } | ||
| const fx = sx - 0.5; | ||
@@ -13,0 +28,0 @@ const fy = sy - 0.5; |
@@ -25,2 +25,7 @@ // Texture preparation for the CPU rasterizer (MCP_M1_PLAN fact 1 & 5): | ||
| const IMAGE_DATA_URL_RE = /^data:image\/([a-z0-9.+-]+);base64,/i; | ||
| /** Cap on the raw base64 payload of an embedded texture (R-7), checked BEFORE | ||
| * decoding so a hostile doc can't spend the heap on the base64 → bytes step. | ||
| * 8 Mchars ≈ 6 MB of PNG — two orders of magnitude past the largest bundled | ||
| * preset (blast-anim, 29 KB total) and ample for a 4096² sheet. */ | ||
| const MAX_TEXTURE_BASE64 = 8 * 1024 * 1024; | ||
| /** Premultiply straight RGBA8 into a Float32 buffer in byte/255 space (fact 1). */ | ||
@@ -72,3 +77,8 @@ function premultiply(pixels, width, height) { | ||
| } | ||
| const bytes = decodeBase64(dataUrl.slice(m[0].length)); | ||
| const payload = dataUrl.slice(m[0].length); | ||
| if (payload.length > MAX_TEXTURE_BASE64) { | ||
| throw new RenderError(`Texture "${ref}" carries ${payload.length} base64 chars; embedded textures are capped at ${MAX_TEXTURE_BASE64} ` + | ||
| `(~${Math.round(MAX_TEXTURE_BASE64 / (1024 * 1024) * 0.75)}MB of PNG). Downscale the image.`); | ||
| } | ||
| const bytes = decodeBase64(payload); | ||
| if (bytes === null) | ||
@@ -75,0 +85,0 @@ throw new RenderError(`Texture "${ref}" has malformed base64 data.`); |
+1
-1
@@ -24,3 +24,3 @@ // createServer — the McpServer factory (unit-testable without a transport). | ||
| // Pinned to package.json version by test/version.test.ts — bump both together. | ||
| export const MCP_VERSION = "0.1.0"; | ||
| export const MCP_VERSION = "0.2.0"; | ||
| export function createServer() { | ||
@@ -27,0 +27,0 @@ const server = new McpServer({ name: "particlr-mcp", version: MCP_VERSION }); |
+11
-4
@@ -10,3 +10,3 @@ // render_effect — CPU-rasterize a .prt effect to PNG frame(s) or one contact | ||
| import { resolveDocArg } from "./handles.js"; | ||
| import { renderEffect, DEFAULT_SIZE, MAX_EDGE, DEFAULT_FRAMES, MAX_FRAMES } from "../raster/render.js"; | ||
| import { renderEffect, DEFAULT_SIZE, MAX_EDGE, DEFAULT_FRAMES, MAX_FRAMES, MAX_FPS, MAX_DURATION, } from "../raster/render.js"; | ||
| import { encodePng } from "../png/encode.js"; | ||
@@ -22,5 +22,6 @@ import { RenderError } from "../raster/errors.js"; | ||
| times: z | ||
| .array(z.number().nonnegative()) | ||
| .array(z.number().nonnegative().max(MAX_DURATION)) | ||
| .max(MAX_FRAMES) | ||
| .optional() | ||
| .describe(`Explicit render times in seconds (snapped to 1/60 boundaries). Max ${MAX_FRAMES}. Overrides frames/fps.`), | ||
| .describe(`Explicit render times in seconds (snapped to 1/60 boundaries). Max ${MAX_FRAMES} entries, each <= ${MAX_DURATION}s. Overrides frames/fps.`), | ||
| frames: z | ||
@@ -30,5 +31,11 @@ .number() | ||
| .positive() | ||
| .max(MAX_FRAMES) | ||
| .optional() | ||
| .describe(`Number of evenly-spaced frames over [0, completion||duration]. Default ${DEFAULT_FRAMES}; max ${MAX_FRAMES}.`), | ||
| fps: z.number().positive().optional().describe("Frames at 0, 1/fps, 2/fps … up to the range end. Overrides `frames`."), | ||
| fps: z | ||
| .number() | ||
| .positive() | ||
| .max(MAX_FPS) | ||
| .optional() | ||
| .describe(`Frames at 0, 1/fps, 2/fps … up to the range end (max ${MAX_FPS}). Overrides \`frames\`.`), | ||
| overrunSeconds: z | ||
@@ -35,0 +42,0 @@ .number() |
@@ -16,2 +16,12 @@ // simulate_effect — step a REAL Effect deterministically in Node and report | ||
| const EPS = 1e-9; | ||
| /** dt/sampleInterval floor (R-6): without it `dt: 1e-9` is ~3e9 synchronous | ||
| * steps and a denormal `sampleInterval` pushes sample objects until OOM, both on | ||
| * the single thread that serves every session. 1 ms is 16× finer than the | ||
| * default 1/60s step — past the point the sim's own MAX_DT clamp is meaningful. */ | ||
| const MIN_DT = 1 / 1000; | ||
| const MIN_SAMPLE_INTERVAL = 1 / 1000; | ||
| /** Emitted samples per layer (R-6). 4096 covers a full 60s run sampled every | ||
| * frame at 60Hz (3601 samples); beyond it the interval widens (with a warning) | ||
| * so the series still spans the run instead of the response growing unbounded. */ | ||
| const MAX_SAMPLES = 4096; | ||
| export const simulateInputShape = { | ||
@@ -32,9 +42,11 @@ doc: z.string().optional().describe("The .prt document as a JSON string."), | ||
| .positive() | ||
| .min(MIN_DT) | ||
| .optional() | ||
| .describe(`Fixed timestep. Default 1/60; clamped to (0, ${MAX_DT}] (the runtime MAX_DT).`), | ||
| .describe(`Fixed timestep. Default 1/60; clamped to [${MIN_DT}, ${MAX_DT}] (the runtime MAX_DT).`), | ||
| sampleInterval: z | ||
| .number() | ||
| .positive() | ||
| .min(MIN_SAMPLE_INTERVAL) | ||
| .optional() | ||
| .describe(`Seconds between count samples. Default ${DEFAULT_SAMPLE_INTERVAL}.`), | ||
| .describe(`Seconds between count samples. Default ${DEFAULT_SAMPLE_INTERVAL}; floor ${MIN_SAMPLE_INTERVAL}, widened if it would emit more than ${MAX_SAMPLES} samples.`), | ||
| }; | ||
@@ -64,3 +76,5 @@ const sample = z.object({ t: z.number(), count: z.number().int() }); | ||
| }; | ||
| const clampDt = (dt) => (dt > MAX_DT ? MAX_DT : dt); | ||
| // R-6: clamp BOTH ends. A non-finite or sub-millisecond dt would otherwise mean | ||
| // up to ~3e9 synchronous steps on the thread that serves every open session. | ||
| const clampDt = (dt) => !Number.isFinite(dt) || dt < MIN_DT ? MIN_DT : dt > MAX_DT ? MAX_DT : dt; | ||
| export function handleSimulate(args) { | ||
@@ -74,6 +88,19 @@ const resolved = resolveDocArg(args); | ||
| const doc = parsed.doc; | ||
| const warnings = []; | ||
| const seed = (args.seed ?? doc.seed) >>> 0; | ||
| const duration = Math.min(args.duration ?? DEFAULT_DURATION, MAX_DURATION); | ||
| const dt = clampDt(args.dt ?? DEFAULT_DT); | ||
| const sampleInterval = args.sampleInterval ?? DEFAULT_SAMPLE_INTERVAL; | ||
| // R-6: floor the sample interval, then widen it if the run would emit more | ||
| // than MAX_SAMPLES per layer — the series keeps spanning the whole run instead | ||
| // of the response growing without bound. `echo` reports the dt actually used. | ||
| const requestedInterval = args.sampleInterval ?? DEFAULT_SAMPLE_INTERVAL; | ||
| let sampleInterval = Number.isFinite(requestedInterval) | ||
| ? Math.max(requestedInterval, MIN_SAMPLE_INTERVAL) | ||
| : DEFAULT_SAMPLE_INTERVAL; | ||
| const widestNeeded = duration / MAX_SAMPLES; | ||
| if (sampleInterval < widestNeeded) { | ||
| warnings.push(`sampleInterval ${sampleInterval}s over ${duration}s would emit more than ${MAX_SAMPLES} samples per layer; ` + | ||
| `widened to ${widestNeeded}s. Shorten duration or raise sampleInterval for finer control.`); | ||
| sampleInterval = widestNeeded; | ||
| } | ||
| const steps = Math.max(1, Math.round(duration / dt)); | ||
@@ -135,3 +162,2 @@ const fx = new Effect(doc, { seed }); | ||
| } | ||
| const warnings = []; | ||
| const layers = fx.layers.map((ls, i) => { | ||
@@ -138,0 +164,0 @@ const cap = capacity[i]; |
+2
-2
| { | ||
| "name": "@particlr/mcp", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "mcpName": "io.github.brac/particlr-mcp", | ||
@@ -40,5 +40,5 @@ "description": "MCP server giving agents a deterministic feedback loop for authoring particlr .prt particle effects: validate, simulate, render to PNG, patch — headless, in Node.", | ||
| "@modelcontextprotocol/sdk": "^1.29.0", | ||
| "@particlr/runtime": "^0.5.2", | ||
| "@particlr/runtime": "^0.6.0", | ||
| "zod": "^3.25" | ||
| } | ||
| } |
Sorry, the diff of this file is too big to display
739895
2.17%2485
7.58%+ Added
- Removed
Updated