feat: add headless OAuth vault seeding
Some checks are pending
CI / build (macos-latest) (push) Waiting to run
CI / build (ubuntu-latest) (push) Waiting to run
CI / build (windows-latest) (push) Waiting to run
pages / Deploy docs (push) Waiting to run

This commit is contained in:
Peter Steinberger 2026-05-09 14:55:44 +01:00
parent ea91086273
commit a64e29b4fe
No known key found for this signature in database
12 changed files with 460 additions and 6 deletions

View File

@ -5,6 +5,11 @@
### Config
- Resolve `${VAR}` and `${VAR:-fallback}` placeholders across string-valued server config fields such as `baseUrl`, `command`/`args`, `tokenCacheDir`, and pre-registered OAuth fields while keeping headers/env/bearer-token placeholders lazy until runtime. (PR #161 / issue #157, thanks @zxyasfas)
- Add `mcporter vault set <server>` and `mcporter vault clear <server>` so headless deployments can seed or clear OAuth vault credentials without reproducing mcporter's internal vault-key format. (Issue #156)
### OAuth
- 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)
## [0.10.2] - 2026-05-09

View File

@ -435,6 +435,18 @@ Providers that do not support dynamic client registration can use a pre-register
Keep client secrets in environment variables or private machine-local configs,
and register the exact `oauthRedirectUrl` with the provider.
Headless deployments that already have OAuth tokens can seed the vault without
reproducing mcporter's internal vault key:
```bash
npx mcporter vault set hubspot --tokens-file ./tokens.json
npx mcporter vault set hubspot --stdin < tokens.json
npx mcporter vault clear hubspot
```
The JSON payload is `{ "tokens": { ... }, "clientInfo": { ... } }`; `tokens`
is required and `clientInfo` is optional.
Provide `configPath` or `rootDir` to CLI/runtime calls when you juggle multiple config files side by side.
#### Config resolution order & system-level configs

View File

@ -231,6 +231,7 @@ String-valued config fields support `${VAR}` and `${VAR:-fallback}` placeholders
- Keep `config/mcporter.json` under version control. Encourage contributors to add sensitive data via env vars (`${LINEAR_API_KEY}`) rather than inline secrets.
- For pre-registered OAuth apps, store the public `oauthClientId` in config and point `oauthClientSecretEnv` at a local environment variable. `oauthClientSecret` is supported for private machine-local configs but should not be committed.
- For headless deployments that already have OAuth credentials, run `mcporter vault set <server> --tokens-file <path>` or `mcporter vault set <server> --stdin` with a JSON payload shaped like `{ "tokens": { ... }, "clientInfo": { ... } }`. This lets mcporter compute the vault key from the resolved server definition instead of duplicating that internal format in scripts.
- Machine-specific additions can live in `~/.mcporter/local.json` or `$XDG_CONFIG_HOME/mcporter/local.json`; point `mcporter config --config ~/.mcporter/local.json add ...` there when you prefer not to touch the repo. Since the runtime only watches one config at a time, CI jobs should always pass `--config config/mcporter.json` (or run from the repo root) for deterministic behavior.
- OAuth tokens, cached server metadata, and generated CLIs should remain outside the repo (`~/.mcporter/...` or the matching `XDG_*_HOME/mcporter/...`, plus `dist/`).

View File

@ -243,6 +243,18 @@ export async function runCli(argv: string[]): Promise<void> {
return;
}
if (resolvedCommand === 'vault') {
if (consumeHelpTokens(resolvedArgs)) {
const { printVaultHelp } = await import('./cli/vault-command.js');
printVaultHelp();
process.exitCode = 0;
return;
}
const { handleVault } = await import('./cli/vault-command.js');
await handleVault(runtime, resolvedArgs);
return;
}
if (resolvedCommand === 'resource' || resolvedCommand === 'resources') {
if (consumeHelpTokens(resolvedArgs)) {
const { printResourceHelp } = await import('./cli/resource-command.js');

View File

@ -84,7 +84,14 @@ function isCallLikeToken(token: string): boolean {
}
function isExplicitCommand(token: string): boolean {
return token === 'list' || token === 'call' || token === 'auth' || token === 'resource' || token === 'resources';
return (
token === 'list' ||
token === 'call' ||
token === 'auth' ||
token === 'vault' ||
token === 'resource' ||
token === 'resources'
);
}
function isUrlToken(token: string): boolean {

View File

@ -67,6 +67,11 @@ function buildCommandSections(colorize: boolean): string[] {
summary: 'Complete OAuth for a server without listing tools',
usage: 'mcporter auth <server | url> [--reset]',
},
{
name: 'vault',
summary: 'Seed or clear OAuth credentials non-interactively',
usage: 'mcporter vault set <server> --tokens-file <path>',
},
],
},
{

143
src/cli/vault-command.ts Normal file
View File

@ -0,0 +1,143 @@
import fs from 'node:fs/promises';
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { Runtime } from '../runtime.js';
import { clearVaultEntry, getOAuthVaultPath, saveVaultEntry } from '../oauth-vault.js';
import { CliUsageError } from './errors.js';
interface VaultPayload {
readonly tokens: OAuthTokens;
readonly clientInfo?: OAuthClientInformationMixed;
}
export interface VaultCommandOptions {
readonly readStdin?: () => Promise<string>;
}
export async function handleVault(
runtime: Pick<Runtime, 'getDefinition'>,
args: string[],
options: VaultCommandOptions = {}
): Promise<void> {
const subcommand = args.shift();
if (subcommand === 'set') {
await handleVaultSet(runtime, args, options);
return;
}
if (subcommand === 'clear') {
await handleVaultClear(runtime, args);
return;
}
throw new CliUsageError('Usage: mcporter vault <set|clear> ...');
}
async function handleVaultSet(
runtime: Pick<Runtime, 'getDefinition'>,
args: string[],
options: VaultCommandOptions
): Promise<void> {
const server = args.shift();
if (!server) {
throw new CliUsageError('Usage: mcporter vault set <server> (--tokens-file <path> | --stdin)');
}
const source = consumeVaultPayloadSource(args);
if (args.length > 0) {
throw new CliUsageError(`Unknown vault set argument '${args[0]}'.`);
}
const definition = runtime.getDefinition(server);
const payload = validateVaultPayload(JSON.parse(await readPayload(source, options)));
await saveVaultEntry(definition, {
tokens: payload.tokens,
...(payload.clientInfo ? { clientInfo: payload.clientInfo } : {}),
});
console.log(`Saved OAuth credentials for '${definition.name}' to ${getOAuthVaultPath()}`);
}
async function handleVaultClear(runtime: Pick<Runtime, 'getDefinition'>, args: string[]): Promise<void> {
const server = args.shift();
if (!server) {
throw new CliUsageError('Usage: mcporter vault clear <server>');
}
if (args.length > 0) {
throw new CliUsageError(`Unknown vault clear argument '${args[0]}'.`);
}
const definition = runtime.getDefinition(server);
await clearVaultEntry(definition, 'all');
console.log(`Cleared OAuth vault entry for '${definition.name}'`);
}
function consumeVaultPayloadSource(args: string[]): { kind: 'file'; path: string } | { kind: 'stdin' } {
const fileIndex = args.indexOf('--tokens-file');
const stdinIndex = args.indexOf('--stdin');
if (fileIndex !== -1 && stdinIndex !== -1) {
throw new CliUsageError("Use either '--tokens-file' or '--stdin', not both.");
}
if (fileIndex !== -1) {
const filePath = args[fileIndex + 1];
if (!filePath) {
throw new CliUsageError("Flag '--tokens-file' requires a path.");
}
args.splice(fileIndex, 2);
return { kind: 'file', path: filePath };
}
if (stdinIndex !== -1) {
args.splice(stdinIndex, 1);
return { kind: 'stdin' };
}
throw new CliUsageError('Usage: mcporter vault set <server> (--tokens-file <path> | --stdin)');
}
async function readPayload(
source: { kind: 'file'; path: string } | { kind: 'stdin' },
options: VaultCommandOptions
): Promise<string> {
if (source.kind === 'file') {
return fs.readFile(source.path, 'utf8');
}
if (options.readStdin) {
return options.readStdin();
}
return new Promise<string>((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function validateVaultPayload(value: unknown): VaultPayload {
if (!value || typeof value !== 'object') {
throw new CliUsageError('Vault payload must be a JSON object.');
}
const record = value as Record<string, unknown>;
if (!record.tokens || typeof record.tokens !== 'object' || Array.isArray(record.tokens)) {
throw new CliUsageError("Vault payload must include a 'tokens' object.");
}
if (
record.clientInfo !== undefined &&
(!record.clientInfo || typeof record.clientInfo !== 'object' || Array.isArray(record.clientInfo))
) {
throw new CliUsageError("Vault payload 'clientInfo' must be an object.");
}
return {
tokens: record.tokens as OAuthTokens,
...(record.clientInfo ? { clientInfo: record.clientInfo as OAuthClientInformationMixed } : {}),
};
}
export function printVaultHelp(): void {
const lines = [
'Usage: mcporter vault <set|clear> ...',
'',
'Commands:',
' vault set <server> --tokens-file <path> Seed OAuth tokens from JSON.',
' vault set <server> --stdin Seed OAuth tokens from stdin JSON.',
' vault clear <server> Remove the server entry from the OAuth vault.',
'',
'Payload:',
' { "tokens": { "access_token": "...", "token_type": "Bearer" }, "clientInfo": { "client_id": "..." } }',
];
console.error(lines.join('\n'));
}

View File

@ -302,10 +302,12 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
if (!this.server) {
return;
}
await new Promise<void>((resolve) => {
this.server?.close(() => resolve());
});
this.server.closeAllConnections?.();
const server = this.server;
this.server = undefined;
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
private ensureAuthorizationDeferred(): Deferred<string> {

View File

@ -1,3 +1,4 @@
import { auth as sdkAuth } from '@modelcontextprotocol/sdk/client/auth.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Logger } from '../logging.js';
@ -19,6 +20,8 @@ export interface ConnectWithAuthOptions {
maxAttempts?: number;
oauthTimeoutMs?: number;
recreateTransport?: (transport: OAuthCapableTransport) => Promise<OAuthCapableTransport>;
serverUrl?: string | URL;
fetchFn?: typeof fetch;
}
interface OAuthConnectState {
@ -122,7 +125,20 @@ export async function connectWithAuth(
while (true) {
try {
return await attemptTransportConnect(client, state);
await attemptTransportConnect(client, state);
if (session && !state.hasCompletedAuthFlow && options.serverUrl) {
await completeProactiveAuthorization(state.activeTransport, session, logger, {
serverName,
oauthTimeoutMs,
serverUrl: options.serverUrl,
fetchFn: options.fetchFn,
});
state.hasCompletedAuthFlow = true;
}
if (session && state.hasCompletedAuthFlow) {
await session.close().catch(() => {});
}
return state.activeTransport;
} catch (error) {
const unauthorized = isUnauthorizedError(error);
if (!shouldRetryAuthorization(state, unauthorized, session)) {
@ -213,6 +229,46 @@ async function completeAuthorizationChallenge(
return nextTransport;
}
async function completeProactiveAuthorization(
transport: OAuthCapableTransport,
session: OAuthSession,
logger: Logger,
options: Pick<ConnectWithAuthOptions, 'serverName' | 'oauthTimeoutMs' | 'serverUrl' | 'fetchFn'>
): Promise<void> {
if (!options.serverUrl) {
return;
}
try {
const result = await sdkAuth(session.provider, {
serverUrl: options.serverUrl,
fetchFn: options.fetchFn,
});
if (result !== 'REDIRECT') {
await session.close().catch(() => {});
return;
}
if (session.hasAuthorizationRedirectStarted?.() === false) {
throw new OAuthAuthorizationNotStartedError(options.serverName ?? 'unknown');
}
logger.warn(
`OAuth authorization required for '${options.serverName ?? 'unknown'}'. Waiting for browser approval...`
);
if (typeof transport.finishAuth !== 'function') {
throw new Error('Transport does not support finishAuth; cannot complete OAuth flow automatically.');
}
const code = await waitForAuthorizationCodeWithTimeout(
session,
logger,
options.serverName,
options.oauthTimeoutMs ?? DEFAULT_OAUTH_CODE_TIMEOUT_MS
);
await transport.finishAuth(code);
await session.close().catch(() => {});
} catch (error) {
throw markOAuthFlowError(error);
}
}
// Race the pending OAuth browser handshake so the runtime can't sit on an unresolved promise forever.
export function waitForAuthorizationCodeWithTimeout(
session: OAuthSession,

View File

@ -308,6 +308,7 @@ async function connectPrimaryHttpTransport(
const createStreamableTransport = () => new StreamableHTTPClientTransport(command.url, transportOptions);
const transport = await connectHttpTransport(client, createStreamableTransport(), oauthSession, logger, {
serverName: definition.name,
serverUrl: command.url,
maxAttempts: options.maxOAuthAttempts,
oauthTimeoutMs: options.oauthTimeoutMs,
recreateTransport: async () => createStreamableTransport(),
@ -337,6 +338,7 @@ async function connectSseFallbackTransport(
logger,
{
serverName: definition.name,
serverUrl: command.url,
maxAttempts: options.maxOAuthAttempts,
oauthTimeoutMs: options.oauthTimeoutMs,
}

View File

@ -1,6 +1,6 @@
import type { Client } from '@modelcontextprotocol/sdk/client';
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { OAuthSession } from '../src/oauth.js';
import {
connectWithAuth,
@ -15,7 +15,24 @@ import {
MockTransport,
} from './helpers/runtime-test-helpers.js';
const mocks = vi.hoisted(() => ({
sdkAuth: vi.fn(),
}));
vi.mock('@modelcontextprotocol/sdk/client/auth.js', async () => {
const actual = await vi.importActual('@modelcontextprotocol/sdk/client/auth.js');
return {
...actual,
auth: mocks.sdkAuth,
};
});
describe('connectWithAuth', () => {
beforeEach(() => {
mocks.sdkAuth.mockReset();
mocks.sdkAuth.mockResolvedValue('AUTHORIZED');
});
it('waits for authorization code and retries connection', async () => {
const connect = vi
.fn()
@ -170,6 +187,80 @@ describe('connectWithAuth', () => {
expect(connectedTransport).toBe(transport);
});
it('runs proactive OAuth after unauthenticated connect succeeds', async () => {
const connect = vi.fn().mockResolvedValueOnce(undefined);
const client = { connect } as unknown as Client;
const { session, waitForAuthorizationCode, resolveNextCode } = createPendingAuthorizationSession();
mocks.sdkAuth.mockResolvedValueOnce('REDIRECT');
const transport = new MockTransport();
const logger = createLogger();
const promise = connectWithAuth(client, transport, session, logger, {
serverName: 'calendar',
maxAttempts: 1,
oauthTimeoutMs: 5000,
serverUrl: new URL('https://calendar.example/mcp'),
});
await flushAuthLoop();
resolveNextCode('proactive-code');
const connectedTransport = await promise;
expect(mocks.sdkAuth).toHaveBeenCalledWith(session.provider, {
serverUrl: new URL('https://calendar.example/mcp'),
fetchFn: undefined,
});
expect(waitForAuthorizationCode).toHaveBeenCalledTimes(1);
expect(transport.calls).toEqual(['proactive-code']);
expect(connect).toHaveBeenCalledTimes(1);
expect(session.close).toHaveBeenCalled();
expect(connectedTransport).toBe(transport);
});
it('closes proactive OAuth sessions when cached tokens already authorize', async () => {
const connect = vi.fn().mockResolvedValueOnce(undefined);
const client = { connect } as unknown as Client;
const { session, waitForAuthorizationCode } = createPendingAuthorizationSession();
mocks.sdkAuth.mockResolvedValueOnce('AUTHORIZED');
const transport = new MockTransport();
const logger = createLogger();
const connectedTransport = await connectWithAuth(client, transport, session, logger, {
serverName: 'calendar',
maxAttempts: 1,
oauthTimeoutMs: 5000,
serverUrl: 'https://calendar.example/mcp',
});
expect(waitForAuthorizationCode).not.toHaveBeenCalled();
expect(transport.calls).toEqual([]);
expect(session.close).toHaveBeenCalled();
expect(connectedTransport).toBe(transport);
});
it('marks proactive OAuth failures as OAuth flow errors', async () => {
const connect = vi.fn().mockResolvedValueOnce(undefined);
const client = { connect } as unknown as Client;
const { session } = createPendingAuthorizationSession();
const authError = new Error('dynamic client registration rejected');
mocks.sdkAuth.mockRejectedValueOnce(authError);
const transport = new MockTransport();
const logger = createLogger();
await expect(
connectWithAuth(client, transport, session, logger, {
serverName: 'calendar',
maxAttempts: 1,
oauthTimeoutMs: 5000,
serverUrl: 'https://calendar.example/mcp',
})
).rejects.toSatisfy((error: unknown) => error === authError && isOAuthFlowError(error));
});
it('marks finishAuth failures as oauth flow errors', async () => {
const connect = vi.fn().mockRejectedValueOnce(new UnauthorizedError('auth needed'));
const client = { connect } as unknown as Client;

118
tests/vault-command.test.ts Normal file
View File

@ -0,0 +1,118 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { handleVault } from '../src/cli/vault-command.js';
import { loadVaultEntry } from '../src/oauth-vault.js';
const definition: ServerDefinition = {
name: 'calendar',
command: {
kind: 'http',
url: new URL('https://calendar.example/mcp'),
headers: { accept: 'application/json, text/event-stream' },
},
auth: 'oauth',
source: { kind: 'local', path: '/tmp/mcporter.json' },
};
describe('vault command', () => {
const originalEnv = { ...process.env };
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-vault-command-'));
process.env = {
...originalEnv,
XDG_DATA_HOME: path.join(tempDir, 'data'),
};
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
it('seeds OAuth credentials from a file using mcporter vault keys', async () => {
const payloadPath = path.join(tempDir, 'tokens.json');
await fs.writeFile(
payloadPath,
JSON.stringify({
tokens: {
access_token: 'access-123',
refresh_token: 'refresh-123',
token_type: 'Bearer',
},
clientInfo: {
client_id: 'client-123',
},
}),
'utf8'
);
await handleVault(runtimeFor(definition), ['set', 'calendar', '--tokens-file', payloadPath]);
await expect(loadVaultEntry(definition)).resolves.toMatchObject({
serverName: 'calendar',
serverUrl: 'https://calendar.example/mcp',
tokens: {
access_token: 'access-123',
refresh_token: 'refresh-123',
token_type: 'Bearer',
},
clientInfo: {
client_id: 'client-123',
},
});
});
it('seeds OAuth credentials from stdin JSON', async () => {
await handleVault(runtimeFor(definition), ['set', 'calendar', '--stdin'], {
readStdin: async () =>
JSON.stringify({
tokens: {
access_token: 'stdin-token',
token_type: 'Bearer',
},
}),
});
await expect(loadVaultEntry(definition)).resolves.toMatchObject({
tokens: {
access_token: 'stdin-token',
token_type: 'Bearer',
},
});
});
it('clears the server vault entry', async () => {
await handleVault(runtimeFor(definition), ['set', 'calendar', '--stdin'], {
readStdin: async () => JSON.stringify({ tokens: { access_token: 'token', token_type: 'Bearer' } }),
});
await handleVault(runtimeFor(definition), ['clear', 'calendar']);
await expect(loadVaultEntry(definition)).resolves.toBeUndefined();
});
it('requires a tokens object', async () => {
await expect(
handleVault(runtimeFor(definition), ['set', 'calendar', '--stdin'], {
readStdin: async () => JSON.stringify({ clientInfo: { client_id: 'client' } }),
})
).rejects.toThrow("Vault payload must include a 'tokens' object.");
});
});
function runtimeFor(server: ServerDefinition) {
return {
getDefinition: (name: string) => {
if (name !== server.name) {
throw new Error(`Unknown MCP server '${name}'.`);
}
return server;
},
};
}