From 8df0bc98a544fbc27b84d9e4ecdc96d1950118b9 Mon Sep 17 00:00:00 2001 From: Starushenko Date: Fri, 22 May 2026 16:26:10 +0300 Subject: [PATCH 1/2] fix(corezoid): allow login/logout to switch environments mid-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login handler refused to apply `account_url`, `workspace_id`, and `stage_id` arguments whenever the corresponding in-memory variable was already populated (`v != "" && accountURL == ""` etc.). Combined with logout only resetting `apiToken`, this meant that once the MCP process started with one set of credentials, the user could not actually switch to a different environment without restarting Claude Code: the args were silently ignored, `.env` changes were ignored, and every tool call kept hitting the host captured at startup. Changes: - login: after `findAndLoadDotEnv()`, unconditionally refresh `accountURL`, `workspaceID`, `stageID`, `apiURL` from env so a swapped `.env` actually takes effect. `apiToken` keeps its conditional refresh so a freshly-issued OAuth token is not clobbered by a stale value. - login: apply `account_url`, `workspace_id`, `stage_id` arguments unconditionally — these express explicit caller intent and should always win over any cached state. When `account_url` changes, clear the derived `COREZOID_API_URL` so it is re-fetched for the new host. - login: derive `COREZOID_API_URL` whenever a token is present but `apiURL` is empty, not only inside the OAuth-success branch. This handles the common case where `ACCESS_TOKEN` is supplied directly in `.env` (browser OAuth is broken on private on-prem instances, see corezoid/corezoid-ai-plugin#7) and the case where `account_url` just changed. - logout: clear `accountURL`, `workspaceID`, `stageID`, `apiURL` in addition to `apiToken` so a follow-up `login` starts from a clean slate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- plugins/corezoid/mcp-server/mcp_server.go | 80 +++++++++++++++++------ 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/plugins/corezoid/mcp-server/mcp_server.go b/plugins/corezoid/mcp-server/mcp_server.go index 2a2557d..582cc99 100644 --- a/plugins/corezoid/mcp-server/mcp_server.go +++ b/plugins/corezoid/mcp-server/mcp_server.go @@ -899,45 +899,56 @@ func handleToolCall(name string, args map[string]interface{}) (result string, is case "login": envPath := envFilePath() - // Re-read .env so that ACCESS_TOKEN (and other vars) added after server - // startup are honoured — prevents triggering OAuth when the token is - // already present in .env. + // Re-read .env so that values updated since server startup are honoured. + // This matters when the user swaps `.env` to switch between environments + // (e.g. dev <-> prod) within a single session: the values in the new + // file must override the in-memory state captured at startup, not the + // other way around. Otherwise every subsequent API call keeps hitting + // the host that was active when the MCP process first started. findAndLoadDotEnv() + accountURL = os.Getenv("ACCOUNT_URL") + workspaceID = os.Getenv("WORKSPACE_ID") + stageID, _ = strconv.Atoi(os.Getenv("COREZOID_STAGE_ID")) + apiURL = os.Getenv("COREZOID_API_URL") + // apiToken keeps its conditional refresh: if a token is already in + // memory we want to *reuse* it (and skip OAuth) rather than overwrite + // from .env, which may have just been cleared by `logout`. if apiToken == "" { apiToken = os.Getenv("ACCESS_TOKEN") } - if accountURL == "" { - accountURL = os.Getenv("ACCOUNT_URL") - } - if workspaceID == "" { - workspaceID = os.Getenv("WORKSPACE_ID") - } - if stageID == 0 { - stageID, _ = strconv.Atoi(os.Getenv("COREZOID_STAGE_ID")) - } - if apiURL == "" { - apiURL = os.Getenv("COREZOID_API_URL") - } // Record initial stageID to detect if it gets set during this call. stageIDAtStart := stageID - // Apply any values passed directly as arguments (bypasses elicitation). - if v := optStrArg(args, "account_url"); v != "" && accountURL == "" { + // Apply any values passed directly as arguments. These represent the + // caller's explicit intent and override values previously loaded from + // `.env` or captured at startup — this is what makes login args a + // viable path to switch environments mid-session. + if v := optStrArg(args, "account_url"); v != "" { + if v != accountURL { + // The account URL changed — the previously derived API URL is + // no longer valid for the new host. Clear it so it gets + // re-derived (or re-elicited) for the new account. + apiURL = "" + os.Unsetenv("COREZOID_API_URL") + if err := removeEnvKey(envPath, "COREZOID_API_URL"); err != nil { + logger.Warn("login: could not clear stale COREZOID_API_URL: %v", err) + } + } accountURL = v os.Setenv("ACCOUNT_URL", v) if err := updateEnvFile(envPath, "ACCOUNT_URL", v); err != nil { logger.Warn("login: could not save ACCOUNT_URL from arg: %v", err) } } - if v := optStrArg(args, "workspace_id"); v != "" && workspaceID == "" { + if v := optStrArg(args, "workspace_id"); v != "" { workspaceID = v os.Setenv("WORKSPACE_ID", v) if err := updateEnvFile(envPath, "WORKSPACE_ID", v); err != nil { logger.Warn("login: could not save WORKSPACE_ID from arg: %v", err) } } - if v := optStrArg(args, "stage_id"); v != "" && stageID == 0 { + if v := optStrArg(args, "stage_id"); v != "" { if id, err := strconv.Atoi(v); err == nil && id != 0 { stageID = id os.Setenv("COREZOID_STAGE_ID", v) @@ -947,6 +958,26 @@ func handleToolCall(name string, args map[string]interface{}) (result string, is } } + // If we already have a token but no derived API URL (e.g. ACCESS_TOKEN + // was supplied directly in `.env` without going through the OAuth flow, + // or the account just changed via the `account_url` arg above), derive + // the corezoid host now so subsequent API calls hit the right server. + // Without this, requests fall back to the stale apiURL captured at + // startup and either time out or hit the wrong environment. + if apiToken != "" && apiURL == "" && accountURL != "" { + corezoidURL, fetchErr := fetchCorezoidAPIURL(accountURL, apiToken) + if fetchErr != nil { + logger.Warn("login: fetchCorezoidAPIURL failed: %v", fetchErr) + } else { + apiURL = corezoidURL + os.Setenv("COREZOID_API_URL", corezoidURL) + if err := updateEnvFile(envPath, "COREZOID_API_URL", corezoidURL); err != nil { + logger.Warn("login: could not save COREZOID_API_URL: %v", err) + } + logger.Info("login: derived COREZOID_API_URL=%q from clients API", corezoidURL) + } + } + logger.Info("login: accountURL=%q workspaceID=%q stageID=%d", accountURL, workspaceID, stageID) // Step 1: ensure Account API URL. @@ -1269,7 +1300,18 @@ func handleToolCall(name string, args map[string]interface{}) (result string, is if err := deleteCredentials(); err != nil { return fmt.Sprintf("Failed to remove credentials: %v", err), true } + // Clear all session-scoped state, not just the token. Without this, + // `accountURL`, `workspaceID`, `stageID` and `apiURL` remain pinned to + // the values captured at server startup — so a follow-up `login` call + // with different args (or a refreshed `.env`) cannot actually switch + // environments, and tools keep hitting the previous host. The .env + // file keeps its non-credential keys intact: `login` re-reads them on + // the next call. apiToken = "" + accountURL = "" + workspaceID = "" + stageID = 0 + apiURL = "" return fmt.Sprintf("Logged out. ACCESS_TOKEN removed from %s.", envFilePath()), false case "pull-process": From 144bfff838a3bf04a05c36dedb8d9cb3fc95f429 Mon Sep 17 00:00:00 2001 From: Starushenko Date: Fri, 22 May 2026 16:46:58 +0300 Subject: [PATCH 2/2] fix(corezoid): make pull-folder work for sub-folder IDs, not only stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP tool `pull-folder` is advertised as exporting "a Corezoid folder/stage" and takes a generic `folder_id`, but internally `downloadStageRecursively` hardcoded `obj_type=stage` for every ID. On the server, `stage` (root container of a project) and `folder` (any sub-container) are distinct object types and the wrong one is rejected: - sub-folder ID with `obj_type=stage` -> proc:error, "Object stage with id N does not exist" - stage ID with `obj_type=folder` -> proc:error, "This object does not exist or is in the trash" `PullZip` only inspects `request_proc` and `ops[0].download_url`, so this inner error is swallowed and re-emitted as the generic "failed to export process: no download_url in response". The result is that `pull-folder` works against a stage ID but appears mysteriously broken for every regular sub-folder ID a user might hand it from the Corezoid UI. Same root cause produced the plugin-side timeouts and `unexpected end of JSON input` errors reported in the env-switch debugging session that preceded this commit. Fix: in `downloadStageRecursively` try `obj_type=folder` first and fall back to `obj_type=stage` on any error. One redundant request only in the rare case the caller passed a real stage ID; no behaviour change on the success path; no public API change; the auto-pull triggered from the `login` flow continues to work because the retry covers stage IDs. Verified by direct curl against an on-prem AZ dev instance (`corezoid-tst.unibank.lan`) -- folder ID 29245 returns a `download_url` with `obj_type=folder` and the previous "no download_url" error with `obj_type=stage`; stage ID 21194 returns `download_url` with `obj_type=stage` and the "does not exist or is in the trash" error with `obj_type=folder`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- plugins/corezoid/mcp-server/pull-project.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/plugins/corezoid/mcp-server/pull-project.go b/plugins/corezoid/mcp-server/pull-project.go index 6e30714..95a493f 100644 --- a/plugins/corezoid/mcp-server/pull-project.go +++ b/plugins/corezoid/mcp-server/pull-project.go @@ -132,7 +132,22 @@ func moveContents(src, dst string) error { } func downloadStageRecursively(e *Executor, folderID int, filePath string) error { - data, err := e.PullZip(folderID, "stage") + // `pull-folder` accepts any container ID — either a stage (root + // folder, `obj_type=stage`) or a regular sub-folder + // (`obj_type=folder`). On the server these are distinct object types + // and the wrong one is rejected: passing a sub-folder ID with + // `obj_type=stage` returns `proc:error, "Object stage with id N does + // not exist"`, and passing a stage ID with `obj_type=folder` returns + // "This object does not exist or is in the trash". Either rejection + // surfaces from `PullZip` as the generic "no download_url in + // response" error, which historically made `pull-folder` look broken + // for every sub-folder ID. Try the more common `folder` type first + // and fall back to `stage`, so callers can keep using a single tool + // for IDs at any level of the project tree. + data, err := e.PullZip(folderID, "folder") + if err != nil { + data, err = e.PullZip(folderID, "stage") + } if err != nil { return fmt.Errorf("failed to PullZip: %w", err) }