Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/marketing/src/app/docs/integrations/mcp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,40 @@ claude mcp add pagespace -- pagespace mcp

**Settings > MCP Servers** → add the \`mcpServers\` block above.

### ChatGPT desktop / Codex

ChatGPT desktop and the Codex CLI share one MCP config — \`~/.codex/config.toml\` — and it's **TOML, not the JSON \`mcpServers\` shape** the clients above use:

\`\`\`toml
[mcp_servers.pagespace]
command = "npx"
args = ["-y", "-p", "@pagespace/cli", "pagespace-mcp"]
startup_timeout_sec = 120

[mcp_servers.pagespace.env]
PAGESPACE_API_URL = "https://pagespace.ai"
PAGESPACE_TOKEN = "mcp_your_token_here"
\`\`\`

Three details matter:

- **The \`-p\` flag is required.** Without it, npx can't pick between the package's two bins and dies with \`could not determine executable to run\` — Codex shows the server as **Tools: (none)**, ChatGPT as a connector that times out.
- **\`startup_timeout_sec = 120\`** gives a cold \`npx\` install room to download the package before the client abandons the handshake.
- **Name a credential in the \`env\` block.** \`pagespace mcp\` never falls back to your personal login; without a token (or \`PAGESPACE_KEY\`), tool calls return an error telling you to configure one.

(\`command = "pagespace"\` works only when the CLI is on the PATH the app launches with — macOS GUI apps don't inherit your shell PATH, so prefer the \`npx\` form. "Auth: Unsupported" beside the server in Codex is normal for stdio servers, not an error.)

### 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.
Comment on lines +124 to +133

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


## Step 3: Capabilities

\`pagespace mcp\` generates its tool list mechanically from the same operation registry that powers the \`pagespace\` CLI and [\`@pagespace/sdk\`](/docs/features/sdk), so the tool surface can't drift from what the CLI itself supports.
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/docs/migrating-from-pagespace-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,67 @@ New (the `env` block naming a credential is required here too):
}
```

## ChatGPT desktop / Codex

ChatGPT desktop and the Codex CLI share one MCP config — `~/.codex/config.toml` — and it's
**TOML, not the JSON `mcpServers` shape** every other client above uses. A working entry,
verified end to end against a live Codex session:

```toml
[mcp_servers.pagespace]
command = "npx"
args = ["-y", "-p", "@pagespace/cli", "pagespace-mcp"]
startup_timeout_sec = 120

[mcp_servers.pagespace.env]
PAGESPACE_API_URL = "https://pagespace.ai"
PAGESPACE_TOKEN = "<TOKEN_FROM_SETTINGS_MCP>"
```

Three details are load-bearing:

- **The `-p` is not optional.** `npx -y @pagespace/cli pagespace-mcp` (no `-p`) dies instantly
with `could not determine executable to run` — this package publishes two bins and neither is
named after the package, so npx can't pick one. Codex renders that dead child as
**Tools: (none)**; ChatGPT shows a connector that times out. If your server shows no tools,
check this flag first.
- **`startup_timeout_sec = 120`** gives a cold `npx` install (first run, cleared cache) room to
download the package before the client gives up on the handshake. The default is much lower.
- **Name a credential in the `env` block** (`PAGESPACE_TOKEN`, or `PAGESPACE_KEY` for a key
minted on this machine). `pagespace mcp` never falls back to your personal `pagespace login`
credential; without an explicit credential the server still starts, but every tool call
returns an error telling you to configure one.

Prefer `command = "pagespace"` with `args = ["mcp"]` only if the CLI is installed globally *and*
on the PATH the app actually launches with — GUI apps on macOS don't inherit your shell PATH, so
an nvm- or Homebrew-installed global can be invisible to ChatGPT even though `which pagespace`
works in your terminal. The `npx` form above sidesteps that.

("Auth: Unsupported" next to the server in Codex is normal for stdio servers — it means no OAuth
flow on the transport, not a configuration problem.)

### OpenAI Secure MCP Tunnel (`tunnel-client`)

For ChatGPT reaching a *private* MCP server through [OpenAI's Secure MCP Tunnel](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels),
`tunnel-client` spawns the stdio server itself via `--mcp-command`:

```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, and both failure modes surface as ChatGPT-side timeouts ("requests
through the tunnel fail"):

- **`-p` is required** in the `--mcp-command`, exactly as above.
- **The credential env vars must exist in the environment where `tunnel-client run` executes** —
the spawned server inherits *that* process's env. Export `PAGESPACE_TOKEN` (or the legacy
`PAGESPACE_AUTH_TOKEN`) and any `PAGESPACE_API_URL` override in the shell, systemd unit, or
container that runs `tunnel-client`, then verify with
`tunnel-client doctor --profile pagespace --explain`.

## Explicit-token variant (agents, CI, headless boxes)

`pagespace login` needs a browser and isn't appropriate for CI or a service account.
Expand Down
45 changes: 33 additions & 12 deletions packages/cli/src/__tests__/pagespace-mcp-bin.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
import { describe, expect, it } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { run } from '../run.js';
import { EXIT_USAGE_ERROR } from '../exit-codes.js';
import { buildPagespaceMcpArgv, runPagespaceMcpBin } from '../pagespace-mcp-bin.js';
import { createFakeCredentialStore, createRecordingSink } from './fake-context.js';

function makeDeps(argv: string[], env: Record<string, string | undefined> = {}) {
// The mcp route now serves (degraded or not) instead of failing closed, so
// every dispatch that reaches it must go through the `createMcpTransport`
// seam — never the module-level handler's real StdioServerTransport, which
// would attach to the test runner's own stdin.
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
return {
argv,
env,
stdout: createRecordingSink(),
stderr: createRecordingSink(),
credentialStore: createFakeCredentialStore(),
createMcpTransport: () => serverTransport,
clientTransport,
};
}

async function connectClient(clientTransport: InstanceType<typeof InMemoryTransport>) {
const client = new Client({ name: 'test-client', version: '0.0.0' });
await client.connect(clientTransport);
return client;
}

describe('buildPagespaceMcpArgv — pure argv parity', () => {
it('forces the "mcp" route with no other argv', () => {
expect(buildPagespaceMcpArgv([])).toEqual(['mcp']);
Expand All @@ -25,11 +40,16 @@ describe('buildPagespaceMcpArgv — pure argv parity', () => {
});

describe('runPagespaceMcpBin — the first-class npx entry point, resolved as a plain function of injected deps', () => {
it('resolves identically to "pagespace mcp": same fail-closed exit code and the same auth-failure message with zero credentials', async () => {
it('resolves identically to "pagespace mcp": same degraded-serve exit code and the same auth warning with zero credentials', async () => {
const aliasDeps = makeDeps([]);
const directDeps = makeDeps(['mcp']);

const [aliasCode, directCode] = await Promise.all([runPagespaceMcpBin(aliasDeps), run(directDeps)]);
const [aliasCode, directCode] = await Promise.all([
runPagespaceMcpBin(aliasDeps),
run(directDeps),
connectClient(aliasDeps.clientTransport),
connectClient(directDeps.clientTransport),
]);

expect(aliasCode).toBe(directCode);
expect(aliasDeps.stderr.lines.join('')).toMatch(/--key|--token/);
Expand All @@ -38,23 +58,24 @@ describe('runPagespaceMcpBin — the first-class npx entry point, resolved as a

it('keeps stdout pure MCP protocol and never frames itself as deprecated', async () => {
const deps = makeDeps([]);
await runPagespaceMcpBin(deps);
await Promise.all([runPagespaceMcpBin(deps), connectClient(deps.clientTransport)]);

expect(deps.stdout.lines.join('')).toBe('');
const allOutput = `${deps.stdout.lines.join('')}${deps.stderr.lines.join('')}`.toLowerCase();
expect(allOutput).not.toContain('deprecat');
});

it('honors the legacy PAGESPACE_API_URL env var end to end (resolved host reaches the auth-failure message)', async () => {
// Zero credentials alone now fails closed on the host-agnostic, Phase 8
// task 4 "no explicit credential" gate before the host is ever
// consulted (see run.test.ts). `--key agent` makes the credential
// explicit (an unresolvable named key, not the ambient default) so
// the flow reaches past that gate into the same host-bearing
// `missingCredentialsMessage` path this test originally targeted.
it('honors the legacy PAGESPACE_API_URL env var end to end (resolved host reaches the tool-call auth failure)', async () => {
// `--key agent` names an explicit (but unresolvable) credential, so the
// server comes up and lazy resolution runs on the first tool call — the
// host-bearing `missingCredentialsMessage` this test targets now arrives
// as that call's error result, not as a startup stderr line (startup
// must stay failure-free for the spawning MCP client's sake).
const deps = makeDeps(['--key', 'agent'], { PAGESPACE_API_URL: 'https://legacy.example.com' });
await runPagespaceMcpBin(deps);
expect(deps.stderr.lines.join('')).toContain('https://legacy.example.com');
const [, client] = await Promise.all([runPagespaceMcpBin(deps), connectClient(deps.clientTransport)]);
const result = await client.callTool({ name: 'drives.list', arguments: {} });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain('https://legacy.example.com');
});

it('still enforces usage errors for unknown extra argv, just like "pagespace mcp <bogus-subcommand>" would', async () => {
Expand Down
Loading