From 381ba3143fc1993d474c1a0fa90b83f2aa02b00e Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:49:59 -0600 Subject: [PATCH 1/6] fix: skip standalone GET SSE so search and execute can run --- src/mcp.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/mcp.ts b/src/mcp.ts index 5a239ac..3a475b8 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -21,6 +21,34 @@ export type ToolCallResult = { isError?: boolean } +function requestMethod( + input: Parameters[0], + init?: Parameters[1], +) { + if (init?.method) return init.method.toUpperCase() + if (typeof Request !== 'undefined' && input instanceof Request) { + return input.method.toUpperCase() + } + return 'GET' +} + +/** + * Streamable HTTP clients open an optional GET SSE after initialize. Kody's + * sessionful `/mcp` GET holds that session until the stream ends, so the + * follow-up POST (`tools/list`, `search`, `execute`) never completes. The + * spec treats GET SSE as optional; 405 tells the SDK to stay on POST. + */ +export function createMcpFetch(fetchFn: typeof fetch = fetch): typeof fetch { + return (input, init) => { + if (requestMethod(input, init) === 'GET') { + return Promise.resolve( + new Response('Method Not Allowed', { status: 405 }), + ) + } + return fetchFn(input, init) + } +} + async function connect( mcpUrl: string, accessToken: string, @@ -32,7 +60,7 @@ async function connect( Authorization: `Bearer ${accessToken}`, }, }, - fetch: fetchFn, + fetch: createMcpFetch(fetchFn ?? fetch), }) const client = new Client({ name: cliName, From bd4512b16ddeba1f59043903fc5bc396c1e668ad Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:50:00 -0600 Subject: [PATCH 2/6] test: cover MCP GET 405 fetch wrapper --- test/cli.test.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/test/cli.test.ts b/test/cli.test.ts index ab85033..70ac00a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { resolveCommand } from '../src/cli.js' -import { formatToolResult } from '../src/mcp.js' +import { createMcpFetch, formatToolResult } from '../src/mcp.js' import { redact } from '../src/redact.js' test('resolveCommand maps subcommands and flags', () => { @@ -30,3 +30,27 @@ test('redact strips token-looking assignments', () => { 'access_token=[redacted] refresh_token=[redacted]', ) }) + +test('createMcpFetch answers GET with 405 and forwards other methods', async () => { + const calls: Array = [] + const fetchFn = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + calls.push(`${init?.method ?? 'GET'} ${String(input)}`) + return new Response('forwarded', { status: 200 }) + }) as typeof fetch + const mcpFetch = createMcpFetch(fetchFn) + + const denied = await mcpFetch('https://kody.codes/mcp', { method: 'GET' }) + assert.equal(denied.status, 405) + assert.deepEqual(calls, []) + + const posted = await mcpFetch('https://kody.codes/mcp', { + method: 'POST', + body: '{}', + }) + assert.equal(posted.status, 200) + assert.equal(await posted.text(), 'forwarded') + assert.deepEqual(calls, ['POST https://kody.codes/mcp']) +}) From 9101a078c5d95f9b8711882ea5a55eb3d4d8423e Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:16:47 -0600 Subject: [PATCH 3/6] fix: speak MCP 2026-07-28 on the stateless /mcp lane --- package.json | 1 + src/defaults.ts | 2 ++ src/mcp.ts | 56 ++++++++++++++++--------------------------------- 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index 3f3b1d5..2550461 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "access": "public" }, "dependencies": { + "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "^1.3.0" }, diff --git a/src/defaults.ts b/src/defaults.ts index 6405c3b..595bce6 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -1,4 +1,6 @@ export const defaultMcpUrl = 'https://kody.codes/mcp' +/** Pin to Kody's stateless `/mcp` lane. */ +export const modernMcpProtocolVersion = '2026-07-28' export const defaultScopes = ['profile', 'email'] as const export const keyringService = 'kody.codes' export const loginTimeoutMs = 5 * 60 * 1000 diff --git a/src/mcp.ts b/src/mcp.ts index 3a475b8..ec1e88a 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,7 +1,9 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { cliName } from './defaults.js' +import { + Client, + StreamableHTTPClientTransport, + UnauthorizedError, +} from '@modelcontextprotocol/client' +import { cliName, modernMcpProtocolVersion } from './defaults.js' import { ensureFreshCredentials, refreshStoredCredentials } from './auth.js' import { readPackageVersion } from './package-info.js' import { redactError } from './redact.js' @@ -21,34 +23,6 @@ export type ToolCallResult = { isError?: boolean } -function requestMethod( - input: Parameters[0], - init?: Parameters[1], -) { - if (init?.method) return init.method.toUpperCase() - if (typeof Request !== 'undefined' && input instanceof Request) { - return input.method.toUpperCase() - } - return 'GET' -} - -/** - * Streamable HTTP clients open an optional GET SSE after initialize. Kody's - * sessionful `/mcp` GET holds that session until the stream ends, so the - * follow-up POST (`tools/list`, `search`, `execute`) never completes. The - * spec treats GET SSE as optional; 405 tells the SDK to stay on POST. - */ -export function createMcpFetch(fetchFn: typeof fetch = fetch): typeof fetch { - return (input, init) => { - if (requestMethod(input, init) === 'GET') { - return Promise.resolve( - new Response('Method Not Allowed', { status: 405 }), - ) - } - return fetchFn(input, init) - } -} - async function connect( mcpUrl: string, accessToken: string, @@ -60,12 +34,15 @@ async function connect( Authorization: `Bearer ${accessToken}`, }, }, - fetch: createMcpFetch(fetchFn ?? fetch), - }) - const client = new Client({ - name: cliName, - version: readPackageVersion(), + ...(fetchFn ? { fetch: fetchFn } : {}), }) + const client = new Client( + { + name: cliName, + version: readPackageVersion(), + }, + { versionNegotiation: { mode: { pin: modernMcpProtocolVersion } } }, + ) await client.connect(transport) return { client, transport } } @@ -168,5 +145,8 @@ export function formatToolResult( function isUnauthorized(error: unknown): boolean { if (!error || typeof error !== 'object') return false const status = 'code' in error ? error.code : undefined - return status === 401 || (error instanceof Error && /401|unauthorized/i.test(error.message)) + return ( + status === 401 || + (error instanceof Error && /401|unauthorized/i.test(error.message)) + ) } From c3dd39e262c02a8ee2ac94d3cff99b8f9a4b4050 Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:17:36 -0600 Subject: [PATCH 4/6] test: assert 2026-07-28 POST discover and drop GET SSE 405 --- README.md | 4 +-- src/cli.ts | 7 +++--- test/cli.test.ts | 65 ++++++++++++++++++++++++++++++++++++------------ 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 78990b3..5ab26e5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Turn one-off agent work into something you can rerun: a local MCP client for [Kody](https://kody.codes) with login, OS keychain token storage, `search`, and -`execute`. +`execute`. Talks MCP `2026-07-28` (Kody's stateless `/mcp` lane). ```bash npx @kodycodes/cli login @@ -45,7 +45,7 @@ Credentials are stored in the OS secret store: - Linux: Secret Service / libsecret If the keychain is unavailable (common on headless Linux), the CLI writes a -`0600` file under `$XDG_CONFIG_HOME/kody` (or `%APPDATA%\\kody` on Windows, +`0600` file under `$XDG_CONFIG_HOME/kody` (or `%APPDATA%\kody` on Windows, `~/Library/Application Support/kody` on macOS). Tokens are never printed. Access tokens refresh automatically on expiry or HTTP 401. diff --git a/src/cli.ts b/src/cli.ts index 28fb611..b2aa578 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ import { parseArgs } from 'node:util' import { readFile } from 'node:fs/promises' -import { defaultMcpUrl } from './defaults.js' +import { defaultMcpUrl, modernMcpProtocolVersion } from './defaults.js' import { usage } from './help.js' import { ensureFreshCredentials, login } from './auth.js' import { deleteCredentials, loadCredentials } from './store.js' @@ -165,6 +165,7 @@ async function dispatch( `${JSON.stringify( { mcpUrl: credentials.mcpUrl, + protocol: modernMcpProtocolVersion, scope: credentials.scope ?? null, tools: tools.map((tool) => tool.name), }, @@ -175,7 +176,7 @@ async function dispatch( return 0 } write( - `Connected to ${credentials.mcpUrl}\nTools: ${tools.map((tool) => tool.name).join(', ') || '(none)'}\n`, + `Connected to ${credentials.mcpUrl} (${modernMcpProtocolVersion})\nTools: ${tools.map((tool) => tool.name).join(', ') || '(none)'}\n`, ) return 0 } @@ -198,7 +199,7 @@ async function dispatch( ? parsed.values.file === '-' ? await readStdin() : await readFile(parsed.values.file, 'utf8') - : parsed.positionals.join('\n').trim() + : parsed.positionals.join('\n').trim() if (!code) { throw new Error('Provide --code, --file, or a module string.') } diff --git a/test/cli.test.ts b/test/cli.test.ts index 70ac00a..178c3dd 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,8 +1,13 @@ import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { test } from 'node:test' import { resolveCommand } from '../src/cli.js' -import { createMcpFetch, formatToolResult } from '../src/mcp.js' +import { modernMcpProtocolVersion } from '../src/defaults.js' +import { formatToolResult, listKodyTools } from '../src/mcp.js' import { redact } from '../src/redact.js' +import { createFileBackend, saveCredentials } from '../src/store.js' test('resolveCommand maps subcommands and flags', () => { assert.equal(resolveCommand(['search', 'what can you do']).command, 'search') @@ -31,26 +36,54 @@ test('redact strips token-looking assignments', () => { ) }) -test('createMcpFetch answers GET with 405 and forwards other methods', async () => { - const calls: Array = [] +test('CLI pins the stateless MCP protocol revision', () => { + assert.equal(modernMcpProtocolVersion, '2026-07-28') +}) + +test('listKodyTools opens the 2026-07-28 lane with POST server/discover, never GET SSE', async () => { + const backend = createFileBackend(join(mkdtempSync(join(tmpdir(), 'kody-cli-')), 'creds.json')) + saveCredentials( + { + version: 1, + mcpUrl: 'https://kody.codes/mcp', + resource: 'https://kody.codes/mcp', + authorizationServerUrl: 'https://kody.codes', + clientId: 'client-1', + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'bearer', + expiresAt: Date.now() + 120_000, + scope: 'profile email', + }, + backend, + ) + const requests: Array<{ method: string; mcpMethod: string | null; protocol: string | null }> = + [] const fetchFn = (async ( - input: Parameters[0], + _input: Parameters[0], init?: Parameters[1], ) => { - calls.push(`${init?.method ?? 'GET'} ${String(input)}`) - return new Response('forwarded', { status: 200 }) + const headers = new Headers(init?.headers) + requests.push({ + method: (init?.method ?? 'GET').toUpperCase(), + mcpMethod: headers.get('mcp-method'), + protocol: headers.get('mcp-protocol-version'), + }) + return new Response('probe-closed', { status: 204 }) }) as typeof fetch - const mcpFetch = createMcpFetch(fetchFn) - - const denied = await mcpFetch('https://kody.codes/mcp', { method: 'GET' }) - assert.equal(denied.status, 405) - assert.deepEqual(calls, []) - const posted = await mcpFetch('https://kody.codes/mcp', { + await assert.rejects( + () => listKodyTools({ mcpUrl: 'https://kody.codes/mcp', backend, fetchFn }), + /2026-07-28|server\/discover|negotiation/i, + ) + assert.ok(requests.length > 0) + assert.equal( + requests.some((request) => request.method === 'GET'), + false, + ) + assert.deepEqual(requests[0], { method: 'POST', - body: '{}', + mcpMethod: 'server/discover', + protocol: '2026-07-28', }) - assert.equal(posted.status, 200) - assert.equal(await posted.text(), 'forwarded') - assert.deepEqual(calls, ['POST https://kody.codes/mcp']) }) From 63d5a81091fe95a27b02c2aefa7b4aca45608fa1 Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:33:13 -0600 Subject: [PATCH 5/6] fix: use MCP client v2 auth and CIMD for login --- README.md | 5 +- package.json | 1 - skills/kody/SKILL.md | 2 +- src/auth.ts | 215 +++++++++++++++++++++--------------------- src/defaults.ts | 12 +++ src/oauth-provider.ts | 115 ++++++++++++++++++++++ test/auth.test.ts | 36 +++++++ 7 files changed, 275 insertions(+), 111 deletions(-) create mode 100644 src/oauth-provider.ts diff --git a/README.md b/README.md index 5ab26e5..dc29bd5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Turn one-off agent work into something you can rerun: a local MCP client for [Kody](https://kody.codes) with login, OS keychain token storage, `search`, and -`execute`. Talks MCP `2026-07-28` (Kody's stateless `/mcp` lane). +`execute`. Talks MCP `2026-07-28` (Kody's stateless `/mcp` lane) and logs in +with Client ID Metadata Documents (SEP-991). ```bash npx @kodycodes/cli login @@ -25,7 +26,7 @@ Or run via `npx @kodycodes/cli` without a global install. | Command | Purpose | | --- | --- | -| `kody login` | Browser OAuth (DCR + PKCE). Stores access and refresh tokens. | +| `kody login` | Browser OAuth (CIMD + PKCE). Stores access and refresh tokens. | | `kody logout` | Deletes stored credentials. | | `kody status` | Shows login state without printing secrets. | | `kody whoami` | Confirms the MCP connection and lists tools. | diff --git a/package.json b/package.json index 2550461..f345ea1 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ }, "dependencies": { "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "^1.3.0" }, "devDependencies": { diff --git a/skills/kody/SKILL.md b/skills/kody/SKILL.md index 5bcf262..7b910b6 100644 --- a/skills/kody/SKILL.md +++ b/skills/kody/SKILL.md @@ -31,7 +31,7 @@ npx @kodycodes/cli skill install kody login ``` -The CLI opens a browser for Kody OAuth (PKCE + dynamic client registration). +The CLI opens a browser for Kody OAuth (PKCE + Client ID Metadata Documents). If a browser cannot open, it prints the URL. Tokens (access + refresh) are stored in the OS keychain on macOS, Windows, and Linux. Linux without Secret Service falls back to a `0600` file under `$XDG_CONFIG_HOME/kody`. diff --git a/src/auth.ts b/src/auth.ts index 9e433ff..845d42e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -5,24 +5,18 @@ import { type ServerResponse, } from 'node:http' import { - discoverOAuthServerInfo, - exchangeAuthorization, - refreshAuthorization, - registerClient, - startAuthorization, -} from '@modelcontextprotocol/sdk/client/auth.js' -import type { - AuthorizationServerMetadata, - OAuthClientInformationMixed, - OAuthTokens, -} from '@modelcontextprotocol/sdk/shared/auth.js' + auth, + type OAuthClientInformationMixed, + type OAuthTokens, +} from '@modelcontextprotocol/client' import { accessTokenSkewMs, + cliRedirectUrl, defaultMcpUrl, defaultScopes, loginTimeoutMs, } from './defaults.js' -import { openUrl } from './open-url.js' +import { createCliOAuthProvider } from './oauth-provider.js' import { redactError } from './redact.js' import { loadCredentials, @@ -41,17 +35,6 @@ export type LoginOptions = { onAuthorizationUrl?: (url: URL) => void } -function clientInformationFrom( - credentials: StoredCredentials, -): OAuthClientInformationMixed { - return { - client_id: credentials.clientId, - ...(credentials.clientSecret - ? { client_secret: credentials.clientSecret } - : {}), - } -} - export function isAccessTokenExpired( credentials: StoredCredentials, now: number = Date.now(), @@ -92,6 +75,37 @@ export function credentialsFromTokens(input: { } } +async function persistAuthorizedSession(input: { + mcpUrl: string + provider: ReturnType + backend?: SecretBackend + previous?: StoredCredentials + now?: number +}): Promise<{ + credentials: StoredCredentials + saved: ReturnType +}> { + const tokens = await input.provider.tokens() + const client = await input.provider.clientInformation() + if (!tokens || !client) { + throw new Error('OAuth completed without tokens. Run `kody login`.') + } + const credentials = credentialsFromTokens({ + mcpUrl: input.mcpUrl, + resource: input.mcpUrl, + authorizationServerUrl: + tokens.issuer ?? + input.previous?.authorizationServerUrl ?? + new URL(input.mcpUrl).origin, + client, + tokens, + previous: input.previous, + now: input.now, + }) + const saved = saveCredentials(credentials, input.backend) + return { credentials, saved } +} + export async function refreshStoredCredentials(input: { credentials: StoredCredentials backend?: SecretBackend @@ -102,26 +116,29 @@ export async function refreshStoredCredentials(input: { if (!credentials.refreshToken) { throw new Error('No refresh token is stored. Run `kody login`.') } - const info = await discoverOAuthServerInfo(credentials.mcpUrl, { - fetchFn: input.fetchFn, + const provider = createCliOAuthProvider({ + mcpUrl: credentials.mcpUrl, + redirectUri: cliRedirectUrl(), + existing: credentials, + loadStoredTokens: true, + openBrowser: false, + expectedState: crypto.randomUUID(), }) - const tokens = await refreshAuthorization(info.authorizationServerUrl, { - metadata: info.authorizationServerMetadata, - clientInformation: clientInformationFrom(credentials), - refreshToken: credentials.refreshToken, - resource: new URL(credentials.resource), + const result = await auth(provider, { + serverUrl: credentials.mcpUrl, + scope: defaultScopes.join(' '), fetchFn: input.fetchFn, }) - const next = credentialsFromTokens({ + if (result !== 'AUTHORIZED') { + throw new Error('Token refresh requires a new `kody login`.') + } + const { credentials: next } = await persistAuthorizedSession({ mcpUrl: credentials.mcpUrl, - resource: credentials.resource, - authorizationServerUrl: credentials.authorizationServerUrl, - client: clientInformationFrom(credentials), - tokens, + provider, + backend: input.backend, previous: credentials, now: input.now, }) - saveCredentials(next, input.backend) return next } @@ -147,22 +164,22 @@ export async function ensureFreshCredentials(input: { }) } -function startCallbackServer(): Promise<{ server: Server; redirectUri: URL }> { +function startCallbackServer(redirectUri: URL): Promise { return new Promise((resolve, reject) => { const server = createServer() - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (!address || typeof address === 'string') { - server.close() - reject(new Error('Could not bind a localhost callback port.')) + const port = Number(redirectUri.port) + server.listen(port, redirectUri.hostname, () => resolve(server)) + server.on('error', (error) => { + if ('code' in error && error.code === 'EADDRINUSE') { + reject( + new Error( + `Login callback port ${port} is in use. Stop the other listener and retry.`, + ), + ) return } - resolve({ - server, - redirectUri: new URL(`http://127.0.0.1:${address.port}/callback`), - }) + reject(error) }) - server.on('error', reject) }) } @@ -171,13 +188,13 @@ function waitForCallback(input: { redirectUri: URL expectedState: string timeoutMs: number -}): Promise { +}): Promise<{ code: string; iss?: string }> { return new Promise((resolve, reject) => { - const finish = (error: Error | null, code?: string) => { + const finish = (error: Error | null, result?: { code: string; iss?: string }) => { clearTimeout(timer) input.server.close() if (error) reject(error) - else resolve(code ?? '') + else resolve(result ?? { code: '' }) } const timer = setTimeout(() => { finish(new Error('Timed out waiting for the browser login.')) @@ -201,6 +218,7 @@ function waitForCallback(input: { } const state = url.searchParams.get('state') const code = url.searchParams.get('code') + const iss = url.searchParams.get('iss') ?? undefined if (state !== input.expectedState || !code) { response .writeHead(400, { 'Content-Type': 'text/html' }) @@ -211,7 +229,7 @@ function waitForCallback(input: { response .writeHead(200, { 'Content-Type': 'text/html' }) .end('

Kody CLI is signed in. You can close this window.

') - finish(null, code) + finish(null, { code, ...(iss ? { iss } : {}) }) } catch (error) { finish(redactError(error)) } @@ -226,70 +244,53 @@ export async function login(options: LoginOptions = {}): Promise<{ backendPath?: string }> { const mcpUrl = options.mcpUrl ?? defaultMcpUrl - const fetchFn = options.fetchFn - const info = await discoverOAuthServerInfo(mcpUrl, { fetchFn }) - const resource = - info.resourceMetadata?.resource ?? - `${new URL(mcpUrl).origin}${new URL(mcpUrl).pathname}` - const { server, redirectUri } = await startCallbackServer() + const redirectUri = cliRedirectUrl() + const expectedState = crypto.randomUUID() + const server = await startCallbackServer(redirectUri) + let authorizationUrl = redirectUri + const provider = createCliOAuthProvider({ + mcpUrl, + redirectUri, + loadStoredTokens: false, + openBrowser: options.openBrowser !== false, + expectedState, + onAuthorizationUrl: (url) => { + authorizationUrl = url + options.onAuthorizationUrl?.(url) + }, + }) try { - const metadata = info.authorizationServerMetadata as - | AuthorizationServerMetadata - | undefined - const client = await registerClient(info.authorizationServerUrl, { - metadata, - clientMetadata: { - redirect_uris: [redirectUri.href], - client_name: 'Kody CLI', - client_uri: 'https://github.com/kody-bot/cli', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - scope: defaultScopes.join(' '), - }, + const first = await auth(provider, { + serverUrl: mcpUrl, scope: defaultScopes.join(' '), - fetchFn, + fetchFn: options.fetchFn, }) - const state = crypto.randomUUID() - const { authorizationUrl, codeVerifier } = await startAuthorization( - info.authorizationServerUrl, - { - metadata, - clientInformation: client, - redirectUrl: redirectUri, + if (first !== 'AUTHORIZED') { + const callback = await waitForCallback({ + server, + redirectUri, + expectedState, + timeoutMs: options.timeoutMs ?? loginTimeoutMs, + }) + const exchanged = await auth(provider, { + serverUrl: mcpUrl, + authorizationCode: callback.code, + ...(callback.iss ? { iss: callback.iss } : {}), scope: defaultScopes.join(' '), - state, - resource: new URL(resource), - }, - ) - options.onAuthorizationUrl?.(authorizationUrl) - if (options.openBrowser !== false) { - await openUrl(authorizationUrl.href) + fetchFn: options.fetchFn, + }) + if (exchanged !== 'AUTHORIZED') { + throw new Error('OAuth redirect completed without tokens.') + } + } else { + server.close() } - const code = await waitForCallback({ - server, - redirectUri, - expectedState: state, - timeoutMs: options.timeoutMs ?? loginTimeoutMs, - }) - const tokens = await exchangeAuthorization(info.authorizationServerUrl, { - metadata, - clientInformation: client, - authorizationCode: code, - codeVerifier, - redirectUri, - resource: new URL(resource), - fetchFn, - }) - const credentials = credentialsFromTokens({ + const { credentials, saved } = await persistAuthorizedSession({ mcpUrl, - resource, - authorizationServerUrl: info.authorizationServerUrl, - client, - tokens, + provider, + backend: options.backend, now: options.now?.(), }) - const saved = saveCredentials(credentials, options.backend) return { credentials, authorizationUrl, diff --git a/src/defaults.ts b/src/defaults.ts index 595bce6..e858878 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -6,3 +6,15 @@ export const keyringService = 'kody.codes' export const loginTimeoutMs = 5 * 60 * 1000 export const accessTokenSkewMs = 60_000 export const cliName = '@kodycodes/cli' +export const cliClientUri = 'https://github.com/kody-bot/cli' +/** Fixed loopback port so CIMD can list an exact redirect URI. */ +export const oauthCallbackPort = 43742 +export const cliClientIdMetadataPath = '/oauth/cli-client-metadata.json' + +export function cliClientMetadataUrl(mcpUrl: string): string { + return new URL(cliClientIdMetadataPath, mcpUrl).href +} + +export function cliRedirectUrl(port: number = oauthCallbackPort): URL { + return new URL(`http://127.0.0.1:${port}/callback`) +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts new file mode 100644 index 0000000..bb7f9cc --- /dev/null +++ b/src/oauth-provider.ts @@ -0,0 +1,115 @@ +import { + type OAuthClientMetadata, + type OAuthClientProvider, + type OAuthDiscoveryState, + type StoredOAuthClientInformation, + type StoredOAuthTokens, + validateClientMetadataUrl, +} from '@modelcontextprotocol/client' +import { + cliClientMetadataUrl, + cliClientUri, + cliName, + cliRedirectUrl, + defaultScopes, +} from './defaults.js' +import type { StoredCredentials } from './store.js' +import { openUrl } from './open-url.js' + +export function buildCliClientMetadata(): OAuthClientMetadata { + return { + client_name: cliName, + client_uri: cliClientUri, + redirect_uris: [cliRedirectUrl().href], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + application_type: 'native', + scope: defaultScopes.join(' '), + } +} + +export function createCliOAuthProvider(input: { + mcpUrl: string + redirectUri: URL + existing?: StoredCredentials + loadStoredTokens: boolean + openBrowser: boolean + expectedState: string + onAuthorizationUrl?: (url: URL) => void +}): OAuthClientProvider { + const clientMetadataUrl = cliClientMetadataUrl(input.mcpUrl) + validateClientMetadataUrl(clientMetadataUrl) + let codeVerifier = '' + let discoveryState: OAuthDiscoveryState | undefined + // Always present a CIMD client_id. Leaving this empty would let the SDK + // fall back to deprecated dynamic client registration. + let clientInformation: StoredOAuthClientInformation | undefined = + input.existing + ? { + client_id: input.existing.clientId, + ...(input.existing.clientSecret + ? { client_secret: input.existing.clientSecret } + : {}), + issuer: input.existing.authorizationServerUrl, + } + : { client_id: clientMetadataUrl } + let tokens: StoredOAuthTokens | undefined = + input.loadStoredTokens && input.existing + ? { + access_token: input.existing.accessToken, + token_type: input.existing.tokenType, + ...(input.existing.refreshToken + ? { refresh_token: input.existing.refreshToken } + : {}), + ...(input.existing.scope ? { scope: input.existing.scope } : {}), + issuer: input.existing.authorizationServerUrl, + } + : undefined + + return { + clientMetadataUrl, + get redirectUrl() { + return input.redirectUri + }, + get clientMetadata() { + return buildCliClientMetadata() + }, + state() { + return input.expectedState + }, + clientInformation() { + return clientInformation + }, + saveClientInformation(next) { + clientInformation = next + }, + tokens() { + return tokens + }, + saveTokens(next) { + tokens = next + }, + async redirectToAuthorization(authorizationUrl) { + input.onAuthorizationUrl?.(authorizationUrl) + if (input.openBrowser) { + await openUrl(authorizationUrl.href) + } + }, + saveCodeVerifier(next) { + codeVerifier = next + }, + codeVerifier() { + if (!codeVerifier) { + throw new Error('OAuth PKCE verifier is missing.') + } + return codeVerifier + }, + saveDiscoveryState(next) { + discoveryState = next + }, + discoveryState() { + return discoveryState + }, + } +} diff --git a/test/auth.test.ts b/test/auth.test.ts index db6181c..6550ee6 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -4,6 +4,15 @@ import { credentialsFromTokens, isAccessTokenExpired, } from '../src/auth.js' +import { + cliClientMetadataUrl, + cliRedirectUrl, + modernMcpProtocolVersion, +} from '../src/defaults.js' +import { + buildCliClientMetadata, + createCliOAuthProvider, +} from '../src/oauth-provider.js' import type { StoredCredentials } from '../src/store.js' const previous: StoredCredentials = { @@ -73,3 +82,30 @@ test('credentialsFromTokens stores a rotated refresh token', () => { assert.equal(next.clientSecret, 'secret') assert.equal(next.expiresAt, 10_000) }) + +test('CLI OAuth identity is CIMD with a fixed loopback redirect', async () => { + const mcpUrl = 'https://kody.codes/mcp' + const metadata = buildCliClientMetadata() + const provider = createCliOAuthProvider({ + mcpUrl, + redirectUri: cliRedirectUrl(), + loadStoredTokens: false, + openBrowser: false, + expectedState: 'state', + }) + assert.equal( + cliClientMetadataUrl(mcpUrl), + 'https://kody.codes/oauth/cli-client-metadata.json', + ) + assert.equal(metadata.client_name, '@kodycodes/cli') + assert.deepEqual(metadata.redirect_uris, [cliRedirectUrl().href]) + assert.equal(metadata.token_endpoint_auth_method, 'none') + assert.equal(metadata.application_type, 'native') + assert.equal(modernMcpProtocolVersion, '2026-07-28') + assert.equal( + provider.clientMetadataUrl, + 'https://kody.codes/oauth/cli-client-metadata.json', + ) + const client = await provider.clientInformation() + assert.equal(client?.client_id, provider.clientMetadataUrl) +}) From c7391b15617159a28c1c5326801cbe1cc43946fe Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:33:13 -0600 Subject: [PATCH 6/6] docs: add ship-pr and orchestrate skills --- .agents/skills/orchestrate/SKILL.md | 50 +++++++++++++++++++++++ .agents/skills/ship-pr/SKILL.md | 61 +++++++++++++++++++++++++++++ AGENTS.md | 11 ++++++ 3 files changed, 122 insertions(+) create mode 100644 .agents/skills/orchestrate/SKILL.md create mode 100644 .agents/skills/ship-pr/SKILL.md create mode 100644 AGENTS.md diff --git a/.agents/skills/orchestrate/SKILL.md b/.agents/skills/orchestrate/SKILL.md new file mode 100644 index 0000000..86256c8 --- /dev/null +++ b/.agents/skills/orchestrate/SKILL.md @@ -0,0 +1,50 @@ +--- +name: orchestrate +description: > + Orchestrate sub-agents for large tasks inside a single environment: plan, + delegate coding to cheap/fast models, parallelize the critical path, keep + reviews lean, and close every loop. Use when acting as an orchestrator or + writing a kickoff for one. Prefer implement when fan-out does not clearly pay. +--- + +# Orchestrate + +This is the kody `orchestrate` skill, scoped to `@kodycodes/cli`. + +Fan out **sub-agents inside one environment** (shared checkout). This repo is +one package; multi-environment fleets are out of scope here. + +Two modes: **be** the orchestrator, or **spawn** one (smarter model) if you are +optimized for cheap/fast execution. + +## Defaults (override only if the user says so) + +- **Prefer implement.** Fan out only when non-conflicting workstreams clearly + beat one implementer. Sequential slices → implement (or one implementer). +- **No orchestration theater.** Ban review → recheck → final → CI-watch chains + per slice. **One** hard-to-reverse review before merge. +- **One CI wait path** — parent `gh pr checks` _or_ a ci-watcher, not both. +- **Targeted tests while iterating;** `npm run validate` before ready-for-review. +- Close loops yourself — don't leave humans as the relay. + +## Role + +1. Plan, delegate, integrate, ship. Bulk-code only when fan-out costs more. +2. Frontier model orchestrates; cheap/fast models implement (prefer Grok 4.5 / + `composer-2.5-fast` for mechanical work). +3. Critical path first; parallelize non-conflicting files; serialize shared + ones. +4. **You do final QA.** Never declare done from sub-agent claims. + +## Fan-out (when it pays) + +- One implementer per independent vertical slice +- Cheap sweeps (parity / errors) +- Audit → prioritized cleanup, then parallel cleanup agents +- One independent "hard-to-reverse / security" review before merge + +## Kickoff (when spawning an orchestrator) + +Keep it short: goals + constraints + out-of-scope; "you orchestrate, don't bulk +code"; preferred implementer model; single-environment; done = falsifiable. +Point at this skill. diff --git a/.agents/skills/ship-pr/SKILL.md b/.agents/skills/ship-pr/SKILL.md new file mode 100644 index 0000000..201d0c3 --- /dev/null +++ b/.agents/skills/ship-pr/SKILL.md @@ -0,0 +1,61 @@ +--- +name: ship-pr +description: > + Babysit a PR: iterate with AI reviewers and CI until green, get it ready, + optionally squash-merge as Kody and watch the publish, then send a Discord + summary. Medium risk waits for AI reviewer(s) and addresses valid feedback. + Use when a pull request needs to be shepherded to done. +--- + +# Ship PR + +This is the kody `ship-pr` skill, scoped to `@kodycodes/cli`. + +## Risk → merge authority + +Self-assess; user policy overrides. + +- **Low** — green CI; nits ignorable; squash-merge when policy allows. +- **Medium** — wait for AI reviewer(s); address **valid** feedback (ignore + insignificant nits / already-fixed / wrong); then merge when policy allows. +- **High** — leave ready-for-review unless the user granted merge authority. + +## Loop + +1. Mark ready — `kody:@kentcdodds/github/pr/set-review-status` + `{ prUrl, status: 'ready' }` (or owner/repo/prNumber). +2. Wait for CI — `gh pr checks` (or compose `loop-on-ci` / `fix-ci`). +3. Fix failures; for **medium+**, wait on AI reviewer(s) and address valid + feedback. Rebase only when actually unmergeable. Local gate is + `npm run validate`. If the change touches login / MCP protocol, also smoke + `whoami`, `search`, and `execute` against `https://kody.codes/mcp`. +4. Green + (medium+: valid feedback cleared) → break. +5. Push → repeat. + +## Gates ≠ CI + +Blocked on a release / trusted-publishing / calendar gate → **end the run** +and schedule a wake. Don't sleep-poll or code-thrash an intentional time +window. + +## Merge / publish + +When policy + risk allow: squash-merge via `kody:@kentcdodds/github/pr/merge` +`{ prUrl, mergeMethod: 'squash' }`. `main` conventional commits publish +`@kodycodes/cli` through semantic-release. Useful: `pr/get-checks`, +`request`, `graphql` on the same package. + +## Done → Discord + +Always summarize (merged or not) with agent / PR / CI / publish links: + +```javascript +import postMessage from 'kody:@kentcdodds/discord/post-message' + +export default async function main() { + return postMessage({ + channelId: '1491568683737157683', + content: '…summary with links…', + }) +} +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d653072 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# @kodycodes/cli + +Official Kody MCP client. `npm run validate` is the local gate (typecheck, +tests, build). + +Skills: + +- [ship-pr](./.agents/skills/ship-pr/SKILL.md) — babysit a PR to green, then + merge when policy allows +- [orchestrate](./.agents/skills/orchestrate/SKILL.md) — fan out sub-agents + inside one checkout when it clearly pays