Skip to content

Commit 7e606c4

Browse files
jancurnclaude
andauthored
Narrow CIMD callback port range to 5 and drop localhost redirect URIs (#189)
* Add public OAuth CIMD and use it as default for mcpc login Host a Client ID Metadata Document (CIMD) at https://apify.github.io/mcpc/client-metadata/v1.json so every mcpc installation presents a consistent client identity on CIMD-capable authorization servers. This follows the same pattern as VS Code and Claude Code. Key changes: - New docs/client-metadata/v1.json served via GitHub Pages - mcpc login now defaults to the hosted CIMD; use --no-client-metadata-url to opt out or --client-metadata-url <url> to override - OAuth callback uses fixed port range 13316-13325 to match the CIMD's registered redirect_uris (CIMD docs are static, so exact-match ports are required by most authorization servers) - Tightened CIMD URL validation per the spec: reject fragments, embedded credentials, and dot path segments - Added logo_uri, tos_uri, policy_uri to the DCR client metadata getter for branding parity with the hosted CIMD https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Use fixed port range 13316–13325 for all OAuth callbacks Drop the branching between CIMD and non-CIMD port ranges. Using the same fixed range in all modes (CIMD, DCR, pre-registered --client-id) makes the callback port predictable for firewalls, docs, and pre-registered clients, and removes a chunk of conditional logic. Pre-registered clients can rely on RFC 8252 loopback-any-port semantics or list the mcpc range in their redirect URIs, same as before. https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Show full default CIMD URL in --client-metadata-url help text https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Shorten default CIMD URL to /mcpc/client.json Matches the draft-ietf-oauth-client-id-metadata-document spec example URL (https://example.com/client.json) and trims 13 chars off the displayed client_id on consent screens. https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Serve GitHub Pages from repo root so / renders the main README Move client.json from docs/ to the repo root and add a minimal _config.yml excluding dev dirs, so https://apify.github.io/mcpc/ shows the project README and https://apify.github.io/mcpc/client.json serves the OAuth CIMD. Note: requires changing the GitHub Pages source in repo Settings from "main / docs" to "main / (root)". https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Fix CIMD metadata URIs: use Pages-hosted paths, drop policy_uri - logo_uri → client-logo.svg (served via GitHub Pages) - tos_uri → LICENSE on GitHub Pages (not raw GitHub blob URL) - Remove policy_uri (no standalone privacy policy page) https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Narrow CIMD callback port range to 5 and drop localhost redirect URIs - Reduce port range from 10 (13316–13325) to 5 (13316–13320). Matches the conservative pattern used by VS Code (1 port) and Claude Code (1 port), while leaving headroom for occasional concurrent logins. - Drop "http://localhost:PORT/callback" entries from the CIMD document in favor of "http://127.0.0.1:PORT/callback" only. RFC 8252 §8.3 explicitly recommends the IP literal over "localhost" to avoid DNS resolution ambiguity (a misconfigured /etc/hosts could route "localhost" to a non-loopback address). mcpc already binds the callback server to 127.0.0.1 only, so the localhost variants were dead weight. - CIMD redirect_uris list shrinks from 20 entries to 5. https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Use 6 non-contiguous OAuth callback ports Replaces the contiguous range (13316–13320) with an explicit list of 6 spread-out ports: 13163, 13316, 16133, 16313, 31316, 31613. A single unrelated process squatting on 13316 is now less likely to also hit the rest of mcpc's callback ports. findAvailablePort() now takes an explicit `ports` list instead of a start+range pair. https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Revert port sorting; start list with 13316 Order: 13316, 13163, 31316, 31613, 16133, 16313. https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Add E2E tests for --callback-port and CIMD defaults Tier 1 (flag-level): - --callback-port appears in `mcpc login --help` - --help shows the full default CIMD URL - --callback-port rejects non-numeric values, 0, values >65535, and negatives (validation now in src/cli/index.ts) Tier 2 (end-to-end port binding): - Starts `mcpc login --callback-port <port> --no-client-metadata-url` against a hanging local HTTP server so the OAuth flow stalls during discovery and keeps its callback server bound. - Polls 127.0.0.1:<port> via bash /dev/tcp probe until it's listening, proving --callback-port is honored end-to-end (not just parsed). - Cleans up both the stall server and the backgrounded mcpc login process regardless of outcome. https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x * Serve full docs/ dir via GitHub Pages and clean up _config.yml List what IS served as a comment (whitelist intent), exclude everything else. Stop cherry-picking inside docs/ — the full directory is now published at /mcpc/docs/. https://claude.ai/code/session_015YnY1wPJF48HUrfdPaFJ1x --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e374e6 commit 7e606c4

7 files changed

Lines changed: 146 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12+
- OAuth callback ports for the hosted CIMD changed from the contiguous range 13316–13325 to 6 non-contiguous ports (13316, 13163, 31316, 31613, 16133, 16313) so one unrelated process is less likely to claim all of them. `localhost` variants dropped from the CIMD's `redirect_uris` in favor of `127.0.0.1` only (per RFC 8252 §8.3, which recommends the IP literal to avoid DNS resolution ambiguity).
1213
- `restart` success message now notes that previous session state (resource subscriptions, pending notifications, async tasks) was lost, since explicit restart always creates a fresh MCP session
1314
- `tasks-list` now shows a hint on how to start a new task (`mcpc @session tools-call <name> [args] --task`) when there are no active tasks
1415
- `mcpc help <command>` now shows a "Did you mean?" suggestion when the command is unknown (e.g., `mcpc help tasks-gfet` → suggests `tasks-get`)

_config.yml

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1-
title: mcpc
2-
description: Universal MCP command-line client
1+
# GitHub Pages configuration for https://apify.github.io/mcpc/
2+
#
3+
# Served content (everything else is excluded):
4+
# / → README.md (project homepage)
5+
# /client-metadata.json → OAuth CIMD document
6+
# /client-logo.svg → Client logo for OAuth consent screens
7+
# /LICENSE → Terms of service (referenced by CIMD tos_uri)
8+
# /CHANGELOG.md → Release history
9+
# /docs/ → Additional documentation, examples, images
10+
#
11+
# Jekyll has no include-only mode, so we exclude everything that
12+
# shouldn't be published. node_modules/ is excluded by default.
13+
314
theme: jekyll-theme-cayman
415

5-
# Dev files and directories not meant for the GitHub Pages site.
6-
# README.md is rendered at the site root.
716
exclude:
817
- CLAUDE.md
918
- CONTRIBUTING.md
19+
- _config.yml
1020
- bin/
1121
- dist/
12-
- docs/claude-skill/
13-
- docs/examples/
1422
- jest.config.js
15-
- node_modules/
16-
- package-lock.json
1723
- package.json
24+
- package-lock.json
1825
- renovate.json
1926
- scripts/
2027
- src/

client-metadata.json

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,11 @@
55
"tos_uri": "https://apify.github.io/mcpc/LICENSE",
66
"redirect_uris": [
77
"http://127.0.0.1:13316/callback",
8-
"http://127.0.0.1:13317/callback",
9-
"http://127.0.0.1:13318/callback",
10-
"http://127.0.0.1:13319/callback",
11-
"http://127.0.0.1:13320/callback",
12-
"http://127.0.0.1:13321/callback",
13-
"http://127.0.0.1:13322/callback",
14-
"http://127.0.0.1:13323/callback",
15-
"http://127.0.0.1:13324/callback",
16-
"http://127.0.0.1:13325/callback",
17-
"http://localhost:13316/callback",
18-
"http://localhost:13317/callback",
19-
"http://localhost:13318/callback",
20-
"http://localhost:13319/callback",
21-
"http://localhost:13320/callback",
22-
"http://localhost:13321/callback",
23-
"http://localhost:13322/callback",
24-
"http://localhost:13323/callback",
25-
"http://localhost:13324/callback",
26-
"http://localhost:13325/callback"
8+
"http://127.0.0.1:13163/callback",
9+
"http://127.0.0.1:31316/callback",
10+
"http://127.0.0.1:31613/callback",
11+
"http://127.0.0.1:16133/callback",
12+
"http://127.0.0.1:16313/callback"
2713
],
2814
"grant_types": ["authorization_code", "refresh_token"],
2915
"response_types": ["code"],

src/cli/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -616,13 +616,23 @@ ${jsonHelp('Interactive prompts are written to stderr, stdout contains a clean J
616616
'Missing required argument: server\n\nExample: mcpc login mcp.apify.com'
617617
);
618618
}
619+
let callbackPort: number | undefined;
620+
if (opts.callbackPort) {
621+
const parsed = parseInt(opts.callbackPort as string, 10);
622+
if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
623+
throw new ClientError(
624+
`Invalid --callback-port value: "${opts.callbackPort as string}". Must be an integer between 1 and 65535.`
625+
);
626+
}
627+
callbackPort = parsed;
628+
}
619629
await auth.login(server, {
620630
profile: opts.profile,
621631
scope: opts.scope,
622632
clientId: opts.clientId,
623633
clientSecret: opts.clientSecret,
624634
clientMetadataUrl: opts.clientMetadataUrl,
625-
...(opts.callbackPort ? { callbackPort: parseInt(opts.callbackPort as string, 10) } : {}),
635+
...(callbackPort !== undefined ? { callbackPort } : {}),
626636
...getOptionsFromCommand(command),
627637
});
628638
});

src/lib/auth/oauth-flow.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,7 @@ import { ClientError } from '../errors.js';
1515
import { createLogger } from '../logger.js';
1616
import { removeKeychainOAuthClientInfo, storeKeychainOAuthClientInfo } from './keychain.js';
1717
import type { AuthProfile } from '../types.js';
18-
import {
19-
MCPC_OAUTH_CALLBACK_PORT,
20-
MCPC_OAUTH_CALLBACK_PORT_RANGE,
21-
validateClientMetadataUrl,
22-
} from './oauth-utils.js';
18+
import { MCPC_OAUTH_CALLBACK_PORTS, validateClientMetadataUrl } from './oauth-utils.js';
2319

2420
const logger = createLogger('oauth-flow');
2521

@@ -257,11 +253,11 @@ function startCallbackServer(port: number): {
257253

258254
/**
259255
* Find an available port for the OAuth callback server.
260-
* Tries `range` consecutive ports starting at `startPort` and returns the
261-
* first free one. Throws if all ports in the range are occupied.
256+
* Tries each port in `ports` in order and returns the first free one.
257+
* Throws if all ports are occupied.
262258
*/
263-
async function findAvailablePort(startPort: number, range: number): Promise<number> {
264-
for (let port = startPort; port < startPort + range; port++) {
259+
async function findAvailablePort(ports: readonly number[]): Promise<number> {
260+
for (const port of ports) {
265261
try {
266262
await new Promise<void>((resolve, reject) => {
267263
const testServer = createServer();
@@ -277,7 +273,7 @@ async function findAvailablePort(startPort: number, range: number): Promise<numb
277273
}
278274
}
279275
throw new ClientError(
280-
`Could not find available port for OAuth callback server (tried ports ${startPort}${startPort + range - 1}). ` +
276+
`Could not find available port for OAuth callback server (tried ports ${ports.join(', ')}). ` +
281277
'Another mcpc login may be in progress — wait for it to finish and try again.'
282278
);
283279
}
@@ -434,10 +430,8 @@ export async function performOAuthFlow(
434430
}
435431

436432
// When --callback-port is set, use that exact port. Otherwise try the
437-
// fixed mcpc range (13316–13325) that matches the hosted CIMD's redirect_uris.
438-
const port = callbackPort
439-
? await findAvailablePort(callbackPort, 1)
440-
: await findAvailablePort(MCPC_OAUTH_CALLBACK_PORT, MCPC_OAUTH_CALLBACK_PORT_RANGE);
433+
// fixed mcpc port list that matches the hosted CIMD's redirect_uris.
434+
const port = await findAvailablePort(callbackPort ? [callbackPort] : MCPC_OAUTH_CALLBACK_PORTS);
441435
const redirectUrl = `http://127.0.0.1:${port}/callback`;
442436

443437
logger.debug(`Using redirect URL: ${redirectUrl}`);

src/lib/auth/oauth-utils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,15 @@ export const DEFAULT_AUTH_PROFILE = 'default';
1313

1414
export const DEFAULT_CLIENT_METADATA_URL = 'https://apify.github.io/mcpc/client-metadata.json';
1515

16-
export const MCPC_OAUTH_CALLBACK_PORT = 13316;
17-
export const MCPC_OAUTH_CALLBACK_PORT_RANGE = 10;
16+
/**
17+
* Loopback ports used by mcpc's OAuth callback server. Matches the
18+
* `redirect_uris` registered in the hosted CIMD document. Tried in order;
19+
* the first available port is used. Non-contiguous values to reduce the
20+
* chance that a single unrelated process claims all of them.
21+
*/
22+
export const MCPC_OAUTH_CALLBACK_PORTS: readonly number[] = [
23+
13316, 13163, 31316, 31613, 16133, 16313,
24+
] as const;
1825

1926
/**
2027
* OAuth token endpoint response (per OAuth 2.0 spec - uses snake_case)

test/e2e/suites/basic/auth-errors.test.sh

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,101 @@ assert_contains "$STDOUT" "--no-client-metadata-url"
158158
assert_contains "$STDOUT" "apify.github.io"
159159
test_pass
160160

161+
test_case "login --help documents --callback-port"
162+
run_mcpc help login
163+
assert_success
164+
assert_contains "$STDOUT" "--callback-port"
165+
test_pass
166+
167+
test_case "login --help shows full default CIMD URL"
168+
run_mcpc help login
169+
assert_success
170+
assert_contains "$STDOUT" "https://apify.github.io/mcpc/client-metadata.json"
171+
test_pass
172+
173+
test_case "login --callback-port with non-numeric value is rejected"
174+
run_xmcpc login mcp.example.com --callback-port abc --no-client-metadata-url
175+
assert_failure
176+
assert_contains "$STDERR" "--callback-port"
177+
test_pass
178+
179+
test_case "login --callback-port with 0 is rejected"
180+
run_xmcpc login mcp.example.com --callback-port 0 --no-client-metadata-url
181+
assert_failure
182+
assert_contains "$STDERR" "--callback-port"
183+
test_pass
184+
185+
test_case "login --callback-port above 65535 is rejected"
186+
run_xmcpc login mcp.example.com --callback-port 65536 --no-client-metadata-url
187+
assert_failure
188+
assert_contains "$STDERR" "--callback-port"
189+
test_pass
190+
191+
test_case "login --callback-port negative is rejected"
192+
run_xmcpc login mcp.example.com --callback-port -1 --no-client-metadata-url
193+
assert_failure
194+
assert_contains "$STDERR" "--callback-port"
195+
test_pass
196+
197+
# =============================================================================
198+
# Test: Tier 2 - end-to-end callback port binding
199+
#
200+
# Starts `mcpc login` in the background with --callback-port set, pointing at
201+
# a local HTTP server that accepts connections but never responds. This keeps
202+
# mcpc in the OAuth-discovery phase, so its callback server stays bound long
203+
# enough to observe. We then verify the expected loopback port is listening.
204+
# This proves --callback-port is honored end-to-end, not just parsed.
205+
# =============================================================================
206+
207+
# Pick a likely-free high port outside mcpc's default list for the callback
208+
TEST_CALLBACK_PORT=47317
209+
210+
test_case "login --callback-port binds the requested port on 127.0.0.1"
211+
212+
# Start a hanging HTTP server on a random local port. mcpc will hit this while
213+
# trying to discover OAuth metadata and block indefinitely, which is what we
214+
# want so we can observe the callback server.
215+
STALL_PORT=47318
216+
node -e "require('http').createServer(()=>{}).listen(${STALL_PORT}, '127.0.0.1')" &
217+
STALL_PID=$!
218+
219+
# Wait for the stall server to be ready
220+
for _ in $(seq 1 20); do
221+
if (echo >/dev/tcp/127.0.0.1/$STALL_PORT) >/dev/null 2>&1; then
222+
break
223+
fi
224+
sleep 0.1
225+
done
226+
227+
# Start mcpc login in the background. --no-client-metadata-url skips CIMD
228+
# so mcpc goes straight to DCR against the stall server (which hangs).
229+
$MCPC login http://127.0.0.1:$STALL_PORT \
230+
--callback-port "$TEST_CALLBACK_PORT" \
231+
--no-client-metadata-url \
232+
</dev/null >/dev/null 2>&1 &
233+
LOGIN_PID=$!
234+
235+
# Poll for the callback port to be bound (up to 10 seconds)
236+
bound=false
237+
for _ in $(seq 1 100); do
238+
if (echo >/dev/tcp/127.0.0.1/$TEST_CALLBACK_PORT) >/dev/null 2>&1; then
239+
bound=true
240+
break
241+
fi
242+
sleep 0.1
243+
done
244+
245+
# Clean up background processes regardless of outcome
246+
_kill_tree "$LOGIN_PID"
247+
wait "$LOGIN_PID" 2>/dev/null || true
248+
_kill_tree "$STALL_PID"
249+
wait "$STALL_PID" 2>/dev/null || true
250+
251+
if [[ "$bound" == "true" ]]; then
252+
test_pass
253+
else
254+
test_fail "Port $TEST_CALLBACK_PORT was not bound within 10s"
255+
exit 1
256+
fi
257+
161258
test_done

0 commit comments

Comments
 (0)