New:Socket for Asana Is Now Available.Learn more
Get Started

@particlr/mcp

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@particlr/mcp - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+20
-5
dist/patch.js

@@ -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)");

@@ -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.`);

@@ -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 });

@@ -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];

{
"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