diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml
index c26130a1..562a2fdf 100644
--- a/.github/workflows/release-windows.yml
+++ b/.github/workflows/release-windows.yml
@@ -110,6 +110,16 @@ jobs:
$ErrorActionPreference = 'Stop'
$shouldPublish = ($env:GITHUB_EVENT_NAME -eq 'push') -or `
($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch' -and $env:PUBLISH_INPUT -eq 'true')
+ # electron-builder auto-detects prerelease from semver suffix in electron/package.json,
+ # but EP_PRE_RELEASE forces the GitHub Releases publisher to mark it Pre-release even
+ # when the runner's environment differs from local. Set it whenever the version has a "-" suffix.
+ $version = (Get-Content electron/package.json | ConvertFrom-Json).version
+ if ($version -match '-') {
+ $env:EP_PRE_RELEASE = 'true'
+ Write-Host "Version $version is EXPERIMENTAL; setting EP_PRE_RELEASE=true"
+ } else {
+ Write-Host "Version $version is STABLE"
+ }
if ($shouldPublish) {
Write-Host "Build mode: PUBLISH"
pwsh -NoProfile -File scripts\build-app-win.ps1 -Publish
diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index b1981a6a..5ea13999 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -676,8 +676,10 @@ class AgentManager:
"do not say 'I'll schedule it', do not call any scheduling tool."
)
sections.append(
- "2. After MCPActivate returns, end the turn; a follow-up turn fires "
- "automatically with the new tools available."
+ "2. After MCPActivate returns, do NOT make any more tool calls in "
+ "this turn. The transport snapshot is locked; calls against the "
+ "new server would hit a stale schema list. The turn ends "
+ "automatically and a hidden continuation fires with the new tools."
)
sections.append(
"3. Don't ask 'should I activate X?' first; MCPActivate already "
@@ -1568,6 +1570,32 @@ class AgentManager:
"session_id": session_id,
"message": result_msg.model_dump(mode="json"),
})
+
+ # Hard-stop the turn after a successful MCPActivate. The CLI snapshots
+ # mcp_servers at transport launch, so any mid-turn tool calls against
+ # the just-activated server would hit a stale schema list and the
+ # model would hallucinate names (e.g. "Notion: Searchpages" instead
+ # of "mcp__notion__search"). The auto-continuation hook below
+ # (pending_continuation) fires a hidden follow-up turn with a
+ # fresh-session restart, so by stopping here we get a clean
+ # transport relaunch with the new server's tools loaded. Without
+ # this stop, success depended on the model voluntarily ending the
+ # turn after reading the activation tool result; failure mode was
+ # the 10x-hallucinated-tool-call spiral.
+ if (
+ hook_tool_name == "mcp__openswarm-mcp-meta__MCPActivate"
+ and isinstance(content, str)
+ and content.startswith("Activated `")
+ and getattr(session, "pending_continuation", False)
+ ):
+ return {
+ "continue_": False,
+ "stopReason": (
+ "MCP server activated; ending turn so its tool schemas "
+ "load into a fresh transport. A hidden continuation "
+ "turn fires automatically."
+ ),
+ }
return {"continue_": True}
try:
diff --git a/backend/apps/agents/mcp_meta_server.py b/backend/apps/agents/mcp_meta_server.py
index 1be1fbd1..f4672136 100644
--- a/backend/apps/agents/mcp_meta_server.py
+++ b/backend/apps/agents/mcp_meta_server.py
@@ -178,8 +178,10 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
"type": "text",
"text": (
f"Activated `{server_name}`. Its tools (`mcp__{server_name}__*`) "
- f"will be callable on the NEXT turn. End this turn now and the user's "
- f"next message will see the new tools."
+ f"are NOT callable in this turn; the transport snapshot is "
+ f"already locked. This turn will end automatically and a "
+ f"hidden continuation turn will fire with the new tools "
+ f"loaded. Do not attempt any other tool call now."
),
}],
}
diff --git a/backend/apps/google_workspace_mcp_shim/__init__.py b/backend/apps/google_workspace_mcp_shim/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/google_workspace_mcp_shim/run.py b/backend/apps/google_workspace_mcp_shim/run.py
new file mode 100644
index 00000000..189b107f
--- /dev/null
+++ b/backend/apps/google_workspace_mcp_shim/run.py
@@ -0,0 +1,57 @@
+"""Run google-workspace-mcp's stdio worker with token refresh redirected
+through our local proxy instead of directly to oauth2.googleapis.com.
+
+google_workspace_mcp.auth.gauth.get_credentials() hardcodes
+token_uri="https://oauth2.googleapis.com/token" and refreshes with
+whatever GOOGLE_WORKSPACE_CLIENT_ID/SECRET are in env on every API call.
+OAuth runs through a rotation pool in openswarm-cloud, so the
+refresh_token is bound to whichever pool slot minted it, not the single
+client baked into the DMG. Refresh directly against Google with the
+wrong client returns unauthorized_client.
+
+This wrapper monkey-patches gauth.get_credentials before the worker
+imports its tool modules, pointing token_uri at GOOGLE_WORKSPACE_TOKEN_URI
+(our local proxy at /api/tools/google-oauth-token, which forwards refresh
+requests to the cloud's pool-aware /api/oauth/google/refresh).
+CLIENT_ID/SECRET become unused placeholders.
+"""
+
+import functools
+import os
+
+import google_workspace_mcp.auth.gauth as gauth
+from google.oauth2.credentials import Credentials
+
+
+@functools.lru_cache(maxsize=1)
+def _patched_get_credentials():
+ refresh_token = os.environ.get("GOOGLE_WORKSPACE_REFRESH_TOKEN")
+ if not refresh_token:
+ raise ValueError("GOOGLE_WORKSPACE_REFRESH_TOKEN env var is required")
+ return Credentials(
+ token=None,
+ refresh_token=refresh_token,
+ token_uri=os.environ.get(
+ "GOOGLE_WORKSPACE_TOKEN_URI",
+ "https://oauth2.googleapis.com/token",
+ ),
+ client_id=os.environ.get("GOOGLE_WORKSPACE_CLIENT_ID", "openswarm-proxy"),
+ client_secret=os.environ.get("GOOGLE_WORKSPACE_CLIENT_SECRET", "openswarm-proxy"),
+ )
+
+
+gauth.get_credentials = _patched_get_credentials
+
+
+from google_workspace_mcp import __main__ as _gw_main # noqa: E402,F401
+from google_workspace_mcp.app import mcp # noqa: E402
+
+
+if __name__ == "__main__":
+ # Upstream google_workspace_mcp.__main__.main() wraps a synchronous
+ # mcp.run() in asyncio.run() which throws "a coroutine was expected,
+ # got None" against current FastMCP. Skip it and invoke FastMCP's
+ # stdio loop directly. The `_gw_main` import above is what actually
+ # registers every tool/prompt/resource module against the shared
+ # `mcp` instance via its top-level imports.
+ mcp.run("stdio")
diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py
index 928d7f8a..21576dbc 100644
--- a/backend/apps/settings/models.py
+++ b/backend/apps/settings/models.py
@@ -53,6 +53,7 @@ class AppSettings(BaseModel):
expand_new_chats_in_dashboard: bool = False
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
+ allow_experimental_updates: bool = False
claude_subscription_token: Optional[str] = None
openai_subscription_token: Optional[str] = None
gemini_subscription_token: Optional[str] = None
diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py
index 7a137744..783a2f6a 100644
--- a/backend/apps/tools_lib/tools_lib.py
+++ b/backend/apps/tools_lib/tools_lib.py
@@ -13,7 +13,7 @@ from urllib.parse import urlencode
import httpx
from dotenv import load_dotenv
-from fastapi import HTTPException, Query
+from fastapi import HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from backend.config.Apps import SubApp
@@ -291,21 +291,37 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
env["PRIVATE_APP_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
if tool.oauth_tokens.get("refresh_token"):
env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"]
- # google_workspace_mcp's auth/gauth.py requires all three of
- # CLIENT_ID / CLIENT_SECRET / REFRESH_TOKEN at startup and does
- # its own token refresh per API call; it ignores any
- # pre-refreshed access_token. v1.0.29's cloud-proxy migration
- # closes the OAuth-flow secret exposure, but the MCP still
- # needs the local secret here. v1.0.30 should either fork the
- # MCP to point token_uri at our cloud refresh proxy, or replace
- # it with a thin in-house Gmail/Drive/Calendar wrapper that
- # consumes a pre-refreshed access_token.
- client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
- client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
- if client_id:
- env["GOOGLE_WORKSPACE_CLIENT_ID"] = client_id
- if client_secret:
- env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret
+ # google_workspace_mcp's gauth.py hardcodes token_uri to
+ # https://oauth2.googleapis.com/token and refreshes using the
+ # local CLIENT_ID/SECRET on every API call. The OAuth flow
+ # itself runs through the cloud's rotation pool, so the
+ # refresh_token is bound to whichever pool slot minted it,
+ # not the single client baked into the DMG. Mismatch -> Google
+ # returns unauthorized_client. We point token_uri at a local
+ # proxy that forwards the refresh to our cloud's pool-aware
+ # /api/oauth/google/refresh endpoint; CLIENT_ID/SECRET become
+ # unused placeholders (gauth.py only validates non-empty).
+ _port = os.environ.get("OPENSWARM_PORT", "8324")
+ env["GOOGLE_WORKSPACE_TOKEN_URI"] = (
+ f"http://127.0.0.1:{_port}/api/tools/google-oauth-token"
+ )
+ env.setdefault("GOOGLE_WORKSPACE_CLIENT_ID", "openswarm-proxy")
+ env.setdefault("GOOGLE_WORKSPACE_CLIENT_SECRET", "openswarm-proxy")
+
+ # Google Workspace MCP: redirect spawn through our shim that
+ # monkey-patches gauth.get_credentials before the worker registers
+ # tools, so token_uri points at our local proxy. Stays a stdio
+ # subprocess; google-workspace-mcp gets installed into uv's
+ # ephemeral env via --with, same way the upstream entry-point
+ # invocation used to do it.
+ if tool.name.lower() == "google workspace" and config.get("type") == "stdio":
+ shim_path = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "google_workspace_mcp_shim",
+ "run.py",
+ )
+ config["command"] = "uv"
+ config["args"] = ["run", "--with", "google-workspace-mcp", "python", shim_path]
# Discord MCP runs as a small Python shim (backend.apps.discord_mcp_shim).
# We pass install_id + base URL via env so the shim subprocess doesn't
@@ -1290,3 +1306,60 @@ async def refresh_airtable_token(tool: ToolDefinition) -> Optional[str]:
async def refresh_hubspot_token(tool: ToolDefinition) -> Optional[str]:
"""Refresh an expired HubSpot OAuth access_token."""
return await _refresh_via_proxy("hubspot", tool, default_expiry=1800)
+
+
+@tools_lib.router.post("/google-oauth-token")
+async def google_oauth_token_proxy(request: Request):
+ """Local mimic of Google's OAuth2 token endpoint for the
+ google-workspace-mcp subprocess.
+
+ google-workspace-mcp's google-auth library posts form-encoded
+ {grant_type, refresh_token, client_id, client_secret} on every
+ expired-token refresh. Because OAuth runs through a cloud-side
+ rotation pool, the local CLIENT_ID/SECRET don't match the pool slot
+ that minted the refresh_token, so a direct refresh against Google
+ returns unauthorized_client. We accept the form-encoded shape,
+ discard the (mismatched) local client creds, and forward the
+ refresh_token to api.openswarm.com/api/oauth/google/refresh which
+ walks the pool to find the issuing slot. The cloud's JSON envelope
+ is reshaped back to Google's native token-endpoint response so
+ google-auth keeps working transparently.
+ """
+ form = await request.form()
+ grant_type = form.get("grant_type") or ""
+ refresh_token = form.get("refresh_token") or ""
+ if grant_type != "refresh_token" or not refresh_token:
+ return Response(
+ content='{"error":"unsupported_grant_type"}',
+ status_code=400,
+ media_type="application/json",
+ )
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ upstream = await client.post(
+ f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/google/refresh",
+ json={"refresh_token": refresh_token},
+ )
+ except Exception as e:
+ return Response(
+ content=f'{{"error":"upstream_unreachable","error_description":"{e}"}}',
+ status_code=502,
+ media_type="application/json",
+ )
+ if upstream.status_code != 200:
+ return Response(
+ content=upstream.text,
+ status_code=upstream.status_code,
+ media_type="application/json",
+ )
+ tokens = (upstream.json() or {}).get("tokens") or {}
+ return Response(
+ content=json.dumps({
+ "access_token": tokens.get("access_token", ""),
+ "expires_in": tokens.get("expires_in", 3600),
+ "scope": tokens.get("scope", ""),
+ "token_type": tokens.get("token_type", "Bearer"),
+ }),
+ status_code=200,
+ media_type="application/json",
+ )
diff --git a/backend/auth.py b/backend/auth.py
index 6e10e12d..154be5d4 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -179,6 +179,13 @@ _AUTH_EXEMPT_EXACT = {
"/api/subscription/activate",
"/api/auth/signin-activate",
"/api/version",
+ # Local Google OAuth token-endpoint proxy: hit by the
+ # google-workspace-mcp subprocess we spawn. It doesn't (and can't
+ # easily) carry the install bearer in google-auth's refresh post.
+ # Localhost binding is the gate, and the route does nothing the
+ # public api.openswarm.com/api/oauth/google/refresh doesn't already
+ # do for any internet caller, so no new attack surface.
+ "/api/tools/google-oauth-token",
}
_AUTH_EXEMPT_PREFIX = (
diff --git a/electron/main.js b/electron/main.js
index c53f27c7..2a3a826a 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -580,6 +580,8 @@ function setupAutoUpdater() {
// Download on detect, install on quit: OS can't replace a running .app/.exe.
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
+ // Renderer pushes the user's experimental-updates setting via IPC right after settings load.
+ autoUpdater.allowPrerelease = false;
autoUpdater.on('update-available', (info) => {
console.log(`Update available: ${info.version}`);
@@ -1082,6 +1084,20 @@ ipcMain.handle('download-update', async () => {
}
});
+ipcMain.handle('set-allow-prerelease', async (_e, value) => {
+ if (!autoUpdater) return { success: false, error: 'Updater not available' };
+ const next = Boolean(value);
+ if (autoUpdater.allowPrerelease === next) return { success: true, changed: false };
+ autoUpdater.allowPrerelease = next;
+ if (!isPackaged) return { success: true, changed: true };
+ try {
+ await autoUpdater.checkForUpdates();
+ } catch (err) {
+ return { success: false, changed: true, error: err?.message || String(err) };
+ }
+ return { success: true, changed: true };
+});
+
ipcMain.handle('install-update', async () => {
if (!autoUpdater) return { installed: false, queued: false };
// Veto while workflow is in flight; lifecycle poller fires deferred install once active drains.
diff --git a/electron/preload.js b/electron/preload.js
index 7ed6ee0e..1885c91f 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -37,6 +37,7 @@ const { contextBridge, ipcRenderer } = require('electron');
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
+ setAllowPrerelease: (value) => ipcRenderer.invoke('set-allow-prerelease', value),
onUpdateAvailable: (cb) => {
const listener = (_event, info) => cb(info);
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index f7dd0110..d7ebc00f 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -206,6 +206,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
const { setMode: setThemeMode } = useThemeMode();
const theme = useAppSelector((s) => s.settings.data.theme);
const loaded = useAppSelector((s) => s.settings.loaded);
+ const allowExperimentalUpdates = useAppSelector((s) => s.settings.data.allow_experimental_updates);
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchModels());
@@ -225,6 +226,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
}, [loaded, theme, setThemeMode]);
+
+ useEffect(() => {
+ if (!loaded) return;
+ (window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates);
+ }, [loaded, allowExperimentalUpdates]);
return <>{children}>;
};
diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx
index 6a3fc0dc..bc960204 100644
--- a/frontend/src/app/pages/Settings/Settings.tsx
+++ b/frontend/src/app/pages/Settings/Settings.tsx
@@ -1884,7 +1884,7 @@ const Settings: React.FC = () => {
Advanced
-
+
Developer mode
Show transport details, environment variables, raw configs, and other technical metadata throughout the app.
@@ -1899,6 +1899,21 @@ const Settings: React.FC = () => {
/>
+
+
+ Experimental updates
+ Receive pre-release builds with new features earlier. These versions may be less stable than normal releases.
+
+ setForm({ ...form, allow_experimental_updates: e.target.checked })}
+ sx={{
+ '& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
+ }}
+ />
+
+
About
diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts
index bc9fd973..750c77d3 100644
--- a/frontend/src/shared/state/settingsSlice.ts
+++ b/frontend/src/shared/state/settingsSlice.ts
@@ -55,6 +55,7 @@ export interface AppSettings {
expand_new_chats_in_dashboard: boolean;
auto_reveal_sub_agents: boolean;
dev_mode: boolean;
+ allow_experimental_updates: boolean;
/** Managed subscription state; surfaces only when user has subscribed via cloud. */
connection_mode?: 'own_key' | 'openswarm-pro';
openswarm_bearer_token?: string | null;
@@ -119,6 +120,7 @@ const initialState: SettingsState = {
expand_new_chats_in_dashboard: false,
auto_reveal_sub_agents: true,
dev_mode: false,
+ allow_experimental_updates: false,
},
loading: false,
loaded: false,
diff --git a/publish.sh b/publish.sh
index fccaa935..c5727222 100755
--- a/publish.sh
+++ b/publish.sh
@@ -1,5 +1,19 @@
#!/bin/bash
# The comment above is shebang, DO NOT REMOVE
+#
+# Publishes a macOS build to GitHub Releases. Channel is auto-detected from
+# the semver string in electron/package.json:
+#
+# Stable: "1.0.37" -> normal GitHub release
+# Experimental: "1.0.37-exp.1" -> marked Pre-release on GitHub; only reaches
+# users with "Experimental updates" enabled
+# in Settings > Advanced.
+#
+# To promote an experimental release to stable: un-check "This is a pre-release"
+# on the GitHub release page (native GitHub UI, no code change needed).
+#
+# Windows release: tag-triggered via .github/workflows/release-windows.yml,
+# which performs the same auto-detection.
PUBLISH_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' 's/\r//g' "$PUBLISH_ABSPATH"
@@ -11,7 +25,18 @@ chmod +x "$PUBLISH_ABSPATH"
PROJECT_ROOT="$(dirname "$PUBLISH_ABSPATH")"
cd "$PROJECT_ROOT"
-echo "Building and deploying to Firebase Hosting..."
+# electron-builder auto-detects prerelease from semver suffix in electron/package.json
+# (e.g. "1.0.37-exp.1" publishes as GitHub Pre-release; "1.0.37" publishes as stable).
+# We also export EP_PRE_RELEASE for belt-and-suspenders so the GitHub Releases publisher
+# can't accidentally promote an experimental build.
+VERSION="$(node -p "require('./electron/package.json').version")"
+if [[ "$VERSION" == *-* ]]; then
+ export EP_PRE_RELEASE=true
+ echo "==> Publishing EXPERIMENTAL release: v$VERSION (will be marked Pre-release on GitHub)"
+else
+ echo "==> Publishing STABLE release: v$VERSION"
+fi
+
bash scripts/build-app.sh --publish
-cd -
\ No newline at end of file
+cd -