// integrations.js — 웹훅 설정 + MCP 서버 연동 관리. // extracted from app.js (v4.3.19-3 split). let _integTab = 'webhooks'; function setIntegTab(tab) { _integTab = tab; ['webhooks', 'mcp'].forEach(t => { const btn = document.getElementById('itab-' + t); const panel = document.getElementById('itab-panel-' + t); if (btn) { btn.style.background = t === tab ? '#eaf2ff' : '#fff'; btn.style.color = t === tab ? '#0d4faf' : 'var(--muted)'; btn.style.borderColor = t === tab ? '#bdd3f6' : 'var(--line)'; } if (panel) panel.style.display = t === tab ? 'block' : 'none'; }); } async function loadIntegrationsTab() { setIntegTab(_integTab); await loadWebhookSettings(); await loadMCPServers(); } // --- Webhooks ----------------------------------------------------------------- async function loadWebhookSettings() { try { const data = await api('/api/settings/hooks'); const h = data.hooks || {}; const cb = document.getElementById('wh-enabled'); const inp = document.getElementById('wh-token'); const pathInp = document.getElementById('wh-path'); if (cb) cb.checked = h.enabled === true; if (inp) inp.value = h.tokenSet ? '••••••••' : ''; if (pathInp) pathInp.value = h.path || '/hooks'; updateWebhookStatus(h.enabled, h.tokenSet); updateWebhookUrlDisplay(h.enabled, h.tokenSet, h.path || '/hooks'); } catch(e) { console.warn('loadWebhookSettings:', e); } } function updateWebhookStatus(enabled, tokenSet) { const dot = document.getElementById('wh-status-dot'); const txt = document.getElementById('wh-status-text'); if (!dot || !txt) return; if (enabled && tokenSet) { dot.style.background = '#22c55e'; txt.textContent = '활성 — 엔드포인트 실행 중'; txt.style.color = '#166534'; } else if (!enabled) { dot.style.background = '#ccc'; txt.textContent = '비활성'; txt.style.color = 'var(--muted)'; } else { dot.style.background = '#f59e0b'; txt.textContent = '활성화됨 — 토큰 미설정'; txt.style.color = '#92400e'; } } function updateWebhookUrlDisplay(enabled, tokenSet, hookPath) { const el = document.getElementById('wh-url-display'); if (!el) return; if (enabled && tokenSet) { const origin = window.location.origin || 'http://localhost:18789'; el.style.display = 'block'; el.textContent = origin + hookPath + '/agent'; } else { el.style.display = 'none'; } } function generateWebhookToken() { const arr = new Uint8Array(24); crypto.getRandomValues(arr); const token = Array.from(arr).map(b => b.toString(16).padStart(2,'0')).join(''); const inp = document.getElementById('wh-token'); if (inp) { inp.value = token; inp.type = 'text'; setTimeout(() => { inp.type = 'password'; }, 4000); } } async function saveWebhookSettings() { const enabled = document.getElementById('wh-enabled')?.checked ?? false; const rawToken = document.getElementById('wh-token')?.value?.trim() || ''; const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks'; try { const r = await api('/api/settings/hooks', { method: 'POST', body: JSON.stringify({ enabled, token: rawToken, path: hookPath }), }); if (r.success) { await loadWebhookSettings(); showIntegMsg('? Webhook settings saved — restart gateway to apply', '#166534', '#f0fdf4'); } else { showIntegMsg('Error: ' + (r.error || 'Unknown error'), '#991b1b', '#fef2f2'); } } catch(e) { showIntegMsg('Save failed: ' + e.message, '#991b1b', '#fef2f2'); } } async function testWebhookEndpoint() { try { const r = await api('/api/settings/hooks/test', { method: 'POST' }); if (r.success) { showIntegMsg('? ' + (r.message || 'Endpoint is active'), '#166534', '#f0fdf4'); } else { showIntegMsg('? ' + (r.error || 'Test failed'), '#991b1b', '#fef2f2'); } } catch(e) { showIntegMsg('Test failed: ' + e.message, '#991b1b', '#fef2f2'); } } function copyWebhookCurl() { const origin = window.location.origin || 'http://localhost:18789'; const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks'; const curl = `curl -X POST ${origin}${hookPath}/agent \\ -H "X-Gateway-Token: YOUR_TOKEN" \\ -H "Content-Type: application/json" \\ -d '{"message": "Hello from webhook", "name": "MyApp", "deliver": true}'`; navigator.clipboard?.writeText(curl).then(() => { showIntegMsg('cURL example copied to clipboard', '#1e40af', '#eff6ff'); }).catch(() => { showIntegMsg('Could not copy — check browser permissions', '#92400e', '#fffbeb'); }); } // --- MCP --------------------------------------------------------------------- let _mcpEditingId = null; const MCP_PRESETS = { filesystem: { name: 'Filesystem', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-filesystem\nC:\\Users', env: '', description: 'Read/write local files' }, github: { name: 'GitHub', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-github', env: 'GITHUB_PERSONAL_ACCESS_TOKEN=', description: 'Repos, PRs, issues' }, postgres: { name: 'PostgreSQL', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-postgres\npostgresql://localhost/mydb', env: '', description: 'Query PostgreSQL' }, sqlite: { name: 'SQLite', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-sqlite\n--db-path\nC:\\path\\to\\db.sqlite', env: '', description: 'Local database' }, brave: { name: 'Brave Search', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-brave-search', env: 'BRAVE_API_KEY=', description: 'Web search' }, memory: { name: 'Memory', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-memory', env: '', description: 'Persistent knowledge store' }, }; async function loadMCPServers() { const el = document.getElementById('mcp-server-list'); if (!el) return; try { const data = await api('/api/mcp/servers'); const servers = data.servers || []; if (servers.length === 0) { el.innerHTML = '
No MCP servers configured yet.
Add one above or click a preset on the right.
'; return; } el.innerHTML = servers.map(s => { const statusColor = s.status === 'connected' ? '#22c55e' : s.status === 'error' ? '#ef4444' : s.status === 'connecting' ? '#f59e0b' : '#9ca3af'; const statusLabel = s.status === 'connected' ? `Connected · ${s.toolCount} tool${s.toolCount !== 1 ? 's' : ''}` : s.status === 'error' ? 'Error' : s.status === 'connecting' ? 'Connecting…' : 'Disconnected'; const toolTip = s.toolNames?.length ? s.toolNames.join(', ') : ''; return `
${escHtml(s.name)} (${escHtml(s.id)})
${statusLabel}${s.error ? ` — ${escHtml(s.error.slice(0,60))}` : ''}
${toolTip ? `
${escHtml(toolTip.slice(0,80))}${toolTip.length>80?'…':''}
` : ''}
${s.status === 'connected' ? `` : `` }
`; }).join(''); } catch(e) { el.innerHTML = `
Failed to load: ${escHtml(e.message)}
`; } } function showMCPAddForm(editData) { _mcpEditingId = editData?.id || null; document.getElementById('mcp-add-form').style.display = 'block'; document.getElementById('mcp-form-title').textContent = _mcpEditingId ? 'MCP 서버 편집' : 'MCP 서버 추가'; document.getElementById('mcp-form-msg').textContent = ''; if (editData) { const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; }; v('mcp-f-id', editData.id); v('mcp-f-name', editData.name); document.getElementById('mcp-f-transport').value = editData.transport || 'stdio'; onMCPTransportChange(); v('mcp-f-command', editData.command || ''); v('mcp-f-args', (editData.args || []).join('\n')); v('mcp-f-env', Object.entries(editData.env || {}).map(([k,v]) => `${k}=${v}`).join('\n')); v('mcp-f-url', editData.url || ''); v('mcp-f-headers', Object.entries(editData.headers || {}).map(([k,v]) => `${k}: ${v}`).join('\n')); const cb = document.getElementById('mcp-f-enabled'); if (cb) cb.checked = editData.enabled !== false; } else { ['mcp-f-id','mcp-f-name','mcp-f-command','mcp-f-args','mcp-f-env','mcp-f-url','mcp-f-headers'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); document.getElementById('mcp-f-transport').value = 'stdio'; onMCPTransportChange(); const cb = document.getElementById('mcp-f-enabled'); if (cb) cb.checked = true; } document.getElementById('mcp-f-id').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function hideMCPAddForm() { document.getElementById('mcp-add-form').style.display = 'none'; _mcpEditingId = null; } function onMCPTransportChange() { const t = document.getElementById('mcp-f-transport')?.value; document.getElementById('mcp-stdio-fields').style.display = t === 'stdio' ? 'block' : 'none'; document.getElementById('mcp-sse-fields').style.display = t === 'sse' ? 'block' : 'none'; } function prefillMCPServer(presetKey) { const p = MCP_PRESETS[presetKey]; if (!p) return; showMCPAddForm(); const v = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; }; v('mcp-f-id', presetKey); v('mcp-f-name', p.name); document.getElementById('mcp-f-transport').value = p.transport; onMCPTransportChange(); v('mcp-f-command', p.command || ''); v('mcp-f-args', p.args || ''); v('mcp-f-env', p.env || ''); v('mcp-f-url', p.url || ''); setIntegTab('mcp'); } async function saveMCPServer() { const id = document.getElementById('mcp-f-id')?.value?.trim(); const name = document.getElementById('mcp-f-name')?.value?.trim(); const transport = document.getElementById('mcp-f-transport')?.value || 'stdio'; const enabled = document.getElementById('mcp-f-enabled')?.checked ?? true; const msgEl = document.getElementById('mcp-form-msg'); if (!id || !name) { if (msgEl) msgEl.textContent = 'ID와 이름은 필수입니다.'; return; } const cfg = { id, name, transport, enabled }; if (transport === 'stdio') { cfg.command = document.getElementById('mcp-f-command')?.value?.trim() || ''; const argsRaw = document.getElementById('mcp-f-args')?.value || ''; cfg.args = argsRaw.split('\n').map(s => s.trim()).filter(Boolean); const envRaw = document.getElementById('mcp-f-env')?.value || ''; cfg.env = {}; envRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => { const eq = line.indexOf('='); if (eq > 0) cfg.env[line.slice(0,eq).trim()] = line.slice(eq+1); }); } else { cfg.url = document.getElementById('mcp-f-url')?.value?.trim() || ''; const headersRaw = document.getElementById('mcp-f-headers')?.value || ''; cfg.headers = {}; headersRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => { const colon = line.indexOf(':'); if (colon > 0) cfg.headers[line.slice(0,colon).trim()] = line.slice(colon+1).trim(); }); } try { const r = await api('/api/mcp/servers', { method: 'POST', body: JSON.stringify(cfg) }); if (r.success) { if (msgEl) msgEl.textContent = '? Saved'; setTimeout(() => hideMCPAddForm(), 600); await loadMCPServers(); // Auto-connect if enabled if (enabled) connectMCPServer(id); } else { if (msgEl) msgEl.textContent = '오류: ' + (r.error || '알 수 없음'); } } catch(e) { if (msgEl) msgEl.textContent = '저장 실패: ' + e.message; } } async function editMCPServer(id) { try { const data = await api('/api/mcp/servers'); const server = (data.servers || []).find(s => s.id === id); if (server) showMCPAddForm(server); } catch(e) { console.warn('editMCPServer:', e); } } async function deleteMCPServer(id) { if (!confirm(`Delete MCP server "${id}"?`)) return; try { await api(`/api/mcp/servers/${id}`, { method: 'DELETE' }); await loadMCPServers(); } catch(e) { showIntegMsg('Delete failed: ' + e.message, '#991b1b', '#fef2f2'); } } async function connectMCPServer(id) { showIntegMsg('Connecting to ' + id + '…', '#1e40af', '#eff6ff'); try { const r = await api(`/api/mcp/servers/${id}/connect`, { method: 'POST' }); if (r.success) { showIntegMsg(`? Connected — ${(r.tools||[]).length} tool(s) available`, '#166534', '#f0fdf4'); } else { showIntegMsg('? Connection failed: ' + (r.error || 'Unknown'), '#991b1b', '#fef2f2'); } await loadMCPServers(); } catch(e) { showIntegMsg('Connect error: ' + e.message, '#991b1b', '#fef2f2'); await loadMCPServers(); } } async function disconnectMCPServer(id) { try { await api(`/api/mcp/servers/${id}/disconnect`, { method: 'POST' }); await loadMCPServers(); showIntegMsg('Disconnected from ' + id, '#475569', '#f8fafc'); } catch(e) { showIntegMsg('Disconnect error: ' + e.message, '#991b1b', '#fef2f2'); } } // --- Shared helper ------------------------------------------------------------ function showIntegMsg(msg, color, bg) { let el = document.getElementById('integ-toast'); if (!el) { el = document.createElement('div'); el.id = 'integ-toast'; el.style.cssText = 'position:fixed;bottom:80px;right:24px;z-index:9999;padding:10px 16px;border-radius:10px;font-size:12px;font-weight:600;max-width:320px;box-shadow:0 4px 16px rgba(0,0,0,0.12);transition:opacity .3s'; document.body.appendChild(el); } el.style.background = bg || '#f0fdf4'; el.style.color = color || '#166534'; el.style.border = `1px solid ${color || '#166534'}33`; el.style.opacity = '1'; el.textContent = msg; clearTimeout(el._timeout); el._timeout = setTimeout(() => { el.style.opacity = '0'; }, 3500); }