mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-26 19:44:51 +02:00
[eric] microsoft 365 mcp
This commit is contained in:
@@ -53,6 +53,7 @@ GOOGLE_SCOPES = [
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
]
|
||||
|
||||
|
||||
_pending_oauth: dict[str, str] = {}
|
||||
|
||||
|
||||
@@ -419,7 +420,6 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
else:
|
||||
env = config.setdefault("env", {})
|
||||
env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
|
||||
# Notion MCP uses NOTION_TOKEN env var
|
||||
if tool.name.lower() == "notion":
|
||||
env["NOTION_TOKEN"] = tool.oauth_tokens["access_token"]
|
||||
if tool.oauth_tokens.get("refresh_token"):
|
||||
@@ -431,6 +431,14 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
if client_secret:
|
||||
env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret
|
||||
|
||||
# Microsoft 365 MCP: use a stable token cache path shared across process spawns
|
||||
if tool.name.lower() == "microsoft 365" and config.get("type") == "stdio":
|
||||
env = config.setdefault("env", {})
|
||||
cache_dir = os.path.join(os.path.expanduser("~"), ".openswarm")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
env["MS365_MCP_TOKEN_CACHE_PATH"] = os.path.join(cache_dir, "ms365-token-cache.json")
|
||||
env["MS365_MCP_SELECTED_ACCOUNT_PATH"] = os.path.join(cache_dir, "ms365-selected-account.json")
|
||||
|
||||
if config.get("type") == "stdio":
|
||||
if config.get("command"):
|
||||
# Check for bundled npm MCP servers — use Electron's Node.js instead of npx
|
||||
@@ -446,8 +454,8 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
config["args"] = [bundle_path]
|
||||
config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1"
|
||||
logger.info(f"Using bundled MCP server for {pkg_name}")
|
||||
elif electron_path:
|
||||
# Check for pre-installed npm package
|
||||
else:
|
||||
# Check for pre-installed npm package (works in both dev and packaged modes)
|
||||
safe_dir = pkg_name.replace("/", "-").replace("@", "")
|
||||
npm_dir = os.path.join(_backend, "npm-servers", safe_dir)
|
||||
pkg_json_path = os.path.join(npm_dir, "node_modules", pkg_name, "package.json")
|
||||
@@ -457,10 +465,13 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
pkg_meta = _json.load(f)
|
||||
bin_field = pkg_meta.get("bin", {})
|
||||
entry = list(bin_field.values())[0] if isinstance(bin_field, dict) else bin_field
|
||||
config["command"] = electron_path
|
||||
config["args"] = [os.path.join(npm_dir, "node_modules", pkg_name, entry)]
|
||||
config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1"
|
||||
logger.info(f"Using pre-installed npm MCP server for {pkg_name}")
|
||||
node_cmd = electron_path or shutil.which("node")
|
||||
if node_cmd:
|
||||
config["command"] = node_cmd
|
||||
config["args"] = [os.path.join(npm_dir, "node_modules", pkg_name, entry)]
|
||||
if electron_path:
|
||||
config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1"
|
||||
logger.info(f"Using pre-installed npm MCP server for {pkg_name}")
|
||||
|
||||
if not os.path.isabs(config.get("command", "")):
|
||||
resolved = _resolve_command(config["command"])
|
||||
@@ -824,13 +835,180 @@ async def discover_tools(tool_id: str):
|
||||
return {"ok": True, "tool": tool.model_dump()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Microsoft 365 device-code login (runs in the backend, not the MCP server)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_m365_login_processes: dict[str, dict] = {} # tool_id -> {proc, device_code, status, email}
|
||||
|
||||
|
||||
def _m365_server_script() -> str:
|
||||
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(
|
||||
_backend, "npm-servers", "softeria-ms-365-mcp-server",
|
||||
"node_modules", "@softeria", "ms-365-mcp-server", "dist", "index.js",
|
||||
)
|
||||
|
||||
|
||||
def _m365_cache_env() -> dict[str, str]:
|
||||
cache_dir = os.path.join(os.path.expanduser("~"), ".openswarm")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return {
|
||||
"MS365_MCP_TOKEN_CACHE_PATH": os.path.join(cache_dir, "ms365-token-cache.json"),
|
||||
"MS365_MCP_SELECTED_ACCOUNT_PATH": os.path.join(cache_dir, "ms365-selected-account.json"),
|
||||
}
|
||||
|
||||
|
||||
@tools_lib.router.post("/{tool_id}/m365/device-login")
|
||||
async def m365_device_login(tool_id: str):
|
||||
"""Start a Microsoft 365 device-code login.
|
||||
|
||||
Spawns the MCP server with --login in a long-lived subprocess.
|
||||
Returns the device code and URL for the user to authenticate.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
tool = _load(tool_id)
|
||||
script = _m365_server_script()
|
||||
if not os.path.isfile(script):
|
||||
raise HTTPException(status_code=500, detail="M365 MCP server not installed")
|
||||
|
||||
node = shutil.which("node")
|
||||
electron = os.environ.get("OPENSWARM_ELECTRON_PATH")
|
||||
cmd = electron or node
|
||||
if not cmd:
|
||||
raise HTTPException(status_code=500, detail="No node/electron found")
|
||||
|
||||
env = {**os.environ, **_m365_cache_env()}
|
||||
if electron:
|
||||
env["ELECTRON_RUN_AS_NODE"] = "1"
|
||||
|
||||
# Kill any existing login process for this tool
|
||||
existing = _m365_login_processes.pop(tool_id, None)
|
||||
if existing and existing.get("proc"):
|
||||
try:
|
||||
existing["proc"].kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[cmd, script, "--login"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
env=env, text=True,
|
||||
)
|
||||
|
||||
# Read stdout lines until we find the device code (MSAL prints it)
|
||||
import threading
|
||||
login_state: dict = {"proc": proc, "status": "waiting_for_code", "device_code": "", "device_code_url": "", "email": None, "output": ""}
|
||||
|
||||
def _read_output():
|
||||
import re
|
||||
for line in proc.stdout:
|
||||
login_state["output"] += line
|
||||
# MSAL device code message contains the URL and code
|
||||
code_match = re.search(r'enter the code\s+(\S+)', line, re.IGNORECASE)
|
||||
url_match = re.search(r'(https://\S+)', line)
|
||||
if code_match:
|
||||
login_state["device_code"] = code_match.group(1)
|
||||
login_state["status"] = "awaiting_auth"
|
||||
if url_match and "microsoft" in url_match.group(1).lower():
|
||||
login_state["device_code_url"] = url_match.group(1)
|
||||
# Process ended — check result
|
||||
proc.wait()
|
||||
remaining_stderr = proc.stderr.read() if proc.stderr else ""
|
||||
login_state["output"] += remaining_stderr
|
||||
if proc.returncode == 0:
|
||||
login_state["status"] = "connected"
|
||||
# Try to extract email from output
|
||||
try:
|
||||
import json as _j
|
||||
result = _j.loads(login_state["output"].strip().split("\n")[-1])
|
||||
if result.get("success"):
|
||||
ud = result.get("userData", {})
|
||||
login_state["email"] = ud.get("userPrincipalName") or ud.get("displayName")
|
||||
except Exception:
|
||||
pass
|
||||
# Update tool status
|
||||
try:
|
||||
t = _load(tool_id)
|
||||
t.auth_status = "connected"
|
||||
if login_state.get("email"):
|
||||
t.connected_account_email = login_state["email"]
|
||||
_save(t)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
login_state["status"] = "error"
|
||||
|
||||
thread = threading.Thread(target=_read_output, daemon=True)
|
||||
thread.start()
|
||||
|
||||
_m365_login_processes[tool_id] = login_state
|
||||
|
||||
# Wait briefly for device code to appear
|
||||
for _ in range(30):
|
||||
if login_state["device_code"]:
|
||||
break
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
if not login_state["device_code"]:
|
||||
return {"status": "error", "message": "Timed out waiting for device code from MCP server"}
|
||||
|
||||
return {
|
||||
"status": "awaiting_auth",
|
||||
"device_code": login_state["device_code"],
|
||||
"device_code_url": login_state["device_code_url"] or "https://login.microsoft.com/device",
|
||||
}
|
||||
|
||||
|
||||
@tools_lib.router.get("/{tool_id}/m365/device-login/status")
|
||||
async def m365_device_login_status(tool_id: str):
|
||||
"""Poll the status of a pending M365 device-code login."""
|
||||
state = _m365_login_processes.get(tool_id)
|
||||
if not state:
|
||||
# Check if already connected via cached token
|
||||
cache_env = _m365_cache_env()
|
||||
cache_path = cache_env["MS365_MCP_TOKEN_CACHE_PATH"]
|
||||
if os.path.isfile(cache_path):
|
||||
tool = _load(tool_id)
|
||||
if tool.auth_status == "connected":
|
||||
return {"status": "connected", "email": tool.connected_account_email}
|
||||
return {"status": "no_login_in_progress"}
|
||||
|
||||
status = state["status"]
|
||||
result: dict = {"status": status}
|
||||
if status == "connected":
|
||||
result["email"] = state.get("email")
|
||||
_m365_login_processes.pop(tool_id, None)
|
||||
elif status == "error":
|
||||
result["message"] = "Login failed"
|
||||
_m365_login_processes.pop(tool_id, None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@tools_lib.router.post("/{tool_id}/m365/disconnect")
|
||||
async def m365_disconnect(tool_id: str):
|
||||
"""Disconnect M365 by clearing the cached token."""
|
||||
tool = _load(tool_id)
|
||||
cache_env = _m365_cache_env()
|
||||
for path in cache_env.values():
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
tool.auth_status = "configured"
|
||||
tool.connected_account_email = None
|
||||
_save(tool)
|
||||
return {"ok": True, "tool": tool.model_dump()}
|
||||
|
||||
|
||||
@tools_lib.router.post("/{tool_id}/oauth/disconnect")
|
||||
async def oauth_disconnect(tool_id: str):
|
||||
"""Clear OAuth tokens and reset auth status so the user can reconnect with a different account."""
|
||||
tool = _load(tool_id)
|
||||
access_token = tool.oauth_tokens.get("access_token")
|
||||
|
||||
if access_token:
|
||||
if access_token and tool.name.lower() != "notion":
|
||||
# Revoke Google tokens
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.post(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "softeria-ms-365-mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@softeria/ms-365-mcp-server": "^0.54.1"
|
||||
}
|
||||
}
|
||||
Generated
+11
-867
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^3.1.1",
|
||||
"electron": "castlabs/electron-releases#v33.4.11+wvcus",
|
||||
"electron": "castlabs/electron-releases#v40.7.0+wvcus",
|
||||
"electron-builder": "^25.1.0"
|
||||
},
|
||||
"build": {
|
||||
|
||||
@@ -69,6 +69,9 @@ import {
|
||||
startOAuth,
|
||||
fetchToolStatus,
|
||||
discoverTools,
|
||||
startDeviceCodeLogin,
|
||||
pollDeviceCodeStatus,
|
||||
disconnectM365,
|
||||
ToolDefinition,
|
||||
BuiltinTool,
|
||||
} from '@/shared/state/toolsSlice';
|
||||
@@ -157,6 +160,20 @@ const INTEGRATIONS: Integration[] = [
|
||||
),
|
||||
authType: 'oauth2',
|
||||
},
|
||||
{
|
||||
id: 'microsoft-365',
|
||||
name: 'Microsoft 365',
|
||||
description: 'Outlook email, Calendar, OneDrive, Excel, OneNote, Tasks, Contacts, Teams, and SharePoint.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@softeria/ms-365-mcp-server'] },
|
||||
color: '#0078D4',
|
||||
website: 'https://github.com/softeria/ms-365-mcp-server',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<path d="M11.4 24H0V12.6L11.4 24zM24 24H12.6V12.6L24 24zM11.4 11.4H0V0l11.4 11.4zM24 11.4H12.6V0L24 11.4z" fill="#0078D4"/>
|
||||
</svg>
|
||||
),
|
||||
authType: 'device_code' as any,
|
||||
},
|
||||
{
|
||||
id: 'notion',
|
||||
name: 'Notion',
|
||||
@@ -439,6 +456,13 @@ const Tools: React.FC = () => {
|
||||
// Integration toggle state
|
||||
const [integrationLoading, setIntegrationLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Device code login dialog state (M365)
|
||||
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
|
||||
const [deviceCodeDialogToolId, setDeviceCodeDialogToolId] = useState<string | null>(null);
|
||||
const [deviceCode, setDeviceCode] = useState('');
|
||||
const [deviceCodeUrl, setDeviceCodeUrl] = useState('');
|
||||
const [deviceCodeStatus, setDeviceCodeStatus] = useState<'loading' | 'awaiting' | 'connected' | 'error'>('loading');
|
||||
|
||||
// Integration credentials dialog state
|
||||
const [credDialogOpen, setCredDialogOpen] = useState(false);
|
||||
const [credDialogToolId, setCredDialogToolId] = useState<string | null>(null);
|
||||
@@ -482,7 +506,7 @@ const Tools: React.FC = () => {
|
||||
}));
|
||||
if (createTool.fulfilled.match(result)) {
|
||||
const newTool = result.payload;
|
||||
if (integration.authType === 'oauth2') {
|
||||
if (integration.authType === 'oauth2' || integration.authType === 'device_code') {
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — connect your account to discover actions` });
|
||||
} else {
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — discovering actions…` });
|
||||
@@ -815,6 +839,56 @@ const Tools: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeviceCodeConnect = async (toolId: string) => {
|
||||
setDeviceCodeDialogToolId(toolId);
|
||||
setDeviceCodeStatus('loading');
|
||||
setDeviceCode('');
|
||||
setDeviceCodeUrl('');
|
||||
setDeviceCodeDialogOpen(true);
|
||||
|
||||
const result = await dispatch(startDeviceCodeLogin(toolId));
|
||||
if (startDeviceCodeLogin.fulfilled.match(result)) {
|
||||
const { device_code, device_code_url } = result.payload;
|
||||
setDeviceCode(device_code);
|
||||
const url = device_code_url || 'https://login.microsoft.com/device';
|
||||
setDeviceCodeUrl(url);
|
||||
setDeviceCodeStatus('awaiting');
|
||||
|
||||
// Auto-open Microsoft login in a popup
|
||||
window.open(url, 'm365-login', 'width=500,height=700,left=200,top=100');
|
||||
|
||||
// Poll for completion
|
||||
const poll = setInterval(async () => {
|
||||
const statusResult = await dispatch(pollDeviceCodeStatus(toolId));
|
||||
if (pollDeviceCodeStatus.fulfilled.match(statusResult)) {
|
||||
const { status, email } = statusResult.payload;
|
||||
if (status === 'connected') {
|
||||
clearInterval(poll);
|
||||
setDeviceCodeStatus('connected');
|
||||
setSnackbar({ open: true, message: `Connected to Microsoft 365${email ? ` as ${email}` : ''}! Discovering actions…` });
|
||||
setDeviceCodeDialogOpen(false);
|
||||
setExpandedToolId(toolId);
|
||||
await dispatch(fetchToolStatus(toolId));
|
||||
dispatch(discoverTools(toolId));
|
||||
} else if (status === 'error') {
|
||||
clearInterval(poll);
|
||||
setDeviceCodeStatus('error');
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Stop polling after 5 minutes
|
||||
setTimeout(() => clearInterval(poll), 300000);
|
||||
} else {
|
||||
setDeviceCodeStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleM365Disconnect = async (toolId: string) => {
|
||||
await dispatch(disconnectM365(toolId));
|
||||
setSnackbar({ open: true, message: 'Disconnected from Microsoft 365' });
|
||||
};
|
||||
|
||||
const openCredentialsDialog = (toolId: string, integration: Integration) => {
|
||||
const tool = items[toolId];
|
||||
const existing = tool?.credentials || {};
|
||||
@@ -1431,7 +1505,7 @@ const Tools: React.FC = () => {
|
||||
</Box>
|
||||
{tool.description && <Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{tool.description}</Typography>}
|
||||
</Box>
|
||||
{!isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
|
||||
{!isDisabled && (tool.auth_type === 'oauth2' || ig?.authType === 'oauth2') && tool.auth_status !== 'connected' && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@@ -1442,6 +1516,17 @@ const Tools: React.FC = () => {
|
||||
Connect {tool.name}
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && ig?.authType === 'device_code' && tool.auth_status !== 'connected' && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<LinkIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={(e) => { e.stopPropagation(); handleDeviceCodeConnect(tool.id); }}
|
||||
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
|
||||
>
|
||||
Connect Microsoft 365
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -1454,12 +1539,12 @@ const Tools: React.FC = () => {
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && ig && tool.auth_status === 'connected' && (
|
||||
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' ? 'Disconnect' : ''}>
|
||||
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code' ? 'Disconnect' : ''}>
|
||||
<Chip
|
||||
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
|
||||
label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'}
|
||||
size="small"
|
||||
onDelete={(ig.credentialFields || ig.authType === 'oauth2') ? (e: React.SyntheticEvent) => { e.stopPropagation(); handleDisconnectIntegration(tool.id, ig); } : undefined}
|
||||
onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => { e.stopPropagation(); ig.authType === 'device_code' ? handleM365Disconnect(tool.id) : handleDisconnectIntegration(tool.id, ig); } : undefined}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }}
|
||||
/>
|
||||
@@ -2028,6 +2113,65 @@ const Tools: React.FC = () => {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Microsoft 365 Device Code Login Dialog */}
|
||||
<Dialog
|
||||
open={deviceCodeDialogOpen}
|
||||
onClose={() => { if (deviceCodeStatus !== 'loading') setDeviceCodeDialogOpen(false); }}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: 4, border: `1px solid ${c.border.subtle}` } }}
|
||||
>
|
||||
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ width: 32, height: 32, borderRadius: 1.5, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: '#0078D418' }}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M11.4 24H0V12.6L11.4 24zM24 24H12.6V12.6L24 24zM11.4 11.4H0V0l11.4 11.4zM24 11.4H12.6V0L24 11.4z" fill="#0078D4"/></svg>
|
||||
</Box>
|
||||
Connect Microsoft 365
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
|
||||
{deviceCodeStatus === 'loading' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 3, justifyContent: 'center' }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.9rem' }}>Generating login code...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{deviceCodeStatus === 'awaiting' && (
|
||||
<>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.6 }}>
|
||||
Open the link below and enter the code to sign in:
|
||||
</Typography>
|
||||
<Box sx={{ bgcolor: c.bg.page, border: `1px solid ${c.border.subtle}`, borderRadius: 2, p: 2, display: 'flex', flexDirection: 'column', gap: 1.5, alignItems: 'center' }}>
|
||||
<Typography component="a" href={deviceCodeUrl} target="_blank" rel="noopener" sx={{ color: c.status.info, fontSize: '0.9rem', fontWeight: 500 }}>
|
||||
{deviceCodeUrl}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: c.font.mono, fontSize: '1.5rem', fontWeight: 700, color: c.text.primary, letterSpacing: 2 }}>
|
||||
{deviceCode}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'center', py: 1 }}>
|
||||
<CircularProgress size={14} />
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>Waiting for you to sign in...</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{deviceCodeStatus === 'connected' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2, justifyContent: 'center' }}>
|
||||
<CheckCircleIcon sx={{ color: c.status.success, fontSize: 20 }} />
|
||||
<Typography sx={{ color: c.status.success, fontSize: '0.9rem', fontWeight: 500 }}>Connected successfully!</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{deviceCodeStatus === 'error' && (
|
||||
<Typography sx={{ color: c.status.error, fontSize: '0.85rem', py: 2, textAlign: 'center' }}>
|
||||
Login failed. Please try again.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDeviceCodeDialogOpen(false)} sx={{ color: c.text.muted, textTransform: 'none' }}>
|
||||
{deviceCodeStatus === 'connected' ? 'Done' : 'Cancel'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Integration Credentials Dialog */}
|
||||
<Dialog
|
||||
open={credDialogOpen}
|
||||
|
||||
@@ -98,6 +98,33 @@ export const startOAuth = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const startDeviceCodeLogin = createAsyncThunk(
|
||||
'tools/startDeviceCodeLogin',
|
||||
async (toolId: string) => {
|
||||
const res = await fetch(`${TOOLS_API}/${toolId}/m365/device-login`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to start device code login');
|
||||
return await res.json();
|
||||
}
|
||||
);
|
||||
|
||||
export const pollDeviceCodeStatus = createAsyncThunk(
|
||||
'tools/pollDeviceCodeStatus',
|
||||
async (toolId: string) => {
|
||||
const res = await fetch(`${TOOLS_API}/${toolId}/m365/device-login/status`);
|
||||
return await res.json();
|
||||
}
|
||||
);
|
||||
|
||||
export const disconnectM365 = createAsyncThunk(
|
||||
'tools/disconnectM365',
|
||||
async (toolId: string) => {
|
||||
const res = await fetch(`${TOOLS_API}/${toolId}/m365/disconnect`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to disconnect M365');
|
||||
const data = await res.json();
|
||||
return data.tool as ToolDefinition;
|
||||
}
|
||||
);
|
||||
|
||||
export const disconnectOAuth = createAsyncThunk(
|
||||
'tools/disconnectOAuth',
|
||||
async (toolId: string) => {
|
||||
|
||||
+23
-3
@@ -18,28 +18,36 @@ if [[ -f "$ENV_FILE" ]]; then
|
||||
fi
|
||||
|
||||
PUBLISH_MODE=false
|
||||
SIGN_MODE=false
|
||||
if [[ "${1:-}" == "--publish" ]]; then
|
||||
PUBLISH_MODE=true
|
||||
SIGN_MODE=true
|
||||
elif [[ "${1:-}" == "--sign" ]]; then
|
||||
SIGN_MODE=true
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo " OpenSwarm Desktop App Builder"
|
||||
if $PUBLISH_MODE; then
|
||||
echo " Mode: PRODUCTION (sign + notarize + publish)"
|
||||
elif $SIGN_MODE; then
|
||||
echo " Mode: SIGNED (sign + notarize, no publish)"
|
||||
else
|
||||
echo " Mode: LOCAL (unsigned)"
|
||||
fi
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
if $PUBLISH_MODE; then
|
||||
if $SIGN_MODE; then
|
||||
missing_vars=()
|
||||
[[ -z "${APPLE_ID:-}" ]] && missing_vars+=("APPLE_ID")
|
||||
[[ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] && missing_vars+=("APPLE_APP_SPECIFIC_PASSWORD")
|
||||
[[ -z "${APPLE_TEAM_ID:-}" ]] && missing_vars+=("APPLE_TEAM_ID")
|
||||
[[ -z "${GH_TOKEN:-}" ]] && missing_vars+=("GH_TOKEN")
|
||||
if $PUBLISH_MODE; then
|
||||
[[ -z "${GH_TOKEN:-}" ]] && missing_vars+=("GH_TOKEN")
|
||||
fi
|
||||
if [[ ${#missing_vars[@]} -gt 0 ]]; then
|
||||
echo "ERROR: Missing required environment variables for --publish mode:"
|
||||
echo "ERROR: Missing required environment variables:"
|
||||
printf ' - %s\n' "${missing_vars[@]}"
|
||||
echo ""
|
||||
echo "See script header for details."
|
||||
@@ -163,7 +171,10 @@ mkdir -p "$STAGING_DIR"
|
||||
rsync -a \
|
||||
--exclude='__pycache__' --exclude='**/__pycache__' \
|
||||
--exclude='*.pyc' --exclude='.venv' \
|
||||
--exclude='data/tools' \
|
||||
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
|
||||
# Create empty tools directory so the app has a place to write
|
||||
mkdir -p "$STAGING_DIR/backend/data/tools"
|
||||
|
||||
rsync -a \
|
||||
--exclude='__pycache__' --exclude='**/__pycache__' \
|
||||
@@ -196,6 +207,15 @@ npm install
|
||||
|
||||
if $PUBLISH_MODE; then
|
||||
npx electron-builder --mac --arm64 --x64 --publish always
|
||||
elif $SIGN_MODE; then
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" == "arm64" ]]; then
|
||||
npx electron-builder --mac --arm64 --publish never
|
||||
elif [[ "$ARCH" == "x86_64" ]]; then
|
||||
npx electron-builder --mac --x64 --publish never
|
||||
else
|
||||
npx electron-builder --mac --publish never
|
||||
fi
|
||||
else
|
||||
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||
ARCH=$(uname -m)
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# One-time Microsoft 365 authentication for OpenSwarm.
|
||||
# Run this once to cache your M365 token. After that, M365 works in OpenSwarm automatically.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
CACHE_DIR="$HOME/.openswarm"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
export MS365_MCP_TOKEN_CACHE_PATH="$CACHE_DIR/ms365-token-cache.json"
|
||||
export MS365_MCP_SELECTED_ACCOUNT_PATH="$CACHE_DIR/ms365-selected-account.json"
|
||||
|
||||
SERVER_SCRIPT="$PROJECT_ROOT/backend/npm-servers/softeria-ms-365-mcp-server/node_modules/@softeria/ms-365-mcp-server/dist/index.js"
|
||||
|
||||
if [ ! -f "$SERVER_SCRIPT" ]; then
|
||||
echo "M365 MCP server not found. Run 'cd backend/npm-servers/softeria-ms-365-mcp-server && npm install' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Microsoft 365 Login for OpenSwarm"
|
||||
echo " ─────────────────────────────────"
|
||||
echo " A browser window will open for you to sign in."
|
||||
echo " After login, the token is cached and M365 works in OpenSwarm automatically."
|
||||
echo ""
|
||||
|
||||
node "$SERVER_SCRIPT" --login
|
||||
|
||||
if [ -f "$MS365_MCP_TOKEN_CACHE_PATH" ]; then
|
||||
echo ""
|
||||
echo " ✓ Token cached at $MS365_MCP_TOKEN_CACHE_PATH"
|
||||
echo " ✓ M365 is ready to use in OpenSwarm!"
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
echo " ✗ Login may have failed — no token cache found."
|
||||
echo ""
|
||||
fi
|
||||
Reference in New Issue
Block a user