var crypto = require('crypto'); var userInput = require('./yoke/user-input'); var actions = require('./mcp-management-service').ACTIONS; var TTL = 10 * 60 * 1000; function result(value, error) { return { content: [{ type: 'text', text: JSON.stringify(value) }], isError: !!error }; } function attachManagementTools(ctx) { var sessions = new WeakMap(), active = new Set(), closed = false; function state(session, generation) { var value = sessions.get(session); if (!value || value.generation !== generation) { value = { generation: generation, receipts: new Map() }; sessions.set(session, value); } value.receipts.forEach(function (entry, key) { if (entry.expires <= Date.now()) value.receipts.delete(key); }); return value; } function definitions(session, owner, generation, authorize) { var sourceOrigin = session.sessionOriginId || null; if (session.spawn || session.delegated || session._delegatedBy || session._pairDelegation || session.sessionProvenance && session.sessionProvenance.kind === 'worker') return []; function check() { authorize(); if ((session.sessionOriginId || null) !== sourceOrigin) throw new Error('MCP source conversation changed.'); if (closed || session.spawn || session.delegated || session._delegatedBy || session._pairDelegation || session._mentionInProgress || session.homeDebatePlanning || session.sessionProvenance && session.sessionProvenance.kind === 'worker') throw new Error('MCP management requires a direct owner-private agent conversation.'); } function receipt(id) { var entry = state(session, generation).receipts.get(id); if (!entry) throw new Error('MCP review receipt expired or belongs to another query. Inspect a new change; do not replay an uncertain effect.'); return entry; } async function read(args) { check(); if (args.changeId !== undefined) { if (Object.keys(args).length !== 1) throw new Error('Read a change receipt by changeId only.'); var entry = receipt(args.changeId); return result({ changeId: args.changeId, status: entry.status, result: entry.output || null, expiresAt: entry.expires, lifetime: 'Source query only, at most ten minutes; no durable retry after restart.' }); } return result(await ctx.service.read(owner, args, check)); } function inspect(args) { check(); var current = state(session, generation); if (current.receipts.size >= 100) throw new Error('This query reached the MCP review limit. Read existing receipts before continuing.'); var plan = ctx.service.prepare(owner, args, session, check), id = 'mcp-change-' + crypto.randomUUID(); var entry = { plan: plan, status: 'inspected', expires: Date.now() + TTL }; current.receipts.set(id, entry); return result({ status: 'inspected', changeId: id, review: plan.review, expiresAt: entry.expires, applied: false }); } async function apply(args) { check(); if (Object.keys(args).length !== 1 || typeof args.changeId !== 'string') throw new Error('Pass only the exact changeId. Agent-provided approval is never accepted.'); var entry = receipt(args.changeId); if (entry.status !== 'inspected') return result({ changeId: args.changeId, status: entry.status, result: entry.output || null, replayed: false }); if (!ctx.humans(session, owner) || session.loop && session.loop.active && session.loop.role !== 'crafting') return result({ status: 'consent_unavailable', applied: false, message: 'The authenticated owner must open this conversation to review the exact MCP change.' }); var controller = new AbortController(), reviewer = null, timer, expired = false; var sourceSignal = session.abortController && session.abortController.signal; function stopped() { controller.abort(); } function live() { check(); if (controller.signal.aborted || Date.now() >= entry.expires || !reviewer || !ctx.canRespond(reviewer, session, owner)) throw new Error('MCP review stopped, expired or owner disconnected.'); } entry.status = 'reviewing'; active.add(controller); if (sourceSignal) sourceSignal.addEventListener('abort', stopped, { once: true }); try { timer = setTimeout(function () { expired = true; controller.abort(); }, Math.min(ctx.consentTimeout || 45000, 45000)); var handler = ctx.userInput && ctx.userInput(session); var answer = await userInput.dispatchUserInput(function (request, respond) { request.authorizeResponse = function (ws) { try { check(); if (controller.signal.aborted || entry.status !== 'reviewing' || !ctx.canRespond(ws, session, owner)) return false; reviewer = ws; return true; } catch (error) { return false; } }; if (typeof handler !== 'function') return respond.cancel('Owner review is unavailable.'); return handler(request, respond); }, { questions: [{ id: 'mcp_change', header: 'MCP change', question: entry.plan.review, allowOther: false, options: [{ label: 'Keep unchanged' }, { label: 'Apply this MCP change' }] }] }, { source: 'product-control', requestId: 'mcp_change_' + crypto.randomUUID(), signal: controller.signal }); clearTimeout(timer); if (answer.status !== 'submitted' || !answer.answers.mcp_change || answer.answers.mcp_change.length !== 1 || answer.answers.mcp_change[0] !== 'Apply this MCP change') { entry.status = expired ? 'expired' : answer.status === 'submitted' ? 'declined' : answer.status; return result({ status: entry.status, applied: false, changeId: args.changeId }); } live(); if (ctx.service.version(owner) !== entry.plan.version) throw new Error('MCP connections changed. Inspect a new change.'); entry.plan.validate(); timer = setTimeout(stopped, Math.max(1, entry.expires - Date.now())); entry.status = 'queued'; entry.output = await entry.plan.execute(reviewer, controller.signal, function () { entry.status = 'started'; }); live(); entry.status = entry.output.status; return result(Object.assign({ changeId: args.changeId }, entry.output)); } catch (error) { entry.output = { message: error.message, inspectBeforeRetry: true }; entry.status = entry.status === 'started' ? 'unknown' : 'refused'; return result({ status: entry.status, changeId: args.changeId, result: entry.output }, true); } finally { function cleanup() { clearTimeout(timer); active.delete(controller); if (sourceSignal) sourceSignal.removeEventListener('abort', stopped); } if (entry.status === 'sign_in_started' && !controller.signal.aborted) controller.signal.addEventListener('abort', cleanup, { once: true }); else cleanup(); } } function wrap(handler) { return function (args) { return Promise.resolve().then(function () { return handler(args || {}); }).catch(function (error) { return result({ status: 'refused', message: error.message }, true); }); }; } return [ { name: 'read_mcp_connections', description: 'Read your own Local, Remote and Client MCP connections, paginated tool details, or an exact change receipt. checkXcode performs a bounded read-only host readiness check. Never returns stored credentials. Read humanSteps for setup that still needs a person.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, changeId: { type: 'string' }, checkXcode: { type: 'boolean' }, offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 100 } }, additionalProperties: false }, handler: wrap(read) }, { name: 'inspect_mcp_connection_change', description: 'Prepare one exact MCP setup/change for authenticated owner review without applying it. Supports local/remote add, enable/disable, test, rename/remove, private browser sign-in, Xcode connect/disconnect, Client MCP toggles/setup and opening secure credential/token/permission forms. Never put credentials in arguments, URLs or chat. Use existing import discovery/proposal tools for vendor import.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: actions }, id: { type: 'string' }, label: { type: 'string', maxLength: 100 }, command: { type: 'string', maxLength: 4096 }, args: { type: 'array', items: { type: 'string' }, maxItems: 100 }, cwd: { type: 'string', maxLength: 4096 }, url: { type: 'string', maxLength: 4096 } }, required: ['action'], additionalProperties: false }, handler: wrap(inspect) }, { name: 'apply_mcp_connection_change', description: 'Show the exact prepared MCP change to the authenticated owner and apply only after that owner approves. Pass only changeId; model approval flags and delegated answers are rejected. Receipts last at most ten minutes in this source query; repeating an ID reads its status and never replays an effect. Stop cancels further work; unknown outcomes require inspection. Opening a form is not credential persistence; starting sign-in is not authentication.', inputSchema: { type: 'object', properties: { changeId: { type: 'string' } }, required: ['changeId'], additionalProperties: false }, handler: wrap(apply) }, ]; } return { definitions: definitions, destroy: function () { closed = true; active.forEach(function (controller) { controller.abort(); }); active.clear(); sessions = new WeakMap(); } }; } module.exports = { attachManagementTools: attachManagementTools };