From d3e825b1bef4b20bf220cfd246b3440067c7735c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 07:40:51 +0000 Subject: [PATCH] fix: include openid in CLI login OAuth scopes MCP whoami and search require openid profile email; defaultScopes only requested profile email, so fresh login sessions hit insufficient_scope. Existing login sessions need `kody logout && kody login` (or re-auth) to pick up openid. whoami/search now hint that re-login when the stored scope lacks openid and the server returns insufficient_scope. Co-authored-by: Kent C. Dodds --- src/defaults.ts | 13 +++++++++++- src/mcp.ts | 50 +++++++++++++++++++++++++++++++++-------------- test/auth.test.ts | 12 ++++++++++++ 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/defaults.ts b/src/defaults.ts index 0825344..98f6be8 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -4,7 +4,18 @@ export const defaultApiUrl = 'https://api.kody.codes' export const apiTokenEnvVar = 'KODY_API_TOKEN' /** Pin to Kody's stateless `/mcp` lane. */ export const modernMcpProtocolVersion = '2026-07-28' -export const defaultScopes = ['profile', 'email'] as const +export const defaultScopes = ['openid', 'profile', 'email'] as const + +/** True when a stored OAuth scope string already includes `openid`. */ +export function scopeIncludesOpenid(scope: string | undefined): boolean { + if (!scope) return false + return scope.split(/\s+/).includes('openid') +} + +/** Hint when MCP whoami/search fail because the login session lacks openid. */ +export const missingOpenidReloginHint = + 'Your login session is missing the openid scope. Run `kody logout && kody login` to refresh.' + export const keyringService = 'kody.codes' export const loginTimeoutMs = 5 * 60 * 1000 export const accessTokenSkewMs = 60_000 diff --git a/src/mcp.ts b/src/mcp.ts index ec1e88a..f259041 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -3,7 +3,12 @@ import { StreamableHTTPClientTransport, UnauthorizedError, } from '@modelcontextprotocol/client' -import { cliName, modernMcpProtocolVersion } from './defaults.js' +import { + cliName, + missingOpenidReloginHint, + modernMcpProtocolVersion, + scopeIncludesOpenid, +} from './defaults.js' import { ensureFreshCredentials, refreshStoredCredentials } from './auth.js' import { readPackageVersion } from './package-info.js' import { redactError } from './redact.js' @@ -59,7 +64,7 @@ export async function callKodyTool( return await invoke(credentials.mcpUrl, credentials.accessToken, input) } catch (error) { if (!(error instanceof UnauthorizedError) && !isUnauthorized(error)) { - throw redactError(error) + throw withOpenidScopeHint(error, credentials.scope) } const refreshed = await refreshStoredCredentials({ credentials, @@ -69,7 +74,7 @@ export async function callKodyTool( try { return await invoke(refreshed.mcpUrl, refreshed.accessToken, input) } catch (retryError) { - throw redactError(retryError) + throw withOpenidScopeHint(retryError, refreshed.scope) } } } @@ -101,19 +106,23 @@ export async function listKodyTools(input: { backend: input.backend, fetchFn: input.fetchFn, }) - const { client, transport } = await connect( - credentials.mcpUrl, - credentials.accessToken, - input.fetchFn, - ) try { - const listed = await client.listTools() - return listed.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - })) - } finally { - await transport.close().catch(() => undefined) + const { client, transport } = await connect( + credentials.mcpUrl, + credentials.accessToken, + input.fetchFn, + ) + try { + const listed = await client.listTools() + return listed.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + })) + } finally { + await transport.close().catch(() => undefined) + } + } catch (error) { + throw withOpenidScopeHint(error, credentials.scope) } } @@ -150,3 +159,14 @@ function isUnauthorized(error: unknown): boolean { (error instanceof Error && /401|unauthorized/i.test(error.message)) ) } + +function withOpenidScopeHint(error: unknown, scope: string | undefined): Error { + const redacted = redactError(error) + if ( + /insufficient_scope/i.test(redacted.message) && + !scopeIncludesOpenid(scope) + ) { + return new Error(`${redacted.message}\n${missingOpenidReloginHint}`) + } + return redacted +} diff --git a/test/auth.test.ts b/test/auth.test.ts index 6550ee6..fd6f516 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -7,7 +7,9 @@ import { import { cliClientMetadataUrl, cliRedirectUrl, + defaultScopes, modernMcpProtocolVersion, + scopeIncludesOpenid, } from '../src/defaults.js' import { buildCliClientMetadata, @@ -28,6 +30,14 @@ const previous: StoredCredentials = { scope: 'profile email', } +test('defaultScopes request openid for login and CIMD metadata', () => { + assert.deepEqual([...defaultScopes], ['openid', 'profile', 'email']) + assert.equal(defaultScopes.join(' '), 'openid profile email') + assert.equal(scopeIncludesOpenid('openid profile email'), true) + assert.equal(scopeIncludesOpenid('profile email'), false) + assert.equal(scopeIncludesOpenid(undefined), false) +}) + test('isAccessTokenExpired uses a one-minute skew', () => { const now = 1_000_000 assert.equal( @@ -101,6 +111,7 @@ test('CLI OAuth identity is CIMD with a fixed loopback redirect', async () => { assert.deepEqual(metadata.redirect_uris, [cliRedirectUrl().href]) assert.equal(metadata.token_endpoint_auth_method, 'none') assert.equal(metadata.application_type, 'native') + assert.equal(metadata.scope, 'openid profile email') assert.equal(modernMcpProtocolVersion, '2026-07-28') assert.equal( provider.clientMetadataUrl, @@ -108,4 +119,5 @@ test('CLI OAuth identity is CIMD with a fixed loopback redirect', async () => { ) const client = await provider.clientInformation() assert.equal(client?.client_id, provider.clientMetadataUrl) + assert.equal(provider.clientMetadata.scope, 'openid profile email') })