fix: preserve headless auth stdout
This commit is contained in:
parent
7ddb433479
commit
33afa744e0
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
dist
|
||||
dist-bun
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
### OAuth
|
||||
|
||||
- Add headless OAuth login support via `--no-browser`, `--browser none`, and `MCPORTER_OAUTH_NO_BROWSER`, emitting parseable authorization URLs for remote auth flows. (PR #171 / issue #169, thanks @feniix)
|
||||
- Proactively complete OAuth for configured HTTP servers that allow unauthenticated `initialize`/`listTools` but require credentials for tool calls, and close the local callback server promptly after browser authorization. (PR #159, thanks @Spacefish)
|
||||
- Refresh expired cached OAuth access tokens during non-interactive `mcporter list` without opening a browser or clearing cached credentials when refresh fails. (Issue #166, thanks @chrisabad)
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import { extractEphemeralServerFlags } from './ephemeral-flags.js';
|
||||
import { persistPreparedEphemeralServer, prepareEphemeralServerTarget } from './ephemeral-target.js';
|
||||
import { looksLikeHttpUrl } from './http-utils.js';
|
||||
import { buildConnectionIssueEnvelope } from './json-output.js';
|
||||
import { logInfo, logWarn } from './logger-context.js';
|
||||
import { getActiveLogger, logInfo, logWarn } from './logger-context.js';
|
||||
import { consumeOutputFormat } from './output-format.js';
|
||||
|
||||
type Runtime = Awaited<ReturnType<typeof createRuntime>>;
|
||||
@ -61,7 +61,9 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
|
||||
const definition = runtime.getDefinition(target);
|
||||
if (shouldReset) {
|
||||
await clearOAuthCaches(definition);
|
||||
logInfo(`Cleared cached credentials for '${target}'.`);
|
||||
if (!noBrowser) {
|
||||
logInfo(`Cleared cached credentials for '${target}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.command.kind === 'stdio' && definition.oauthCommand) {
|
||||
@ -80,16 +82,20 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
|
||||
if (!noBrowser) {
|
||||
logInfo(`Initiating OAuth flow for '${target}'...`);
|
||||
}
|
||||
const tools = await runtime.listTools(target, {
|
||||
autoAuthorize: true,
|
||||
...(noBrowser
|
||||
? {
|
||||
oauthSessionOptions: buildNoBrowserOAuthOptions(format, markAuthorizationOutputEmitted),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const tools = await withInfoLogsSuppressed(noBrowser, () =>
|
||||
runtime.listTools(target, {
|
||||
autoAuthorize: true,
|
||||
...(noBrowser
|
||||
? {
|
||||
oauthSessionOptions: buildNoBrowserOAuthOptions(format, markAuthorizationOutputEmitted),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
);
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
logInfo(`Authorization complete. ${tools.length} tool${tools.length === 1 ? '' : 's'} available.`);
|
||||
if (!noBrowser) {
|
||||
logInfo(`Authorization complete. ${tools.length} tool${tools.length === 1 ? '' : 's'} available.`);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
await persistPreparedEphemeralServer(runtime, prepared);
|
||||
@ -117,6 +123,20 @@ export async function handleAuth(runtime: Runtime, args: string[]): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
async function withInfoLogsSuppressed<T>(enabled: boolean, task: () => Promise<T>): Promise<T> {
|
||||
if (!enabled) {
|
||||
return task();
|
||||
}
|
||||
const logger = getActiveLogger();
|
||||
const originalInfo = logger.info.bind(logger);
|
||||
logger.info = () => {};
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
logger.info = originalInfo;
|
||||
}
|
||||
}
|
||||
|
||||
async function runStdioAuth(definition: ServerDefinition, options: { noBrowser?: boolean } = {}): Promise<void> {
|
||||
const authArgs = [...(definition.command.kind === 'stdio' ? (definition.command.args ?? []) : [])];
|
||||
if (definition.oauthCommand) {
|
||||
|
||||
@ -168,6 +168,45 @@ describe('mcporter auth ad-hoc support', () => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps no-browser stdout parseable when info logging is enabled', async () => {
|
||||
const [{ handleAuth }, { getActiveLogger, setLogLevel }] = await Promise.all([
|
||||
cliModulePromise,
|
||||
import('../src/cli/logger-context.js'),
|
||||
]);
|
||||
const definition = {
|
||||
name: 'linear',
|
||||
command: { kind: 'http', url: new URL('https://mcp.linear.app/mcp') },
|
||||
auth: 'oauth',
|
||||
} as ServerDefinition;
|
||||
const listTools = vi.fn(async (_target, options?: Record<string, unknown>) => {
|
||||
const oauthSessionOptions = options?.oauthSessionOptions as
|
||||
| { onAuthorizationUrl?: (request: { authorizationUrl: string; redirectUrl: string }) => void | Promise<void> }
|
||||
| undefined;
|
||||
await oauthSessionOptions?.onAuthorizationUrl?.({
|
||||
authorizationUrl: 'https://auth.example.com/authorize?state=abc',
|
||||
redirectUrl: 'http://127.0.0.1:54321/callback',
|
||||
});
|
||||
getActiveLogger().info('runtime OAuth status');
|
||||
return [{ name: 'ok' }];
|
||||
});
|
||||
const runtime = {
|
||||
getDefinitions: () => [definition],
|
||||
registerDefinition: vi.fn(),
|
||||
listTools,
|
||||
getDefinition: () => definition,
|
||||
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
setLogLevel('info');
|
||||
await handleAuth(runtime, ['linear', '--no-browser']);
|
||||
expect(logSpy.mock.calls.map(([message]) => message)).toEqual(['https://auth.example.com/authorize?state=abc']);
|
||||
} finally {
|
||||
setLogLevel('warn');
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('prints auth-start JSON once and keeps later failures off stdout', async () => {
|
||||
const { handleAuth } = await cliModulePromise;
|
||||
const definition = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user