feat: support static oauth clients

This commit is contained in:
Peter Steinberger 2026-05-04 08:03:13 +01:00
parent caa00dd3a4
commit dd33721d89
No known key found for this signature in database
20 changed files with 312 additions and 5 deletions

View File

@ -25,6 +25,7 @@
### Config
- Support pre-registered OAuth clients via `oauthClientId`/`oauthClientSecretEnv` and token endpoint auth method overrides for providers without dynamic client registration. (Issue #132)
- Respect configured stdio `cwd` values, including relative paths resolved from the config file and `~` home expansion. (PR #147 / issue #146, thanks @solomonneas)
## [0.9.0] - 2026-04-18

View File

@ -415,6 +415,26 @@ npx mcporter config add notion https://mcp.notion.com/mcp --auth oauth
npx mcporter auth notion
```
Providers that do not support dynamic client registration can use a pre-registered app:
```jsonc
{
"mcpServers": {
"hubspot": {
"baseUrl": "https://mcp.hubspot.com/mcp",
"auth": "oauth",
"oauthClientId": "your-client-id",
"oauthClientSecretEnv": "HUBSPOT_CLIENT_SECRET",
"oauthTokenEndpointAuthMethod": "client_secret_post",
"oauthRedirectUrl": "http://127.0.0.1:3434/callback",
},
},
}
```
Keep client secrets in environment variables or private machine-local configs,
and register the exact `oauthRedirectUrl` with the provider.
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

@ -114,6 +114,7 @@ Use `--scope home|project` with `mcporter config add` to pick the write target e
- `--transport http|sse|stdio`
- `--url` or `--command`/`--stdio`
- `--env`, `--header`, `--token-cache-dir`, `--description`, `--tag`, `--client-name`, `--oauth-redirect-url`
- `--oauth-client-id`, `--oauth-client-secret-env`, `--oauth-token-endpoint-auth-method` for pre-registered OAuth clients.
- `--copy-from importKind:name` to clone settings from an imported entry before editing.
- `--dry-run` shows the JSON diff without writing, while `--persist <path>` overrides the destination file.
@ -197,7 +198,10 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
| `auth` | Currently only `oauth` is recognized. Any other string is ignored (treated as undefined) to avoid stale state from other clients. `mcporter list` can still reuse an existing OAuth token cache for older HTTP entries missing this marker. |
| `tokenCacheDir` | Directory for OAuth tokens; still honored, but mcporter now keeps a centralized vault in `~/.mcporter/credentials.json` (legacy per-server caches are auto-migrated). Supports `~` expansion. |
| `clientName` | Optional identifier some servers use for telemetry/audience segmentation. |
| `oauthRedirectUrl` | Override the default localhost callback. Useful when tunneling OAuth through Codespaces or remote dev boxes. |
| `oauthClientId` | Pre-registered OAuth client id for providers that do not support dynamic client registration. |
| `oauthClientSecretEnv` | Environment variable containing the OAuth client secret. Prefer this over committing `oauthClientSecret` directly. |
| `oauthTokenEndpointAuthMethod` | Optional token endpoint auth method override, for example `client_secret_post` when the provider requires client credentials in the token request body. |
| `oauthRedirectUrl` | Override the default localhost callback. Required for many pre-registered OAuth apps because the provider must allowlist the exact redirect URI. Also useful when tunneling OAuth through Codespaces or remote dev boxes. |
| `oauthScope` | Optional explicit OAuth scope string. If omitted, mcporter lets the MCP SDK derive scope from server/auth metadata. Use this as an escape hatch for providers that require explicit scopes but dont publish `scopes_supported`. |
| `oauthCommand.args` | For STDIO servers that ship a custom auth subcommand (e.g., Gmail MCP). mcporter will spawn the stdio command with these args when you run `mcporter auth <name>`, so you dont need to call `npx ... auth` manually. |
| `allowedTools` / `allowed_tools` | Optional exact-name allowlist. Only listed tools appear in `mcporter list` and can be called. An empty array blocks all tools. Cannot be combined with `blockedTools`. |
@ -214,6 +218,7 @@ mcporter normalizes headers to include `Accept: application/json, text/event-str
## Project vs. Machine Layers
- 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.
- Machine-specific additions can live in `~/.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/<name>/`, `dist/`).

View File

@ -24,7 +24,7 @@ Set `"imports": []` when you want to disable auto-merging entirely, or supply a
- `{ "mcpServers": { ... } }` (Cursor-style).
- `{ "servers": { ... } }` (older VS Code previews).
- **TOML container**: Codex uses TOML files with `[mcp_servers.<name>]` tables. Only `.codex/config.toml` is recognized.
- **Shared fields**: We convert JSON/TOML entries into mcporters schema, honoring `baseUrl`, `command` (string or array), `args`, `headers`, `env`, `bearerToken`, `bearerTokenEnv`, `description`, `tokenCacheDir`, `clientName`, and `auth`. Extra properties are ignored.
- **Shared fields**: We convert JSON/TOML entries into mcporters schema, honoring `baseUrl`, `command` (string or array), `args`, `headers`, `env`, `bearerToken`, `bearerTokenEnv`, `description`, `tokenCacheDir`, `clientName`, `oauthClientId`, `oauthClientSecretEnv`, `oauthTokenEndpointAuthMethod`, and `auth`. Extra properties are ignored.
## Import Support Matrix

View File

@ -14,8 +14,8 @@ This file tracks limitations that users regularly run into. Most of these requir
- Use Supabases supported clients (Cursor, Windsurf).
- Self-host their MCP server and configure PAT headers / custom OAuth.
- Ask Supabase to accept the MCP scope or publish their scope list.
- GitHubs MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilots backend expects pre-registered client credentials. Until GitHub publishes a dynamic-registration API (or client secrets), mcporter cannot interact with their hosted server.
- Some hosted servers reject dynamic client registration before returning any authorization URL. mcporter now fails those flows immediately instead of waiting for a browser callback that cannot arrive. If the provider supports a pre-registered client name, set `clientName` in config; otherwise use the provider's supported client or token/header workaround.
- GitHubs MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilots backend expects pre-registered client credentials. Configure `oauthClientId`/`oauthClientSecretEnv` only if the provider gives you a usable OAuth app; otherwise use their supported client or token/header workaround.
- Some hosted servers reject dynamic client registration before returning any authorization URL. mcporter now fails those flows immediately instead of waiting for a browser callback that cannot arrive. If the provider supports a pre-registered OAuth app, configure `oauthClientId`, `oauthClientSecretEnv`, and the required `oauthTokenEndpointAuthMethod`; otherwise use the provider's supported client or token/header workaround.
## Output schemas missing/buggy on many servers

View File

@ -103,6 +103,38 @@
"description": "Client identifier for server telemetry (snake_case)",
"type": "string"
},
"oauthClientId": {
"description": "Pre-registered OAuth client id (camelCase)",
"type": "string"
},
"oauth_client_id": {
"description": "Pre-registered OAuth client id (snake_case)",
"type": "string"
},
"oauthClientSecret": {
"description": "Pre-registered OAuth client secret (camelCase)",
"type": "string"
},
"oauth_client_secret": {
"description": "Pre-registered OAuth client secret (snake_case)",
"type": "string"
},
"oauthClientSecretEnv": {
"description": "Environment variable containing the OAuth client secret",
"type": "string"
},
"oauth_client_secret_env": {
"description": "Environment variable containing the OAuth client secret",
"type": "string"
},
"oauthTokenEndpointAuthMethod": {
"description": "OAuth token endpoint auth method, e.g. client_secret_post",
"type": "string"
},
"oauth_token_endpoint_auth_method": {
"description": "OAuth token endpoint auth method, e.g. client_secret_post",
"type": "string"
},
"oauthRedirectUrl": {
"description": "Custom OAuth redirect URL (camelCase)",
"type": "string"

View File

@ -23,6 +23,9 @@ export interface SerializedServerDefinition {
readonly auth?: string;
readonly tokenCacheDir?: string;
readonly clientName?: string;
readonly oauthClientId?: string;
readonly oauthClientSecretEnv?: string;
readonly oauthTokenEndpointAuthMethod?: string;
readonly oauthRedirectUrl?: string;
readonly oauthScope?: string;
readonly allowedTools?: readonly string[];
@ -143,6 +146,9 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
auth: definition.auth,
tokenCacheDir: definition.tokenCacheDir,
clientName: definition.clientName,
oauthClientId: definition.oauthClientId,
oauthClientSecretEnv: definition.oauthClientSecretEnv,
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
allowedTools: definition.allowedTools,
@ -162,6 +168,9 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
auth: definition.auth,
tokenCacheDir: definition.tokenCacheDir,
clientName: definition.clientName,
oauthClientId: definition.oauthClientId,
oauthClientSecretEnv: definition.oauthClientSecretEnv,
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
allowedTools: definition.allowedTools,

View File

@ -19,6 +19,9 @@ export type AddFlags = {
headers: Record<string, string>;
tokenCacheDir?: string;
clientName?: string;
oauthClientId?: string;
oauthClientSecretEnv?: string;
oauthTokenEndpointAuthMethod?: string;
oauthRedirectUrl?: string;
auth?: string;
copyFrom?: string;
@ -147,6 +150,18 @@ function extractAddFlags(args: string[]): AddFlags {
flags.clientName = requireValue(args, index, token);
args.splice(index, 2);
continue;
case '--oauth-client-id':
flags.oauthClientId = requireValue(args, index, token);
args.splice(index, 2);
continue;
case '--oauth-client-secret-env':
flags.oauthClientSecretEnv = requireValue(args, index, token);
args.splice(index, 2);
continue;
case '--oauth-token-endpoint-auth-method':
flags.oauthTokenEndpointAuthMethod = requireValue(args, index, token);
args.splice(index, 2);
continue;
case '--oauth-redirect-url':
flags.oauthRedirectUrl = requireValue(args, index, token);
args.splice(index, 2);
@ -284,6 +299,15 @@ function applyFlagsToEntry(entry: RawEntry, flags: AddFlags): void {
if (flags.clientName) {
entry.clientName = flags.clientName;
}
if (flags.oauthClientId) {
entry.oauthClientId = flags.oauthClientId;
}
if (flags.oauthClientSecretEnv) {
entry.oauthClientSecretEnv = flags.oauthClientSecretEnv;
}
if (flags.oauthTokenEndpointAuthMethod) {
entry.oauthTokenEndpointAuthMethod = flags.oauthTokenEndpointAuthMethod;
}
if (flags.oauthRedirectUrl) {
entry.oauthRedirectUrl = flags.oauthRedirectUrl;
}

View File

@ -51,6 +51,12 @@ export const CONFIG_HELP_ENTRIES: Record<ConfigSubcommand, ConfigHelpEntry> = {
{ flag: '--header KEY=value', description: 'Attach HTTP headers (repeatable).' },
{ flag: '--token-cache-dir <path>', description: 'Override where OAuth tokens are persisted.' },
{ flag: '--client-name <name>', description: 'Customize the OAuth client identifier.' },
{ flag: '--oauth-client-id <id>', description: 'Use a pre-registered OAuth client id.' },
{ flag: '--oauth-client-secret-env <env>', description: 'Read the OAuth client secret from an env var.' },
{
flag: '--oauth-token-endpoint-auth-method <method>',
description: 'Set token auth, e.g. client_secret_post.',
},
{ flag: '--oauth-redirect-url <url>', description: 'Set a custom OAuth redirect URL.' },
{ flag: '--auth <strategy>', description: 'Force the auth type (e.g., oauth).' },
{ flag: '--copy-from <import:name>', description: 'Start with an imported definition by name.' },

View File

@ -9,6 +9,9 @@ export type SerializedServerDefinition = {
auth?: ServerDefinition['auth'];
tokenCacheDir?: string;
clientName?: string;
oauthClientId?: string;
oauthClientSecretEnv?: string;
oauthTokenEndpointAuthMethod?: string;
oauthRedirectUrl?: string;
oauthScope?: string;
allowedTools?: readonly string[];
@ -32,6 +35,9 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
auth: definition.auth,
tokenCacheDir: definition.tokenCacheDir,
clientName: definition.clientName,
oauthClientId: definition.oauthClientId,
oauthClientSecretEnv: definition.oauthClientSecretEnv,
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
allowedTools: definition.allowedTools,
@ -49,6 +55,9 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
auth: definition.auth,
tokenCacheDir: definition.tokenCacheDir,
clientName: definition.clientName,
oauthClientId: definition.oauthClientId,
oauthClientSecretEnv: definition.oauthClientSecretEnv,
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
allowedTools: definition.allowedTools,

View File

@ -194,10 +194,18 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
const auth = typeof def.auth === 'string' ? def.auth : undefined;
const tokenCacheDir = typeof def.tokenCacheDir === 'string' ? def.tokenCacheDir : undefined;
const clientName = typeof def.clientName === 'string' ? def.clientName : undefined;
const record = def as Record<string, unknown>;
const oauthClientId = stringFromAliases(record, 'oauthClientId', 'oauth_client_id');
const oauthClientSecret = stringFromAliases(record, 'oauthClientSecret', 'oauth_client_secret');
const oauthClientSecretEnv = stringFromAliases(record, 'oauthClientSecretEnv', 'oauth_client_secret_env');
const oauthTokenEndpointAuthMethod = stringFromAliases(
record,
'oauthTokenEndpointAuthMethod',
'oauth_token_endpoint_auth_method'
);
const oauthRedirectUrl = typeof def.oauthRedirectUrl === 'string' ? def.oauthRedirectUrl : undefined;
const oauthScope = typeof def.oauthScope === 'string' ? def.oauthScope : undefined;
const headers = toStringRecord((def as Record<string, unknown>).headers);
const record = def as Record<string, unknown>;
const oauthCommand = getOauthCommand(record.oauthCommand ?? record.oauth_command);
const rawLifecycle = getRawLifecycle(record.lifecycle);
const logging = getLogging(record.logging);
@ -213,6 +221,10 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
auth,
tokenCacheDir,
clientName,
oauthClientId,
oauthClientSecret,
oauthClientSecretEnv,
oauthTokenEndpointAuthMethod,
oauthRedirectUrl,
oauthScope,
oauthCommand,
@ -370,6 +382,16 @@ function getOauthCommand(value: unknown): ServerDefinition['oauthCommand'] | und
return args ? { args } : undefined;
}
function stringFromAliases(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string') {
return value;
}
}
return undefined;
}
function toStringRecord(value: unknown): Record<string, string> | undefined {
if (typeof value !== 'object' || value === null) {
return undefined;

View File

@ -15,6 +15,11 @@ export function normalizeServerEntry(
const auth = normalizeAuth(raw.auth);
const tokenCacheDir = normalizePath(raw.tokenCacheDir ?? raw.token_cache_dir);
const clientName = raw.clientName ?? raw.client_name;
const oauthClientId = raw.oauthClientId ?? raw.oauth_client_id ?? undefined;
const oauthClientSecret = raw.oauthClientSecret ?? raw.oauth_client_secret ?? undefined;
const oauthClientSecretEnv = raw.oauthClientSecretEnv ?? raw.oauth_client_secret_env ?? undefined;
const oauthTokenEndpointAuthMethod =
raw.oauthTokenEndpointAuthMethod ?? raw.oauth_token_endpoint_auth_method ?? undefined;
const oauthRedirectUrl = raw.oauthRedirectUrl ?? raw.oauth_redirect_url ?? undefined;
const oauthScope = raw.oauthScope ?? raw.oauth_scope ?? undefined;
const oauthCommandRaw = raw.oauthCommand ?? raw.oauth_command;
@ -61,6 +66,10 @@ export function normalizeServerEntry(
auth,
tokenCacheDir,
clientName,
oauthClientId,
oauthClientSecret,
oauthClientSecretEnv,
oauthTokenEndpointAuthMethod,
oauthRedirectUrl,
oauthScope,
oauthCommand: defaultedOauthCommand,

View File

@ -81,6 +81,20 @@ export const RawEntrySchema = z
token_cache_dir: z.string().optional().describe('Directory for caching OAuth tokens (snake_case)'),
clientName: z.string().optional().describe('Client identifier for server telemetry (camelCase)'),
client_name: z.string().optional().describe('Client identifier for server telemetry (snake_case)'),
oauthClientId: z.string().optional().describe('Pre-registered OAuth client id (camelCase)'),
oauth_client_id: z.string().optional().describe('Pre-registered OAuth client id (snake_case)'),
oauthClientSecret: z.string().optional().describe('Pre-registered OAuth client secret (camelCase)'),
oauth_client_secret: z.string().optional().describe('Pre-registered OAuth client secret (snake_case)'),
oauthClientSecretEnv: z.string().optional().describe('Environment variable containing the OAuth client secret'),
oauth_client_secret_env: z.string().optional().describe('Environment variable containing the OAuth client secret'),
oauthTokenEndpointAuthMethod: z
.string()
.optional()
.describe('OAuth token endpoint auth method, e.g. client_secret_post'),
oauth_token_endpoint_auth_method: z
.string()
.optional()
.describe('OAuth token endpoint auth method, e.g. client_secret_post'),
oauthRedirectUrl: z.string().optional().describe('Custom OAuth redirect URL (camelCase)'),
oauth_redirect_url: z.string().optional().describe('Custom OAuth redirect URL (snake_case)'),
oauthScope: z.string().optional().describe('OAuth scope override (camelCase)'),
@ -181,6 +195,10 @@ export interface ServerDefinition {
readonly auth?: string;
readonly tokenCacheDir?: string;
readonly clientName?: string;
readonly oauthClientId?: string;
readonly oauthClientSecret?: string;
readonly oauthClientSecretEnv?: string;
readonly oauthTokenEndpointAuthMethod?: string;
readonly oauthRedirectUrl?: string;
readonly oauthScope?: string;
readonly oauthCommand?: {

View File

@ -131,6 +131,28 @@ function convertExternalEntry(value: Record<string, unknown>): RawEntry | null {
result.clientName = clientName;
}
const oauthClientId = asString(value.oauthClientId ?? value.oauth_client_id);
if (oauthClientId) {
result.oauthClientId = oauthClientId;
}
const oauthClientSecret = asString(value.oauthClientSecret ?? value.oauth_client_secret);
if (oauthClientSecret) {
result.oauthClientSecret = oauthClientSecret;
}
const oauthClientSecretEnv = asString(value.oauthClientSecretEnv ?? value.oauth_client_secret_env);
if (oauthClientSecretEnv) {
result.oauthClientSecretEnv = oauthClientSecretEnv;
}
const oauthTokenEndpointAuthMethod = asString(
value.oauthTokenEndpointAuthMethod ?? value.oauth_token_endpoint_auth_method
);
if (oauthTokenEndpointAuthMethod) {
result.oauthTokenEndpointAuthMethod = oauthTokenEndpointAuthMethod;
}
const url = asString(value.baseUrl ?? value.base_url ?? value.url ?? value.serverUrl ?? value.server_url);
if (url) {
result.baseUrl = url;

View File

@ -72,6 +72,11 @@ function serializeRawEntry(server: ServerDefinition): RawEntry {
...(server.auth ? { auth: server.auth } : {}),
...(server.tokenCacheDir ? { tokenCacheDir: server.tokenCacheDir } : {}),
...(server.clientName ? { clientName: server.clientName } : {}),
...(server.oauthClientId ? { oauthClientId: server.oauthClientId } : {}),
...(server.oauthClientSecretEnv ? { oauthClientSecretEnv: server.oauthClientSecretEnv } : {}),
...(server.oauthTokenEndpointAuthMethod
? { oauthTokenEndpointAuthMethod: server.oauthTokenEndpointAuthMethod }
: {}),
...(server.oauthRedirectUrl ? { oauthRedirectUrl: server.oauthRedirectUrl } : {}),
...(server.oauthScope ? { oauthScope: server.oauthScope } : {}),
...(server.oauthCommand ? { oauthCommand: server.oauthCommand } : {}),

View File

@ -237,6 +237,10 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
}
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
const staticClient = buildStaticClientInformation(this.definition, this.redirectUrlValue);
if (staticClient) {
return staticClient;
}
return this.persistence.readClientInfo();
}
@ -352,6 +356,38 @@ function firstRedirectUri(client: OAuthClientInformationMixed | undefined): stri
return typeof first === 'string' ? first : undefined;
}
function buildStaticClientInformation(
definition: ServerDefinition,
redirectUrl: URL
): OAuthClientInformationMixed | undefined {
if (!definition.oauthClientId) {
return undefined;
}
const clientSecret = resolveOAuthClientSecret(definition);
const metadata = {
client_id: definition.oauthClientId,
...(clientSecret ? { client_secret: clientSecret } : {}),
redirect_uris: [redirectUrl.toString()],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
...(definition.oauthTokenEndpointAuthMethod
? { token_endpoint_auth_method: definition.oauthTokenEndpointAuthMethod }
: {}),
};
return metadata as OAuthClientInformationMixed;
}
function resolveOAuthClientSecret(definition: ServerDefinition): string | undefined {
if (definition.oauthClientSecretEnv) {
const value = process.env[definition.oauthClientSecretEnv];
if (!value) {
throw new Error(`Environment variable '${definition.oauthClientSecretEnv}' is required for OAuth client secret.`);
}
return value;
}
return definition.oauthClientSecret;
}
export const __oauthInternals = {
openExternal,
};

View File

@ -37,6 +37,12 @@ describe('config add flag parsing', () => {
'oauth',
'--client-name',
'mcporter',
'--oauth-client-id',
'client-123',
'--oauth-client-secret-env',
'HUBSPOT_CLIENT_SECRET',
'--oauth-token-endpoint-auth-method',
'client_secret_post',
'--oauth-redirect-url',
'https://example.com/callback',
'--dry-run',
@ -56,6 +62,9 @@ describe('config add flag parsing', () => {
expect(entry.description).toBe('desc');
expect(entry.auth).toBe('oauth');
expect(entry.clientName).toBe('mcporter');
expect(entry.oauthClientId).toBe('client-123');
expect(entry.oauthClientSecretEnv).toBe('HUBSPOT_CLIENT_SECRET');
expect(entry.oauthTokenEndpointAuthMethod).toBe('client_secret_post');
expect(entry.oauthRedirectUrl).toBe('https://example.com/callback');
});

View File

@ -124,4 +124,45 @@ describe('config normalization', () => {
expect(camel?.oauthScope).toBe('openid profile');
expect(snake?.oauthScope).toBe('email');
});
it('normalizes pre-registered OAuth client fields', async () => {
await fs.mkdir(TEMP_DIR, { recursive: true });
const configPath = path.join(TEMP_DIR, 'mcporter-oauth-client.json');
await fs.writeFile(
configPath,
JSON.stringify(
{
mcpServers: {
camel: {
baseUrl: 'https://example.com/mcp',
auth: 'oauth',
oauthClientId: 'client-123',
oauthClientSecretEnv: 'OAUTH_SECRET',
oauthTokenEndpointAuthMethod: 'client_secret_post',
},
snake: {
baseUrl: 'https://example.com/mcp',
auth: 'oauth',
oauth_client_id: 'client-456',
oauth_client_secret: 'secret-inline',
oauth_token_endpoint_auth_method: 'client_secret_basic',
},
},
},
null,
2
),
'utf8'
);
const servers = await loadServerDefinitions({ configPath });
const camel = servers.find((entry) => entry.name === 'camel');
const snake = servers.find((entry) => entry.name === 'snake');
expect(camel?.oauthClientId).toBe('client-123');
expect(camel?.oauthClientSecretEnv).toBe('OAUTH_SECRET');
expect(camel?.oauthTokenEndpointAuthMethod).toBe('client_secret_post');
expect(snake?.oauthClientId).toBe('client-456');
expect(snake?.oauthClientSecret).toBe('secret-inline');
expect(snake?.oauthTokenEndpointAuthMethod).toBe('client_secret_basic');
});
});

View File

@ -16,6 +16,10 @@ describe('config render helpers', () => {
auth: 'oauth',
tokenCacheDir: '/tmp/cache',
clientName: 'mcporter',
oauthClientId: 'client-123',
oauthClientSecret: 'do-not-render',
oauthClientSecretEnv: 'OAUTH_SECRET',
oauthTokenEndpointAuthMethod: 'client_secret_post',
oauthRedirectUrl: 'https://example.com/callback',
oauthScope: 'openid profile',
allowedTools: ['read'],
@ -31,12 +35,16 @@ describe('config render helpers', () => {
auth: 'oauth',
tokenCacheDir: '/tmp/cache',
clientName: 'mcporter',
oauthClientId: 'client-123',
oauthClientSecretEnv: 'OAUTH_SECRET',
oauthTokenEndpointAuthMethod: 'client_secret_post',
oauthRedirectUrl: 'https://example.com/callback',
oauthScope: 'openid profile',
allowedTools: ['read'],
env: { FOO: 'bar' },
source: { kind: 'import', path: '/tmp/source.json' },
});
expect(payload).not.toHaveProperty('oauthClientSecret');
});
it('serializes stdio definitions with command metadata', () => {

View File

@ -37,6 +37,7 @@ describe('FileOAuthClientProvider session lifecycle', () => {
afterEach(async () => {
vi.restoreAllMocks();
delete process.env.MCPORTER_TEST_OAUTH_SECRET;
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
@ -86,6 +87,36 @@ describe('FileOAuthClientProvider session lifecycle', () => {
await session.close();
});
it('returns configured static OAuth client information without dynamic registration', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);
process.env.MCPORTER_TEST_OAUTH_SECRET = 'client-secret-value';
const definition: ServerDefinition = {
name: 'test-oauth-static-client',
description: 'Test OAuth server',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'oauth',
tokenCacheDir,
oauthClientId: 'client-123',
oauthClientSecretEnv: 'MCPORTER_TEST_OAUTH_SECRET',
oauthTokenEndpointAuthMethod: 'client_secret_post',
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = await createOAuthSession(definition, logger);
const clientInfo = await session.provider.clientInformation();
expect(clientInfo).toMatchObject({
client_id: 'client-123',
client_secret: 'client-secret-value',
token_endpoint_auth_method: 'client_secret_post',
});
await session.close();
});
it('clears stale client registrations when redirect URI changes with dynamic ports', async () => {
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
tempDirs.push(tokenCacheDir);