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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,12 @@ Commands:
env Manage environment configurations (add, remove, switch, list, claim)
doctor Diagnose WorkOS integration issues
skills Manage WorkOS skills for coding agents (install, uninstall, list)
mcp Manage the WorkOS MCP server in coding agents
setup Set up WorkOS skills and the MCP server

Skills auto-install to detected coding agents on `workos install` and `workos auth login`. Use `workos skills list` to check status, `workos doctor` to detect stale skills, or `workos doctor --fix` to refresh them in place (constrained to `workos/` and `workos-widgets/`).
`workos setup` installs WorkOS skills and configures the MCP server only after consent. Use `workos skills list` to check skill status, `workos mcp status` to check whether the server definition is configured, or `workos doctor --fix` to refresh stale skills.

MCP configuration and OAuth authentication are separate states. The WorkOS CLI never inspects a coding agent's credentials, so "configured" means the server definition is in place — it cannot prove that OAuth is usable in any agent. Each agent owns its own OAuth; with Codex, for example, complete or refresh it with `codex mcp login workos` in your normal host shell. See the [WorkOS MCP setup and recovery guide](https://workos.com/docs/mcp) for user-global and trusted-project-only configuration.

Resource Management:
organization (org) Manage organizations
Expand Down
2 changes: 1 addition & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ async function runCli(): Promise<void> {
registerSubcommand(
yargs,
'install',
'Add the WorkOS MCP server to detected coding agents',
'Configure the WorkOS MCP server in detected coding agents',
(y) =>
y.option('agent', {
alias: 'a',
Expand Down
211 changes: 203 additions & 8 deletions src/commands/mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('../utils/ui.js', () => {
};
return {
default: {
log: { info: record, success: record, error: record, warn: record, step: record, message: record },
log: { info: record, success: record, error: record, warn: record, hint: record, step: record, message: record },
},
};
});
Expand Down Expand Up @@ -168,6 +168,19 @@ describe('Claude Code client', () => {
expect(res.error).toContain('--transport');
});

it('add falls back to name-only presence for a client that cannot report its endpoint', async () => {
// Claude Code exposes no endpoint introspection, so the listed name is the
// best evidence available — it keeps the benefit of the doubt that a
// Codex-style unreadable endpoint does not get.
mockExec((_c, args) => {
if (args.includes('add')) return { status: 1, stderr: 'boom' };
if (args.includes('list')) return { status: 0, stdout: 'workos: https://mcp.workos.com/mcp (HTTP) - Connected' };
return { status: 0 };
});

expect((await claude().add()).outcome).toBe('installed');
});

it('isInstalled matches the workos: list line', async () => {
mockExec(() => ({
status: 0,
Expand Down Expand Up @@ -200,9 +213,22 @@ describe('Claude Code client', () => {
describe('Codex client', () => {
const codex = () => clientByKey('codex');

it('add maps a clean exit to installed', async () => {
it('add maps a clean exit to configured with unverified OAuth metadata', async () => {
mockExec(() => ({ status: 0 }));
expect((await codex().add()).outcome).toBe('installed');
const result = await codex().add();
expect(result).toMatchObject({
outcome: 'installed',
configuration: { scope: 'user', authentication: 'unknown' },
recovery: {
docsUrl: 'https://workos.com/docs/mcp',
hints: expect.arrayContaining([
expect.objectContaining({
command: 'codex mcp login workos',
hostShellRequired: true,
}),
]),
},
});
});

it('add maps a version gap (unknown --url) to failed', async () => {
Expand All @@ -212,15 +238,106 @@ describe('Codex client', () => {
expect(res.error).toContain('--url');
});

it('add reports installed when a non-zero exit still persisted the server (OAuth-flow timeout)', async () => {
it('add reports configured with host action required when OAuth times out after persistence', async () => {
// Codex writes the config, then its post-add OAuth flow blocks and times
// out with a non-zero status — but `mcp list` proves the server landed.
mockExec((_c, args) => {
if (args.includes('add')) return { status: 1, stdout: "Added global MCP server 'workos'." };
if (args.includes('list')) return { status: 0, stdout: 'workos https://mcp.workos.com/mcp enabled' };
if (args.includes('get')) {
return {
status: 0,
stdout: JSON.stringify({
name: 'workos',
transport: { type: 'streamable_http', url: 'https://mcp.workos.com/mcp' },
}),
};
}
return { status: 0 };
});
expect(await codex().add()).toMatchObject({
outcome: 'installed',
configuration: { scope: 'user', authentication: 'action-required' },
});
});

it('add rejects an "already exists" collision that points at a stale endpoint', async () => {
// A name collision only counts as idempotent success if the entry we
// collided with is actually ours.
mockExec((_c, args) => {
if (args.includes('add')) return { status: 1, stderr: "MCP server 'workos' already exists" };
if (args.includes('get')) {
return {
status: 0,
stdout: JSON.stringify({
name: 'workos',
transport: { type: 'streamable_http', url: 'https://stale.example/mcp' },
}),
};
}
return { status: 0 };
});

const res = await codex().add();
expect(res.outcome).toBe('failed');
expect(res.error).toContain('https://stale.example/mcp');
});

it('add accepts an "already exists" collision at the intended endpoint', async () => {
mockExec((_c, args) => {
if (args.includes('add')) return { status: 1, stderr: "MCP server 'workos' already exists" };
if (args.includes('get')) {
return {
status: 0,
stdout: JSON.stringify({
name: 'workos',
transport: { type: 'streamable_http', url: 'https://mcp.workos.com/mcp' },
}),
};
}
return { status: 0 };
});

expect((await codex().add()).outcome).toBe('already-installed');
});

it('add stays failed when the surviving endpoint cannot be read back', async () => {
// Codex can be asked for its effective endpoint, so a `get` we can't parse
// is an unverified claim, not a success — the entry may still be stale.
mockExec((_c, args) => {
if (args.includes('add')) return { status: 1, stderr: 'boom' };
if (args.includes('list')) return { status: 0, stdout: 'workos https://mcp.workos.com/mcp enabled' };
if (args.includes('get')) return { status: 1, stderr: 'unknown flag --json' };
return { status: 0 };
});
expect((await codex().add()).outcome).toBe('installed');

const res = await codex().add();
expect(res.outcome).toBe('failed');
expect(res.error).toContain('could not read');
});

it('add stays failed when a stale entry survives the failed add at the wrong endpoint', async () => {
// The name is present, but it belongs to an older definition — the add
// never applied our endpoint, so this must not read as freshly configured.
mockExec((_c, args) => {
if (args.includes('add')) return { status: 1, stderr: 'boom' };
if (args.includes('list')) return { status: 0, stdout: 'workos https://stale.example/mcp enabled' };
if (args.includes('get')) {
return {
status: 0,
stdout: JSON.stringify({
name: 'workos',
transport: { type: 'streamable_http', url: 'https://stale.example/mcp' },
}),
};
}
return { status: 0 };
});

const res = await codex().add();
expect(res.outcome).toBe('failed');
expect(res.error).toContain('https://stale.example/mcp');
expect(res.error).toContain('https://mcp.workos.com/mcp');
});

it('add stays failed on a non-zero exit when the server did NOT land', async () => {
Expand All @@ -240,6 +357,28 @@ describe('Codex client', () => {
expect(await codex().isInstalled()).toBe(true);
});

it('reads the effective Codex MCP URL from get --json', async () => {
mockExec((_c, args) => {
if (args.includes('get')) {
return {
status: 0,
stdout: JSON.stringify({
name: 'workos',
transport: { type: 'streamable_http', url: 'https://mcp.workos.com/mcp' },
}),
};
}
return { status: 0 };
});

expect(await codex().getConfiguredUrl()).toBe('https://mcp.workos.com/mcp');
});

it('returns null when Codex get output is unavailable or malformed', async () => {
mockExec(() => ({ status: 0, stdout: 'not-json' }));
expect(await codex().getConfiguredUrl()).toBeNull();
});

it('remove maps "No MCP server named" (exit 0) to not-installed', async () => {
mockExec(() => ({ status: 0, stdout: "No MCP server named 'workos' found." }));
expect((await codex().remove()).outcome).toBe('not-installed');
Expand Down Expand Up @@ -330,6 +469,9 @@ describe('runMcpInstall / runMcpRemove (human mode)', () => {
expect(joined).toContain('Claude Code');
expect(joined).toContain('Codex');
expect(joined).toContain('Cursor');
expect(joined).toContain('Codex: configured (user scope)');
expect(joined).toContain('codex mcp login workos');
expect(joined).toContain('https://workos.com/docs/mcp');
});

it('prints the no-agents message and exits 0 when none are detected', async () => {
Expand Down Expand Up @@ -421,16 +563,69 @@ describe('JSON output mode', () => {
expect(output.data.agents[0].outcome).toBe('removed');
});

it('runMcpStatus emits { data: { agents: [...] } } with availability + install flags', async () => {
it('runMcpStatus distinguishes configured state while retaining the legacy installed flag', async () => {
makeDir('.cursor');
writeCursorConfig('{ "mcpServers": { "workos": { "url": "https://w" } } }');
mockExec((_c, args) => (args[0] === '--version' ? { status: 1 } : { status: 0 }));
await runMcpStatus();
const output = JSON.parse(consoleOutput[0]);
expect(output.data.agents).toHaveLength(3);
const cursor = output.data.agents.find((a: { agent: string }) => a.agent === 'cursor');
expect(cursor).toMatchObject({ agent: 'cursor', displayName: 'Cursor', available: true, installed: true });
expect(cursor).toMatchObject({
agent: 'cursor',
displayName: 'Cursor',
available: true,
configured: true,
installed: true,
// Cursor's OAuth is no more verifiable than Codex's, so it carries the
// same caveat — the annotation follows the capability, not the agent name.
authentication: 'not-verified',
});
const claude = output.data.agents.find((a: { agent: string }) => a.agent === 'claude-code');
expect(claude).toMatchObject({ available: false, installed: false });
expect(claude).toMatchObject({
available: false,
configured: false,
installed: false,
authentication: null,
});
});
});

describe('runMcpStatus (human mode)', () => {
it('names every unverified client and takes recovery commands from the client', async () => {
makeDir('.codex');
makeDir('.cursor');
writeCursorConfig('{ "mcpServers": { "workos": { "url": "https://mcp.workos.com/mcp" } } }');
mockExec((_c, args) => {
if (args[0] === '--version') return { status: 0 };
if (args.includes('list')) return { status: 0, stdout: 'workos https://mcp.workos.com/mcp enabled' };
if (args.includes('get')) {
return {
status: 0,
stdout: JSON.stringify({
name: 'workos',
transport: { type: 'streamable_http', url: 'https://mcp.workos.com/mcp' },
}),
};
}
return { status: 0 };
});

await runMcpStatus();

const joined = uiLogs.join('\n');
expect(joined).toContain('OAuth is managed by');
expect(joined).toContain('Codex');
expect(joined).toContain('Cursor');
expect(joined).toContain('codex mcp login workos');
expect(joined).toContain('https://workos.com/docs/mcp');
});

it('prints no OAuth caveat when nothing is configured', async () => {
mockExec((_c, args) => (args[0] === '--version' ? { status: 1 } : { status: 0 }));

await runMcpStatus();

expect(uiLogs.join('\n')).not.toContain('OAuth is managed by');
});
});
Loading
Loading