feat(list): use cached oauth for non-interactive list
This commit is contained in:
parent
ac36bdde7d
commit
d0cdf3a0c5
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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>();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user