diff --git a/.changeset/fix-allow-http-oauth-resource-url.md b/.changeset/fix-allow-http-oauth-resource-url.md new file mode 100644 index 000000000..684cea7e5 --- /dev/null +++ b/.changeset/fix-allow-http-oauth-resource-url.md @@ -0,0 +1,5 @@ +--- +"@adcp/sdk": patch +--- + +Fixed `--allow-http` not propagating to `MCPOAuthProvider.validateResourceURL`. Local dev MCP servers on `http://localhost` now work with `--oauth` without requiring `ngrok` or `--allow-http`: loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always allowed in resource URL validation, matching the existing `ClientCredentialsFlow` loopback carve-out. Non-loopback HTTP resource URLs are gated on the `--allow-http` flag, which is now correctly threaded from all five CLI OAuth call sites through `createCLIOAuthProvider` to the provider. Also adds `--allow-http` to the top-level `--help` OPTIONS output. diff --git a/bin/adcp.js b/bin/adcp.js index 721bdf73e..f0f8a7976 100755 --- a/bin/adcp.js +++ b/bin/adcp.js @@ -944,11 +944,13 @@ async function maybeRunInlineOAuth(agentArg, args, { jsonOutput } = {}) { if (!args.includes('--oauth')) return; if (!agentArg) return; + const allowHttp = args.includes('--allow-http'); + if (isAlias(agentArg)) { const saved = getAgent(agentArg); if (!saved) return; try { - await ensureOAuthTokensForAlias(agentArg, saved.url, { quiet: jsonOutput }); + await ensureOAuthTokensForAlias(agentArg, saved.url, { quiet: jsonOutput, allowHttp }); } catch (err) { const hint = `Run: adcp --save-auth ${agentArg} ${saved.url} --oauth to re-register`; if (jsonOutput) { @@ -1006,7 +1008,7 @@ async function maybeRunInlineOAuth(agentArg, args, { jsonOutput } = {}) { * * @returns The updated saved agent record (with oauth_tokens and oauth_client). */ -async function ensureOAuthTokensForAlias(alias, url, { quiet = false } = {}) { +async function ensureOAuthTokensForAlias(alias, url, { quiet = false, allowHttp = false } = {}) { const existing = getAgent(alias); if (existing && hasValidOAuthTokens(existing)) { if (!quiet) console.log(`Using saved OAuth tokens for '${alias}'.`); @@ -1030,7 +1032,7 @@ async function ensureOAuthTokensForAlias(alias, url, { quiet = false } = {}) { const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js'); const { UnauthorizedError } = require('@modelcontextprotocol/sdk/client/auth.js'); - const oauthProvider = createCLIOAuthProvider(tempAgent, { quiet }); + const oauthProvider = createCLIOAuthProvider(tempAgent, { quiet, allowHttp }); const mcpClient = new MCPClient({ name: 'adcp-cli', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL(url), { authProvider: oauthProvider }); @@ -1396,6 +1398,7 @@ OPTIONS: --wait Wait for async/webhook responses --json Raw JSON output --debug Debug output + --allow-http Allow http:// and private-IP targets (dev loops only) --allow-v2 Suppress the v2-sunset warning (unsupported since 2026-04-20) --help, -h Show help @@ -5044,7 +5047,7 @@ credential material — never sync or commit. const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js'); const { UnauthorizedError } = require('@modelcontextprotocol/sdk/client/auth.js'); - const oauthProvider = createCLIOAuthProvider(tempAgent); + const oauthProvider = createCLIOAuthProvider(tempAgent, { allowHttp: args.includes('--allow-http') }); const mcpClient = new MCPClient({ name: 'adcp-cli', version: '1.0.0' }); const createTransport = () => new StreamableHTTPClientTransport(new URL(url), { authProvider: oauthProvider }); @@ -5251,6 +5254,7 @@ credential material — never sync or commit. const { customHeaders: cliHeaders, consumedTokens: headerTokens } = parseHeaderFlags(args); const useOAuth = args.includes('--oauth'); const clearOAuth = args.includes('--clear-oauth'); + const allowHttp = args.includes('--allow-http'); // Validate protocol flag if provided if (protocolFlag && protocolFlag !== 'mcp' && protocolFlag !== 'a2a') { @@ -5464,7 +5468,7 @@ credential material — never sync or commit. const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js'); const { UnauthorizedError } = require('@modelcontextprotocol/sdk/client/auth.js'); - const oauthProvider = createCLIOAuthProvider(agentConfig, { quiet: jsonOutput }); + const oauthProvider = createCLIOAuthProvider(agentConfig, { quiet: jsonOutput, allowHttp }); const mcpClient = new MCPClient({ name: 'adcp-cli', version: '1.0.0' }); const createTransport = () => new StreamableHTTPClientTransport(new URL(agentUrl), { authProvider: oauthProvider }); @@ -5594,6 +5598,7 @@ credential material — never sync or commit. // Create OAuth provider const oauthProvider = createCLIOAuthProvider(agentConfig, { quiet: jsonOutput, + allowHttp, }); // Create MCP client @@ -5963,7 +5968,7 @@ credential material — never sync or commit. const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js'); const { UnauthorizedError } = require('@modelcontextprotocol/sdk/client/auth.js'); - const oauthProvider = createCLIOAuthProvider(agentConfig, { quiet: jsonOutput }); + const oauthProvider = createCLIOAuthProvider(agentConfig, { quiet: jsonOutput, allowHttp }); const mcpClient = new MCPClient({ name: 'adcp-cli', version: '1.0.0' }); const createTransport = () => new StreamableHTTPClientTransport(new URL(agentUrl), { authProvider: oauthProvider }); diff --git a/src/lib/auth/oauth/MCPOAuthProvider.ts b/src/lib/auth/oauth/MCPOAuthProvider.ts index 0e4e4f879..8331c2c67 100644 --- a/src/lib/auth/oauth/MCPOAuthProvider.ts +++ b/src/lib/auth/oauth/MCPOAuthProvider.ts @@ -52,12 +52,14 @@ export class MCPOAuthProvider implements OAuthClientProvider { private readonly storage?: OAuthConfigStorage; private readonly flowHandler: OAuthFlowHandler; private readonly _clientMetadata: OAuthClientMetadata; + private readonly allowHttp: boolean; constructor(config: OAuthProviderConfig) { this.agent = config.agent; this.storage = config.storage; this.flowHandler = config.flowHandler; this._clientMetadata = config.clientMetadata; + this.allowHttp = config.allowHttp ?? false; } /** @@ -67,7 +69,8 @@ export class MCPOAuthProvider implements OAuthClientProvider { agent: AgentConfig, flowHandler: OAuthFlowHandler, storage?: OAuthConfigStorage, - clientMetadataOverrides?: Partial + clientMetadataOverrides?: Partial, + allowHttp?: boolean ): MCPOAuthProvider { // Build complete client metadata with required fields const clientMetadata: OAuthClientMetadata = { @@ -81,6 +84,7 @@ export class MCPOAuthProvider implements OAuthClientProvider { flowHandler, storage, clientMetadata, + allowHttp, }); } @@ -104,6 +108,11 @@ export class MCPOAuthProvider implements OAuthClientProvider { * We allow cross-origin resource URLs because agent configs are pre-configured * by the user, not discovered from untrusted sources. The authorization server * remains the final gatekeeper for token audience validation. + * + * HTTP is allowed for loopback hosts (localhost, 127.0.0.1, [::1]) matching + * the RFC 6749 §3.1.2.1 loopback carve-out and the ClientCredentialsFlow + * precedent. For non-loopback HTTP, pass `allowHttp: true` in the config + * (CLI: `--allow-http`). */ async validateResourceURL(serverUrl: string | URL, resource?: string): Promise { if (!resource) { @@ -112,11 +121,22 @@ export class MCPOAuthProvider implements OAuthClientProvider { const resourceURL = new URL(resource); - if (resourceURL.protocol !== 'https:') { - throw new Error(`Server at ${serverUrl} advertised non-HTTPS resource URL: ${resource}`); + if (resourceURL.protocol === 'https:') { + return resourceURL; + } + + const host = resourceURL.hostname; + // URL.hostname returns '[::1]' (with brackets) for IPv6 loopback literals; + // bare '::1' is unreachable after new URL() normalization. + const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; + if (isLoopback || this.allowHttp) { + return resourceURL; } - return resourceURL; + throw new Error( + `Server at ${serverUrl} advertised non-HTTPS resource URL: ${resource}` + + ` — pass --allow-http for non-loopback HTTP targets` + ); } /** diff --git a/src/lib/auth/oauth/index.ts b/src/lib/auth/oauth/index.ts index 8526637a2..a0832f42a 100644 --- a/src/lib/auth/oauth/index.ts +++ b/src/lib/auth/oauth/index.ts @@ -146,6 +146,8 @@ export function createCLIOAuthProvider( quiet?: boolean; /** Storage for persisting agent config */ storage?: OAuthConfigStorage; + /** Allow HTTP resource URLs for non-loopback hosts (CLI: --allow-http) */ + allowHttp?: boolean; } ): MCPOAuthProvider { const flowConfig: CLIFlowHandlerConfig = { @@ -167,6 +169,7 @@ export function createCLIOAuthProvider( flowHandler, storage: options?.storage, clientMetadata, + allowHttp: options?.allowHttp, }); } @@ -189,6 +192,8 @@ export function createNonInteractiveOAuthProvider( clientMetadata?: Partial; /** Storage for persisting refreshed tokens back to disk. */ storage?: OAuthConfigStorage; + /** Allow HTTP resource URLs for non-loopback hosts. */ + allowHttp?: boolean; } ): MCPOAuthProvider { const { NonInteractiveFlowHandler } = require('./NonInteractiveFlowHandler'); @@ -205,6 +210,7 @@ export function createNonInteractiveOAuthProvider( flowHandler, storage: options?.storage, clientMetadata, + allowHttp: options?.allowHttp, }); } diff --git a/src/lib/auth/oauth/types.ts b/src/lib/auth/oauth/types.ts index 59d7bad43..87be38f6b 100644 --- a/src/lib/auth/oauth/types.ts +++ b/src/lib/auth/oauth/types.ts @@ -96,6 +96,14 @@ export interface OAuthProviderConfig { /** OAuth client metadata (required - use DEFAULT_CLIENT_METADATA as base) */ clientMetadata: OAuthClientMetadata; + + /** + * Allow HTTP resource URLs for non-loopback hosts. Loopback hosts + * (localhost, 127.0.0.1, [::1]) are always allowed regardless of this + * flag. This mirrors the `allowPrivateIp` option in ClientCredentialsFlow. + * Set to true when the CLI `--allow-http` flag is present. + */ + allowHttp?: boolean; } /** diff --git a/test/mcp-oauth-provider-validate-resource-url.test.js b/test/mcp-oauth-provider-validate-resource-url.test.js new file mode 100644 index 000000000..d652a8a3d --- /dev/null +++ b/test/mcp-oauth-provider-validate-resource-url.test.js @@ -0,0 +1,88 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +const { MCPOAuthProvider } = require('../dist/lib/auth/oauth/index.js'); + +function makeProvider(allowHttp = false) { + // Minimal stub for OAuthFlowHandler + const flowHandler = { + getRedirectUrl: () => 'http://localhost:8766/callback', + redirectToAuthorization: async () => {}, + waitForCallback: async () => 'code', + cleanup: async () => {}, + }; + return new MCPOAuthProvider({ + agent: { id: 'test', name: 'Test', agent_uri: 'https://example.com/mcp', protocol: 'mcp' }, + flowHandler, + clientMetadata: { + client_name: 'Test', + redirect_uris: ['http://localhost:8766/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }, + allowHttp, + }); +} + +describe('MCPOAuthProvider.validateResourceURL', () => { + it('returns undefined when resource is absent', async () => { + const provider = makeProvider(); + assert.strictEqual(await provider.validateResourceURL('https://agent.example.com/mcp'), undefined); + assert.strictEqual(await provider.validateResourceURL('https://agent.example.com/mcp', undefined), undefined); + }); + + it('accepts HTTPS resource URLs', async () => { + const provider = makeProvider(); + const url = await provider.validateResourceURL('https://agent.example.com/mcp', 'https://agent.example.com'); + assert.strictEqual(url?.href, 'https://agent.example.com/'); + }); + + it('accepts http://localhost resource URLs without allowHttp (loopback carve-out)', async () => { + const provider = makeProvider(false); + const url = await provider.validateResourceURL('http://localhost:3000/figma/mcp', 'http://localhost:3000/figma'); + assert.strictEqual(url?.hostname, 'localhost'); + }); + + it('accepts http://127.0.0.1 resource URLs without allowHttp (loopback carve-out)', async () => { + const provider = makeProvider(false); + const url = await provider.validateResourceURL('http://127.0.0.1:3000/mcp', 'http://127.0.0.1:3000'); + assert.strictEqual(url?.hostname, '127.0.0.1'); + }); + + it('accepts http://[::1] resource URLs without allowHttp (loopback carve-out)', async () => { + const provider = makeProvider(false); + const url = await provider.validateResourceURL('http://[::1]:3000/mcp', 'http://[::1]:3000'); + assert.strictEqual(url?.hostname, '[::1]'); + }); + + it('rejects non-loopback HTTP resource URLs when allowHttp is false', async () => { + const provider = makeProvider(false); + await assert.rejects( + () => provider.validateResourceURL('http://internal.example.com/mcp', 'http://internal.example.com'), + err => err.message.includes('non-HTTPS resource URL') + ); + }); + + it('error message for non-loopback HTTP mentions --allow-http flag', async () => { + const provider = makeProvider(false); + await assert.rejects( + () => provider.validateResourceURL('http://dev.internal/mcp', 'http://dev.internal'), + err => err.message.includes('--allow-http') + ); + }); + + it('accepts non-loopback HTTP resource URLs when allowHttp is true', async () => { + const provider = makeProvider(true); + const url = await provider.validateResourceURL('http://dev.internal/mcp', 'http://dev.internal/figma'); + assert.strictEqual(url?.hostname, 'dev.internal'); + }); + + it('does not treat localhost.attacker.com as loopback', async () => { + const provider = makeProvider(false); + await assert.rejects( + () => provider.validateResourceURL('http://localhost.attacker.com/mcp', 'http://localhost.attacker.com'), + err => err.message.includes('non-HTTPS resource URL') + ); + }); +});