From aa4fdcf1cf4e4fc8b4b233f697274f0b5ce7a90a Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 22 Jul 2026 12:16:44 +0300 Subject: [PATCH] AIT-239 add zero-touch Claude MCP auth --- src/api/client.ts | 36 +++++++---- src/auth/login.ts | 4 ++ src/auth/logout.ts | 2 + src/commands/__tests__/mcp.test.ts | 79 ++++++++++++++++++++++++ src/commands/doctor.ts | 3 + src/commands/mcp.ts | 97 ++++++++++++++++++++++++++++++ src/index.ts | 2 + 7 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 src/commands/__tests__/mcp.test.ts create mode 100644 src/commands/mcp.ts diff --git a/src/api/client.ts b/src/api/client.ts index 4e3ecee..d73bde8 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -80,6 +80,28 @@ async function refreshToken( }; } +async function validAccessToken( + creds: NonNullable>>, +): Promise { + if (isAgentCredential(creds)) return creds.accessToken; + const exp = decodeJwtExp(creds.accessToken); + if (exp === 0 || Date.now() / 1000 <= exp - 60) return creds.accessToken; + try { + const refreshed = await refreshToken(creds.refreshToken); + await saveCredentials(refreshed); + return refreshed.accessToken; + } catch { + throw new AuthError('Session expired. Run: hookmyapp login'); + } +} + +/** Return the same fresh Bearer token used by normal CLI API requests. */ +export async function getValidAccessToken(): Promise { + const creds = await readCredentials(); + if (!creds) throw new AuthError('Not logged in. Run: hookmyapp login'); + return validAccessToken(creds); +} + export async function forceTokenRefresh(): Promise { const creds = await readCredentials(); if (!creds) { @@ -288,19 +310,7 @@ export async function apiClient( } })(); - let { accessToken } = creds; - - // Check if token is expired (with 60-second buffer) - const exp = decodeJwtExp(accessToken); - if (exp > 0 && Date.now() / 1000 > exp - 60) { - try { - const refreshed = await refreshToken(creds.refreshToken); - await saveCredentials(refreshed); - accessToken = refreshed.accessToken; - } catch { - throw new AuthError('Session expired. Run: hookmyapp login'); - } - } + const accessToken = await validAccessToken(creds); const baseUrl = getEffectiveApiUrl(); diff --git a/src/auth/login.ts b/src/auth/login.ts index 05d36b6..d106601 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -13,6 +13,7 @@ import { } from '../config/env-profiles.js'; import { posthogAliasAndIdentify } from '../observability/posthog.js'; import { parseSandboxSessions, type WhatsAppSandboxSession } from '../api/sandbox-session.js'; +import { maybeInstallClaudeMcp } from '../commands/mcp.js'; // --- Phase 122 bootstrap-code exchange DTO --- // Mirrors backend/src/auth/bootstrap/dto/exchange-bootstrap.dto.ts (Wave 1 @@ -90,6 +91,7 @@ async function pollForTokens(opts: { email: u.email, name: fullName.length > 0 ? fullName : undefined, }); + maybeInstallClaudeMcp(); console.log(`\n${c.success(icon.success)} Logged in successfully\n`); return; } @@ -443,6 +445,7 @@ export async function runBootstrapCodeExchange( activeWorkspaceId: data.workspace.id, activeWorkspaceSlug: data.workspace.name, }); + maybeInstallClaudeMcp(); // Phase 125 — alias machineId → workosSub once per (machine, user) and // emit cli_logged_in. workspace publicId is already on disk above so @@ -582,6 +585,7 @@ async function persistAgentCredential( scopes: cred.scopes, }); await revalidateActiveWorkspace(json); + maybeInstallClaudeMcp(); if (json) { process.stdout.write( JSON.stringify({ diff --git a/src/auth/logout.ts b/src/auth/logout.ts index 2dbef3f..0c502f9 100644 --- a/src/auth/logout.ts +++ b/src/auth/logout.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { readCredentials, deleteCredentials } from './store.js'; import { isAgentCredential } from '../storage/secrets.js'; import { addExamples } from '../output/help.js'; +import { removeClaudeMcp } from '../commands/mcp.js'; export function logoutCommand(program: Command): void { const logout = program @@ -30,6 +31,7 @@ export function logoutCommand(program: Command): void { } await deleteCredentials(); + removeClaudeMcp(); if (json) { process.stdout.write( diff --git a/src/commands/__tests__/mcp.test.ts b/src/commands/__tests__/mcp.test.ts new file mode 100644 index 0000000..115e994 --- /dev/null +++ b/src/commands/__tests__/mcp.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { getValidAccessToken } from '../../api/client.js'; + +vi.mock('node:child_process', () => ({ spawnSync: vi.fn() })); +vi.mock('../../api/client.js', () => ({ getValidAccessToken: vi.fn() })); +vi.mock('../../config/env-profiles.js', () => ({ + getEffectiveApiUrl: () => 'https://api.hookmyapp.com', +})); + +import { installClaudeMcp, maybeInstallClaudeMcp, printMcpHeaders, removeClaudeMcp } from '../mcp.js'; + +describe('MCP setup', () => { + beforeEach(() => vi.clearAllMocks()); + + test('prints only the dynamic Authorization header JSON', async () => { + vi.mocked(getValidAccessToken).mockResolvedValue('hmok_test'); + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await printMcpHeaders(); + + expect(write).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith('{"Authorization":"Bearer hmok_test"}\n'); + }); + + test('installs a user-scoped Claude headersHelper without storing a token', () => { + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as never); + + installClaudeMcp(); + + const [, args] = vi.mocked(spawnSync).mock.calls[0]; + expect(args).toEqual([ + 'mcp', + 'add-json', + '--scope', + 'user', + 'hookmyapp', + JSON.stringify({ + type: 'http', + url: 'https://api.hookmyapp.com/mcp', + headersHelper: 'hookmyapp mcp-headers', + }), + ]); + expect(JSON.stringify(args)).not.toContain('Bearer'); + }); + + test('skips automatic setup when Claude Code is absent', () => { + vi.mocked(spawnSync).mockReturnValueOnce({ + status: null, + error: Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + } as never); + + maybeInstallClaudeMcp(true); + + expect(spawnSync).toHaveBeenCalledOnce(); + }); + + test('replaces an existing Claude entry', () => { + vi.mocked(spawnSync) + .mockReturnValueOnce({ status: 1, stderr: 'already exists' } as never) + .mockReturnValueOnce({ status: 0 } as never) + .mockReturnValueOnce({ status: 0 } as never); + + installClaudeMcp(); + + expect(spawnSync).toHaveBeenCalledTimes(3); + expect(vi.mocked(spawnSync).mock.calls[1][1]).toEqual(['mcp', 'remove', '--scope', 'user', 'hookmyapp']); + }); + + test('removes only the user-scoped HookMyApp entry', () => { + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as never); + + removeClaudeMcp(true); + + expect(spawnSync).toHaveBeenCalledWith('claude', ['mcp', 'remove', '--scope', 'user', 'hookmyapp'], { + encoding: 'utf8', + }); + }); +}); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d764d69..3d26a95 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { isJsonMode } from '../output/format.js'; import { getEffectiveApiUrl } from '../config/env-profiles.js'; import { readWorkspaceConfig } from './workspace.js'; import { addExamples } from '../output/help.js'; +import { getClaudeMcpStatus } from './mcp.js'; // `hard` checks gate the prereq (a FAIL → non-zero exit). Informational checks // (auth/workspace/default-channel) are reported, never a crash or exit failure. @@ -37,6 +38,8 @@ export async function collectDoctorReport( checks.push({ id: 'npm', label: 'npm', ok: npm !== null, hard: true, detail: npm ?? 'not found on PATH' }); const npx = toolVersion('npx'); checks.push({ id: 'npx', label: 'npx', ok: npx !== null, hard: true, detail: npx ?? 'not found on PATH' }); + const mcp = getClaudeMcpStatus(); + checks.push({ id: 'mcp', label: 'HookMyApp MCP (Claude)', ok: mcp.ok, hard: false, detail: mcp.detail }); } if (opts.checkNetwork !== false) { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..18a07c7 --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,97 @@ +import { spawnSync } from 'node:child_process'; +import type { Command } from 'commander'; +import { getEffectiveApiUrl } from '../config/env-profiles.js'; +import { ConfigurationError } from '../output/error.js'; +import { addExamples } from '../output/help.js'; + +const MCP_NAME = 'hookmyapp'; + +function mcpUrl(): string { + return `${getEffectiveApiUrl().replace(/\/$/, '')}/mcp`; +} + +export async function printMcpHeaders(): Promise { + const { getValidAccessToken } = await import('../api/client.js'); + const token = await getValidAccessToken(); + process.stdout.write(JSON.stringify({ Authorization: `Bearer ${token}` }) + '\n'); +} + +export function installClaudeMcp(): void { + const config = JSON.stringify({ + type: 'http', + url: mcpUrl(), + headersHelper: 'hookmyapp mcp-headers', + }); + const args = ['mcp', 'add-json', '--scope', 'user', MCP_NAME, config]; + let result = spawnSync('claude', args, { encoding: 'utf8' }); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + if (result.status !== 0 && output.includes('already exists')) { + removeClaudeMcp(true); + result = spawnSync('claude', args, { encoding: 'utf8' }); + } + if (result.error || result.status !== 0) { + throw new ConfigurationError( + result.error?.message || result.stderr.trim() || 'Claude Code MCP setup failed', + 'MCP_INSTALL_FAILED', + ); + } +} + +export function maybeInstallClaudeMcp(force = false): void { + if (!force && process.env.NODE_ENV === 'test') return; + const probe = spawnSync('claude', ['--version'], { encoding: 'utf8' }); + if (probe.error || probe.status !== 0) return; + try { + installClaudeMcp(); + } catch (err) { + process.stderr.write( + `HookMyApp login succeeded, but Claude MCP setup failed: ${(err as Error).message}\n` + + 'Run: hookmyapp mcp install --agent claude\n', + ); + } +} + +export function removeClaudeMcp(force = false): void { + if (!force && process.env.NODE_ENV === 'test') return; + spawnSync('claude', ['mcp', 'remove', '--scope', 'user', MCP_NAME], { + encoding: 'utf8', + }); +} + +export function getClaudeMcpStatus(): { ok: boolean; detail: string } { + const result = spawnSync('claude', ['mcp', 'get', MCP_NAME], { + encoding: 'utf8', + }); + if (result.error?.message.includes('ENOENT')) { + return { ok: false, detail: 'Claude Code not found' }; + } + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + return result.status === 0 && output.includes('Connected') + ? { ok: true, detail: 'connected' } + : { + ok: false, + detail: 'not connected — run: hookmyapp mcp install --agent claude', + }; +} + +export function registerMcpCommand(program: Command): void { + const headers = program.command('mcp-headers', { hidden: true }).action(printMcpHeaders); + addExamples(headers, '\nEXAMPLES:\n $ hookmyapp mcp-headers\n $ hookmyapp --env staging mcp-headers'); + + const mcp = program.command('mcp').description('Configure HookMyApp MCP access'); + addExamples(mcp, '\nEXAMPLES:\n $ hookmyapp mcp install --agent claude\n $ hookmyapp doctor'); + const install = mcp + .command('install') + .requiredOption('--agent ', 'Agent to configure (claude)') + .action((opts: { agent: string }) => { + if (opts.agent !== 'claude') { + throw new ConfigurationError(`Unsupported agent "${opts.agent}". Supported: claude`, 'MCP_AGENT_UNSUPPORTED'); + } + installClaudeMcp(); + process.stdout.write('HookMyApp MCP configured for Claude Code.\n'); + }); + addExamples( + install, + '\nEXAMPLES:\n $ hookmyapp mcp install --agent claude\n $ hookmyapp --env staging mcp install --agent claude', + ); +} diff --git a/src/index.ts b/src/index.ts index dd2d379..50a5b26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { registerCustomersCommand } from './commands/customers.js'; import { registerSandboxCommand } from './commands/sandbox/index.js'; import { registerListenCommand } from './commands/sandbox-listen/index.js'; import { registerConfigCommand } from './commands/config.js'; +import { registerMcpCommand } from './commands/mcp.js'; import { CliError, UnexpectedError, @@ -162,6 +163,7 @@ program.configureOutput({ loginCommand(program); logoutCommand(program); registerCredentialsCommand(program); +registerMcpCommand(program); // Channel management registerChannelsCommand(program);