Skip to content

Commit c59ac6f

Browse files
authored
Merge branch 'main' into claude/fix-session-options-xZywy
2 parents 3236102 + 6adf785 commit c59ac6f

File tree

5 files changed

+103
-20
lines changed

5 files changed

+103
-20
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- `mcpc close @session`, `mcpc restart @session`, and `mcpc shell @session` command-first syntax as alternatives to `mcpc @session close/restart/shell`
1212
- E2E tests now run under the Bun runtime (in addition to Node.js); use `./test/e2e/run.sh --runtime bun` or `npm run test:e2e:bun`
1313

14+
### Fixed
15+
- `parseServerArg()` now handles well Windows drive-letter config paths as well as other ambiguous cases
16+
1417
### Changed
1518
- **Breaking:** CLI syntax redesigned to command-first style. All commands now start with a verb; MCP operations require a named session.
1619

docs/TODOs.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,9 @@ Run "mcpc --help" for usage information.
3333

3434
- mcpc @session --timeout ... / mcpc @session <cmd> --timeout ... has no effect
3535

36-
- createSessionProgram() advertises --header and --profile options for mcpc @session ..., but these values are never applied: withMcpClient()/SessionClient ignore headers/profile overrides and always use the session’s stored config. This is misleading for users and makes it easy to think a command is authenticated/modified when it isn’t. Either wire these options into session execution (e.g. by updating/restarting the session/bridge) or remove them from the session program/help.
37-
38-
- parseServerArg() splits config entries using the first : (arg.indexOf(':')). This breaks Windows paths with drive letters (e.g. C:\Users\me\mcp.json:filesystem), which would be parsed as file=C entry=\Users\.... Consider special-casing ^[A-Za-z]:[\\/] and/or using lastIndexOf(':') for the file/entry delimiter to keep Windows paths working
39-
- parseServerArg() treats any string containing : (that wasn’t recognized as a URL) as a config file:entry. This will misclassify inputs like example.com:foo (invalid host:port) as a config file named example.com. Consider tightening the config heuristic (e.g. require the left side to look like a file path or have a known config extension) and return null for ambiguous/invalid host:port inputs.
36+
IN PROGRESS - createSessionProgram() advertises --header and --profile options for mcpc @session ..., but these values are never applied:
37+
withMcpClient()
38+
/SessionClient ignore headers/profile overrides and always use the session’s stored config. This is misleading for users and makes it easy to think a command is authenticated/modified when it isn’t. Either wire these options into session execution (e.g. by updating/restarting the session/bridge) or remove them from the session program/help.
4039

4140
- validateOptions() relies on KNOWN_OPTIONS, but several options used by subcommands are missing (e.g. --scope on login, -r/--payment-required, --amount, --expiry for x402 sign, and session flags like -o/--output, --max-size). This will cause valid commands to fail early with "Unknown option" before routing to the correct Commander program. Either expand KNOWN_OPTIONS to cover all CLI flags (including subcommand-specific ones) or change validation to only check global options (e.g. only scan args before the first non-option command token
4241
- login introduces a --scope option here, but the pre-parse validateOptions() step uses KNOWN_OPTIONS from parser.ts, which currently does not include --scope. As a result, mcpc login <server> --scope ... will fail early with "Unknown option: --scope" before Commander runs. Add --scope to the known options list or make option validation command-aware.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"build": "tsc",
3333
"build:watch": "tsc --watch",
3434
"build:readme": "./scripts/update-readme.sh",
35-
"test": "npm run build && npm run test:unit && ./test/e2e/run.sh --no-build --parallel 4 && ./test/e2e/run.sh --no-build --runtime bun",
35+
"test": "npm run build && npm run test:unit && ./test/e2e/run.sh --no-build --parallel 4 && ./test/e2e/run.sh --no-build --parallel 4 --runtime bun",
3636
"test:unit": "jest",
3737
"test:watch": "jest --watch",
3838
"test:coverage": "npm run test:coverage:unit && npm run test:coverage:e2e && npm run test:coverage:merge",

src/cli/parser.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,34 @@ function isValidUrlWithHost(str: string): boolean {
277277
}
278278
}
279279

280+
/**
281+
* Returns true if s looks like a filesystem path rather than a hostname.
282+
* Used to decide whether the left side of a colon is a file path or a host.
283+
*/
284+
function looksLikeFilePath(s: string): boolean {
285+
// Unix absolute or home-relative paths
286+
if (s.startsWith('/') || s.startsWith('~')) return true;
287+
// Explicit relative paths
288+
if (s.startsWith('./') || s.startsWith('../')) return true;
289+
// Windows absolute paths: C:\ or C:/
290+
if (/^[A-Za-z]:[/\\]/.test(s)) return true;
291+
// Contains a directory separator (e.g. subdir/file.json)
292+
if (s.includes('/') || s.includes('\\')) return true;
293+
// Known config file extensions without any path prefix
294+
if (/\.(json|yaml|yml)$/i.test(s)) return true;
295+
return false;
296+
}
297+
280298
/**
281299
* Parse a server argument into a URL or config file entry.
282300
*
283301
* 1. URL: arg (as-is, or prefixed with https:// or http://) is a valid URL with a non-empty host.
284302
* Args that start with a path character (/, ~, .) skip the prefix check to avoid false positives
285303
* (e.g. https://~/ or https:///// parse with unusual hosts but are clearly file paths).
286-
* 2. Config entry: colon present with non-empty text on both sides → file:entry
287-
* 3. Otherwise: returns null (caller should report an error)
304+
* 2. If arg contains "://" but failed URL validation above → null (invalid full-URL syntax).
305+
* 3. Config entry: colon present, entry non-empty, AND left side looks like a file path.
306+
* Windows drive-letter paths (C:\...) use lastIndexOf(':') so the drive colon is skipped.
307+
* 4. Otherwise: returns null (caller should report an error)
288308
*/
289309
export function parseServerArg(
290310
arg: string
@@ -294,27 +314,39 @@ export function parseServerArg(
294314
return { type: 'url', url: arg };
295315
}
296316

297-
// Step 1b: try adding https:// / http:// prefix for bare hostnames and host:port combos.
317+
// Step 1b: if arg contains "://" it's clearly intended as a full URL — don't fall through to
318+
// the config-entry heuristic (e.g. "https://host:badport" should not become file="https").
319+
if (arg.includes('://')) {
320+
return null;
321+
}
322+
323+
// Step 2: try adding https:// prefix for bare hostnames and host:port combos.
298324
// Skip if arg starts with a path character — those are file paths, not hostnames.
299325
// Skip if arg ends with ':' — dangling colon is not a valid hostname.
300-
const startsWithPathChar = arg.startsWith('/') || arg.startsWith('~') || arg.startsWith('.');
326+
const isWindowsDrive = /^[A-Za-z]:[/\\]/.test(arg);
327+
const startsWithPathChar =
328+
arg.startsWith('/') || arg.startsWith('~') || arg.startsWith('.') || isWindowsDrive;
301329
if (!startsWithPathChar && !arg.endsWith(':')) {
302-
if (isValidUrlWithHost('https://' + arg) || isValidUrlWithHost('http://' + arg)) {
330+
if (isValidUrlWithHost('https://' + arg)) {
303331
return { type: 'url', url: arg };
304332
}
305333
}
306334

307-
// Step 2: config file entry — colon with non-empty text on both sides
308-
const colonIndex = arg.indexOf(':');
335+
// Step 3: config file entry — colon separates file path from entry name.
336+
// The left side must look like a file path (not a bare hostname).
337+
// Special case: Windows drive-letter paths (C:\...) have a colon at position 1;
338+
// use lastIndexOf(':') so we skip that drive colon and find the entry separator.
339+
const colonIndex = isWindowsDrive ? arg.lastIndexOf(':') : arg.indexOf(':');
340+
309341
if (colonIndex > 0 && colonIndex < arg.length - 1) {
310-
return {
311-
type: 'config',
312-
file: arg.substring(0, colonIndex),
313-
entry: arg.substring(colonIndex + 1),
314-
};
342+
const file = arg.substring(0, colonIndex);
343+
const entry = arg.substring(colonIndex + 1);
344+
if (looksLikeFilePath(file)) {
345+
return { type: 'config', file, entry };
346+
}
315347
}
316348

317-
// Step 3: unrecognised
349+
// Step 4: unrecognised
318350
return null;
319351
}
320352

test/unit/cli/index.test.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ describe('parseServerArg', () => {
5151

5252
const result2 = parseServerArg('http://mcp.apify.com');
5353
expect(result2).toEqual({ type: 'url', url: 'http://mcp.apify.com' });
54+
55+
const result3 = parseServerArg('http://mcp.apify.com:8000');
56+
expect(result3).toEqual({ type: 'url', url: 'http://mcp.apify.com:8000' });
5457
});
5558

5659
it('should parse a URL with path (no colon-entry) as URL', () => {
@@ -59,6 +62,9 @@ describe('parseServerArg', () => {
5962

6063
const result2 = parseServerArg('mcp.apify.com/v1');
6164
expect(result2).toEqual({ type: 'url', url: 'mcp.apify.com/v1' });
65+
66+
const result3 = parseServerArg('mcp.apify.com:8000/v1');
67+
expect(result3).toEqual({ type: 'url', url: 'mcp.apify.com:8000/v1' });
6268
});
6369

6470
it('should parse ~/.vscode/mcp.json:filesystem as config', () => {
@@ -76,14 +82,31 @@ describe('parseServerArg', () => {
7682
expect(result).toEqual({ type: 'config', file: '/absolute/path.json', entry: 'entry' });
7783
});
7884

85+
it('should parse .json extension as config', () => {
86+
const result = parseServerArg('./config.json:myserver');
87+
expect(result).toEqual({ type: 'config', file: './config.json', entry: 'myserver' });
88+
89+
const result2 = parseServerArg('config.json:myserver');
90+
expect(result2).toEqual({ type: 'config', file: 'config.json', entry: 'myserver' });
91+
});
92+
7993
it('should parse .yaml extension as config', () => {
8094
const result = parseServerArg('./config.yaml:myserver');
8195
expect(result).toEqual({ type: 'config', file: './config.yaml', entry: 'myserver' });
96+
97+
const result2 = parseServerArg('config.yaml:myserver');
98+
expect(result2).toEqual({ type: 'config', file: 'config.yaml', entry: 'myserver' });
8299
});
83100

84101
it('should parse .yml extension as config', () => {
85-
const result = parseServerArg('config.yml:myserver');
86-
expect(result).toEqual({ type: 'config', file: 'config.yml', entry: 'myserver' });
102+
const result = parseServerArg('./config.yml:myserver');
103+
expect(result).toEqual({ type: 'config', file: './config.yml', entry: 'myserver' });
104+
105+
const result2 = parseServerArg('config.yml:myserver');
106+
expect(result2).toEqual({ type: 'config', file: 'config.yml', entry: 'myserver' });
107+
108+
const result3 = parseServerArg('../config.yml:myserver');
109+
expect(result3).toEqual({ type: 'config', file: '../config.yml', entry: 'myserver' });
87110
});
88111

89112
it('should NOT parse hostname:port as config', () => {
@@ -108,6 +131,32 @@ describe('parseServerArg', () => {
108131
it('should return null for trailing-colon input', () => {
109132
expect(parseServerArg('file:')).toBeNull();
110133
});
134+
135+
it('should return null for hostname:non-numeric-port (not a valid URL or file path)', () => {
136+
expect(parseServerArg('example.com:foo')).toBeNull();
137+
expect(parseServerArg('myhost:notaport')).toBeNull();
138+
});
139+
140+
it('should return null for https:// URL with invalid port', () => {
141+
expect(parseServerArg('https://mcp.apify.com:invalid')).toBeNull();
142+
expect(parseServerArg('http://example.com:badport')).toBeNull();
143+
});
144+
145+
it('should return null for other invalid full-URL syntax', () => {
146+
expect(parseServerArg('https://host:badport/path')).toBeNull();
147+
});
148+
149+
it('should parse Windows drive-letter config paths correctly', () => {
150+
const result = parseServerArg('C:\\Users\\me\\mcp.json:filesystem');
151+
expect(result).toEqual({
152+
type: 'config',
153+
file: 'C:\\Users\\me\\mcp.json',
154+
entry: 'filesystem',
155+
});
156+
157+
const result2 = parseServerArg('D:/projects/config.yaml:myserver');
158+
expect(result2).toEqual({ type: 'config', file: 'D:/projects/config.yaml', entry: 'myserver' });
159+
});
111160
});
112161

113162
describe('extractOptions', () => {

0 commit comments

Comments
 (0)