Ctrl K
ComunidadGuías…

lib/mcp-remote-connections.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
var crypto = require('crypto');
var Client = require('@modelcontextprotocol/sdk/client/index.js').Client;
var SSEClientTransport = require('@modelcontextprotocol/sdk/client/sse.js').SSEClientTransport;
var localTransport = require('./mcp-local-transport').localTransport;
var StreamableHTTPClientTransport = require('@modelcontextprotocol/sdk/client/streamableHttp.js').StreamableHTTPClientTransport;
var network = require('./mcp-network');
var oauth = require('./mcp-oauth');
var createLoginBrowser = require('./vendor-login-browser').createLoginBrowser;
var xcode = require('./mcp-xcode');

function createRemoteConnections(ctx) {
  var accounts = new Map(), live = new Map(), flows = new Map(), queues = new Map();
  var remoteFetch = ctx.remoteFetch || network.createRemoteFetch();
  var closed = false;
  var xcodeSupport = xcode.createXcodeSupport(ctx.xcodeOptions), xcodeChecks = new Map(), xcodeGenerations = new Map();
  function xcodeIdentity(owner) { return ctx.ownerIdentity ? ctx.ownerIdentity(owner) : null; }
  function xcodeState(owner) { records(owner); return xcodeChecks.get(owner) || null; }
  function sameRuntimeConfig(left, right) {
    return left && right && left.integration === right.integration && left.url === right.url && left.enabled === right.enabled && left.authMode === right.authMode && left.token === right.token && JSON.stringify(left.auth || null) === JSON.stringify(right.auth || null) && JSON.stringify([left.transport, left.command, left.args, left.env, left.cwd, left.headers, left.envHeaders, left.references, left.disabledTools, left.enabledTools, left.toolPatterns, left.toolPrefix]) === JSON.stringify([right.transport, right.command, right.args, right.env, right.cwd, right.headers, right.envHeaders, right.references, right.disabledTools, right.enabledTools, right.toolPatterns, right.toolPrefix]);
  }
  function invalidate(owner, previous, current) {
    var byId = new Map(current.filter(function (row) { return row.source !== 'computer'; }).map(function (row) { return [row.id, row]; }));
    previous.filter(function (row) { return row.source !== 'computer'; }).forEach(function (row) {
      var next = byId.get(row.id);
      if (!next || !sameRuntimeConfig(row, next)) disconnect(owner, row.id);
    });
  }
  function records(owner) {
    ctx.authorize(owner);
    var account = accounts.get(owner), snapshot = typeof ctx.storage.snapshot === 'function' ? ctx.storage.snapshot(owner) : { records: ctx.storage.load(owner), revision: typeof ctx.storage.revision === 'function' ? ctx.storage.revision(owner) : null };
    var loaded = snapshot.records, revision = snapshot.revision;
    if (!account) accounts.set(owner, account = { records: loaded, revision: revision });
    else if (account.revision !== revision) { invalidate(owner, account.records, loaded); account.records = loaded; account.revision = revision; }
    return account.records;
  }
  function persist(owner) {
    ctx.authorize(owner); if (closed) throw new Error('MCP connections closed.');
    var account = accounts.get(owner);
    if (!account) throw new Error('MCP connection snapshot is unavailable.');
    var revision;
    try { revision = ctx.storage.save(owner, account.records, account.revision); }
    catch (error) { accounts.delete(owner); invalidate(owner, account.records, []); throw error; }
    if (account) account.revision = revision;
  }
  function find(owner, id) {
    var row = records(owner).find(function (item) { return item.id === id; });
    if (!row) throw new Error('Connection is unavailable.');
    return row;
  }
  function key(owner, id) { return JSON.stringify([owner, id]); }
  function check(owner, row) {
    if (closed || find(owner, row.id) !== row) throw new Error('Connection changed.');
    if (row.source === 'local' && ctx.ownerIdentity) ctx.ownerIdentity(owner);
    if (xcode.isXcode(row)) {
      xcode.validate(row); var identity = xcodeIdentity(owner); xcodeSupport.checkIdentity(identity);
      var entry = live.get(key(owner, row.id));
      if (entry && entry.identityKey !== xcodeSupport.identityKey(identity)) throw new Error('Xcode OS user identity changed. Reconnect from Local MCP → Xcode.');
    }
  }
  function changed(owner) { if (!closed) ctx.changed(owner); }
  function view(owner) {
    return records(owner).filter(function (row) { return row.source !== 'computer'; }).map(function (row) {
      var entry = live.get(key(owner, row.id));
      return { id: row.id, name: row.id, label: row.label, source: row.source, integration: row.integration, transport: row.transport || 'http', url: row.url ? new URL(row.url).origin + new URL(row.url).pathname : null, provenance: row.provenance, references: require('./mcp-import-credentials').requiredNames(row),
        projectEnabled: !!row.enabled, extensionEnabled: true, tools: row.tools || [], toolCount: (row.tools || []).length,
        status: entry ? entry.status : (row.provenance || row.tools ? 'idle' : 'unavailable'), error: entry ? entry.error : '', authMode: row.authMode || 'auto' };
    });
  }
  async function disconnect(owner, id) {
    var entry = live.get(key(owner, id));
    live.delete(key(owner, id));
    if (entry && entry.client) await entry.client.close().catch(function () {});
  }
  function provider(owner, row, flow, control) {
    function authorize() { if (control) control.authorize(); check(owner, row); }
    return oauth.createOAuthProvider(row, function () { authorize(); persist(owner); }, flow, authorize);
  }
  async function connect(owner, row, flow, control) {
    function validateSetup() { if (control) control.authorize(); check(owner, row); }
    function stopped() { disconnect(owner, row.id); }
    validateSetup();
    if (xcode.isXcode(row)) { xcode.validate(row); xcodeSupport.checkIdentity(xcodeIdentity(owner)); }
    await disconnect(owner, row.id);
    validateSetup();
    if (flow && flow.cancelled) throw new Error('Sign-in expired.');
    var client = new Client({ name: 'clay', version: '1.0.0' }, { capabilities: {} });
    if (row.source !== 'local' && ((row.references || []).length || Object.keys(row.envHeaders || {}).length)) throw new Error('Configure required credentials in Clay before connecting.');
    var options = { fetch: async function (input, init) { validateSetup(); var response = await remoteFetch.fetch(input, init); validateSetup(); return response; }, requestInit: { headers: row.headers || {} } };
    if (row.authMode === 'token' && row.token) options.requestInit = { headers: Object.assign({}, row.headers || {}, { Authorization: 'Bearer ' + row.token }) };
    else if (flow || (row.auth && row.auth.tokens)) options.authProvider = provider(owner, row, flow, flow ? control : null);
    var transport = row.source === 'local' ? (ctx.localTransport || localTransport)(row, ctx.ownerIdentity ? ctx.ownerIdentity(owner) : null) : (row.transport === 'sse' ? new SSEClientTransport(new URL(row.url), options) : new StreamableHTTPClientTransport(new URL(row.url), options));
    var entry = { client: client, transport: transport, status: 'connecting', error: '', identityKey: xcode.isXcode(row) ? xcodeSupport.identityKey(xcodeIdentity(owner)) : null };
    if (xcode.isXcode(row)) client.onclose = function () {
      if (live.get(key(owner, row.id)) !== entry || entry.status !== 'ready') return;
      entry.status = 'unavailable'; entry.error = 'Xcode disconnected. Open your project in Xcode and reconnect from Local MCP → Xcode.'; changed(owner);
    };
    live.set(key(owner, row.id), entry); changed(owner);
    if (control && control.signal) control.signal.addEventListener('abort', stopped, { once: true });
    try {
      await client.connect(transport, { timeout: 30000 });
      validateSetup();
      var tools = [], cursor, seen = new Set();
      do {
        var page = await client.listTools(cursor ? { cursor: cursor } : {}, { timeout: 30000 });
        validateSetup();
        tools = tools.concat(page.tools);
        if (tools.length > 1000 || seen.size >= 100 || (page.nextCursor && seen.has(page.nextCursor))) throw new Error('Tool catalog exceeds the supported limit.');
        cursor = page.nextCursor; seen.add(cursor);
      } while (cursor);
      if (JSON.stringify(tools).length > 2 * 1024 * 1024) throw new Error('Tool catalog exceeds the supported limit.');
      validateSetup();
      if (live.get(key(owner, row.id)) !== entry || (flow && flow.cancelled)) throw new Error('Connection changed.');
      tools = tools.filter(function (tool) {
        return require('./mcp-tool-policy').permits(row, tool.name);
      });
      if (xcode.isXcode(row) && !tools.length) throw new Error('Xcode did not expose any tools.');
      require('./mcp-proxy').validateDefinitions(tools);
      row.tools = tools; entry.status = 'ready'; persist(owner);
      return entry;
    } catch (error) {
      var requiresAuth = error.code === 401 || error.code === 403 || error.name === 'UnauthorizedError' || (flow && flow.authorizationUrl);
      entry.status = requiresAuth ? 'auth_required' : 'unavailable';
      entry.error = xcode.isXcode(row) ? 'Could not connect to Xcode. Open your project, turn on “Allow external agents to use Xcode tools” in Xcode Settings → Intelligence, and approve Clay’s connection in Xcode if asked. Then try again.' : requiresAuth ? 'Sign in or provide an access token to connect.' : row.source === 'local' ? 'Could not start the command. Check its dependency and working directory.' : 'Could not connect. Check the address and try again.';
      if (!flow) await client.close().catch(function () {});
      return entry;
    } finally { if (control && control.signal) control.signal.removeEventListener('abort', stopped); control = null; changed(owner); }
  }
  function cancelLogin(owner, id) {
    var idKey = key(owner, id), flow = flows.get(idKey);
    if (!flow) return;
    flow.cancelled = true; flows.delete(idKey); clearTimeout(flow.timer);
    if (flow.controlCleanup) flow.controlCleanup();
    if (flow.browser) { flow.browser.close(); ctx.sendOwner(owner, { type: 'mcp_login_closed', browserId: flow.browser.id }); }
    flow.close();
  }
  async function signIn(owner, row, ws, control) {
    cancelLogin(owner, row.id);
    var identity = ctx.identity(ws);
    var flow = await oauth.createCallback(async function (code, issuer) {
      if (control) control.authorize(); check(owner, row);
      if (flows.get(key(owner, row.id)) !== flow) throw new Error('Sign-in expired.');
      var metadata = row.auth && row.auth.discovery;
      var expected = metadata && metadata.authorizationServerMetadata && metadata.authorizationServerMetadata.issuer;
      if ((metadata && metadata.authorizationServerMetadata && metadata.authorizationServerMetadata.authorization_response_iss_parameter_supported && !issuer) || (issuer && (!expected || issuer !== expected))) throw new Error('Sign-in issuer mismatch.');
      var entry = live.get(key(owner, row.id));
      if (!entry || !entry.transport) throw new Error('Sign in again.');
      await entry.transport.finishAuth(code);
      if (control) control.authorize(); check(owner, row);
      if (flows.get(key(owner, row.id)) !== flow) throw new Error('Sign-in expired.');
      var ready = await connect(owner, row, null, control);
      if (ready.status !== 'ready') throw new Error('Connection failed after sign-in.');
      setTimeout(function () { if (flows.get(key(owner, row.id)) === flow) { cancelLogin(owner, row.id); changed(owner); } }, 500);
    });
    try { if (control) control.authorize(); check(owner, row); } catch (error) { flow.close(); throw error; }
    if (control && control.signal) {
      var abortLogin = function () { cancelLogin(owner, row.id); };
      control.signal.addEventListener('abort', abortLogin, { once: true });
      flow.controlCleanup = function () { control.signal.removeEventListener('abort', abortLogin); };
    }
    flows.set(key(owner, row.id), flow);
    flow.timer = setTimeout(function () { cancelLogin(owner, row.id); }, 10 * 60 * 1000); flow.timer.unref();
    row.authMode = 'auto'; delete row.token;
    // A fresh loopback redirect requires a matching client registration.
    row.auth = { redirectUrl: flow.redirectUrl }; persist(owner);
    var entry = await connect(owner, row, flow, control);
    if (!flow.authorizationUrl) { cancelLogin(owner, row.id); if (entry.status !== 'ready') throw new Error('This server could not start browser sign-in. Use an access token if supported.'); return; }
    if (control) control.authorize(); check(owner, row);
    if (flow.cancelled) throw new Error('Sign-in expired.');
    try { flow.browser = (ctx.createLoginBrowser || createLoginBrowser)({ ws: ws, vendor: 'mcp', initialUrl: flow.authorizationUrl,
      osUserInfo: identity, restrictedNetwork: true,
      canAccess: function (peer) { return flows.get(key(owner, row.id)) === flow && ctx.canAccess(peer, owner); },
      sendTo: function (peer, message) { ctx.sendTo(peer, Object.assign({}, message, { type: message.type.replace('vendor_login_', 'mcp_login_'), connectionId: row.id })); } });
    } catch (error) { cancelLogin(owner, row.id); throw new Error('Could not open the sign-in browser. Retry or use an access token.'); }
    ctx.sendTo(ws, { type: 'mcp_login_ready', browserId: flow.browser.id, connectionId: row.id, label: row.label });
  }
  function serial(owner, task) {
    var pending = (queues.get(owner) || Promise.resolve()).catch(function () {}).then(task);
    queues.set(owner, pending);
    pending.finally(function () { if (queues.get(owner) === pending) queues.delete(owner); }).catch(function () {});
    return pending;
  }
  function commitImport(owner, select, authorize) {
    authorize();
    var imported = select();
    records(owner);
    if (accounts.get(owner).revision !== imported.revision) throw new Error('Connections changed. Scan again.');
    if (records(owner).some(function (item) { return item.importSignature === imported.signature || imported.config.url && item.url === imported.config.url; })) throw new Error('This server is already imported.');
    authorize();
    var row = Object.assign({}, imported.config, { id: 'remote-' + crypto.randomBytes(8).toString('hex'), importSignature: imported.signature, enabled: false });
    records(owner).push(row); persist(owner);
    return row;
  }
  function importConnection(owner, select, authorize) {
    return serial(owner, function () { var row = commitImport(owner, select, authorize); changed(owner); return row.id; });
  }
  async function inspectXcode(owner, authorize) {
    records(owner); authorize();
    var inspected = await xcodeSupport.inspect(xcodeIdentity(owner), authorize);
    authorize(); xcodeChecks.set(owner, inspected); changed(owner); return inspected;
  }
  function managementVersion(owner) {
    var rows = records(owner);
    return crypto.createHash('sha256').update(JSON.stringify([accounts.get(owner).revision, rows])).digest('hex');
  }
  function command(owner, msg, ws, control) {
    var expected = control && control.version;
    function admitted() {
      if (!control) return;
      control.authorize();
      if (expected !== managementVersion(owner)) throw new Error('MCP connections changed. Inspect the change again.');
    }
    try { admitted(); } catch (error) { return Promise.reject(error); }
    var requestedXcodeGeneration = xcodeGenerations.get(owner) || 0;
    if (msg.action === 'xcode_disconnect') {
      try {
        records(owner);
        if (!ctx.canAccess(ws, owner)) throw new Error('Xcode access changed.');
        if (control && control.started) control.started();
        xcodeGenerations.set(owner, (xcodeGenerations.get(owner) || 0) + 1);
        var stopped = records(owner).find(xcode.isXcode);
        if (stopped) { stopped.enabled = false; persist(owner); disconnect(owner, stopped.id); }
        changed(owner); return Promise.resolve(stopped ? stopped.id : null);
      } catch (error) { return Promise.reject(error); }
    }
    // Revocation takes effect before any queued network work completes.
    if (msg.action === 'remove' || (msg.action === 'enable' && msg.enabled !== true)) {
      try {
        var revoked = find(owner, msg.id);
        if (revoked.source === 'computer') throw new Error('Not a remote connection.');
        if (xcode.isXcode(revoked)) xcodeGenerations.set(owner, (xcodeGenerations.get(owner) || 0) + 1);
        if (control && control.started) control.started();
        revoked.enabled = false; cancelLogin(owner, revoked.id); persist(owner);
        disconnect(owner, revoked.id); changed(owner);
        if (control) expected = managementVersion(owner);
      } catch (error) { return Promise.reject(error); }
    }
    return serial(owner, async function () {
      admitted();
      if (control && control.started) control.started();
      var row;
      if (msg.action === 'xcode_check' || msg.action === 'xcode_connect') {
        var identity = xcodeIdentity(owner), identityKey = xcodeSupport.identityKey(identity);
        var generation = requestedXcodeGeneration;
        function authorizeXcode() {
          records(owner);
          if (control) control.authorize();
          if (closed || !ctx.canAccess(ws, owner) || (xcodeGenerations.get(owner) || 0) !== generation || xcodeSupport.identityKey(xcodeIdentity(owner)) !== identityKey) throw new Error('Xcode access changed or connection was cancelled. Check the connection again.');
        }
        authorizeXcode();
        var inspected = await inspectXcode(owner, authorizeXcode);
        authorizeXcode();
        if (msg.action === 'xcode_check') return null;
        if (!inspected.ready) throw new Error(inspected.message);
        row = records(owner).find(xcode.isXcode);
        if (!row) {
          if (records(owner).filter(function (item) { return item.source !== 'computer'; }).length >= 30) throw new Error('Remove an unused MCP connection before adding Xcode.');
          row = Object.assign(xcode.configuration(), { id: 'remote-' + crypto.randomBytes(8).toString('hex') }); records(owner).push(row);
        }
        xcode.validate(row); row.enabled = false; persist(owner);
        var connected = await connect(owner, row, null, control);
        authorizeXcode(); check(owner, row);
        if (connected.status !== 'ready') throw new Error(connected.error);
        row.enabled = true; persist(owner);
      } else if (msg.action === 'import') {
        row = commitImport(owner, function () { return ctx.selectImport(owner, ws, msg); }, function () { if (!ctx.canAccess(ws, owner)) throw new Error('MCP account is unavailable.'); });
      } else if (msg.action === 'add_local') {
        records(owner);
        if (!ctx.canAccess(ws, owner) || typeof ctx.ownerIdentity !== 'function') throw new Error('Local MCP requires your authorized OS account.');
        var localConfig = require('./mcp-local-configuration').configuration(msg, ctx.ownerIdentity(owner));
        if (records(owner).filter(function (item) { return item.source !== 'computer'; }).length >= 30) throw new Error('Remove an unused MCP connection before adding another.');
        if (records(owner).some(function (item) { return item.source === 'local' && item.command === localConfig.command && JSON.stringify(item.args || []) === JSON.stringify(localConfig.args) && item.cwd === localConfig.cwd; })) throw new Error('This local MCP server is already saved. Open it from your connections.');
        if (!ctx.canAccess(ws, owner)) throw new Error('MCP account is unavailable.');
        row = Object.assign({}, localConfig, { id: 'remote-' + crypto.randomBytes(8).toString('hex') });
        records(owner).push(row); persist(owner);
      } else if (msg.action === 'add') {
        var url = network.remoteUrl(msg.url).href;
        if (records(owner).filter(function (item) { return item.source !== 'computer'; }).length >= 30) throw new Error('You can add up to 30 remote connections per project.');
        if (records(owner).some(function (item) { return item.url === url; })) throw new Error('This server is already connected. Open it from your connections.');
        row = { id: 'remote-' + crypto.randomBytes(8).toString('hex'), source: 'url', url: url, label: new URL(url).hostname, enabled: false };
        records(owner).push(row); persist(owner);
        await connect(owner, row, null, control);
      } else {
        row = find(owner, msg.id);
        if (row.source === 'computer') throw new Error('Not a managed connection.');
        if (row.source === 'local' && !row.provenance && !xcode.isXcode(row) && !ctx.canAccess(ws, owner)) throw new Error('Local MCP access changed.');
        if (msg.action === 'enable') { if (xcode.isXcode(row) && msg.enabled === true) throw new Error('Connect Xcode from Local MCP → Xcode.'); row.enabled = msg.enabled === true; persist(owner); if (row.enabled && (row.provenance || row.source === 'local')) { try { await connect(owner, row, null, control); } catch (error) { row.enabled = false; persist(owner); throw error; } } }
        else if (msg.action === 'rename') {
          if (typeof msg.label !== 'string' || !msg.label.trim() || msg.label.length > 100) throw new Error('Use a name between 1 and 100 characters.');
          row.label = msg.label.trim(); persist(owner);
        } else if (msg.action === 'remove') {
          cancelLogin(owner, row.id); await disconnect(owner, row.id);
          var account = accounts.get(owner);
          accounts.set(owner, { records: records(owner).filter(function (item) { return item !== row; }), revision: account && account.revision }); persist(owner);
        } else if (msg.action === 'test') { cancelLogin(owner, row.id); await connect(owner, row, null, control); }
        else if (msg.action === 'credentials') {
          if (!msg.values || typeof msg.values !== 'object' || Array.isArray(msg.values)) throw new Error('Enter required credential values.');
          var next = require('./mcp-import-credentials').applyCredentials(row, msg.values);
          cancelLogin(owner, row.id); Object.assign(row, next); persist(owner); await disconnect(owner, row.id);
        } else if (msg.action === 'token') {
          if (typeof msg.token !== 'string' || !msg.token.trim() || msg.token.length > 8192 || /[\r\n]/.test(msg.token)) throw new Error('Enter a valid access token.');
          cancelLogin(owner, row.id); row.token = msg.token.trim(); row.authMode = 'token'; delete row.auth; persist(owner); await connect(owner, row, null, control);
        } else if (msg.action === 'signin') { if (row.source === 'local') throw new Error('This server uses process environment credentials.'); await signIn(owner, row, ws, control); }
        else throw new Error('Unsupported connection action.');
      }
      if (control) control.authorize();
      changed(owner);
      return row.id;
    });
  }
  function handleBrowser(owner, ws, msg) {
    var flow = flows.get(key(owner, msg.connectionId));
    if (!flow || !flow.browser || flow.browser.id !== msg.browserId || !ctx.canAccess(ws, owner)) return;
    if (msg.type === 'mcp_login_cancel') cancelLogin(owner, msg.connectionId);
    else if (msg.type === 'mcp_login_browser_attach') flow.browser.attach(ws);
    else if (msg.type === 'mcp_login_browser_input') flow.browser.input(ws, msg.event);
  }
  async function call(owner, id, name, args, authorize) {
    return serial(owner, async function () {
      authorize(); var row = find(owner, id);
      if (!row.enabled) throw new Error('Connection is disabled.');
      if (!require('./mcp-tool-policy').permits(row, name)) throw new Error('Tool is excluded by the imported vendor policy.');
      var entry = live.get(key(owner, id));
      if (!entry || entry.status !== 'ready') entry = await connect(owner, row);
      authorize(); check(owner, row);
      if (!row.enabled || entry.status !== 'ready') throw new Error('MCP connection is not ready. Open MCP / Skills to reconnect.');
      if (!(row.tools || []).some(function (tool) { return tool.name === name; })) throw new Error('Tool is unavailable.');
      // Never replay a tools/call after an ambiguous transport failure.
      var result;
      try { result = await entry.client.callTool({ name: name, arguments: args || {} }, undefined, { timeout: xcode.isXcode(row) ? 300000 : 60000 }); }
      catch (error) {
        authorize(); check(owner, row);
        if (live.get(key(owner, id)) === entry) {
          entry.status = error.code === 401 || error.code === 403 || error.name === 'UnauthorizedError' ? 'auth_required' : 'unavailable';
          entry.error = entry.status === 'auth_required' ? 'Sign in again to reconnect.' : 'The tool call could not complete. Check the service before retrying.';
          changed(owner);
        }
        throw new Error('MCP tool call failed. Open MCP / Skills to check the connection.');
      }
      authorize(); check(owner, row);
      if (!row.enabled || live.get(key(owner, id)) !== entry) throw new Error('Connection was disabled or changed.');
      return result;
    });
  }
  function computerEnabled(owner, name, fallback) {
    var row = records(owner).find(function (item) { return item.source === 'computer' && item.name === name; });
    return row ? row.enabled : !!fallback;
  }
  function toggleComputer(owner, name, enabled) {
    var list = records(owner), row = list.find(function (item) { return item.source === 'computer' && item.name === name; });
    if (!row) { row = { id: crypto.randomUUID(), source: 'computer', name: name }; list.push(row); }
    row.enabled = !!enabled; persist(owner); changed(owner);
  }
  function destroy() {
    flows.forEach(function (flow, flowKey) { var pair = JSON.parse(flowKey); cancelLogin(pair[0], pair[1]); });
    closed = true; live.forEach(function (entry) { entry.client.close().catch(function () {}); }); live.clear();
    remoteFetch.close();
  }
  return { view: view, managementVersion: managementVersion, inspectXcode: inspectXcode, command: command, importConnection: importConnection, call: call, handleBrowser: handleBrowser, destroy: destroy, xcodeState: xcodeState,
    computerEnabled: computerEnabled, toggleComputer: toggleComputer };
}
module.exports = { createRemoteConnections: createRemoteConnections };