feat: support static oauth clients
This commit is contained in:
parent
caa00dd3a4
commit
dd33721d89
@ -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
|
||||
|
||||
20
README.md
20
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
|
||||
|
||||
@ -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 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 <name>`, 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/<name>/`, `dist/`).
|
||||
|
||||
|
||||
@ -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 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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.' },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
36
src/oauth.ts
36
src/oauth.ts
@ -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,
|
||||
};
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user