feat(list): use cached oauth for non-interactive list

This commit is contained in:
Peter Steinberger 2025-11-25 17:22:37 +01:00
parent ac36bdde7d
commit d0cdf3a0c5
5 changed files with 86 additions and 2 deletions

View File

@ -1,7 +1,9 @@
# mcporter Changelog
## [Unreleased]
- Nothing yet.
### CLI
- `mcporter list` now uses cached OAuth access tokens (if present) for the all-servers view without opening browser windows, so previously authorized servers no longer show spurious “auth required” in non-interactive listings.
- `pnpm test --filter <pattern>` now works by translating to a Vitest file pattern, avoiding the prior “Unknown option --filter” error.
## [0.6.3] - 2025-11-22
### Runtime & CLI

View File

@ -153,7 +153,7 @@ export async function handleList(
const startedAt = Date.now();
try {
const tools = await withTimeout(
runtime.listTools(server.name, { autoAuthorize: false }),
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
perServerTimeoutMs
);
return {

View File

@ -44,11 +44,13 @@ export interface CallOptions {
export interface ListToolsOptions {
readonly includeSchema?: boolean;
readonly autoAuthorize?: boolean;
readonly allowCachedAuth?: boolean;
}
interface ConnectOptions {
readonly maxOAuthAttempts?: number;
readonly skipCache?: boolean;
readonly allowCachedAuth?: boolean;
}
export interface Runtime {
@ -152,6 +154,7 @@ class McpRuntime implements Runtime {
const context = await this.connect(server, {
maxOAuthAttempts: autoAuthorize ? undefined : 0,
skipCache: !autoAuthorize,
allowCachedAuth: options.allowCachedAuth,
});
try {
const tools: ServerToolInfo[] = [];
@ -248,6 +251,7 @@ class McpRuntime implements Runtime {
maxOAuthAttempts: options.maxOAuthAttempts,
oauthTimeoutMs: this.oauthTimeoutMs ?? OAUTH_CODE_TIMEOUT_MS,
onDefinitionPromoted: (promoted) => this.definitions.set(promoted.name, promoted),
allowCachedAuth: options.allowCachedAuth,
});
if (useCache) {

View File

@ -1,3 +1,6 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
@ -20,6 +23,25 @@ function attachStdioTraceLogging(_transport: StdioClientTransport, _label?: stri
// so runtime callers can opt-in without sprinkling conditional checks everywhere.
}
async function loadCachedAccessToken(definition: ServerDefinition): Promise<string | undefined> {
const tokenDir = definition.tokenCacheDir ?? path.join(os.homedir(), '.mcporter', definition.name);
const tokensPath = path.join(tokenDir, 'tokens.json');
try {
const buffer = await fs.readFile(tokensPath, 'utf8');
const parsed = JSON.parse(buffer);
const token = parsed?.access_token;
if (typeof token === 'string' && token.trim().length > 0) {
return token;
}
return undefined;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined;
}
throw error;
}
}
export interface ClientContext {
readonly client: Client;
readonly transport: Transport & { close(): Promise<void> };
@ -31,6 +53,7 @@ export interface CreateClientContextOptions {
readonly maxOAuthAttempts?: number;
readonly oauthTimeoutMs?: number;
readonly onDefinitionPromoted?: (definition: ServerDefinition) => void;
readonly allowCachedAuth?: boolean;
}
export async function createClientContext(
@ -42,6 +65,38 @@ export async function createClientContext(
const client = new Client(clientInfo);
let activeDefinition = definition;
if (
options.allowCachedAuth &&
activeDefinition.auth === 'oauth' &&
activeDefinition.command.kind === 'http'
) {
try {
const cached = await loadCachedAccessToken(activeDefinition);
if (cached) {
const existingHeaders = activeDefinition.command.headers ?? {};
if (!('Authorization' in existingHeaders)) {
activeDefinition = {
...activeDefinition,
command: {
...activeDefinition.command,
headers: {
...existingHeaders,
Authorization: `Bearer ${cached}`,
},
},
};
logger.debug?.(`Using cached OAuth access token for '${activeDefinition.name}' (non-interactive).`);
}
}
} catch (error) {
logger.debug?.(
`Failed to read cached OAuth token for '${activeDefinition.name}': ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
return withEnvOverrides(activeDefinition.env, async () => {
if (activeDefinition.command.kind === 'stdio') {
const resolvedEnvOverrides =

View File

@ -181,6 +181,29 @@ describe('CLI list classification and routing', () => {
expect(listTools).toHaveBeenCalledWith('shadcn', expect.anything());
});
it('enables cached OAuth when listing all servers', async () => {
const { handleList } = await cliModulePromise;
const definition: ServerDefinition = {
name: 'linear',
description: 'Linear MCP',
auth: 'oauth',
command: { kind: 'http', url: new URL('https://mcp.linear.app/sse') },
source: { kind: 'local', path: '/tmp/config.json' },
};
const listTools = vi.fn().mockResolvedValue([{ name: 'ok' }]);
const runtime = {
getDefinitions: () => [definition],
listTools,
} as unknown as Awaited<ReturnType<typeof import('../src/runtime.js')['createRuntime']>>;
await handleList(runtime, []);
expect(listTools).toHaveBeenCalledWith('linear', {
autoAuthorize: false,
allowCachedAuth: true,
});
});
it('registers an ad-hoc HTTP server when URL is provided', async () => {
const { handleList } = await cliModulePromise;
const definitions = new Map<string, ServerDefinition>();