From c6be89f211d1adf5aeec107e4ed2b31394f7ced8 Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Sat, 4 Apr 2026 16:29:17 -0400 Subject: [PATCH 1/2] feat(cli): add `token` subcommand for Graph API bearer token export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Print a Microsoft Graph API bearer token to stdout for use in sandboxed environments (e.g., NemoClaw) where the macOS Keychain is unavailable. Follows `gh auth token` convention: token to stdout, errors to stderr. Auto-selects when one mailbox is configured; requires --mailbox if multiple exist (exit 2 with list). Strictly noninteractive — expired auth directs user to `configure` instead of starting device code flow. Includes stdout flush await to prevent truncation when CLI wrapper calls process.exit() after runCli() resolves. --- packages/email-mcp/src/cli.ts | 62 ++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/email-mcp/src/cli.ts b/packages/email-mcp/src/cli.ts index 1d521d7..707bb55 100644 --- a/packages/email-mcp/src/cli.ts +++ b/packages/email-mcp/src/cli.ts @@ -157,6 +157,8 @@ export async function runCli(args: string[]): Promise { return await runConfigure(opts); case 'status': return await runStatus(); + case 'token': + return await runToken(opts); case 'help': printHelp(); return 0; @@ -653,6 +655,63 @@ export async function runStatus(): Promise { return 0; } +async function runToken(opts: CliOptions): Promise { + const { + DelegatedAuthManager, + listConfiguredMailboxesWithMetadata, + loadMailboxMetadata, + } = await import('@usejunior/provider-microsoft'); + + let metadata; + + if (opts.mailbox) { + metadata = await loadMailboxMetadata(opts.mailbox); + if (!metadata) { + process.stderr.write(`Error: mailbox "${opts.mailbox}" not found.\n`); + return 2; + } + } else { + const mailboxes = await listConfiguredMailboxesWithMetadata(); + if (mailboxes.length === 0) { + process.stderr.write('Error: no mailboxes configured. Run: npx email-agent-mcp configure\n'); + return 1; + } + if (mailboxes.length > 1) { + process.stderr.write('Error: multiple mailboxes configured. Use --mailbox to select:\n'); + for (const m of mailboxes) { + process.stderr.write(` ${m.emailAddress ?? m.mailboxName}\n`); + } + return 2; + } + metadata = mailboxes[0]!; + } + + const auth = new DelegatedAuthManager( + { mode: 'delegated', clientId: metadata.clientId }, + metadata.mailboxName, + ); + + try { + await auth.reconnect(); + const token = await auth.getAccessToken(); + + // Await stdout flush — the CLI wrapper calls process.exit() after runCli() + // resolves, which can truncate piped output if the write hasn't drained. + await new Promise((resolve, reject) => { + process.stdout.write(token, (err) => err ? reject(err) : resolve()); + }); + return 0; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('interaction_required') || msg.includes('invalid_grant')) { + process.stderr.write('Error: authentication expired. Run: npx email-agent-mcp configure\n'); + } else { + process.stderr.write(`Error: ${msg}\n`); + } + return 1; + } +} + function printHelp(): void { console.error(` email-agent-mcp — Email connectivity for AI agents @@ -667,6 +726,7 @@ COMMANDS: email-agent-mcp setup Configure a mailbox (interactive) email-agent-mcp configure Configure a mailbox (interactive, alias for setup) email-agent-mcp status Show account + connection health + email-agent-mcp token Print a Graph API bearer token to stdout email-agent-mcp serve Force MCP server mode email-agent-mcp help Show this help @@ -677,7 +737,7 @@ OPTIONS: --poll-interval Poll interval in seconds (default 10, min 2) --nemoclaw NemoClaw egress bootstrap --log-level Log level (debug, info, warn, error) - --mailbox Mailbox name (default: "default") + --mailbox Mailbox alias or email address (required if multiple configured) --provider Auth provider (microsoft, gmail) --client-id OAuth client ID override `.trim()); From a4ad7ca8b346f1d24146c1ed273ca7c495447e91 Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Mon, 6 Apr 2026 14:22:27 -0400 Subject: [PATCH 2/2] test(cli): add unit tests for token subcommand edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover: - No mailboxes configured → exit 1 with guidance - Unknown --mailbox → exit 2 with "not found" - Multiple mailboxes without --mailbox → exit 2 with list - Expired/missing credentials → exit 1 (auth error) - Arg parsing for token and token --mailbox --- packages/email-mcp/src/cli.test.ts | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/email-mcp/src/cli.test.ts b/packages/email-mcp/src/cli.test.ts index 91ed039..6225070 100644 --- a/packages/email-mcp/src/cli.test.ts +++ b/packages/email-mcp/src/cli.test.ts @@ -353,6 +353,95 @@ describe('cli/EMAIL_AGENT_MCP_HOME', () => { }); }); +describe('cli/Token Subcommand', () => { + let tmpDir: string; + let originalHome: string | undefined; + let stderrSpy: ReturnType; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'email-agent-mcp-token-test-')); + originalHome = process.env['EMAIL_AGENT_MCP_HOME']; + process.env['EMAIL_AGENT_MCP_HOME'] = tmpDir; + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(async () => { + stderrSpy.mockRestore(); + if (originalHome === undefined) { + delete process.env['EMAIL_AGENT_MCP_HOME']; + } else { + process.env['EMAIL_AGENT_MCP_HOME'] = originalHome; + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('Scenario: Token with no mailboxes returns exit 1', async () => { + const exitCode = await runCli(['token']); + expect(exitCode).toBe(1); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('no mailboxes configured'), + ); + }); + + it('Scenario: Token with --mailbox nonexistent returns exit 2', async () => { + const exitCode = await runCli(['token', '--mailbox', 'nonexistent']); + expect(exitCode).toBe(2); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('not found'), + ); + }); + + it('Scenario: Token with multiple mailboxes and no --mailbox returns exit 2', async () => { + const { writeFile, mkdir } = await import('node:fs/promises'); + const tokensDir = join(tmpDir, 'tokens'); + await mkdir(tokensDir, { recursive: true }); + + await writeFile(join(tokensDir, 'alice-at-example-com.json'), JSON.stringify({ + mailboxName: 'alice-at-example-com', + emailAddress: 'alice@example.com', + clientId: 'test-client-id', + })); + await writeFile(join(tokensDir, 'bob-at-example-com.json'), JSON.stringify({ + mailboxName: 'bob-at-example-com', + emailAddress: 'bob@example.com', + clientId: 'test-client-id', + })); + + const exitCode = await runCli(['token']); + expect(exitCode).toBe(2); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('multiple mailboxes configured'), + ); + }); + + it('Scenario: Token with single mailbox and expired auth returns exit 1', async () => { + const { writeFile, mkdir } = await import('node:fs/promises'); + const tokensDir = join(tmpDir, 'tokens'); + await mkdir(tokensDir, { recursive: true }); + + await writeFile(join(tokensDir, 'expired-at-example-com.json'), JSON.stringify({ + mailboxName: 'expired-at-example-com', + emailAddress: 'expired@example.com', + clientId: 'test-client-id', + })); + + // reconnect() will fail because there are no real cached credentials + const exitCode = await runCli(['token']); + expect(exitCode).toBe(1); + }); + + it('Scenario: Token arg parsing', () => { + const opts = parseCliArgs(['token']); + expect(opts.command).toBe('token'); + }); + + it('Scenario: Token with --mailbox arg parsing', () => { + const opts = parseCliArgs(['token', '--mailbox', 'steven@usejunior.com']); + expect(opts.command).toBe('token'); + expect(opts.mailbox).toBe('steven@usejunior.com'); + }); +}); + describe('cli/Poll Interval Validation', () => { it('Scenario: Poll interval below 2 is clamped', () => { const opts = parseCliArgs(['watch', '--poll-interval', '1']);