Newer
Older
cortex-hub / frontend / src / services / mcpService.js
/**
 * mcpService.js
 *
 * Handles registration of Model Context Protocol (MCP) tools with the browser.
 *
 * Key design decisions:
 * 1. `navigator.modelContext` is injected by the WebMCP browser extension as a
 *    content script. It is NOT available synchronously at module load time.
 *    We must poll for it and queue tools registered before it's ready.
 * 2. The spec is still in flux. We pass both `execute` (2025+ spec) and `callback`
 *    (older Chrome builds) so it works regardless of which draft is active.
 */

class McpService {
    constructor() {
        this._ready = false;                // true once navigator.modelContext is confirmed available
        this._pendingTools = [];            // tools queued before context was available
        this._registeredTools = new Map(); // name → tool definition

        // Start polling for the injected context
        this._waitForContext();
    }

    // ── Dynamic check (always reads live from window) ─────────────────────────
    get isSupported() {
        return typeof window !== 'undefined' && !!window.navigator?.modelContext;
    }

    // ── Poll until navigator.modelContext appears (up to 8 seconds) ───────────
    _waitForContext() {
        if (this.isSupported) {
            // Already available (e.g. flag built into browser, not injected)
            this._onContextReady();
            return;
        }

        let attempts = 0;
        const MAX_ATTEMPTS = 80; // 80 × 100ms = 8s

        const poll = setInterval(() => {
            attempts++;
            if (this.isSupported) {
                clearInterval(poll);
                this._onContextReady();
            } else if (attempts >= MAX_ATTEMPTS) {
                clearInterval(poll);
                console.warn(
                    '[MCP] navigator.modelContext not detected after 8s.\n' +
                    '      → Ensure the WebMCP extension is installed OR Chrome flag is enabled:\n' +
                    '        chrome://flags/#enable-webmcp-testing'
                );
            }
        }, 100);
    }

    // ── Called once modelContext becomes available ────────────────────────────
    _onContextReady() {
        this._ready = true;
        console.log(
            `%c[MCP] ✓ navigator.modelContext detected — registering ${this._pendingTools.length} queued tools`,
            'color: #6366f1; font-weight: bold;'
        );
        // Flush the queue
        const pending = [...this._pendingTools];
        this._pendingTools = [];
        for (const tool of pending) {
            this._doRegister(tool);
        }
    }

    // ── Internal registration (only call when _ready = true) ─────────────────
    _doRegister(tool) {
        try {
            // Idempotent: if the tool is already registered, unregister first
            if (this._registeredTools.has(tool.name)) {
                try {
                    if (window.navigator.modelContext.unregisterTool) {
                        window.navigator.modelContext.unregisterTool(tool.name);
                    }
                } catch (_) { /* ignore */ }
                this._registeredTools.delete(tool.name);
            }

            // Normalize handler function — spec has used multiple property names
            const normalized = { ...tool };
            const fn = tool.execute || tool.callback || tool.handler;
            if (fn) {
                normalized.execute  = fn;  // MCP spec 2025-Q2+
                normalized.callback = fn;  // older Chrome/extension builds
            }

            window.navigator.modelContext.registerTool(normalized);
            this._registeredTools.set(tool.name, tool);
            console.log(`%c[MCP] ✓ ${tool.name}`, 'color: #22c55e; font-weight: bold;');
        } catch (err) {
            console.error(`[MCP] ✗ Failed to register '${tool.name}':`, err);
        }
    }

    // ── Public API ────────────────────────────────────────────────────────────

    /**
     * Register a tool. If modelContext isn't ready yet, the tool is queued
     * and will be registered automatically when the context becomes available.
     */
    registerTool(tool) {
        if (this._ready) {
            this._doRegister(tool);
        } else {
            // Deduplicate: replace if already queued with same name
            this._pendingTools = this._pendingTools.filter(t => t.name !== tool.name);
            this._pendingTools.push(tool);
            console.log(`[MCP] ⏳ Queued: ${tool.name}`);
        }
    }

    /**
     * Unregister a tool by name.
     */
    unregisterTool(toolName) {
        // Always remove from pending queue
        this._pendingTools = this._pendingTools.filter(t => t.name !== toolName);

        if (!this.isSupported) return;
        try {
            if (window.navigator.modelContext.unregisterTool) {
                window.navigator.modelContext.unregisterTool(toolName);
            }
            this._registeredTools.delete(toolName);
            console.log(`[MCP] ↩ Unregistered: ${toolName}`);
        } catch (err) {
            console.error(`[MCP] Failed to unregister '${toolName}':`, err);
        }
    }

    /** Returns true only after modelContext was confirmed available. */
    isActive() {
        return this._ready;
    }

    /** Debug helper — list all currently registered tool names. */
    listRegistered() {
        return [...this._registeredTools.keys()];
    }
}

const mcpService = new McpService();
export default mcpService;