chore(oauth): centralize credentials

This commit is contained in:
Peter Steinberger 2025-12-03 16:41:09 +00:00
parent 53a32e2429
commit e1bdcda28e
10 changed files with 401 additions and 107 deletions

View File

@ -2,7 +2,8 @@
## [Unreleased]
### CLI
- Nothing yet.
- Centralized OAuth credentials in a shared vault (`~/.mcporter/.credentials.json`) while still honoring per-server `tokenCacheDir` when present; legacy per-server caches are migrated automatically.
- `mcporter auth --reset` now clears the vault and legacy caches without crashing on corrupted credential files, making re-auth reliable for servers like Gmail.
## [0.6.6] - 2025-11-28
### CLI

View File

@ -25,6 +25,7 @@ import { DaemonClient } from './daemon/client.js';
import { createKeepAliveRuntime } from './daemon/runtime-wrapper.js';
import { analyzeConnectionError } from './error-classifier.js';
import { isKeepAliveServer } from './lifecycle.js';
import { clearOAuthCaches } from './oauth-persistence.js';
import { createRuntime, MCPORTER_VERSION } from './runtime.js';
export { parseCallArguments } from './cli/call-arguments.js';
@ -471,14 +472,8 @@ export async function handleAuth(runtime: Awaited<ReturnType<typeof createRuntim
const definition = runtime.getDefinition(target);
if (shouldReset) {
const tokenDir = definition.tokenCacheDir;
if (tokenDir) {
// Drop the cached credentials so the next auth run starts cleanly.
await fsPromises.rm(tokenDir, { recursive: true, force: true });
logInfo(`Cleared cached credentials for '${target}' at ${tokenDir}`);
} else {
logWarn(`Server '${target}' does not expose a token cache path.`);
}
await clearOAuthCaches(definition);
logInfo(`Cleared cached credentials for '${target}'.`);
}
// Kick off the interactive OAuth flow without blocking list output. We retry once if the

View File

@ -1,8 +1,8 @@
import fs from 'node:fs/promises';
import { loadServerDefinitions } from '../../config.js';
import { CliUsageError } from '../errors.js';
import { resolveServerDefinition } from './shared.js';
import type { ConfigCliOptions } from './types.js';
import { clearOAuthCaches } from '../../oauth-persistence.js';
export async function handleLoginCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
if (args.length === 0) {
@ -18,10 +18,6 @@ export async function handleLogoutCommand(options: ConfigCliOptions, args: strin
}
const servers = await loadServerDefinitions(options.loadOptions);
const target = resolveServerDefinition(name, servers);
if (!target.tokenCacheDir) {
console.log(`Server '${name}' does not expose a token cache directory.`);
return;
}
await fs.rm(target.tokenCacheDir, { recursive: true, force: true });
console.log(`Cleared cached credentials for '${target.name}' (${target.tokenCacheDir})`);
await clearOAuthCaches(target);
console.log(`Cleared cached credentials for '${target.name}'`);
}

View File

@ -15,9 +15,6 @@ export async function handleDoctorCommand(options: ConfigCliOptions, _args: stri
if (server.command.kind === 'stdio' && !path.isAbsolute(server.command.cwd)) {
issues.push(`Server '${server.name}' has a non-absolute working directory.`);
}
if (server.auth === 'oauth' && !server.tokenCacheDir) {
issues.push(`Server '${server.name}' enables OAuth but lacks a token cache directory.`);
}
}
if (issues.length === 0) {
console.log('Config looks good.');

View File

@ -1,5 +1,3 @@
import os from 'node:os';
import path from 'node:path';
import type { CommandSpec, RawEntry, ServerDefinition, ServerLoggingOptions, ServerSource } from './config-schema.js';
import { expandHome } from './env.js';
import { resolveLifecycle } from './lifecycle.js';
@ -41,9 +39,6 @@ export function normalizeServerEntry(
throw new Error(`Server '${name}' is missing a baseUrl/url or command definition in mcporter.json`);
}
const resolvedTokenCacheDir =
auth === 'oauth' ? (tokenCacheDir ?? path.join(os.homedir(), '.mcporter', name)) : (tokenCacheDir ?? undefined);
const lifecycle = resolveLifecycle(name, raw.lifecycle, command);
const logging = normalizeLogging(raw.logging);
@ -53,7 +48,7 @@ export function normalizeServerEntry(
command,
env,
auth,
tokenCacheDir: resolvedTokenCacheDir,
tokenCacheDir,
clientName,
oauthRedirectUrl,
source,

267
src/oauth-persistence.ts Normal file
View File

@ -0,0 +1,267 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { ServerDefinition } from './config.js';
import { readJsonFile, writeJsonFile } from './fs-json.js';
import type { Logger } from './logging.js';
import { clearVaultEntry, loadVaultEntry, saveVaultEntry } from './oauth-vault.js';
export type OAuthClearScope = 'all' | 'client' | 'tokens' | 'verifier' | 'state';
export interface OAuthPersistence {
describe(): string;
readTokens(): Promise<OAuthTokens | undefined>;
saveTokens(tokens: OAuthTokens): Promise<void>;
readClientInfo(): Promise<OAuthClientInformationMixed | undefined>;
saveClientInfo(info: OAuthClientInformationMixed): Promise<void>;
readCodeVerifier(): Promise<string | undefined>;
saveCodeVerifier(value: string): Promise<void>;
readState(): Promise<string | undefined>;
saveState(value: string): Promise<void>;
clear(scope: OAuthClearScope): Promise<void>;
}
class DirectoryPersistence implements OAuthPersistence {
private readonly tokenPath: string;
private readonly clientInfoPath: string;
private readonly codeVerifierPath: string;
private readonly statePath: string;
constructor(private readonly root: string, private readonly logger?: Logger) {
this.tokenPath = path.join(root, 'tokens.json');
this.clientInfoPath = path.join(root, 'client.json');
this.codeVerifierPath = path.join(root, 'code_verifier.txt');
this.statePath = path.join(root, 'state.txt');
}
describe(): string {
return this.root;
}
private async ensureDir() {
await fs.mkdir(this.root, { recursive: true });
}
async readTokens(): Promise<OAuthTokens | undefined> {
return readJsonFile<OAuthTokens>(this.tokenPath);
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await this.ensureDir();
await writeJsonFile(this.tokenPath, tokens);
this.logger?.debug?.(`Saved tokens to ${this.tokenPath}`);
}
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
}
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
await this.ensureDir();
await writeJsonFile(this.clientInfoPath, info);
}
async readCodeVerifier(): Promise<string | undefined> {
try {
return (await fs.readFile(this.codeVerifierPath, 'utf8')).trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined;
}
throw error;
}
}
async saveCodeVerifier(value: string): Promise<void> {
await this.ensureDir();
await fs.writeFile(this.codeVerifierPath, value, 'utf8');
}
async readState(): Promise<string | undefined> {
return readJsonFile<string>(this.statePath);
}
async saveState(value: string): Promise<void> {
await this.ensureDir();
await writeJsonFile(this.statePath, value);
}
async clear(scope: OAuthClearScope): Promise<void> {
const files: string[] = [];
if (scope === 'all' || scope === 'tokens') files.push(this.tokenPath);
if (scope === 'all' || scope === 'client') files.push(this.clientInfoPath);
if (scope === 'all' || scope === 'verifier') files.push(this.codeVerifierPath);
if (scope === 'all' || scope === 'state') files.push(this.statePath);
await Promise.all(
files.map(async (file) => {
try {
await fs.unlink(file);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
})
);
}
}
class VaultPersistence implements OAuthPersistence {
constructor(private readonly definition: ServerDefinition) {}
describe(): string {
return '~/.mcporter/.credentials.json (vault)';
}
async readTokens(): Promise<OAuthTokens | undefined> {
return (await loadVaultEntry(this.definition))?.tokens;
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await saveVaultEntry(this.definition, { tokens });
}
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
return (await loadVaultEntry(this.definition))?.clientInfo;
}
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
await saveVaultEntry(this.definition, { clientInfo: info });
}
async readCodeVerifier(): Promise<string | undefined> {
return (await loadVaultEntry(this.definition))?.codeVerifier;
}
async saveCodeVerifier(value: string): Promise<void> {
await saveVaultEntry(this.definition, { codeVerifier: value });
}
async readState(): Promise<string | undefined> {
return (await loadVaultEntry(this.definition))?.state;
}
async saveState(value: string): Promise<void> {
await saveVaultEntry(this.definition, { state: value });
}
async clear(scope: OAuthClearScope): Promise<void> {
await clearVaultEntry(this.definition, scope);
}
}
class CompositePersistence implements OAuthPersistence {
constructor(private readonly stores: OAuthPersistence[]) {}
describe(): string {
return this.stores.map((store) => store.describe()).join(' + ');
}
async readTokens(): Promise<OAuthTokens | undefined> {
for (const store of this.stores) {
const result = await store.readTokens();
if (result) return result;
}
return undefined;
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await Promise.all(this.stores.map((store) => store.saveTokens(tokens)));
}
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
for (const store of this.stores) {
const result = await store.readClientInfo();
if (result) return result;
}
return undefined;
}
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
await Promise.all(this.stores.map((store) => store.saveClientInfo(info)));
}
async readCodeVerifier(): Promise<string | undefined> {
for (const store of this.stores) {
const result = await store.readCodeVerifier();
if (result) return result;
}
return undefined;
}
async saveCodeVerifier(value: string): Promise<void> {
await Promise.all(this.stores.map((store) => store.saveCodeVerifier(value)));
}
async readState(): Promise<string | undefined> {
for (const store of this.stores) {
const result = await store.readState();
if (result) return result;
}
return undefined;
}
async saveState(value: string): Promise<void> {
await Promise.all(this.stores.map((store) => store.saveState(value)));
}
async clear(scope: OAuthClearScope): Promise<void> {
await Promise.all(this.stores.map((store) => store.clear(scope)));
}
}
export async function buildOAuthPersistence(
definition: ServerDefinition,
logger?: Logger
): Promise<OAuthPersistence> {
const vault = new VaultPersistence(definition);
const stores: OAuthPersistence[] = [vault];
if (definition.tokenCacheDir) {
stores.unshift(new DirectoryPersistence(definition.tokenCacheDir, logger));
}
// Migrate legacy default per-server cache (~/.mcporter/<name>) into the vault if present.
const legacyDir = path.join(os.homedir(), '.mcporter', definition.name);
if (!definition.tokenCacheDir && legacyDir) {
const legacy = new DirectoryPersistence(legacyDir, logger);
const legacyTokens = await legacy.readTokens();
const legacyClient = await legacy.readClientInfo();
const legacyVerifier = await legacy.readCodeVerifier();
const legacyState = await legacy.readState();
if (legacyTokens || legacyClient || legacyVerifier || legacyState) {
if (legacyTokens) {
await vault.saveTokens(legacyTokens);
}
if (legacyClient) await vault.saveClientInfo(legacyClient);
if (legacyVerifier) await vault.saveCodeVerifier(legacyVerifier);
if (legacyState) await vault.saveState(legacyState);
logger?.info?.(`Migrated legacy OAuth cache for '${definition.name}' into vault.`);
}
}
return stores.length === 1 ? vault : new CompositePersistence(stores);
}
export async function clearOAuthCaches(
definition: ServerDefinition,
logger?: Logger,
scope: OAuthClearScope = 'all'
): Promise<void> {
const persistence = await buildOAuthPersistence(definition, logger);
await persistence.clear(scope);
const legacyDir = path.join(os.homedir(), '.mcporter', definition.name);
if (legacyDir && (!definition.tokenCacheDir || legacyDir !== definition.tokenCacheDir)) {
const legacy = new DirectoryPersistence(legacyDir, logger);
await legacy.clear(scope);
}
}
export async function readCachedAccessToken(definition: ServerDefinition, logger?: Logger): Promise<string | undefined> {
const persistence = await buildOAuthPersistence(definition, logger);
const tokens = await persistence.readTokens();
if (tokens && typeof tokens.access_token === 'string' && tokens.access_token.trim().length > 0) {
return tokens.access_token;
}
return undefined;
}

99
src/oauth-vault.ts Normal file
View File

@ -0,0 +1,99 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { ServerDefinition } from './config.js';
import { readJsonFile, writeJsonFile } from './fs-json.js';
const VAULT_PATH = path.join(os.homedir(), '.mcporter', '.credentials.json');
type VaultKey = string;
export interface VaultEntry {
serverName: string;
serverUrl?: string;
tokens?: OAuthTokens;
clientInfo?: OAuthClientInformationMixed;
codeVerifier?: string;
state?: string;
updatedAt: string;
}
interface VaultFile {
version: 1;
entries: Record<VaultKey, VaultEntry>;
}
async function readVault(): Promise<VaultFile> {
try {
const existing = await readJsonFile<VaultFile>(VAULT_PATH);
if (existing && existing.version === 1 && existing.entries && typeof existing.entries === 'object') {
return existing;
}
} catch {
// Corrupt or unreadable vault; reset to empty.
}
return { version: 1, entries: {} };
}
async function writeVault(contents: VaultFile): Promise<void> {
const dir = path.dirname(VAULT_PATH);
await fs.mkdir(dir, { recursive: true });
await writeJsonFile(VAULT_PATH, contents);
}
export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
const descriptor = {
name: definition.name,
url: definition.command.kind === 'http' ? definition.command.url.toString() : null,
command:
definition.command.kind === 'stdio'
? { command: definition.command.command, args: definition.command.args ?? [] }
: null,
};
const hash = crypto.createHash('sha256').update(JSON.stringify(descriptor)).digest('hex').slice(0, 16);
return `${definition.name}|${hash}`;
}
export async function loadVaultEntry(definition: ServerDefinition): Promise<VaultEntry | undefined> {
const vault = await readVault();
return vault.entries[vaultKeyForDefinition(definition)];
}
export async function saveVaultEntry(definition: ServerDefinition, patch: Partial<VaultEntry>): Promise<void> {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
const current = vault.entries[key] ?? {
serverName: definition.name,
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
updatedAt: new Date().toISOString(),
};
vault.entries[key] = {
...current,
...patch,
updatedAt: new Date().toISOString(),
};
await writeVault(vault);
}
export async function clearVaultEntry(definition: ServerDefinition, scope: 'all' | 'tokens' | 'client' | 'verifier' | 'state'): Promise<void> {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
const existing = vault.entries[key];
if (!existing) {
return;
}
if (scope === 'all') {
delete vault.entries[key];
} else {
const updated: VaultEntry = { ...existing };
if (scope === 'tokens') delete updated.tokens;
if (scope === 'client') delete updated.clientInfo;
if (scope === 'verifier') delete updated.codeVerifier;
if (scope === 'state') delete updated.state;
updated.updatedAt = new Date().toISOString();
vault.entries[key] = updated;
}
await writeVault(vault);
}

View File

@ -1,9 +1,6 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { URL } from 'node:url';
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
import type {
@ -12,7 +9,8 @@ import type {
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import type { ServerDefinition } from './config.js';
import { readJsonFile, writeJsonFile } from './fs-json.js';
import type { OAuthPersistence } from './oauth-persistence.js';
import { buildOAuthPersistence } from './oauth-persistence.js';
const CALLBACK_HOST = '127.0.0.1';
const CALLBACK_PATH = '/callback';
@ -57,35 +55,24 @@ function openExternal(url: string) {
}
}
// ensureDirectory guarantees a directory exists before writing JSON blobs.
async function ensureDirectory(dir: string) {
await fs.mkdir(dir, { recursive: true });
}
// FileOAuthClientProvider persists OAuth session artifacts to disk and captures callback redirects.
class FileOAuthClientProvider implements OAuthClientProvider {
private readonly tokenPath: string;
private readonly clientInfoPath: string;
private readonly codeVerifierPath: string;
private readonly statePath: string;
// PersistentOAuthClientProvider persists OAuth session artifacts to disk and captures callback redirects.
class PersistentOAuthClientProvider implements OAuthClientProvider {
private readonly metadata: OAuthClientMetadata;
private readonly logger: OAuthLogger;
private readonly persistence: OAuthPersistence;
private redirectUrlValue: URL;
private authorizationDeferred: Deferred<string> | null = null;
private server?: http.Server;
private constructor(
private readonly definition: ServerDefinition,
tokenCacheDir: string,
persistence: OAuthPersistence,
redirectUrl: URL,
logger: OAuthLogger
) {
this.tokenPath = path.join(tokenCacheDir, 'tokens.json');
this.clientInfoPath = path.join(tokenCacheDir, 'client.json');
this.codeVerifierPath = path.join(tokenCacheDir, 'code_verifier.txt');
this.statePath = path.join(tokenCacheDir, 'state.txt');
this.redirectUrlValue = redirectUrl;
this.logger = logger;
this.persistence = persistence;
this.metadata = {
client_name: definition.clientName ?? `mcporter (${definition.name})`,
redirect_uris: [this.redirectUrlValue.toString()],
@ -100,11 +87,10 @@ class FileOAuthClientProvider implements OAuthClientProvider {
definition: ServerDefinition,
logger: OAuthLogger
): Promise<{
provider: FileOAuthClientProvider;
provider: PersistentOAuthClientProvider;
close: () => Promise<void>;
}> {
const tokenDir = definition.tokenCacheDir ?? path.join(os.homedir(), '.mcporter', definition.name);
await ensureDirectory(tokenDir);
const persistence = await buildOAuthPersistence(definition, logger);
const server = http.createServer();
const overrideRedirect = definition.oauthRedirectUrl ? new URL(definition.oauthRedirectUrl) : null;
@ -134,7 +120,7 @@ class FileOAuthClientProvider implements OAuthClientProvider {
redirectUrl.pathname = callbackPath;
}
const provider = new FileOAuthClientProvider(definition, tokenDir, redirectUrl, logger);
const provider = new PersistentOAuthClientProvider(definition, persistence, redirectUrl, logger);
provider.attachServer(server);
return {
provider,
@ -193,30 +179,30 @@ class FileOAuthClientProvider implements OAuthClientProvider {
}
async state(): Promise<string> {
const existing = await readJsonFile<string>(this.statePath);
const existing = await this.persistence.readState();
if (existing) {
return existing;
}
const state = randomUUID();
await writeJsonFile(this.statePath, state);
await this.persistence.saveState(state);
return state;
}
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
return this.persistence.readClientInfo();
}
async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
await writeJsonFile(this.clientInfoPath, clientInformation);
await this.persistence.saveClientInfo(clientInformation);
}
async tokens(): Promise<OAuthTokens | undefined> {
return readJsonFile<OAuthTokens>(this.tokenPath);
return this.persistence.readTokens();
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await writeJsonFile(this.tokenPath, tokens);
this.logger.info(`Saved OAuth tokens for ${this.definition.name} to ${this.tokenPath}`);
await this.persistence.saveTokens(tokens);
this.logger.info(`Saved OAuth tokens for ${this.definition.name} (${this.persistence.describe()})`);
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
@ -227,37 +213,20 @@ class FileOAuthClientProvider implements OAuthClientProvider {
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
await fs.writeFile(this.codeVerifierPath, codeVerifier, 'utf8');
await this.persistence.saveCodeVerifier(codeVerifier);
}
async codeVerifier(): Promise<string> {
const value = await fs.readFile(this.codeVerifierPath, 'utf8');
const value = await this.persistence.readCodeVerifier();
if (!value) {
throw new Error(`Missing PKCE code verifier for ${this.definition.name}`);
}
return value.trim();
}
// invalidateCredentials removes cached files to force the next OAuth flow.
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
const removals: string[] = [];
if (scope === 'all' || scope === 'tokens') {
removals.push(this.tokenPath);
}
if (scope === 'all' || scope === 'client') {
removals.push(this.clientInfoPath);
}
if (scope === 'all' || scope === 'verifier') {
removals.push(this.codeVerifierPath);
}
await Promise.all(
removals.map(async (file) => {
try {
await fs.unlink(file);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
})
);
await this.persistence.clear(scope);
}
// waitForAuthorizationCode resolves once the local callback server captures a redirect.
@ -295,7 +264,7 @@ export interface OAuthSession {
// createOAuthSession spins up a file-backed OAuth provider and callback server for the target definition.
export async function createOAuthSession(definition: ServerDefinition, logger: OAuthLogger): Promise<OAuthSession> {
const { provider, close } = await FileOAuthClientProvider.create(definition, logger);
const { provider, close } = await PersistentOAuthClientProvider.create(definition, logger);
const waitForAuthorizationCode = () => provider.waitForAuthorizationCode();
return {
provider,

View File

@ -1,5 +1,3 @@
import os from 'node:os';
import path from 'node:path';
import type { ServerDefinition } from './config.js';
import { analyzeConnectionError } from './error-classifier.js';
import type { Logger } from './logging.js';
@ -15,12 +13,10 @@ export function maybeEnableOAuth(definition: ServerDefinition, logger: Logger):
if (!isAdHocSource) {
return undefined;
}
const tokenCacheDir = definition.tokenCacheDir ?? path.join(os.homedir(), '.mcporter', definition.name);
logger.info(`Detected OAuth requirement for '${definition.name}'. Launching browser flow...`);
return {
...definition,
auth: 'oauth',
tokenCacheDir,
};
}

View File

@ -1,6 +1,3 @@
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';
@ -14,6 +11,7 @@ import { materializeHeaders } from '../runtime-header-utils.js';
import { isUnauthorizedError, maybeEnableOAuth } from '../runtime-oauth-support.js';
import { closeTransportAndWait } from '../runtime-process-utils.js';
import { connectWithAuth, OAuthTimeoutError } from './oauth.js';
import { readCachedAccessToken } from '../oauth-persistence.js';
import { resolveCommandArgument, resolveCommandArguments } from './utils.js';
const STDIO_TRACE_ENABLED = process.env.MCPORTER_STDIO_TRACE === '1';
@ -23,25 +21,6 @@ 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> };
@ -67,7 +46,7 @@ export async function createClientContext(
if (options.allowCachedAuth && activeDefinition.auth === 'oauth' && activeDefinition.command.kind === 'http') {
try {
const cached = await loadCachedAccessToken(activeDefinition);
const cached = await readCachedAccessToken(activeDefinition, logger);
if (cached) {
const existingHeaders = activeDefinition.command.headers ?? {};
if (!('Authorization' in existingHeaders)) {