Skip to content

fix(cli): pagespace mcp never dies or hangs before the MCP handshake + ChatGPT/Codex docs - #2327

Open
2witstudios wants to merge 3 commits into
masterfrom
pu/mcp-bridge
Open

fix(cli): pagespace mcp never dies or hangs before the MCP handshake + ChatGPT/Codex docs#2327
2witstudios wants to merge 3 commits into
masterfrom
pu/mcp-bridge

Conversation

@2witstudios

@2witstudios 2witstudios commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Problem

A user reported hours of ChatGPT MCP timeouts with the new CLI while the old `pagespace-mcp` npm package kept working. Live stdio probes against the published CLI confirmed three startup failure modes, all of which an MCP client (ChatGPT desktop, Codex, Claude Desktop, stdio bridges) can only render as a server that never came up:

  1. No explicit credential → exit 1 before the transport connects. The old package warned and served; every migrated config's client is built against that tolerance.
  2. Stored oauth credential + unreachable host → infinite silent hang. `enforceAuth` ran OAuth discovery + refresh before `initialize` with no fetch timeout — and then purged the stored credential on the transient failure, turning an outage into a forced re-login.
  3. Keychain read at startup can block forever on a GUI access prompt in headless spawns.

Separately, the field failure that actually broke a live config: `npx -y @pagespace/cli pagespace-mcp` without `-p` dies with `could not determine executable to run` (two bins, neither named after the package) — Codex shows Tools: (none). No doc showed the ChatGPT/Codex TOML config shape.

Fix

  • `lazyAuth` route property: for `mcp`, run.ts never calls `resolveCredentialSource` when nothing explicit is named (zero store reads — strictly stronger than the old gate) and wires a `FailingAuthProvider`; with a credential named, resolution defers to the first tool call via `createLazyAuthProvider` (keychain read bounded 5s, retries on next call, never purges).
  • OAuth discovery + silent-refresh fetches gain AbortSignal timeouts (10s); refresh timeout throws the SDK `TimeoutError` so it classifies retryable.
  • `commands/mcp.ts` serves degraded through a stub sdk with no credential — `initialize`/`tools/list` always answer instantly, every `tools/call` returns the actionable `isError` message; `ctx.sdk` structurally unreachable without an explicit credential.
  • `tool-convert` passes the auth error's remediation text through (old fixed hint said `pagespace login` — the one remediation mcp refuses).
  • `RunDependencies.createMcpTransport` seam so run-level tests use `InMemoryTransport`.
  • Docs: ChatGPT desktop / Codex section (TOML `~/.codex/config.toml`, load-bearing `-p`, `startup_timeout_sec`, GUI-PATH caveat) in the migration guide + marketing MCP page.

The Phase 8 task 4 invariant (never ride the ambient login credential) is preserved structurally — the no-credential path can no longer even read the store, and the zero-side-effect assertions in run.test.ts got stronger.

Verification

  • 1126 CLI tests green (incl. new no-hang tests: hung keychain, black-holed host, concurrency/memoization of the lazy provider); typecheck 16/16; lint green.
  • Live stdio probes on the built CLI: no-env serves 71 tools in ~210ms with per-call auth errors; stored-oauth + black-holed host answers `initialize` in ~180ms and errors the call after the bounded timeout with the credential left intact.
  • Real-client end-to-end: a live Codex session spawned the server and `drives.list` returned 56 drives.

Sibling change (separate repo, not in this PR): `pagespace-mcp` 5.2.8 corrects its deprecation notice, which pointed users at `pagespace login` (refused by mcp) and the renamed `pagespace tokens create`.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QZ4Dp6SazFhnSpDHmk15cd

Summary by CodeRabbit

  • New Features
    • MCP servers now remain available without credentials, showing tools while returning clear authentication guidance when actions are attempted.
    • Authentication is resolved only when needed, improving startup behavior and avoiding unnecessary credential access.
    • Added timeout handling for credential discovery and token refresh requests.
  • Documentation
    • Added setup and migration guidance for ChatGPT Desktop, Codex CLI, and OpenAI Secure MCP Tunnel.
    • Clarified required npx configuration, environment variables, timeouts, PATH settings, and credential inheritance.
  • Bug Fixes
    • Authentication errors now include provider-specific remediation details instead of generic guidance.

2witstudios and others added 2 commits August 4, 2026 00:22
An MCP client that spawns 'pagespace mcp' (ChatGPT desktop, Codex, Claude
Desktop, stdio bridges) can only observe a pre-transport exit or hang as a
server that never came up — rendered as hours-long timeouts. Three startup
blockers caused exactly that, all confirmed by live stdio probes against
the published CLI:

- no explicit credential: exited 1 before connecting the transport
- stored oauth credential + unreachable host: OAuth discovery/refresh ran
  before initialize with no fetch timeout — hung forever, and enforceAuth
  then purged the stored credential on the transient failure
- keychain read at startup could block on a GUI access prompt

The mcp route now serves unconditionally and fails per-call instead
(matching the old pagespace-mcp package's tolerance every migrated config
was built against), while keeping the Phase 8 task 4 invariant strictly
stronger than before:

- routes gain lazyAuth; run.ts skips resolveCredentialSource entirely for
  mcp with nothing explicit named (zero store reads, zero network — the
  ambient login credential cannot even be read) and wires a
  FailingAuthProvider whose message surfaces on every tools/call
- with an explicit credential, resolution (keychain read bounded at 5s)
  and discovery/refresh (both fetches now abort at 10s) defer to the first
  tool call via createLazyAuthProvider; failed resolutions retry on the
  next call and never purge the stored credential
- commands/mcp.ts serves degraded through a stub sdk when no credential is
  named — ctx.sdk is structurally unreachable without one
- tool-convert passes the AuthenticationError remediation text through
  instead of the fixed 'Run "pagespace login"' hint (the one remediation
  mcp deliberately refuses)
- RunDependencies.createMcpTransport seam so run-level tests drive the mcp
  route over InMemoryTransport, never the test process's stdio

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ4Dp6SazFhnSpDHmk15cd
…ing npx -p flag, startup timeout

ChatGPT desktop and Codex share ~/.codex/config.toml (TOML), a shape no
PageSpace doc showed — hand-translating the JSON mcpServers examples is
exactly where the -p flag gets dropped, and npx then dies with 'could not
determine executable to run', which Codex renders as Tools: (none) and
ChatGPT as a connector timeout (reproduced live). Documents the verified
working block, why -p is required (two bins, neither named after the
package), startup_timeout_sec for cold npx installs, the GUI-PATH caveat
for the global-install form, and that Codex's 'Auth: Unsupported' label is
normal for stdio servers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ4Dp6SazFhnSpDHmk15cd
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The CLI now starts MCP without explicit credentials, defers authentication until tool use, and returns actionable per-call errors. Discovery and refresh requests have abortable timeouts. Tests use in-memory MCP transports. Documentation covers ChatGPT, Codex, and Secure MCP Tunnel setup.

MCP authentication and serving

Layer / File(s) Summary
Authentication timeouts and lazy provider
packages/cli/src/auth/..., packages/cli/src/auth/__tests__/...
Discovery and refresh requests now abort after configurable timeouts. Lazy providers defer resolution, share concurrent requests, cache successful results, and retry failures.
Lazy route dispatch and transport injection
packages/cli/src/router/..., packages/cli/src/run.ts, packages/cli/src/__tests__/run.test.ts
The MCP route enables lazy authentication. Dispatch avoids ambient credentials, bounds blocked credential reads, and supports injected MCP transports for tests.
Degraded MCP serving and error results
packages/cli/src/commands/mcp.ts, packages/cli/src/mcp/..., packages/cli/src/commands/__tests__/mcp.test.ts
MCP serves its tool registry without credentials. Tool calls return provider-supplied authentication errors instead of failing during startup.
MCP integration coverage and setup documentation
packages/cli/src/__tests__/pagespace-mcp-bin.test.ts, packages/cli/src/pagespace-mcp-bin.ts, apps/marketing/src/app/docs/integrations/mcp/page.tsx, packages/cli/docs/migrating-from-pagespace-mcp.md
Integration tests connect in-memory MCP clients. Setup documentation adds the required npx -p usage, timeout settings, credentials, and tunnel configuration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant MCPTransport
  participant MCPRoute
  participant AuthProvider
  participant PageSpaceSDK
  MCPClient->>MCPTransport: Connect
  MCPTransport->>MCPRoute: Start mcp route
  MCPRoute-->>MCPClient: Tool registry and serving status
  MCPClient->>MCPRoute: Invoke tool
  MCPRoute->>AuthProvider: Resolve credentials
  AuthProvider->>PageSpaceSDK: Request access token when configured
  PageSpaceSDK-->>AuthProvider: Token or authentication error
  AuthProvider-->>MCPRoute: Credential result
  MCPRoute-->>MCPClient: Tool result or actionable authentication error
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the MCP startup fix and the added ChatGPT/Codex documentation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/mcp-bridge

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0d3d0ffb0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +64 to +65
if (timedOut) {
throw new DiscoveryError(`Timed out reaching ${url} after ${timeoutMs}ms`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Classify discovery timeouts as retryable

When an explicitly named stored OAuth credential encounters a transient discovery timeout, this throws a plain DiscoveryError. OAuthTokenProvider passes it to classifyRefreshFailure, which recognizes only SDK network/timeout/rate-limit/server errors as retryable (packages/sdk/src/auth/decide.ts:51-60), so it marks the provider permanently unauthenticated (packages/sdk/src/auth/oauth.ts:138-147). Consequently, even after the host recovers, every subsequent MCP tool call immediately fails until the server is restarted, contrary to the intended next-call retry behavior; emit the SDK TimeoutError here (and a retryable network error for the adjacent fetch-failure path).

Useful? React with 👍 / 👎.

…stdio server

The reported ChatGPT downtime traced to OpenAI's Secure MCP Tunnel:
tunnel-client spawns the stdio MCP server via --mcp-command, so the two
failure modes documented for Codex apply identically and both render as
ChatGPT-side timeouts — a missing -p in the npx invocation, and credential
env vars absent from the environment tunnel-client run executes in (the
child inherits that env, not ChatGPT's). Documents the working
--mcp-command, the env rule, and the doctor --explain check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ4Dp6SazFhnSpDHmk15cd

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/cli/src/auth/discover.ts (1)

55-70: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The abort timeout bounds only the fetch, not the response body read, in both authentication requests. Both files clear the abort timer in the finally of the fetch try block. Header retrieval is bounded; the body read that follows is not. A host that sends headers and then stalls the body still hangs the first pagespace mcp tool call — the failure mode this change targets.

  • packages/cli/src/auth/discover.ts#L55-L70: move clearTimeout(timer) into an outer finally that also covers await response.json() on Line 76, and map a timeout abort raised during the body read to the Timed out reaching ${url} after ${timeoutMs}ms message.
  • packages/cli/src/auth/silent-refresh.ts#L69-L92: move clearTimeout(timer) into an outer finally that also covers await response.text() on Line 94, and map a timeout abort raised during the body read to the same TimeoutError with operation: 'auth.refresh'.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/auth/discover.ts` around lines 55 - 70, Extend the timeout
coverage through the response body reads in both sites: in
packages/cli/src/auth/discover.ts lines 55-70, wrap response.json() in the same
timed operation, move clearTimeout(timer) to an outer finally, and map timeout
aborts to the existing DiscoveryError message; in
packages/cli/src/auth/silent-refresh.ts lines 69-92, do the equivalent for
response.text(), preserving the TimeoutError with operation: 'auth.refresh'.
packages/cli/src/__tests__/run.test.ts (1)

620-641: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the MCP handshake timer-independent under fake timers.

vi.useFakeTimers() starts before runMcp calls client.connect(clientTransport), and MCP SDK requests use a timer-backed request timeout. Enable fake timers only after runMcp returns so connect/initialize cannot wait on fake timers that tests may not advance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/__tests__/run.test.ts` around lines 620 - 641, Update the
test around runMcp so fake timers are enabled only after await runMcp(deps)
completes, allowing client.connect/clientTransport initialization to use real
timers; then enable fake timers before advancing the 5,000 ms bounded
credential-store timeout, preserving the existing assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/marketing/src/app/docs/integrations/mcp/page.tsx`:
- Around line 124-133: Remove the self-hosted PAGESPACE_API_URL guidance from
the OpenAI Secure MCP Tunnel section in
apps/marketing/src/app/docs/integrations/mcp/page.tsx (lines 124-133) and the
corresponding migration guidance in
packages/cli/docs/migrating-from-pagespace-mcp.md (lines 146-154). Keep the
hosted PAGESPACE_TOKEN environment-variable instructions and remaining tunnel
setup guidance unchanged.

In `@packages/cli/src/run.ts`:
- Around line 320-339: Update createLazyAuthProvider so the credential-store
resolution uses one shared in-flight promise, reusing it when a prior
resolveCredentialSource call is still pending instead of starting another read
after resolveWithTimeout rejects. Preserve retry behavior after the underlying
read settles, while ensuring concurrent or subsequent tool calls join the
outstanding resolution.

---

Nitpick comments:
In `@packages/cli/src/__tests__/run.test.ts`:
- Around line 620-641: Update the test around runMcp so fake timers are enabled
only after await runMcp(deps) completes, allowing client.connect/clientTransport
initialization to use real timers; then enable fake timers before advancing the
5,000 ms bounded credential-store timeout, preserving the existing assertions.

In `@packages/cli/src/auth/discover.ts`:
- Around line 55-70: Extend the timeout coverage through the response body reads
in both sites: in packages/cli/src/auth/discover.ts lines 55-70, wrap
response.json() in the same timed operation, move clearTimeout(timer) to an
outer finally, and map timeout aborts to the existing DiscoveryError message; in
packages/cli/src/auth/silent-refresh.ts lines 69-92, do the equivalent for
response.text(), preserving the TimeoutError with operation: 'auth.refresh'.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5382a754-b217-497f-b6de-22d50391190c

📥 Commits

Reviewing files that changed from the base of the PR and between ae92ec0 and 4c60e48.

📒 Files selected for processing (19)
  • apps/marketing/src/app/docs/integrations/mcp/page.tsx
  • packages/cli/docs/migrating-from-pagespace-mcp.md
  • packages/cli/src/__tests__/pagespace-mcp-bin.test.ts
  • packages/cli/src/__tests__/run.test.ts
  • packages/cli/src/auth/__tests__/discover.test.ts
  • packages/cli/src/auth/__tests__/lazy-provider.test.ts
  • packages/cli/src/auth/__tests__/silent-refresh.test.ts
  • packages/cli/src/auth/discover.ts
  • packages/cli/src/auth/lazy-provider.ts
  • packages/cli/src/auth/silent-refresh.ts
  • packages/cli/src/commands/__tests__/mcp.test.ts
  • packages/cli/src/commands/mcp.ts
  • packages/cli/src/mcp/__tests__/serve.test.ts
  • packages/cli/src/mcp/__tests__/tool-convert.test.ts
  • packages/cli/src/mcp/tool-convert.ts
  • packages/cli/src/pagespace-mcp-bin.ts
  • packages/cli/src/router/router.ts
  • packages/cli/src/router/routes.ts
  • packages/cli/src/run.ts

Comment on lines +124 to +133
### OpenAI Secure MCP Tunnel

Using [OpenAI's Secure MCP Tunnel](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels) to reach a private server from ChatGPT? \`tunnel-client\` spawns the stdio server itself:

\`\`\`bash
tunnel-client init --profile pagespace --tunnel-id <your-tunnel-id> \\
--mcp-command "npx -y -p @pagespace/cli pagespace-mcp"
\`\`\`

The same two rules apply — \`-p\` is required in the \`--mcp-command\`, and the credential env vars (\`PAGESPACE_TOKEN\`, plus \`PAGESPACE_API_URL\` for self-hosted) must be exported **in the environment where \`tunnel-client run\` executes**, since the spawned server inherits that process's env. Both mistakes surface in ChatGPT as tool calls that time out; \`tunnel-client doctor --profile pagespace --explain\` shows whether the server actually came up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove unsupported self-hosted deployment guidance.

PageSpace is hosted only. Do not instruct users to configure PAGESPACE_API_URL for self-hosted instances.

  • apps/marketing/src/app/docs/integrations/mcp/page.tsx#L124-L133: remove the self-hosted PAGESPACE_API_URL guidance.
  • packages/cli/docs/migrating-from-pagespace-mcp.md#L146-L154: remove the self-hosted PAGESPACE_API_URL guidance.

Based on learnings, “PageSpace is exclusively offered as a hosted service (pagespace.com) and is no longer self-hosted.”

📍 Affects 2 files
  • apps/marketing/src/app/docs/integrations/mcp/page.tsx#L124-L133 (this comment)
  • packages/cli/docs/migrating-from-pagespace-mcp.md#L146-L154
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/marketing/src/app/docs/integrations/mcp/page.tsx` around lines 124 -
133, Remove the self-hosted PAGESPACE_API_URL guidance from the OpenAI Secure
MCP Tunnel section in apps/marketing/src/app/docs/integrations/mcp/page.tsx
(lines 124-133) and the corresponding migration guidance in
packages/cli/docs/migrating-from-pagespace-mcp.md (lines 146-154). Keep the
hosted PAGESPACE_TOKEN environment-variable instructions and remaining tunnel
setup guidance unchanged.

Source: Learnings

Comment thread packages/cli/src/run.ts
Comment on lines +320 to +339
async function resolveWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new AuthenticationError(
`Reading the stored credential timed out after ${timeoutMs}ms — the OS keychain may be locked or ` +
'waiting on an access prompt this process cannot show. Pass --token (or set PAGESPACE_TOKEN) in ' +
'the MCP config to avoid the credential store entirely.',
),
);
}, timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A timed-out credential-store read is abandoned, and each retry starts another one.

Promise.race rejects, but the underlying resolveCredentialSource promise stays pending forever when the keychain read never returns. createLazyAuthProvider clears its memo on rejection, so every later tool call calls credentialStore.get again. On a machine with a blocked keychain prompt, outstanding native reads accumulate for the life of the MCP server — one per tool call.

Keep the retry behavior, but do not start a second read while one is still outstanding.

♻️ Proposed guard for the outstanding read
+let outstandingCredentialRead: Promise<unknown> | null = null;
+
 async function resolveWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {

An alternative is to reuse one shared resolution promise inside the createLazyAuthProvider callback at Lines 208-221, so a still-blocked read is joined instead of duplicated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/run.ts` around lines 320 - 339, Update
createLazyAuthProvider so the credential-store resolution uses one shared
in-flight promise, reusing it when a prior resolveCredentialSource call is still
pending instead of starting another read after resolveWithTimeout rejects.
Preserve retry behavior after the underlying read settles, while ensuring
concurrent or subsequent tool calls join the outstanding resolution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant