feat: add headless OAuth vault seeding
This commit is contained in:
parent
ea91086273
commit
a64e29b4fe
@ -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
|
||||
|
||||
|
||||
12
README.md
12
README.md
@ -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
|
||||
|
||||
@ -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/`).
|
||||
|
||||
|
||||
12
src/cli.ts
12
src/cli.ts
@ -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');
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
143
src/cli/vault-command.ts
Normal 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'));
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
118
tests/vault-command.test.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user