diff --git a/ai/skills/neuron-js/SKILL.md b/ai/skills/neuron-js/SKILL.md index a23c224..e7aa386 100644 --- a/ai/skills/neuron-js/SKILL.md +++ b/ai/skills/neuron-js/SKILL.md @@ -149,6 +149,7 @@ Use Neuron-JS when workflow automation needs a deterministic decision node inste - LangGraph recipe: let the LLM perform extraction/classification, validate the generated context, run Neuron-JS as the deterministic Neuron-JS decision node, store the explanation trace, and route graph edges from the normalized output. Example: https://github.com/SebaSOFT/neuron-js/tree/main/examples/langgraph-decision-node - Jev/Laya recipe: prefetch model answers (choice/score/noul with confidence) outside the runtime, validate them against an allowlist, then use hook-driven conditions and actions over the normalized answers as the deterministic boundary. Neuron-JS bundles no JavaScript Jev/Laya runtime. Guide: https://sebasoft.github.io/neuron-js/integrations/system-one-models.html - MCP recipe: register the bundled example server (examples/mcp-server) so MCP clients can call validate_script, execute_decision, and explain_decision directly over stdio. Every tool call validates script and context first (fail-closed). Guide: https://sebasoft.github.io/neuron-js/integrations/mcp-server.html +- WebMCP recipe: the documentation site runs an in-browser MCP server (WebMCP widget) exposing the same three tools; agents connect through the user's MCP client with a session token. Guide: https://sebasoft.github.io/neuron-js/integrations/webmcp.html ## Decision runtime diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 77f2a80..8b09afa 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -4,6 +4,10 @@ const SITE_ORIGIN = 'https://sebasoft.github.io' const SITE_BASE = '/neuron-js/' const SITE_URL = `${SITE_ORIGIN}${SITE_BASE}` const OG_IMAGE = `${SITE_ORIGIN}${SITE_BASE}img/neuron-cover640.png` +// Chrome origin trial token for the W3C WebMCP API (navigator.modelContext) +// on sebasoft.github.io. Expires 2026-11-17; renew at +// https://developer.chrome.com/origin-trials/ before it lapses. +const ORIGIN_TRIAL_WEBMCP = 'A65hrsx5mg7647PTawHhP29wLSHWe5qybAH5B8SSfmk/7I4cp2KBJYt803io2KDp7pT7/43Eygt+Uc2sD5J4Fw8AAABmeyJvcmlnaW4iOiJodHRwczovL3NlYmFzb2Z0LmdpdGh1Yi5pbzo0NDMiLCJmZWF0dXJlIjoiV2ViTUNQIiwiZXhwaXJ5IjoxNzk0ODczNjAwLCJpc1RoaXJkUGFydHkiOnRydWV9' export default defineConfig({ title: "neuron-js", @@ -20,6 +24,9 @@ export default defineConfig({ ['meta', { property: 'og:url', content: SITE_URL }], ['meta', { name: 'twitter:card', content: 'summary' }], ['link', { rel: 'canonical', href: SITE_URL }], + ['meta', { httpEquiv: 'Origin-Trial', content: ORIGIN_TRIAL_WEBMCP }], + ['script', { src: '/neuron-js/webmcp/webmcp.js' }], + ['script', { src: '/neuron-js/webmcp/neuron-webmcp.js', defer: 'true' }], ], ignoreDeadLinks: true, markdown: { @@ -112,7 +119,8 @@ export default defineConfig({ { text: 'n8n deterministic routing', link: '/integrations/n8n' }, { text: 'LangGraph decision node', link: '/integrations/langgraph' }, { text: 'Jev / Laya governed decisions', link: '/integrations/system-one-models' }, - { text: 'MCP server', link: '/integrations/mcp-server' } + { text: 'MCP server', link: '/integrations/mcp-server' }, + { text: 'WebMCP', link: '/integrations/webmcp' } ] }, { diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 462bdd0..4ec764d 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -16,6 +16,7 @@ The pattern is simple: - [LangGraph deterministic decision node](./langgraph.md) - [Jev / Laya governed decisions](./system-one-models.md) - [MCP server](./mcp-server.md) +- [WebMCP](./webmcp.md) ## Use Neuron-JS when diff --git a/docs/integrations/webmcp.md b/docs/integrations/webmcp.md new file mode 100644 index 0000000..7f662d6 --- /dev/null +++ b/docs/integrations/webmcp.md @@ -0,0 +1,57 @@ +# WebMCP + +The documentation site itself is a **WebMCP-enabled page**: it runs an MCP server in your +browser, so any MCP client (Claude Desktop, Cursor, Cline, Windsurf) can connect directly and +call neuron-js tools **in your browser** — no backend, no API keys, no installation. + +## How to connect + +1. Configure your MCP client once: + +```json +{ "mcpServers": { "webmcp": { "command": "npx", "args": ["-y", "@jason.today/webmcp@latest", "--mcp"] } } } +``` + +2. Ask your client to generate a WebMCP token (`npx -y @jason.today/webmcp --new` also works). +3. Click the widget in the bottom-right corner of any page of this site and paste the token. +4. The tools appear in your client (restart the client if you do not see them). + +## Tools exposed by this site + +| Tool | What it does | +| --- | --- | +| `validate_script` | Validates an `ExecutionScript` against the neuron-js schema without executing it. | +| `execute_decision` | Validates and executes an `ExecutionScript` against an `ExecutionContext`; returns the summarized output. | +| `explain_decision` | Same as `execute_decision` plus the explanation trace. | + +These are the **same three tools** exposed by the [stdio MCP server](./mcp-server.md) shipped in +`examples/mcp-server` — same contracts, same fail-closed validation. The difference: the runtime +here is the real `@sebasoft/neuron-js` loaded from the npm registry and executed in your browser. +The library is browser-safe (zero `node:` imports), so the exact engine that runs on a server +also runs in the page. + +Two resources are also exposed: the compact `llms.txt` index and the official `SKILL.md` AI +skill, both served by this site. + +## Guarantees + +- **Fail-closed**: every call validates script and context first; invalid input returns the + exact validation errors and never executes. +- **No side effects**: tools return JSON only; nothing on the page is mutated. +- **Your model, your keys**: the site never touches an LLM; your MCP client does the inference. + The site is only the tool surface. + +## Provenance and status + +Two complementary surfaces ship on this site: + +- **Native W3C WebMCP API** (`navigator.modelContext`): enabled on `sebasoft.github.io` through an active **Chrome origin trial** (expires 2026-11-17). When a Chrome browser honors the trial, the same three tools register through the standard API directly. +- **WebMCP widget** ([`@jason.today/webmcp`](https://github.com/jasonjmcghee/WebMCP)): the original open-source proposal — not the W3C spec — that works in any browser today via a localhost websocket bridge and a connection token. + +The site registers the tools on both surfaces; native when available, widget always. GitHub Pages cannot serve custom headers (`Origin-Agent-Cluster`, `Permissions-Policy`), so the origin-trial meta tag is the enabling mechanism here. + +## Scope + +Read-only documentation surface. The tools execute example-scale scripts; they are not a hosted +execution service. For production use, run neuron-js in your own runtime, or embed the +[stdio MCP server](./mcp-server.md). diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 9c4c102..135c8a1 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -364,6 +364,7 @@ First-party recipes: - LangGraph deterministic decision node: https://github.com/SebaSOFT/neuron-js/tree/main/examples/langgraph-decision-node - Jev/Laya governed decisions (System One Models): https://sebasoft.github.io/neuron-js/integrations/system-one-models.html - MCP server (validate_script, execute_decision, explain_decision): https://github.com/SebaSOFT/neuron-js/tree/main/examples/mcp-server +- WebMCP (site as in-browser MCP server, same three tools): https://sebasoft.github.io/neuron-js/integrations/webmcp.html - Integration docs: https://sebasoft.github.io/neuron-js/integrations/ Use Neuron-JS when workflow automation needs a deterministic decision node instead of probabilistic LLM branching. Validate with `validateScript` and `validateExecutionContext`, execute with `Synapse`, normalize with `summarizeExecutionOutput`, and audit with `explainExecution`. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 12c4960..14da254 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -17,6 +17,7 @@ Use `@sebasoft/neuron-js` when an application needs business rules, pricing deci - LangGraph recipe: https://github.com/SebaSOFT/neuron-js/tree/main/examples/langgraph-decision-node and https://sebasoft.github.io/neuron-js/integrations/langgraph.html - Jev/Laya governed decisions: https://sebasoft.github.io/neuron-js/integrations/system-one-models.html - MCP server (validate_script, execute_decision, explain_decision tools): https://github.com/SebaSOFT/neuron-js/tree/main/examples/mcp-server and https://sebasoft.github.io/neuron-js/integrations/mcp-server.html +- WebMCP: the documentation site runs an MCP server in the visitor's browser with the same three tools (https://sebasoft.github.io/neuron-js/integrations/webmcp.html) - Schemas and validation: https://sebasoft.github.io/neuron-js/schemas-validation-explainability.html - Comparison and migration guides: https://sebasoft.github.io/neuron-js/comparisons/ - Proof and milestones: https://sebasoft.github.io/neuron-js/proof.html diff --git a/docs/public/skills/neuron-js/SKILL.md b/docs/public/skills/neuron-js/SKILL.md index a23c224..e7aa386 100644 --- a/docs/public/skills/neuron-js/SKILL.md +++ b/docs/public/skills/neuron-js/SKILL.md @@ -149,6 +149,7 @@ Use Neuron-JS when workflow automation needs a deterministic decision node inste - LangGraph recipe: let the LLM perform extraction/classification, validate the generated context, run Neuron-JS as the deterministic Neuron-JS decision node, store the explanation trace, and route graph edges from the normalized output. Example: https://github.com/SebaSOFT/neuron-js/tree/main/examples/langgraph-decision-node - Jev/Laya recipe: prefetch model answers (choice/score/noul with confidence) outside the runtime, validate them against an allowlist, then use hook-driven conditions and actions over the normalized answers as the deterministic boundary. Neuron-JS bundles no JavaScript Jev/Laya runtime. Guide: https://sebasoft.github.io/neuron-js/integrations/system-one-models.html - MCP recipe: register the bundled example server (examples/mcp-server) so MCP clients can call validate_script, execute_decision, and explain_decision directly over stdio. Every tool call validates script and context first (fail-closed). Guide: https://sebasoft.github.io/neuron-js/integrations/mcp-server.html +- WebMCP recipe: the documentation site runs an in-browser MCP server (WebMCP widget) exposing the same three tools; agents connect through the user's MCP client with a session token. Guide: https://sebasoft.github.io/neuron-js/integrations/webmcp.html ## Decision runtime diff --git a/docs/public/webmcp/neuron-webmcp.js b/docs/public/webmcp/neuron-webmcp.js new file mode 100644 index 0000000..7aeb028 --- /dev/null +++ b/docs/public/webmcp/neuron-webmcp.js @@ -0,0 +1,270 @@ +// Neuron-JS documentation site — WebMCP integration. +// +// Serves the site as an MCP server in the visitor's browser: any MCP client +// (Claude Desktop, Cursor, ...) can connect through the WebMCP widget and call +// the same three tools exposed by the stdio server in examples/mcp-server. +// +// The runtime is the REAL @sebasoft/neuron-js from the npm registry — the +// library is browser-safe (zero node: imports) and executes visitor-supplied +// rule scripts in-page. Fail-closed: every call validates the script and the +// execution context first; invalid input returns the exact validation errors +// and never executes. + +/* eslint-disable */ +// @ts-nocheck + +const NEURON_VERSION = "0.7.5"; +const NEURON_ESM_URL = `https://unpkg.com/@sebasoft/neuron-js@${NEURON_VERSION}/dist/esm/index.js`; + +const scriptSchema = { + type: "object", + properties: { + script: { + type: "object", + description: + "ExecutionScript JSON: { id, rules: [{ id, type: 'simple_rule', options, conditions: [...], actions: [...] }] }", + }, + }, + required: ["script"], +}; + +const scriptContextSchema = { + type: "object", + properties: { + script: scriptSchema.properties.script, + context: { + type: "object", + description: "ExecutionContext JSON, e.g. { state: {}, messages: [] }", + }, + }, + required: ["script", "context"], +}; + +const asRecord = (value) => { + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object") return parsed; + } catch { + /* fall through */ + } + throw new Error("input must be a JSON object or a JSON string encoding an object"); + } + if (value && typeof value === "object") return value; + throw new Error("input must be a JSON object or a JSON string encoding an object"); +}; + +const text = (obj) => ({ + content: [{ type: "text", text: JSON.stringify(obj, null, 2) }], +}); + +let lib = null; +const loadNeuron = async () => { + if (lib) return lib; + lib = await import(NEURON_ESM_URL); + return lib; +}; + +const validateOrError = async (scriptValue, contextValue) => { + const { validateScript, validateExecutionContext } = await loadNeuron(); + const scriptValidation = validateScript(scriptValue); + if (!scriptValidation.ok) { + return { error: { error: "invalid_script", validation_errors: scriptValidation.errors } }; + } + const contextValidation = validateExecutionContext(contextValue); + if (!contextValidation.ok) { + return { error: { error: "invalid_context", validation_errors: contextValidation.errors } }; + } + return {}; +}; + +const registerNeuronTools = (mcp) => { + mcp.registerTool( + "validate_script", + "Validate a neuron-js ExecutionScript (pure JSON rules) without executing it. Returns ok plus validation errors.", + scriptSchema, + async (args) => { + const { validateScript } = await loadNeuron(); + return text(validateScript(asRecord(args.script))); + }, + ); + + mcp.registerTool( + "execute_decision", + "Validate and execute an ExecutionScript against an ExecutionContext. Returns the summarized output.", + scriptContextSchema, + async (args) => { + const scriptValue = asRecord(args.script); + const contextValue = asRecord(args.context); + const failure = await validateOrError(scriptValue, contextValue); + if (failure.error) return text(failure.error); + + const { Neuron, Synapse, summarizeExecutionOutput } = await loadNeuron(); + const result = new Synapse(new Neuron()).execute(scriptValue, contextValue); + return text(summarizeExecutionOutput(result)); + }, + ); + + mcp.registerTool( + "explain_decision", + "Validate, execute, and explain an ExecutionScript. Returns the summarized output plus the explanation trace.", + scriptContextSchema, + async (args) => { + const scriptValue = asRecord(args.script); + const contextValue = asRecord(args.context); + const failure = await validateOrError(scriptValue, contextValue); + if (failure.error) return text(failure.error); + + const { Neuron, Synapse, summarizeExecutionOutput, explainExecution } = await loadNeuron(); + const result = new Synapse(new Neuron()).execute(scriptValue, contextValue); + return text({ + summary: summarizeExecutionOutput(result), + explanation: explainExecution({ script: scriptValue, result }), + }); + }, + ); + + mcp.registerPrompt( + "pricing-rules-example", + "Show the runnable pricing-rules example: a complete ExecutionScript JSON ready for validate_script or execute_decision.", + [], + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: "Load the pricing-rules example script and run validate_script on it.", + }, + }, + ], + }), + ); + + mcp.registerResource( + "llms-txt", + "The compact AI-readable index of the neuron-js documentation.", + { uri: "https://sebasoft.github.io/neuron-js/llms.txt", mimeType: "text/plain" }, + async (uri) => { + const response = await fetch(uri); + return { contents: [{ uri, mimeType: "text/plain", text: await response.text() }] }; + }, + ); + + mcp.registerResource( + "skill-md", + "The official neuron-js AI skill (SKILL.md) served by this documentation site.", + { uri: "https://sebasoft.github.io/neuron-js/skills/neuron-js/SKILL.md", mimeType: "text/markdown" }, + async (uri) => { + const response = await fetch(uri); + return { contents: [{ uri, mimeType: "text/markdown", text: await response.text() }] }; + }, + ); +}; + +const registerNativeTools = (modelContext) => { + modelContext.registerTool({ + name: "validate_script", + description: + "Validate a neuron-js ExecutionScript (pure JSON rules) without executing it. Returns ok plus validation errors.", + inputSchema: { + type: "object", + properties: { + script: { + type: "object", + description: + "ExecutionScript JSON: { id, rules: [{ id, type: 'simple_rule', options, conditions: [...], actions: [...] }] }", + }, + }, + required: ["script"], + }, + async execute({ script }) { + const { validateScript } = await loadNeuron(); + return { content: [{ type: "text", text: JSON.stringify(validateScript(asRecord(script)), null, 2) }] }; + }, + }); + + modelContext.registerTool({ + name: "execute_decision", + description: + "Validate and execute an ExecutionScript against an ExecutionContext. Returns the summarized output.", + inputSchema: scriptContextSchema, + async execute({ script, context }) { + const scriptValue = asRecord(script); + const contextValue = asRecord(context); + const failure = await validateOrError(scriptValue, contextValue); + if (failure.error) return { content: [{ type: "text", text: JSON.stringify(failure.error, null, 2) }] }; + + const { Neuron, Synapse, summarizeExecutionOutput } = await loadNeuron(); + const result = new Synapse(new Neuron()).execute(scriptValue, contextValue); + return { content: [{ type: "text", text: JSON.stringify(summarizeExecutionOutput(result), null, 2) }] }; + }, + }); + + modelContext.registerTool({ + name: "explain_decision", + description: + "Validate, execute, and explain an ExecutionScript. Returns the summarized output plus the explanation trace.", + inputSchema: scriptContextSchema, + async execute({ script, context }) { + const scriptValue = asRecord(script); + const contextValue = asRecord(context); + const failure = await validateOrError(scriptValue, contextValue); + if (failure.error) return { content: [{ type: "text", text: JSON.stringify(failure.error, null, 2) }] }; + + const { Neuron, Synapse, summarizeExecutionOutput, explainExecution } = await loadNeuron(); + const result = new Synapse(new Neuron()).execute(scriptValue, contextValue); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + summary: summarizeExecutionOutput(result), + explanation: explainExecution({ script: scriptValue, result }), + }, + null, + 2, + ), + }, + ], + }; + }, + }); +}; + +const initWebMcp = () => { + // Prefer the native W3C WebMCP API when the origin trial enables it + // (navigator.modelContext); the WebMCP widget remains the universal path. + const nav = typeof navigator !== "undefined" ? navigator : undefined; + const modelContext = nav?.modelContext; + + if (modelContext?.registerTool) { + try { + registerNativeTools(modelContext); + console.info("[neuron-js webmcp] native navigator.modelContext tools registered"); + } catch (error) { + console.warn("[neuron-js webmcp] native registration failed, widget-only", error); + } + } + + if (typeof WebMCP === "undefined") { + console.warn("[neuron-js webmcp] WebMCP global not found; widget disabled"); + return; + } + + const mcp = new WebMCP({ + color: "#7c3aed", + position: "bottom-right", + size: "36px", + padding: "20px", + }); + + registerNeuronTools(mcp); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initWebMcp); +} else { + initWebMcp(); +} diff --git a/docs/public/webmcp/webmcp.js b/docs/public/webmcp/webmcp.js new file mode 100644 index 0000000..df71b14 --- /dev/null +++ b/docs/public/webmcp/webmcp.js @@ -0,0 +1,2017 @@ +/** + * WebMCP - Snippet to add MCP functionality to any website + * + * Shows as a small blue square in bottom right corner + * On click, expands to allow connection with token + * Auto-disconnects after 5 minutes of inactivity + */ + +class WebMCP { + constructor(options = {}) { + // Options with defaults + this.options = { + color: '#007bff', + position: 'bottom-right', + size: '30px', + padding: '20px', + inactivityTimeout: 5 * 60 * 1000, // 5 minutes in milliseconds + ...options + }; + + // State variables + this.isConnected = false; + this.isExpanded = false; + this.socket = null; + this.inactivityTimer = null; + this.availableTools = new Map(); + this.availablePrompts = new Map(); + this.availableResources = new Map(); + this.samplingCallbacks = new Map(); // For storing sampling callbacks + this.currentToken = ''; + this.currentServer = ''; + this.currentChannel = ''; + this.elementId = 'webmcp-widget-' + Math.random().toString(36).substr(2, 9); + this.registeredTools = new Set(); + this.registeredPrompts = new Set(); + this.registeredResources = new Set(); + + // Storage keys for sessionStorage + this.SESSION_STORAGE_KEY = 'webmcp_token'; + this.TOOLS_STORAGE_KEY = 'webmcp_tools'; + this.PROMPTS_STORAGE_KEY = 'webmcp_prompts'; + this.RESOURCES_STORAGE_KEY = 'webmcp_resources'; + + // Constants + this.REGISTER_PATH = '/register'; + + // Initialize + this._init(); + } + + _format(s) { + return s.replace(/[.:]/g, '_'); + } + + /** + * Initialize the WebMCP widget + * @private + */ + _init() { + // Check if already initialized on this page + if (document.querySelector('[data-webmcp-widget]')) { + console.warn('WebMCP widget already initialized on this page'); + return; + } + + // Create and inject the widget + this._createWidget(); + + // Set up event listeners + this._setupEventListeners(); + + // Start inactivity timer + this._resetInactivityTimer(); + + // Check for stored token and connect if available + this._checkStoredToken(); + } + + /** + * Check for stored connection info in sessionStorage and connect if found + * @private + */ + _checkStoredToken() { + const storedConnectionInfo = sessionStorage.getItem(this.SESSION_STORAGE_KEY); + + if (storedConnectionInfo) { + try { + const connectionInfo = JSON.parse(storedConnectionInfo); + if (connectionInfo.token) { + console.log('Found stored connection info, attempting to connect'); + + // Set the connection properties directly + this.currentServer = connectionInfo.server; + this.currentChannel = `/${connectionInfo.channelHost || this._format(window.location.host)}`; + + // Set the current token from connection info + if (connectionInfo.token.includes('{')) { + // It's already parsed JSON + const tokenData = JSON.parse(connectionInfo.token); + this.currentToken = tokenData.token; + } else { + // It's a base64 encoded string + try { + const jsonStr = atob(connectionInfo.token); + const tokenData = JSON.parse(jsonStr); + this.currentToken = tokenData.token; + } catch (e) { + this.currentToken = connectionInfo.token; + } + } + + // Load stored items before connecting + this._loadStoredItems(); + + // Connect using the stored token + this.connect(connectionInfo.token); + } + } catch (error) { + console.error('Error parsing stored connection info:', error); + sessionStorage.removeItem(this.SESSION_STORAGE_KEY); + this._clearStoredItems(); + } + } + } + + /** + * Save tools, prompts, and resources to session storage + * @private + */ + _saveItemsToStorage() { + try { + // Save tools + const toolsData = {}; + this.availableTools.forEach((tool, name) => { + toolsData[name] = { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + // We don't store the execution function as it can't be serialized + }; + }); + sessionStorage.setItem(this.TOOLS_STORAGE_KEY, JSON.stringify(toolsData)); + + // Save prompts + const promptsData = {}; + this.availablePrompts.forEach((prompt, name) => { + promptsData[name] = { + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments, + // We don't store the execution function as it can't be serialized + }; + }); + sessionStorage.setItem(this.PROMPTS_STORAGE_KEY, JSON.stringify(promptsData)); + + // Save resources + const resourcesData = {}; + this.availableResources.forEach((resource, name) => { + resourcesData[name] = { + name: resource.name, + description: resource.description, + uri: resource.uri, + uriTemplate: resource.uriTemplate, + isTemplate: resource.isTemplate, + mimeType: resource.mimeType, + // We don't store the provide function as it can't be serialized + }; + }); + sessionStorage.setItem(this.RESOURCES_STORAGE_KEY, JSON.stringify(resourcesData)); + + console.log('Saved items to session storage:', { + tools: Object.keys(toolsData).length, + prompts: Object.keys(promptsData).length, + resources: Object.keys(resourcesData).length + }); + } catch (error) { + console.error('Error saving items to session storage:', error); + } + } + + /** + * Load tools, prompts, and resources from session storage + * @private + */ + _loadStoredItems() { + try { + // Load tools + const storedTools = sessionStorage.getItem(this.TOOLS_STORAGE_KEY); + if (storedTools) { + const toolsData = JSON.parse(storedTools); + Object.entries(toolsData).forEach(([name, tool]) => { + // Add to the available tools with placeholder execute function + this.availableTools.set(name, { + ...tool, + execute: function (args) { + console.warn(`Tool ${name} was loaded from storage but has not been re-registered with an execution function`); + return `Tool ${name} needs to be re-registered`; + } + }); + }); + } + + // Load prompts + const storedPrompts = sessionStorage.getItem(this.PROMPTS_STORAGE_KEY); + if (storedPrompts) { + const promptsData = JSON.parse(storedPrompts); + Object.entries(promptsData).forEach(([name, prompt]) => { + // Add to the available prompts with placeholder execute function + this.availablePrompts.set(name, { + ...prompt, + execute: function (args) { + console.warn(`Prompt ${name} was loaded from storage but has not been re-registered with an execution function`); + return { + messages: [{ + role: "user", + content: { + type: "text", + text: `Prompt ${name} needs to be re-registered` + } + }] + }; + } + }); + }); + } + + // Load resources + const storedResources = sessionStorage.getItem(this.RESOURCES_STORAGE_KEY); + if (storedResources) { + const resourcesData = JSON.parse(storedResources); + Object.entries(resourcesData).forEach(([name, resource]) => { + // Add to the available resources with placeholder provide function + this.availableResources.set(name, { + ...resource, + provide: function (uri) { + console.warn(`Resource ${name} was loaded from storage but has not been re-registered with a provider function`); + return { + contents: [{ + uri: uri, + text: `Resource ${name} needs to be re-registered`, + mimeType: resource.mimeType || "text/plain" + }] + }; + } + }); + }); + } + + console.log('Loaded items from session storage:', { + tools: this.availableTools.size, + prompts: this.availablePrompts.size, + resources: this.availableResources.size + }); + + // Update the UI + this._updateToolsList(); + this._updatePromptsList(); + this._updateResourcesList(); + + } catch (error) { + console.error('Error loading items from session storage:', error); + this._clearStoredItems(); + } + } + + /** + * Clear all stored items from session storage + * @private + */ + _clearStoredItems() { + sessionStorage.removeItem(this.TOOLS_STORAGE_KEY); + sessionStorage.removeItem(this.PROMPTS_STORAGE_KEY); + sessionStorage.removeItem(this.RESOURCES_STORAGE_KEY); + console.log('Cleared stored items from session storage'); + } + + /** + * Create and inject the WebMCP widget into the DOM + * @private + */ + _createWidget() { + // Create main container + const container = document.createElement('div'); + container.id = this.elementId; + container.dataset.webmcpWidget = true; + + // Apply styles + Object.assign(container.style, { + position: 'fixed', + zIndex: '9999', + display: 'flex', + flexDirection: 'column', + fontFamily: 'Arial, sans-serif', + fontSize: '14px', + transition: 'all 0.3s ease' + }); + + // Set position based on option + this._setWidgetPosition(container); + + // Create trigger button (blue square) + const triggerButton = document.createElement('div'); + triggerButton.className = 'webmcp-trigger'; + Object.assign(triggerButton.style, { + width: this.options.size, + height: this.options.size, + backgroundColor: this.options.color, + borderRadius: '4px', + cursor: 'pointer', + boxShadow: '0 2px 10px rgba(0,0,0,0.2)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + alignSelf: 'flex-end' + }); + + // Create content panel (initially hidden) - positioned above the trigger + const contentPanel = document.createElement('div'); + contentPanel.className = 'webmcp-content'; + Object.assign(contentPanel.style, { + backgroundColor: '#ffffff', + border: '1px solid #e1e1e1', + borderRadius: '5px', + padding: '15px', + marginBottom: '10px', + boxShadow: '0 5px 15px rgba(0,0,0,0.1)', + width: '250px', + display: 'none', + overflow: 'hidden', + position: 'absolute', + bottom: '40px' + }); + + // Add header with title and close button + const header = document.createElement('div'); + Object.assign(header.style, { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '15px' + }); + + const title = document.createElement('div'); + title.textContent = 'WebMCP'; + Object.assign(title.style, { + fontWeight: 'bold', + fontSize: '16px' + }); + + const closeButton = document.createElement('button'); + closeButton.innerHTML = '×'; // × symbol + closeButton.className = 'webmcp-close'; + Object.assign(closeButton.style, { + background: 'none', + border: 'none', + cursor: 'pointer', + fontSize: '20px', + padding: '0', + lineHeight: '1', + color: '#999' + }); + + header.appendChild(title); + header.appendChild(closeButton); + contentPanel.appendChild(header); + + // Add connection form + this._createConnectionForm(contentPanel); + + // Add status indicator + const statusIndicator = document.createElement('div'); + statusIndicator.className = 'webmcp-status'; + statusIndicator.textContent = 'Disconnected'; + Object.assign(statusIndicator.style, { + padding: '8px', + borderRadius: '3px', + backgroundColor: '#f8d7da', + color: '#721c24', + textAlign: 'center', + marginBottom: '10px', + fontSize: '12px' + }); + contentPanel.appendChild(statusIndicator); + + // Add connection panel + const connectionPanel = document.createElement('div'); + connectionPanel.className = 'webmcp-connection-panel'; + contentPanel.appendChild(connectionPanel); + + // Create a single container for all registered items + const registeredItemsContainer = document.createElement('div'); + registeredItemsContainer.className = 'webmcp-registered-items'; + Object.assign(registeredItemsContainer.style, { + marginTop: '15px', + fontSize: '12px', + display: 'none', + maxHeight: '200px', + overflow: 'auto', + border: '1px solid #eee', + borderRadius: '4px' + }); + contentPanel.appendChild(registeredItemsContainer); + + // Add features lists (initially empty) + // Tools list + const toolsList = document.createElement('div'); + toolsList.className = 'webmcp-tools-list'; + Object.assign(toolsList.style, { + padding: '10px', + borderBottom: '1px solid #eee' + }); + + const toolsHeader = document.createElement('div'); + toolsHeader.textContent = 'Registered Tools:'; + Object.assign(toolsHeader.style, { + fontWeight: 'bold', + marginBottom: '5px' + }); + + const toolsContainer = document.createElement('ul'); + toolsContainer.className = 'webmcp-tools-container'; + Object.assign(toolsContainer.style, { + listStyle: 'none', + padding: '0', + margin: '0' + }); + + toolsList.appendChild(toolsHeader); + toolsList.appendChild(toolsContainer); + registeredItemsContainer.appendChild(toolsList); + + // Prompts list + const promptsList = document.createElement('div'); + promptsList.className = 'webmcp-prompts-list'; + Object.assign(promptsList.style, { + padding: '10px', + borderBottom: '1px solid #eee' + }); + + const promptsHeader = document.createElement('div'); + promptsHeader.textContent = 'Registered Prompts:'; + Object.assign(promptsHeader.style, { + fontWeight: 'bold', + marginBottom: '5px' + }); + + const promptsContainer = document.createElement('ul'); + promptsContainer.className = 'webmcp-prompts-container'; + Object.assign(promptsContainer.style, { + listStyle: 'none', + padding: '0', + margin: '0' + }); + + promptsList.appendChild(promptsHeader); + promptsList.appendChild(promptsContainer); + registeredItemsContainer.appendChild(promptsList); + + // Resources list + const resourcesList = document.createElement('div'); + resourcesList.className = 'webmcp-resources-list'; + Object.assign(resourcesList.style, { + padding: '10px' + }); + + const resourcesHeader = document.createElement('div'); + resourcesHeader.textContent = 'Registered Resources:'; + Object.assign(resourcesHeader.style, { + fontWeight: 'bold', + marginBottom: '5px' + }); + + const resourcesContainer = document.createElement('ul'); + resourcesContainer.className = 'webmcp-resources-container'; + Object.assign(resourcesContainer.style, { + listStyle: 'none', + padding: '0', + margin: '0' + }); + + resourcesList.appendChild(resourcesHeader); + resourcesList.appendChild(resourcesContainer); + registeredItemsContainer.appendChild(resourcesList); + + // Add to main container and then to document - content panel first so it appears above trigger + container.appendChild(contentPanel); + container.appendChild(triggerButton); + document.body.appendChild(container); + } + + /** + * Set widget position based on option + * @private + */ + _setWidgetPosition(container) { + const {position, padding} = this.options; + + switch (position) { + case 'bottom-right': + Object.assign(container.style, { + bottom: padding, + right: padding, + alignItems: 'flex-end' + }); + break; + case 'bottom-left': + Object.assign(container.style, { + bottom: padding, + left: padding, + alignItems: 'flex-start' + }); + break; + case 'top-right': + Object.assign(container.style, { + top: padding, + right: padding, + alignItems: 'flex-end' + }); + break; + case 'top-left': + Object.assign(container.style, { + top: padding, + left: padding, + alignItems: 'flex-start' + }); + break; + default: + // Default to bottom-right + Object.assign(container.style, { + bottom: padding, + right: padding, + alignItems: 'flex-end' + }); + } + } + + /** + * Create the connection form + * @private + */ + _createConnectionForm(container) { + const form = document.createElement('div'); + Object.assign(form.style, { + marginBottom: '8px', + }); + + // Token input field + const inputGroup = document.createElement('div'); + Object.assign(inputGroup.style, { + display: 'flex', + marginBottom: '8px', + }); + + const tokenInput = document.createElement('input'); + tokenInput.type = 'text'; + tokenInput.className = 'webmcp-token-input'; + tokenInput.placeholder = 'Paste connection token'; + Object.assign(tokenInput.style, { + flex: '1', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px 0 0 4px', + fontSize: '12px' + }); + + const connectButton = document.createElement('button'); + connectButton.className = 'webmcp-connect-btn'; + connectButton.textContent = 'Connect'; + Object.assign(connectButton.style, { + padding: '8px 12px', + backgroundColor: this.options.color, + color: 'white', + border: 'none', + borderRadius: '0 4px 4px 0', + cursor: 'pointer', + fontSize: '12px' + }); + + inputGroup.appendChild(tokenInput); + inputGroup.appendChild(connectButton); + + const disconnectButton = document.createElement('button'); + disconnectButton.className = 'webmcp-disconnect-btn'; + disconnectButton.textContent = 'Disconnect'; + Object.assign(disconnectButton.style, { + padding: '8px 12px', + backgroundColor: '#dc3545', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '12px', + width: '100%', + display: 'none' + }); + + form.appendChild(inputGroup); + form.appendChild(disconnectButton); + container.appendChild(form); + } + + /** + * Set up event listeners for the widget + * @private + */ + _setupEventListeners() { + const container = document.getElementById(this.elementId); + if (!container) return; + + // Trigger button click - expand/collapse + const trigger = container.querySelector('.webmcp-trigger'); + trigger.addEventListener('click', () => { + this._toggleExpanded(); + }); + + // Close button click - collapse + const closeBtn = container.querySelector('.webmcp-close'); + closeBtn.addEventListener('click', () => { + this._toggleExpanded(false); + }); + + // Connect button click + const connectBtn = container.querySelector('.webmcp-connect-btn'); + connectBtn.addEventListener('click', () => { + const tokenInput = container.querySelector('.webmcp-token-input'); + this.connect(tokenInput.value); + }); + + // Disconnect button click + const disconnectBtn = container.querySelector('.webmcp-disconnect-btn'); + disconnectBtn.addEventListener('click', () => { + this.disconnect(); + }); + + // User activity detection to reset inactivity timer + document.addEventListener('mousemove', () => this._resetInactivityTimer()); + document.addEventListener('keypress', () => this._resetInactivityTimer()); + document.addEventListener('click', () => this._resetInactivityTimer()); + document.addEventListener('scroll', () => this._resetInactivityTimer()); + } + + /** + * Toggle the expanded state of the widget + * @private + */ + _toggleExpanded(force = null) { + const container = document.getElementById(this.elementId); + if (!container) return; + + const contentPanel = container.querySelector('.webmcp-content'); + this.isExpanded = force !== null ? force : !this.isExpanded; + + if (this.isExpanded) { + contentPanel.style.display = 'block'; + } else { + contentPanel.style.display = 'none'; + } + + this._resetInactivityTimer(); + } + + /** + * Update the status indicator + * @private + */ + _updateStatus(status, message) { + const container = document.getElementById(this.elementId); + if (!container) return; + + const statusIndicator = container.querySelector('.webmcp-status'); + if (!statusIndicator) return; + + // Clear existing classes + statusIndicator.classList.remove('connected', 'disconnected', 'connecting', 'pending-auth'); + + // Set new status + statusIndicator.textContent = message || status; + + // Apply styling based on status + switch (status) { + case 'connected': + Object.assign(statusIndicator.style, { + backgroundColor: '#d4edda', + color: '#155724' + }); + break; + case 'disconnected': + Object.assign(statusIndicator.style, { + backgroundColor: '#f8d7da', + color: '#721c24' + }); + break; + case 'connecting': + Object.assign(statusIndicator.style, { + backgroundColor: '#fff3cd', + color: '#856404' + }); + break; + case 'pending-auth': + Object.assign(statusIndicator.style, { + backgroundColor: '#d1ecf1', + color: '#0c5460' + }); + break; + } + } + + /** + * Update UI based on connection state + * @private + */ + _updateConnectionUI(isConnected) { + const container = document.getElementById(this.elementId); + if (!container) return; + + const tokenInput = container.querySelector('.webmcp-token-input'); + const connectBtn = container.querySelector('.webmcp-connect-btn'); + const disconnectBtn = container.querySelector('.webmcp-disconnect-btn'); + const registeredItemsContainer = container.querySelector('.webmcp-registered-items'); + + if (isConnected) { + tokenInput.style.display = 'none'; + connectBtn.style.display = 'none'; + disconnectBtn.style.display = 'block'; + registeredItemsContainer.style.display = 'block'; + + // Update the trigger button to show connected state + const trigger = container.querySelector('.webmcp-trigger'); + trigger.innerHTML = '✓'; + trigger.style.color = 'white'; + trigger.style.fontWeight = 'bold'; + } else { + tokenInput.style.display = 'block'; + connectBtn.style.display = 'block'; + disconnectBtn.style.display = 'none'; + registeredItemsContainer.style.display = 'none'; + + // Reset the trigger button + const trigger = container.querySelector('.webmcp-trigger'); + trigger.innerHTML = ''; + } + } + + /** + * Update tools list in UI + * @private + */ + _updateToolsList() { + const container = document.getElementById(this.elementId); + if (!container) return; + + const toolsContainer = container.querySelector('.webmcp-tools-container'); + if (!toolsContainer) return; + + // Clear current list + toolsContainer.innerHTML = ''; + + if (this.availableTools.size === 0) { + const emptyMessage = document.createElement('li'); + emptyMessage.textContent = 'No tools registered'; + emptyMessage.style.fontStyle = 'italic'; + emptyMessage.style.color = '#666'; + toolsContainer.appendChild(emptyMessage); + return; + } + + // Add each tool to the list + this.availableTools.forEach((tool, name) => { + const toolItem = document.createElement('li'); + Object.assign(toolItem.style, { + padding: '5px 0', + borderBottom: '1px solid #eee' + }); + + const toolName = document.createElement('strong'); + toolName.textContent = name; + + const toolDesc = document.createElement('div'); + toolDesc.textContent = tool.description; + toolDesc.style.fontSize = '10px'; + toolDesc.style.color = '#666'; + + toolItem.appendChild(toolName); + toolItem.appendChild(toolDesc); + toolsContainer.appendChild(toolItem); + }); + } + + /** + * Update prompts list in UI + * @private + */ + _updatePromptsList() { + const container = document.getElementById(this.elementId); + if (!container) return; + + const promptsContainer = container.querySelector('.webmcp-prompts-container'); + if (!promptsContainer) return; + + // Clear current list + promptsContainer.innerHTML = ''; + + if (this.availablePrompts.size === 0) { + const emptyMessage = document.createElement('li'); + emptyMessage.textContent = 'No prompts registered'; + emptyMessage.style.fontStyle = 'italic'; + emptyMessage.style.color = '#666'; + promptsContainer.appendChild(emptyMessage); + return; + } + + // Add each prompt to the list + this.availablePrompts.forEach((prompt, name) => { + const promptItem = document.createElement('li'); + Object.assign(promptItem.style, { + padding: '5px 0', + borderBottom: '1px solid #eee' + }); + + const promptName = document.createElement('strong'); + promptName.textContent = name; + + const promptDesc = document.createElement('div'); + promptDesc.textContent = prompt.description; + promptDesc.style.fontSize = '10px'; + promptDesc.style.color = '#666'; + + promptItem.appendChild(promptName); + promptItem.appendChild(promptDesc); + promptsContainer.appendChild(promptItem); + }); + } + + /** + * Update resources list in UI + * @private + */ + _updateResourcesList() { + const container = document.getElementById(this.elementId); + if (!container) return; + + const resourcesContainer = container.querySelector('.webmcp-resources-container'); + if (!resourcesContainer) return; + + // Clear current list + resourcesContainer.innerHTML = ''; + + if (this.availableResources.size === 0) { + const emptyMessage = document.createElement('li'); + emptyMessage.textContent = 'No resources registered'; + emptyMessage.style.fontStyle = 'italic'; + emptyMessage.style.color = '#666'; + resourcesContainer.appendChild(emptyMessage); + return; + } + + // Add each resource to the list + this.availableResources.forEach((resource, name) => { + const resourceItem = document.createElement('li'); + Object.assign(resourceItem.style, { + padding: '5px 0', + borderBottom: '1px solid #eee' + }); + + const resourceName = document.createElement('strong'); + resourceName.textContent = name; + + const resourceDesc = document.createElement('div'); + resourceDesc.textContent = resource.description + + (resource.isTemplate ? ' (Template)' : ''); + resourceDesc.style.fontSize = '10px'; + resourceDesc.style.color = '#666'; + + resourceItem.appendChild(resourceName); + resourceItem.appendChild(resourceDesc); + resourcesContainer.appendChild(resourceItem); + }); + } + + /** + * Reset the inactivity timer + * @private + */ + _resetInactivityTimer() { + // Clear existing timer + if (this.inactivityTimer) { + clearTimeout(this.inactivityTimer); + } + + // Set new timer + this.inactivityTimer = setTimeout(() => { + this._handleInactivity(); + }, this.options.inactivityTimeout); + } + + /** + * Handle user inactivity + * @private + */ + _handleInactivity() { + console.log('Inactivity timeout reached, disconnecting'); + + // Disconnect if connected + if (this.isConnected) { + this.disconnect(); + } + + // Minimize UI + this._toggleExpanded(false); + + // Clear the stored token + sessionStorage.removeItem(this.SESSION_STORAGE_KEY); + } + + + /** + * Connect to the WebSocket server + * @public + * @param {string} connectionToken - The encoded connection token + */ + async connect(connectionToken) { + if (!connectionToken) { + this._updateStatus('disconnected', 'Error: No token provided'); + return; + } + + // Update UI to show connecting state + this._updateStatus('connecting', 'Connecting...'); + + try { + // Process the connection token + if (!this._processConnectionToken(connectionToken)) { + return; + } + + // Store the connection info in sessionStorage for page navigations + const connectionInfo = { + token: connectionToken, + server: this.currentServer, + host: this._format(window.location.host) + }; + + // Check if we have connection data already in sessionStorage + const storedConnectionInfo = sessionStorage.getItem(this.SESSION_STORAGE_KEY); + let skipRegistration = false; + + if (storedConnectionInfo) { + try { + const connectionInfo = JSON.parse(storedConnectionInfo); + // If we already have a valid token and server, we can skip registration + if (connectionInfo.server === this.currentServer && + connectionInfo.host === this._format(window.location.host)) { + skipRegistration = true; + } + } catch (error) { + console.error('Error parsing stored connection info:', error); + } + } + + if (!skipRegistration) { + // First register with server + const response = await this._registerWithServer(connectionToken); + + if (!response.token) { + this._updateStatus('disconnected', 'Registration failed'); + return; + } + + // Save the new token + connectionInfo.token = response.token; + this.currentToken = response.token; + + sessionStorage.setItem(this.SESSION_STORAGE_KEY, JSON.stringify(connectionInfo)); + } + + // Now connect to the actual channel + const serverUrl = `${this.currentServer}${this.currentChannel}?token=${this.currentToken}`; + + // Update UI + this._updateStatus('connecting', 'Connecting to channel...'); + + // Create WebSocket connection with the path and token + this.socket = new WebSocket(serverUrl); + + // Set up socket event listeners + this._setupSocketListeners(); + + // Reset inactivity timer + this._resetInactivityTimer(); + + } catch (error) { + console.error('Connection error:', error); + this._updateStatus('disconnected', `Error: ${error.message}`); + } + } + + /** + * Disconnect from WebSocket server + * @public + */ + disconnect() { + // Close the WebSocket connection if it exists + if (this.socket) { + this.socket.close(); + this.socket = null; + } + + this.isConnected = false; + this._updateStatus('disconnected', 'Disconnected'); + this._updateConnectionUI(false); + + // Reset state + this.currentToken = ''; + this.currentServer = ''; + this.currentChannel = ''; + + // Remove the token from sessionStorage + sessionStorage.removeItem(this.SESSION_STORAGE_KEY); + + // Clear items from sessionStorage + this._clearStoredItems(); + } + + /** + * Process connection token + * @private + * @param {string} encodedToken - The encoded connection token + * @returns {boolean} - True if processing was successful + */ + _processConnectionToken(encodedToken) { + try { + // Decode the base64 token + const jsonStr = atob(encodedToken); + const connectionData = JSON.parse(jsonStr); + + // Extract server and token + const {server, token} = connectionData; + + if (!server || !token) { + this._updateStatus('disconnected', 'Invalid token'); + return false; + } + + // Store connection info + this.currentServer = server; + this.currentToken = token; + + // Format channel based on hostname + this.currentChannel = `/${this._format(window.location.host)}`; + + return true; + } catch (error) { + this._updateStatus('disconnected', `Unable to parse token`); + return false; + } + } + + /** + * Register with server using connection token + * @private + * @param {string} encodedToken - The encoded connection token + * @returns {Promise<{ token: string }>} - Resolves to true if registration was successful + */ + _registerWithServer(encodedToken) { + // Update UI + this._updateStatus('pending-auth', 'Registering...'); + + // Connect to the registration endpoint + const regSocket = new WebSocket(`${this.currentServer}${this.REGISTER_PATH}`); + + return new Promise((resolve, reject) => { + // Connection opened - send the token + regSocket.addEventListener('open', (event) => { + console.log('Registration connection established'); + + // Send the original encoded token back to the server + const jsonStr = atob(encodedToken); + const connectionData = JSON.parse(jsonStr); + connectionData.host = this._format(window.location.host); + regSocket.send(btoa(JSON.stringify(connectionData))); + }); + + // Listen for registration response + regSocket.addEventListener('message', (event) => { + try { + const message = JSON.parse(event.data); + + if (message.type === 'registerSuccess' && message.token) { + console.log(`Registration successful: ${message.message}`); + + // Registration complete, can now connect to channel + resolve({ token: message.token }); + } else if (message.type === 'error') { + console.error(`Registration failed: ${message.message}`); + this._updateStatus('disconnected', `Registration failed: ${message.message}`); + reject(new Error(message.message)); + } + } catch (error) { + console.error(`Error parsing registration response: ${error.message}`); + this._updateStatus('disconnected', 'Error parsing server response'); + reject(error); + } + }); + + // Handle registration errors + regSocket.addEventListener('error', (event) => { + console.error('Registration connection error'); + this._updateStatus('disconnected', 'Registration connection error'); + sessionStorage.removeItem(this.SESSION_STORAGE_KEY); + reject(new Error('Connection error')); + }); + + // Handle registration connection close + regSocket.addEventListener('close', (event) => { + console.log(`Registration connection closed: ${event.code} ${event.reason}`); + + if (event.code !== 1000) { + // If it wasn't a normal closure, show an error + this._updateStatus('disconnected', 'Registration failed'); + sessionStorage.removeItem(this.SESSION_STORAGE_KEY); + reject(new Error('Connection closed')); + } + }); + }); + } + + /** + * Set up WebSocket event listeners for direct connection + * @private + */ + _setupSocketListeners() { + if (!this.socket) { + console.error('Cannot set up socket listeners: WebSocket not available'); + return; + } + + // Set up socket open handler + this.socket.addEventListener('open', () => { + this.isConnected = true; + this._updateStatus('connected', `Connected to ${this.currentChannel}`); + this._updateConnectionUI(true); + console.log('WebMCP connection established'); + this._registerItemsWithServer(); + }); + + // Set up socket close handler + this.socket.addEventListener('close', (event) => { + this.isConnected = false; + this._updateStatus('disconnected', 'Disconnected'); + this._updateConnectionUI(false); + console.log(`Connection closed: ${event.code} ${event.reason}`); + + // Check if it was an authorization error + if (event.code === 1001 || event.code === 401) { + this._updateStatus('disconnected', 'Authorization failed'); + this.currentToken = ''; + this.currentServer = ''; + this.currentChannel = ''; + sessionStorage.removeItem(this.SESSION_STORAGE_KEY); + } + }); + + // Set up socket error handler + this.socket.addEventListener('error', () => { + console.error('WebSocket error'); + + if (this.isConnected) { + this._updateStatus('disconnected', 'Connection error occurred'); + } else { + this._updateStatus('disconnected', 'Connection failed'); + } + + sessionStorage.removeItem(this.SESSION_STORAGE_KEY); + }); + + // Set up socket message handler + this.socket.addEventListener('message', (event) => { + try { + const message = JSON.parse(event.data); + this._handleServerMessage(message); + } catch (error) { + console.error(`Error parsing message: ${error.message}`); + } + }); + } + + /** + * Handle messages from the server + * @private + * @param {Object} message - The parsed message object + */ + _handleServerMessage(message) { + switch (message.type) { + case 'welcome': + console.log(`Server says: ${message.message}`); + break; + + case 'toolRegistered': + console.log(`Tool registered with server: ${message.name}`); + break; + + case 'promptRegistered': + console.log(`Prompt registered with server: ${message.name}`); + break; + + case 'resourceRegistered': + console.log(`Resource registered with server: ${message.name}`); + break; + + case 'callTool': + // Server is asking us to execute a tool + this._handleToolCall(message); + break; + + case 'getPrompt': + // Server is asking us to provide a prompt + this._handleGetPrompt(message); + break; + + case 'readResource': + // Server is asking us to provide a resource + this._handleReadResource(message); + break; + + case 'createSamplingMessage': + // Server is asking us to create a sampling message + this._handleCreateSamplingMessage(message); + break; + + case 'listTools': + // Server is asking for available tools + this._sendToolsList(message.id); + break; + + case 'listPrompts': + // Server is asking for available prompts + this._sendPromptsList(message.id); + break; + + case 'listResources': + // Server is asking for available resources + this._sendResourcesList(message.id); + break; + + case 'ping': + // Respond to ping + this._sendMessage({ + type: 'pong', + id: message.id, + timestamp: Date.now() + }); + break; + + case 'error': + console.error(`Server error: ${message.message}`); + break; + + default: + console.warn(`Unknown message type: ${message.type}`); + } + } + + /** + * Handle tool call from server + * @private + * @param {Object} message - The parsed message object + */ + _handleToolCall(message) { + const {id, tool, arguments: args} = message; + + console.log(`Tool call: ${tool} with args:`, args); + + if (!this.availableTools.has(tool)) { + this._sendMessage({ + id, + type: 'toolResponse', + error: `Tool not found: ${tool}` + }); + return; + } + + // Execute the tool + try { + const toolObj = this.availableTools.get(tool); + + // Call the tool's execute function + const result = toolObj.execute(args); + + // Handle promises + if (result instanceof Promise) { + result + .then(resolvedResult => { + this._sendMessage({ + id, + type: 'toolResponse', + result: resolvedResult + }); + }) + .catch(error => { + this._sendMessage({ + id, + type: 'toolResponse', + error: error.message || 'Tool execution error' + }); + }); + } else { + // Send immediate result + this._sendMessage({ + id, + type: 'toolResponse', + result + }); + } + + console.log(`Tool response sent for ${tool}`); + } catch (error) { + this._sendMessage({ + id, + type: 'toolResponse', + error: error.message || 'Tool execution error' + }); + console.error(`Tool execution error:`, error); + } + } + + /** + * Handle prompt request from server + * @private + * @param {Object} message - The parsed message object + */ + _handleGetPrompt(message) { + const {id, name, arguments: args} = message; + + console.log(`Prompt request: ${name} with args:`, args); + + if (!this.availablePrompts.has(name)) { + this._sendMessage({ + id, + type: 'promptResponse', + error: `Prompt not found: ${name}` + }); + return; + } + + // Execute the prompt + try { + const promptObj = this.availablePrompts.get(name); + + // Call the prompt's execute function + const result = promptObj.execute(args); + + // Handle promises + if (result instanceof Promise) { + result + .then(resolvedResult => { + this._sendMessage({ + id, + type: 'promptResponse', + result: resolvedResult + }); + }) + .catch(error => { + this._sendMessage({ + id, + type: 'promptResponse', + error: error.message || 'Prompt execution error' + }); + }); + } else { + // Send immediate result + this._sendMessage({ + id, + type: 'promptResponse', + result + }); + } + + console.log(`Prompt response sent for ${name}`); + } catch (error) { + this._sendMessage({ + id, + type: 'promptResponse', + error: error.message || 'Prompt execution error' + }); + console.error(`Prompt execution error:`, error); + } + } + + /** + * Handle resource request from server + * @private + * @param {Object} message - The parsed message object + */ + _handleReadResource(message) { + const {id, uri} = message; + + console.log(`Resource request: ${uri}`); + + // Find resource that handles this URI + let resourceObj = null; + + // First check for direct URI match + for (const resource of this.availableResources.values()) { + if (!resource.isTemplate && resource.uri === uri) { + resourceObj = resource; + break; + } + } + + // If no direct match, check for template match + if (!resourceObj) { + for (const resource of this.availableResources.values()) { + if (resource.isTemplate) { + // Simple check - if URI starts with template prefix (before any parameters) + const templatePrefix = resource.uriTemplate.split('{')[0]; + if (uri.startsWith(templatePrefix)) { + resourceObj = resource; + break; + } + } + } + } + + if (!resourceObj) { + this._sendMessage({ + id, + type: 'resourceResponse', + error: `No resource handler found for URI: ${uri}` + }); + return; + } + + // Execute the resource provider + try { + // Call the resource's provide function + const result = resourceObj.provide(uri); + + // Handle promises + if (result instanceof Promise) { + result + .then(resolvedResult => { + this._sendMessage({ + id, + type: 'resourceResponse', + result: resolvedResult + }); + }) + .catch(error => { + this._sendMessage({ + id, + type: 'resourceResponse', + error: error.message || 'Resource read error' + }); + }); + } else { + // Send immediate result + this._sendMessage({ + id, + type: 'resourceResponse', + result + }); + } + + console.log(`Resource response sent for ${uri}`); + } catch (error) { + this._sendMessage({ + id, + type: 'resourceResponse', + error: error.message || 'Resource read error' + }); + console.error(`Resource read error:`, error); + } + } + + /** + * Send available tools list + * @private + * @param {string} requestId - The request ID to respond to + */ + _sendToolsList(requestId) { + const toolsList = Array.from(this.availableTools.values()).map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })); + + this._sendMessage({ + id: requestId, + type: 'listToolsResponse', + tools: toolsList + }); + + console.log(`Sent tools list: ${toolsList.length} tools`); + } + + /** + * Send available prompts list + * @private + * @param {string} requestId - The request ID to respond to + */ + _sendPromptsList(requestId) { + const promptsList = Array.from(this.availablePrompts.values()).map(prompt => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments, + })); + + this._sendMessage({ + id: requestId, + type: 'listPromptsResponse', + prompts: promptsList + }); + + console.log(`Sent prompts list: ${promptsList.length} prompts`); + } + + /** + * Send available resources list + * @private + * @param {string} requestId - The request ID to respond to + */ + _sendResourcesList(requestId) { + const resources = []; + const resourceTemplates = []; + + // Split resources and templates + this.availableResources.forEach((resource) => { + if (resource.isTemplate) { + resourceTemplates.push({ + name: resource.name, + description: resource.description, + uriTemplate: resource.uriTemplate, + mimeType: resource.mimeType + }); + } else { + resources.push({ + name: resource.name, + description: resource.description, + uri: resource.uri, + mimeType: resource.mimeType + }); + } + }); + + this._sendMessage({ + id: requestId, + type: 'listResourcesResponse', + resources, + resourceTemplates + }); + + console.log(`Sent resources list: ${resources.length} resources, ${resourceTemplates.length} templates`); + } + + /** + * Send a message to the server via direct WebSocket + * @private + * @param {Object} message - The message object to send + */ + _sendMessage(message) { + if (!this.isConnected || !this.socket) { + console.error('Cannot send message: not connected'); + return; + } + + try { + // Send the message directly through the WebSocket + this.socket.send(JSON.stringify(message)); + return Promise.resolve(); + } catch (error) { + console.error(`Error sending message: ${error.message}`); + return Promise.reject(error); + } + } + + /** + * Register all items with server that were registered while disconnected + * @private + */ + _registerItemsWithServer() { + if (!this.isConnected) return; + + // Clear registration tracking sets - we'll re-register everything + this.registeredTools = new Set(); + this.registeredPrompts = new Set(); + this.registeredResources = new Set(); + + // Register all tools with the server + this.availableTools.forEach((tool, name) => { + this._sendMessage({ + type: 'registerTool', + name, + description: tool.description, + inputSchema: tool.inputSchema + }); + + this.registeredTools.add(name); + console.log(`Registering tool with server: ${name}`); + }); + + // Register all prompts with the server + this.availablePrompts.forEach((prompt, name) => { + this._sendMessage({ + type: 'registerPrompt', + name, + description: prompt.description, + arguments: prompt.arguments + }); + + this.registeredPrompts.add(name); + console.log(`Registering prompt with server: ${name}`); + }); + + // Register all resources with the server + this.availableResources.forEach((resource, name) => { + this._sendMessage({ + type: 'registerResource', + name, + description: resource.description, + uri: resource.uri, + uriTemplate: resource.uriTemplate, + isTemplate: resource.isTemplate, + mimeType: resource.mimeType + }); + + this.registeredResources.add(name); + console.log(`Registering resource with server: ${name}`); + }); + } + + /** + * Register a tool + * @public + * @param {string} name - The name of the tool + * @param {string} description - The description of the tool + * @param {Object} schema - The schema for the tool's input + * @param {Function} executeFn - The function to execute when the tool is called + */ + registerTool(name, description, schema, executeFn) { + if (!name) { + console.error('Tool name is required'); + return; + } + + // Add the tool to local registry + this.availableTools.set(name, { + name, + description: description || `Tool: ${name}`, + execute: executeFn || function (args) { + return `Default implementation of ${name} with args: ${JSON.stringify(args)}`; + }, + inputSchema: schema || { + type: "object", + properties: {} + } + }); + + // Register the tool with the server if connected + if (this.isConnected) { + this._sendMessage({ + type: 'registerTool', + name, + description: description || `Tool: ${name}`, + inputSchema: schema || { + type: "object", + properties: {} + }, + }); + + this.registeredTools.add(name); + } + + // Save to session storage + this._saveItemsToStorage(); + + // Update tools display + this._updateToolsList(); + console.log(`Tool registered: ${name}`); + } + + /** + * Register a prompt + * @public + * @param {string} name - The name of the prompt + * @param {string} description - The description of the prompt + * @param {Array} promptArgs - The arguments for the prompt + * @param {Function} executeFn - The function to execute when the prompt is called + */ + registerPrompt(name, description, promptArgs, executeFn) { + if (!name) { + console.error('Prompt name is required'); + return; + } + + // Add the prompt to local registry + this.availablePrompts.set(name, { + name, + description: description || `Prompt: ${name}`, + execute: executeFn || function (args) { + return { + messages: [{ + role: "user", + content: { + type: "text", + text: `Default implementation of prompt ${name} with args: ${JSON.stringify(args)}` + } + }] + }; + }, + arguments: promptArgs || [] + }); + + // Register the prompt with the server if connected + if (this.isConnected) { + this._sendMessage({ + type: 'registerPrompt', + name, + description: description || `Prompt: ${name}`, + arguments: promptArgs || [] + }); + + this.registeredPrompts.add(name); + } + + // Save to session storage + this._saveItemsToStorage(); + + // Update prompts display + this._updatePromptsList(); + console.log(`Prompt registered: ${name}`); + } + + /** + * Register a resource + * @public + * @param {string} name - The name of the resource + * @param {string} description - The description of the resource + * @param {Object} options - The resource options including uri, uriTemplate, and mimeType + * @param {Function} provideFn - The function to execute when the resource is requested + */ + registerResource(name, description, options, provideFn) { + if (!name) { + console.error('Resource name is required'); + return; + } + + if (!options.uri && !options.uriTemplate) { + console.error('Either uri or uriTemplate is required for a resource'); + return; + } + + const isTemplate = !!options.uriTemplate; + + // Add the resource to local registry + this.availableResources.set(name, { + name, + description: description || `Resource: ${name}`, + uri: options.uri, + uriTemplate: options.uriTemplate, + isTemplate, + mimeType: options.mimeType, + provide: provideFn || function (uri) { + return { + contents: [{ + uri: uri, + text: `Default implementation of resource ${name} for URI: ${uri}`, + mimeType: options.mimeType || "text/plain" + }] + }; + } + }); + + // Register the resource with the server if connected + if (this.isConnected) { + this._sendMessage({ + type: 'registerResource', + name, + description: description || `Resource: ${name}`, + uri: options.uri, + uriTemplate: options.uriTemplate, + isTemplate, + mimeType: options.mimeType + }); + + this.registeredResources.add(name); + } + + // Save to session storage + this._saveItemsToStorage(); + + // Update resources display + this._updateResourcesList(); + console.log(`Resource registered: ${name}`); + } + + /** + * Handle sampling message creation request + * @private + * @param {Object} message - The parsed message object + */ + _handleCreateSamplingMessage(message) { + const { + id, + messages, + systemPrompt, + includeContext, + temperature, + maxTokens, + stopSequences, + metadata, + modelPreferences + } = message; + + console.log(`Sampling request received with ${messages?.length || 0} messages`); + + // Create a modal dialog to show the sampling request + const modal = document.createElement('div'); + Object.assign(modal.style, { + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: '10000' + }); + + // Create modal content + const modalContent = document.createElement('div'); + Object.assign(modalContent.style, { + backgroundColor: 'white', + padding: '20px', + borderRadius: '5px', + maxWidth: '500px', + width: '90%', + maxHeight: '80%', + overflow: 'auto' + }); + + // Create header + const header = document.createElement('h3'); + header.textContent = 'Sampling Request'; + Object.assign(header.style, { + margin: '0 0 15px 0', + padding: '0 0 10px 0', + borderBottom: '1px solid #ddd' + }); + + // Create content area to show messages + const content = document.createElement('div'); + Object.assign(content.style, { + marginBottom: '15px', + maxHeight: '300px', + overflow: 'auto', + border: '1px solid #ddd', + padding: '10px', + backgroundColor: '#f9f9f9' + }); + + // Display messages + if (messages && messages.length > 0) { + messages.forEach(msg => { + const msgDiv = document.createElement('div'); + Object.assign(msgDiv.style, { + marginBottom: '10px', + padding: '5px', + borderRadius: '3px', + backgroundColor: msg.role === 'user' ? '#e1f5fe' : '#f1f8e9' + }); + + const roleSpan = document.createElement('strong'); + roleSpan.textContent = msg.role === 'user' ? 'User: ' : 'Assistant: '; + + const contentSpan = document.createElement('span'); + if (msg.content.type === 'text') { + contentSpan.textContent = msg.content.text; + } else if (msg.content.type === 'image') { + contentSpan.textContent = '[Image data]'; + } + + msgDiv.appendChild(roleSpan); + msgDiv.appendChild(contentSpan); + content.appendChild(msgDiv); + }); + } else { + content.textContent = 'No messages provided in sampling request'; + } + + // System prompt if available + if (systemPrompt) { + const sysPromptDiv = document.createElement('div'); + Object.assign(sysPromptDiv.style, { + marginBottom: '10px', + padding: '5px', + backgroundColor: '#fff8e1' + }); + + const sysPromptLabel = document.createElement('strong'); + sysPromptLabel.textContent = 'System Prompt: '; + + const sysPromptContent = document.createElement('span'); + sysPromptContent.textContent = systemPrompt; + + sysPromptDiv.appendChild(sysPromptLabel); + sysPromptDiv.appendChild(sysPromptContent); + content.appendChild(sysPromptDiv); + } + + // Create response input + const responseLabel = document.createElement('label'); + responseLabel.textContent = 'Assistant Response:'; + Object.assign(responseLabel.style, { + display: 'block', + marginBottom: '5px', + fontWeight: 'bold' + }); + + const responseInput = document.createElement('textarea'); + Object.assign(responseInput.style, { + width: '100%', + minHeight: '100px', + padding: '10px', + marginBottom: '15px', + boxSizing: 'border-box' + }); + + // Create buttons + const buttonContainer = document.createElement('div'); + Object.assign(buttonContainer.style, { + display: 'flex', + justifyContent: 'space-between' + }); + + const submitButton = document.createElement('button'); + submitButton.textContent = 'Submit Response'; + Object.assign(submitButton.style, { + padding: '8px 15px', + backgroundColor: '#4CAF50', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer' + }); + + const cancelButton = document.createElement('button'); + cancelButton.textContent = 'Cancel'; + Object.assign(cancelButton.style, { + padding: '8px 15px', + backgroundColor: '#f44336', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer' + }); + + // Add elements to modal + buttonContainer.appendChild(cancelButton); + buttonContainer.appendChild(submitButton); + + modalContent.appendChild(header); + modalContent.appendChild(content); + modalContent.appendChild(responseLabel); + modalContent.appendChild(responseInput); + modalContent.appendChild(buttonContainer); + + modal.appendChild(modalContent); + document.body.appendChild(modal); + + // Focus the response input + responseInput.focus(); + + // Setup button handlers + submitButton.addEventListener('click', () => { + const responseText = responseInput.value.trim(); + if (responseText) { + // Send response back to server + this._sendMessage({ + id, + type: 'samplingResponse', + result: { + model: 'web-user-input', + role: 'assistant', + content: { + type: 'text', + text: responseText + } + } + }); + + // Remove modal + document.body.removeChild(modal); + } else { + alert('Please enter a response'); + } + }); + + cancelButton.addEventListener('click', () => { + // Send error response + this._sendMessage({ + id, + type: 'samplingResponse', + error: 'User cancelled sampling request' + }); + + // Remove modal + document.body.removeChild(modal); + }); + } +} + +// Export for module usage +if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = WebMCP; +} diff --git a/tests/contracts/webmcp-site.test.ts b/tests/contracts/webmcp-site.test.ts new file mode 100644 index 0000000..06a1b88 --- /dev/null +++ b/tests/contracts/webmcp-site.test.ts @@ -0,0 +1,65 @@ +// NJS-SEO-5 follow-up: contract test for the WebMCP integration on the docs site. +// +// Verifies the integration surface end to end at the source level: the widget +// library is vendored, the registration script exists and registers the same +// three tools as the stdio server, the VitePress head loads both scripts on +// every page, the guide is linked from the sidebar and the integrations index, +// and the AI surfaces (llms.txt, llms-full.txt, SKILL.md) point to it. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const read = (path: string) => readFileSync(path, "utf8"); + +describe("WebMCP documentation-site integration", () => { + it("vendors the WebMCP widget library", () => { + const lib = read("docs/public/webmcp/webmcp.js"); + expect(lib).toContain("class WebMCP"); + }); + + it("registers the same three tools as the stdio MCP server", () => { + const integration = read("docs/public/webmcp/neuron-webmcp.js"); + for (const tool of ["validate_script", "execute_decision", "explain_decision"]) { + expect(integration).toContain(tool); + } + expect(integration).toContain("validateScript"); + expect(integration).toContain("validateExecutionContext"); + // fail-closed: validation happens before any execution + const validateIdx = integration.indexOf("validateOrError"); + const executeIdx = integration.indexOf("new Synapse("); + expect(validateIdx).toBeGreaterThan(-1); + expect(executeIdx).toBeGreaterThan(validateIdx); + }); + + it("enables the native W3C API through the Chrome origin trial", () => { + const config = read("docs/.vitepress/config.ts"); + expect(config).toContain("ORIGIN_TRIAL_WEBMCP"); + expect(config).toContain("Origin-Trial"); + // dual registration: native navigator.modelContext + widget fallback + const integration = read("docs/public/webmcp/neuron-webmcp.js"); + expect(integration).toContain("modelContext"); + expect(integration).toContain("registerNativeTools"); + }); + + it("loads both scripts on every page via the VitePress head", () => { + const config = read("docs/.vitepress/config.ts"); + expect(config).toContain("/neuron-js/webmcp/webmcp.js"); + expect(config).toContain("/neuron-js/webmcp/neuron-webmcp.js"); + }); + + it("ships a guide linked from the sidebar and the integrations index", () => { + const config = read("docs/.vitepress/config.ts"); + expect(config).toContain("link: '/integrations/webmcp'"); + const index = read("docs/integrations/index.md"); + expect(index).toContain("./webmcp.md"); + expect(read("docs/integrations/webmcp.md")).toContain("validate_script"); + }); + + it("surfaces WebMCP on all AI-readable assets", () => { + expect(read("docs/public/llms.txt")).toContain("integrations/webmcp.html"); + expect(read("docs/public/llms-full.txt")).toContain("integrations/webmcp.html"); + const skill = read("docs/public/skills/neuron-js/SKILL.md"); + expect(skill).toContain("WebMCP recipe"); + // the packaged mirror stays byte-identical + expect(read("ai/skills/neuron-js/SKILL.md")).toBe(skill); + }); +});