From dd33721d89e9df7809ebe680235358f90a00013f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 08:03:13 +0100 Subject: [PATCH] feat: support static oauth clients --- CHANGELOG.md | 1 + README.md | 20 ++++++++++++++++ docs/config.md | 7 +++++- docs/import.md | 2 +- docs/known-issues.md | 4 ++-- mcporter.schema.json | 32 +++++++++++++++++++++++++ src/cli-metadata.ts | 9 ++++++++ src/cli/config/add.ts | 24 +++++++++++++++++++ src/cli/config/help.ts | 6 +++++ src/cli/config/render.ts | 9 ++++++++ src/cli/generate/definition.ts | 24 ++++++++++++++++++- src/config-normalize.ts | 9 ++++++++ src/config-schema.ts | 18 +++++++++++++++ src/config/imports/external.ts | 22 ++++++++++++++++++ src/generated-daemon-runtime.ts | 5 ++++ src/oauth.ts | 36 +++++++++++++++++++++++++++++ tests/config-add-flags.test.ts | 9 ++++++++ tests/config-normalize.test.ts | 41 +++++++++++++++++++++++++++++++++ tests/config-render.test.ts | 8 +++++++ tests/oauth-session.test.ts | 31 +++++++++++++++++++++++++ 20 files changed, 312 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b23d724..b287bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4c062a3..5eee3a1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/config.md b/docs/config.md index 8df17af..79e6fb7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 ` 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 don’t 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 `, so you don’t 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//`, `dist/`). diff --git a/docs/import.md b/docs/import.md index 6a367c1..3df1d9e 100644 --- a/docs/import.md +++ b/docs/import.md @@ -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.]` tables. Only `.codex/config.toml` is recognized. -- **Shared fields**: We convert JSON/TOML entries into mcporter’s 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 mcporter’s 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 diff --git a/docs/known-issues.md b/docs/known-issues.md index a4d4ea5..5075820 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -14,8 +14,8 @@ This file tracks limitations that users regularly run into. Most of these requir - Use Supabase’s 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. -- GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilot’s 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. +- GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilot’s 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 diff --git a/mcporter.schema.json b/mcporter.schema.json index ab99ee3..163f1fd 100644 --- a/mcporter.schema.json +++ b/mcporter.schema.json @@ -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" diff --git a/src/cli-metadata.ts b/src/cli-metadata.ts index ef865e5..4a2c598 100644 --- a/src/cli-metadata.ts +++ b/src/cli-metadata.ts @@ -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, diff --git a/src/cli/config/add.ts b/src/cli/config/add.ts index c9e858c..0508a33 100644 --- a/src/cli/config/add.ts +++ b/src/cli/config/add.ts @@ -19,6 +19,9 @@ export type AddFlags = { headers: Record; 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; } diff --git a/src/cli/config/help.ts b/src/cli/config/help.ts index bb644ef..01d1b27 100644 --- a/src/cli/config/help.ts +++ b/src/cli/config/help.ts @@ -51,6 +51,12 @@ export const CONFIG_HELP_ENTRIES: Record = { { flag: '--header KEY=value', description: 'Attach HTTP headers (repeatable).' }, { flag: '--token-cache-dir ', description: 'Override where OAuth tokens are persisted.' }, { flag: '--client-name ', description: 'Customize the OAuth client identifier.' }, + { flag: '--oauth-client-id ', description: 'Use a pre-registered OAuth client id.' }, + { flag: '--oauth-client-secret-env ', description: 'Read the OAuth client secret from an env var.' }, + { + flag: '--oauth-token-endpoint-auth-method ', + description: 'Set token auth, e.g. client_secret_post.', + }, { flag: '--oauth-redirect-url ', description: 'Set a custom OAuth redirect URL.' }, { flag: '--auth ', description: 'Force the auth type (e.g., oauth).' }, { flag: '--copy-from ', description: 'Start with an imported definition by name.' }, diff --git a/src/cli/config/render.ts b/src/cli/config/render.ts index decc20c..2c3e3e6 100644 --- a/src/cli/config/render.ts +++ b/src/cli/config/render.ts @@ -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, diff --git a/src/cli/generate/definition.ts b/src/cli/generate/definition.ts index a7c5cc4..2bfabfa 100644 --- a/src/cli/generate/definition.ts +++ b/src/cli/generate/definition.ts @@ -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; + 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).headers); - const record = def as Record; 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, ...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 | undefined { if (typeof value !== 'object' || value === null) { return undefined; diff --git a/src/config-normalize.ts b/src/config-normalize.ts index 9623cbd..7b8f164 100644 --- a/src/config-normalize.ts +++ b/src/config-normalize.ts @@ -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, diff --git a/src/config-schema.ts b/src/config-schema.ts index a67f1fa..f0beb64 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -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?: { diff --git a/src/config/imports/external.ts b/src/config/imports/external.ts index 293272c..99628b4 100644 --- a/src/config/imports/external.ts +++ b/src/config/imports/external.ts @@ -131,6 +131,28 @@ function convertExternalEntry(value: Record): 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; diff --git a/src/generated-daemon-runtime.ts b/src/generated-daemon-runtime.ts index ca41448..4ae990c 100644 --- a/src/generated-daemon-runtime.ts +++ b/src/generated-daemon-runtime.ts @@ -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 } : {}), diff --git a/src/oauth.ts b/src/oauth.ts index 653447f..5cd6371 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -237,6 +237,10 @@ class PersistentOAuthClientProvider implements OAuthClientProvider { } async clientInformation(): Promise { + 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, }; diff --git a/tests/config-add-flags.test.ts b/tests/config-add-flags.test.ts index dbb399c..214110f 100644 --- a/tests/config-add-flags.test.ts +++ b/tests/config-add-flags.test.ts @@ -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'); }); diff --git a/tests/config-normalize.test.ts b/tests/config-normalize.test.ts index 5880513..f3ad224 100644 --- a/tests/config-normalize.test.ts +++ b/tests/config-normalize.test.ts @@ -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'); + }); }); diff --git a/tests/config-render.test.ts b/tests/config-render.test.ts index f8a18c7..5fc8a04 100644 --- a/tests/config-render.test.ts +++ b/tests/config-render.test.ts @@ -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', () => { diff --git a/tests/oauth-session.test.ts b/tests/oauth-session.test.ts index e8e3418..61dfbf6 100644 --- a/tests/oauth-session.test.ts +++ b/tests/oauth-session.test.ts @@ -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);