// Live state as an MCP-shaped resource extension on `document.modelContext`. // // WebMCP is tools-only today, so this is entirely ours. We model it on the real // MCP resource pattern (URI identity, list/read/subscribe, an `updated` event) // so it reads as a natural extension and converges if/when WebMCP formalizes // resources. The methods attach directly onto `document.modelContext` (an // `EventTarget`) as an additive Napster surface — state lives on the standard // object rather than a separate global. // // Consumed by the Napster agent over our own path; NOT interoperable with // third-party WebMCP agents until the standard adds resources. import { getModelContextWithResources } from './model-context.js'; import { debugLog, edgeWarn } from './debug.js'; import type { ModelContext, ModelContextWithResources, ResourceDescriptor, ResourceInfo, ResourceUpdate, } from './types.js'; /** Marker so we install the extension at most once per modelContext object. */ const INSTALLED = Symbol.for('napster-edge-mcp/resources-installed'); const RESOURCE_UPDATED = 'resourceupdated'; const RESOURCE_LIST_CHANGED = 'resourcelistchanged'; interface InternalResource { uri: string; name: string; description?: string; mimeType?: string; get: () => unknown | Promise; } /** * Install the resource extension onto a `document.modelContext` object. Safe to * call repeatedly: the second call is a no-op. Mutates `mc` in place and returns * it typed with the resource surface. */ export function installResourceExtension(mc: ModelContext): ModelContextWithResources { const target = mc as ModelContextWithResources & { [INSTALLED]?: boolean }; if (target[INSTALLED]) return target; // Per-page private registries. `producerUnsubs` holds the teardown for each // resource's push source, so we can unwire it on re-register / unregister. const registry = new Map(); const producerUnsubs = new Map void>(); // Last-DISPATCHED value per URI, JSON-serialized, for echo suppression. App // stores often fire several times per operation (pending → value → pending // again) while the resource's slice re-reads to the same value; without // suppression every consumer (agent bridge, panels) receives each echo. // Deduping here, at the source, gives all of them a clean signal for free. const lastEmitted = new Map(); function dispatchListChanged(): void { target.dispatchEvent(new Event(RESOURCE_LIST_CHANGED)); } // Read a resource's current value, re-reading the producer's getter. On // failure, suppress the update and warn (the next change retries) — one // failing resource must not break the channel. async function emitUpdate(uri: string): Promise { const resource = registry.get(uri); if (!resource) return; let value: unknown; try { value = await resource.get(); } catch (err) { warn(`resource '${uri}' get() threw during update; emission suppressed`, err); return; } // Echo suppression: skip the dispatch when the value serializes identically // to the LAST dispatched value for this uri (consecutive dedupe only — // A→B→A emits three times). If the value doesn't serialize (cycles, // BigInt, …), fail OPEN: always dispatch, and drop the stored baseline so // a broken serialization can never suppress a real change. let serialized: string | undefined; try { serialized = JSON.stringify(value); } catch { serialized = undefined; } if (typeof serialized === 'string') { if (lastEmitted.get(uri) === serialized) { debugLog(`resource "${uri}" push suppressed (value unchanged)`); return; } lastEmitted.set(uri, serialized); } else { lastEmitted.delete(uri); } const detail: ResourceUpdate = { uri, value }; target.dispatchEvent(new CustomEvent(RESOURCE_UPDATED, { detail })); debugLog(`resource "${uri}" changed →`, value); } function registerResource(resource: ResourceDescriptor): () => void { if (!resource || typeof resource.uri !== 'string' || !resource.uri) { throw new Error('[edge-mcp] resource requires a non-empty uri'); } if (typeof resource.name !== 'string' || !resource.name) { throw new Error(`[edge-mcp] resource '${resource.uri}' requires a non-empty name`); } if (typeof resource.get !== 'function') { throw new Error(`[edge-mcp] resource '${resource.uri}' requires a get() function`); } // Tear down any prior push subscription for this uri BEFORE installing the // new one — re-registering must never leave a stale subscription wired up. const priorUnsub = producerUnsubs.get(resource.uri); if (priorUnsub) { safeCall(priorUnsub); producerUnsubs.delete(resource.uri); } // A (re-)registered resource starts with a fresh dedupe baseline — its // first push always dispatches, even if the new producer reads the same // value the old one last emitted. lastEmitted.delete(resource.uri); const isNew = !registry.has(resource.uri); registry.set(resource.uri, { uri: resource.uri, name: resource.name, description: resource.description, mimeType: resource.mimeType, get: resource.get as () => unknown | Promise, }); if (typeof resource.subscribe === 'function') { const unsub = resource.subscribe(() => { void emitUpdate(resource.uri); }); if (typeof unsub === 'function') { producerUnsubs.set(resource.uri, unsub); } else { warn( `resource '${resource.uri}' subscribe() did not return an unsubscribe function. ` + 'The subscription will leak. Return a teardown function from subscribe, or remove ' + 'subscribe to make the resource pull-only.', ); } } if (isNew) { dispatchListChanged(); debugLog(`registered resource "${resource.uri}" (${resource.name})`); } let unregistered = false; return () => { if (unregistered) return; unregistered = true; const u = producerUnsubs.get(resource.uri); if (u) { safeCall(u); producerUnsubs.delete(resource.uri); } lastEmitted.delete(resource.uri); if (registry.delete(resource.uri)) dispatchListChanged(); }; } function getResources(): ResourceInfo[] { return Array.from(registry.values(), (r) => ({ uri: r.uri, name: r.name, description: r.description, mimeType: r.mimeType, })); } async function readResource(uri: string): Promise { const resource = registry.get(uri); if (!resource) return undefined; try { const value = (await resource.get()) as T; debugLog(`resource "${uri}" read →`, value); return value; } catch (err) { warn(`readResource('${uri}') failed; returning undefined`, err); return undefined; } } function subscribeResource( uri: string, handler: (update: ResourceUpdate) => void, ): () => void { const listener = (evt: Event): void => { const detail = (evt as CustomEvent).detail; if (!detail || detail.uri !== uri) return; try { handler(detail); } catch (err) { // One bad handler must not break the dispatch for others. warn(`subscribeResource('${uri}') handler threw`, err); } }; target.addEventListener(RESOURCE_UPDATED, listener); debugLog(`resource "${uri}" — consumer subscribed`); return () => { target.removeEventListener(RESOURCE_UPDATED, listener); debugLog(`resource "${uri}" — consumer unsubscribed`); }; } Object.defineProperties(target, { registerResource: { value: registerResource, configurable: true, writable: true }, getResources: { value: getResources, configurable: true, writable: true }, readResource: { value: readResource, configurable: true, writable: true }, subscribeResource: { value: subscribeResource, configurable: true, writable: true }, [INSTALLED]: { value: true, configurable: true }, }); return target; } /** * Producer-side convenience: register a live-state resource on * `document.modelContext` without casting. Equivalent to * `document.modelContext.registerResource(...)` but SSR-safe (a no-op when no * model context is present). Returns an unregister function. */ export function registerResource(resource: ResourceDescriptor): () => void { const mc = getModelContextWithResources(); if (!mc || typeof mc.registerResource !== 'function') return () => {}; return mc.registerResource(resource); } function safeCall(fn: () => void): void { try { fn(); } catch { /* ignore teardown errors */ } } function warn(message: string, err?: unknown): void { const detail = err ? `: ${err instanceof Error ? err.message : String(err)}` : ''; edgeWarn(`${message}${detail}`); }