Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-allow-http-oauth-resource-url.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 11 additions & 6 deletions bin/adcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}'.`);
Expand All @@ -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 });

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -5594,6 +5598,7 @@ credential material — never sync or commit.
// Create OAuth provider
const oauthProvider = createCLIOAuthProvider(agentConfig, {
quiet: jsonOutput,
allowHttp,
});

// Create MCP client
Expand Down Expand Up @@ -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 });
Expand Down
28 changes: 24 additions & 4 deletions src/lib/auth/oauth/MCPOAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -67,7 +69,8 @@ export class MCPOAuthProvider implements OAuthClientProvider {
agent: AgentConfig,
flowHandler: OAuthFlowHandler,
storage?: OAuthConfigStorage,
clientMetadataOverrides?: Partial<OAuthClientMetadata>
clientMetadataOverrides?: Partial<OAuthClientMetadata>,
allowHttp?: boolean
): MCPOAuthProvider {
// Build complete client metadata with required fields
const clientMetadata: OAuthClientMetadata = {
Expand All @@ -81,6 +84,7 @@ export class MCPOAuthProvider implements OAuthClientProvider {
flowHandler,
storage,
clientMetadata,
allowHttp,
});
}

Expand All @@ -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<URL | undefined> {
if (!resource) {
Expand All @@ -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`
);
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/lib/auth/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -167,6 +169,7 @@ export function createCLIOAuthProvider(
flowHandler,
storage: options?.storage,
clientMetadata,
allowHttp: options?.allowHttp,
});
}

Expand All @@ -189,6 +192,8 @@ export function createNonInteractiveOAuthProvider(
clientMetadata?: Partial<OAuthClientMetadata>;
/** 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');
Expand All @@ -205,6 +210,7 @@ export function createNonInteractiveOAuthProvider(
flowHandler,
storage: options?.storage,
clientMetadata,
allowHttp: options?.allowHttp,
});
}

Expand Down
8 changes: 8 additions & 0 deletions src/lib/auth/oauth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
88 changes: 88 additions & 0 deletions test/mcp-oauth-provider-validate-resource-url.test.js
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
Loading