[eric] add Slack MCP integration with Electron-based auto token capture

This commit is contained in:
ciregenz
2026-04-06 18:14:00 -07:00
parent 69f3a66c70
commit 28b2a110ce
3 changed files with 170 additions and 18 deletions
+90
View File
@@ -529,3 +529,93 @@ ipcMain.handle('open-external', (_event, url) => {
shell.openExternal(url);
}
});
ipcMain.handle('connect-slack', async () => {
const win = new BrowserWindow({
width: 900,
height: 750,
title: 'Sign in to Slack',
parent: mainWindow || undefined,
modal: false,
autoHideMenuBar: true,
webPreferences: {
partition: 'persist:slack-auth',
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
// Override the global window-open handler so new tabs/windows from Slack
// (e.g. workspace redirects) navigate this popup instead of getting
// hijacked into a dashboard browser card.
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
win.loadURL(url).catch(() => {});
}
return { action: 'deny' };
});
// Block slack:// deep-link attempts (they'd try to launch the native app
// and fail). Slack always falls through to a web URL after the deep link
// fails, so just swallow these.
win.webContents.on('will-navigate', (event, url) => {
if (url.startsWith('slack://')) {
event.preventDefault();
}
});
try {
await win.loadURL('https://app.slack.com/signin');
} catch (err) {
if (!win.isDestroyed()) win.close();
throw new Error(`Failed to load Slack: ${err.message}`);
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
clearInterval(pollInterval);
clearTimeout(timeoutHandle);
if (!win.isDestroyed()) win.close();
fn(value);
};
win.on('closed', () => {
if (!settled) {
settled = true;
clearInterval(pollInterval);
clearTimeout(timeoutHandle);
reject(new Error('Sign-in window was closed'));
}
});
const pollInterval = setInterval(async () => {
if (win.isDestroyed()) return;
try {
const token = await win.webContents.executeJavaScript(
'(() => { try { return window.boot_data && window.boot_data.api_token; } catch(e) { return null; } })()'
);
if (typeof token === 'string' && token.startsWith('xoxc-')) {
const cookies = await win.webContents.session.cookies.get({ url: 'https://slack.com' });
const dCookie = cookies.find((c) => c.name === 'd');
if (dCookie && dCookie.value) {
// The d cookie value may or may not already include the xoxd- prefix
// depending on how Slack encodes it. Normalize it.
const raw = decodeURIComponent(dCookie.value);
const cookie = raw.startsWith('xoxd-') ? raw : `xoxd-${raw}`;
finish(resolve, { token, cookie });
}
}
} catch (_) {
// page navigating, ignore
}
}, 1000);
const timeoutHandle = setTimeout(() => {
finish(reject, new Error('Sign-in timed out after 10 minutes'));
}, 10 * 60 * 1000);
});
});
+1
View File
@@ -12,6 +12,7 @@ const { contextBridge, ipcRenderer } = require('electron');
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
connectSlack: () => ipcRenderer.invoke('connect-slack'),
capturePage: (rect) => ipcRenderer.invoke('capture-page', rect),
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
+79 -18
View File
@@ -233,6 +233,28 @@ const INTEGRATIONS: Integration[] = [
),
authType: 'oauth2',
},
{
id: 'slack',
name: 'Slack',
description: 'Search messages, send messages, read channels, DMs, and threads in Slack workspaces.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'slack-mcp-server@latest', '--transport', 'stdio'], env: { SLACK_MCP_ADD_MESSAGE_TOOL: 'true' } },
color: '#4A154B',
website: 'https://github.com/korotovsky/slack-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<path d="M5.04 15.16a2.53 2.53 0 0 1-2.52 2.53A2.53 2.53 0 0 1 0 15.16a2.53 2.53 0 0 1 2.52-2.52h2.52v2.52zm1.27 0a2.53 2.53 0 0 1 2.52-2.52 2.53 2.53 0 0 1 2.53 2.52v6.32A2.53 2.53 0 0 1 8.83 24a2.53 2.53 0 0 1-2.52-2.52v-6.32z" fill="#E01E5A"/>
<path d="M8.83 5.04a2.53 2.53 0 0 1-2.52-2.52A2.53 2.53 0 0 1 8.83 0a2.53 2.53 0 0 1 2.53 2.52v2.52H8.83zm0 1.27a2.53 2.53 0 0 1 2.53 2.52 2.53 2.53 0 0 1-2.53 2.53H2.52A2.53 2.53 0 0 1 0 8.83a2.53 2.53 0 0 1 2.52-2.52h6.31z" fill="#36C5F0"/>
<path d="M18.96 8.83a2.53 2.53 0 0 1 2.52-2.52A2.53 2.53 0 0 1 24 8.83a2.53 2.53 0 0 1-2.52 2.53h-2.52V8.83zm-1.27 0a2.53 2.53 0 0 1-2.52 2.53 2.53 2.53 0 0 1-2.53-2.53V2.52A2.53 2.53 0 0 1 15.17 0a2.53 2.53 0 0 1 2.52 2.52v6.31z" fill="#2EB67D"/>
<path d="M15.17 18.96a2.53 2.53 0 0 1 2.52 2.52A2.53 2.53 0 0 1 15.17 24a2.53 2.53 0 0 1-2.53-2.52v-2.52h2.53zm0-1.27a2.53 2.53 0 0 1-2.53-2.52 2.53 2.53 0 0 1 2.53-2.53h6.31A2.53 2.53 0 0 1 24 15.17a2.53 2.53 0 0 1-2.52 2.52h-6.31z" fill="#ECB22E"/>
</svg>
),
connectLabel: 'Connect Slack',
connectInstructions: 'On macOS with Chrome, tokens are auto-extracted. Otherwise: open app.slack.com in Chrome → F12 → Console → type `JSON.stringify({token: boot_data.api_token, cookie: document.cookie.match(/d=([^;]+)/)?.[1]})` → copy the result.',
credentialFields: [
{ key: 'SLACK_MCP_XOXC_TOKEN', label: 'Slack Token (xoxc-...)', placeholder: 'Auto-detected via Sign in, or paste xoxc- token' },
{ key: 'SLACK_MCP_XOXD_TOKEN', label: 'Slack Cookie (xoxd-...)', placeholder: 'Auto-detected via Sign in, or paste xoxd- cookie' },
],
},
];
const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling'];
@@ -972,6 +994,37 @@ const Tools: React.FC = () => {
}
};
const handleSlackAutoConnect = async () => {
if (!credDialogToolId || !credDialogIntegration) return;
const slackBridge = (window as any).openswarm?.connectSlack;
if (!slackBridge) {
setSnackbar({ open: true, message: 'Slack auto-connect requires the desktop app', severity: 'error' });
return;
}
setCredDialogSaving(true);
try {
const { token, cookie } = await slackBridge();
const creds = { SLACK_MCP_XOXC_TOKEN: token, SLACK_MCP_XOXD_TOKEN: cookie };
const result = await dispatch(updateTool({
id: credDialogToolId,
credentials: creds,
auth_type: 'env_vars',
auth_status: 'connected',
}));
if (updateTool.fulfilled.match(result)) {
setCredDialogOpen(false);
setSnackbar({ open: true, message: 'Slack connected! Re-discovering actions…' });
dispatch(discoverTools(credDialogToolId));
} else {
setSnackbar({ open: true, message: 'Failed to save Slack credentials', severity: 'error' });
}
} catch (err: any) {
setSnackbar({ open: true, message: err?.message || 'Slack sign-in cancelled', severity: 'error' });
} finally {
setCredDialogSaving(false);
}
};
const handleDisconnectIntegration = async (toolId: string, integration: Integration) => {
if (integration.authType === 'oauth2') {
// Revoke the token on Google's side (fire-and-forget)
@@ -2237,35 +2290,43 @@ const Tools: React.FC = () => {
{credDialogIntegration?.connectLabel || 'Connect'}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
{credDialogIntegration?.connectInstructions && (
{credDialogIntegration?.id === 'slack' ? (
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.5, bgcolor: c.bg.secondary, px: 2, py: 1.5, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
{credDialogIntegration.connectInstructions}
Click <strong>Sign in with Slack</strong> below a Slack window will open. Sign in normally and the window will close automatically once you reach your workspace.
</Typography>
) : (
<>
{credDialogIntegration?.connectInstructions && (
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.5, bgcolor: c.bg.secondary, px: 2, py: 1.5, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
{credDialogIntegration.connectInstructions}
</Typography>
)}
{(credDialogIntegration?.credentialFields || []).map((field) => (
<TextField
key={field.key}
label={field.label}
placeholder={field.placeholder}
value={credDialogValues[field.key] || ''}
onChange={(e) => setCredDialogValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
fullWidth
size="small"
helperText={field.helpText}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page, fontFamily: c.font.mono, fontSize: '0.85rem' } }}
/>
))}
</>
)}
{(credDialogIntegration?.credentialFields || []).map((field) => (
<TextField
key={field.key}
label={field.label}
placeholder={field.placeholder}
value={credDialogValues[field.key] || ''}
onChange={(e) => setCredDialogValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
fullWidth
size="small"
helperText={field.helpText}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page, fontFamily: c.font.mono, fontSize: '0.85rem' } }}
/>
))}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setCredDialogOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>Cancel</Button>
<Button
variant="contained"
onClick={handleCredentialsSave}
disabled={credDialogSaving || (credDialogIntegration?.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim())}
onClick={credDialogIntegration?.id === 'slack' ? handleSlackAutoConnect : handleCredentialsSave}
disabled={credDialogSaving || (credDialogIntegration?.id !== 'slack' && (credDialogIntegration?.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim()))}
startIcon={credDialogSaving ? <CircularProgress size={14} /> : <LinkIcon sx={{ fontSize: 14 }} />}
sx={{ bgcolor: credDialogIntegration?.color || c.accent.primary, '&:hover': { bgcolor: credDialogIntegration?.color || c.accent.pressed, filter: 'brightness(0.9)' }, textTransform: 'none', borderRadius: 2 }}
>
Connect
{credDialogIntegration?.id === 'slack' ? (credDialogSaving ? 'Waiting for sign-in…' : 'Sign in with Slack') : 'Connect'}
</Button>
</DialogActions>
</Dialog>