Skip to content

Commit 1efbb02

Browse files
refactor(local-mcp): share transport + IPv6 parsing across the feature
Address the review-swarm reuse findings. - Extract `parseClaudeJsonTransport` in mcp-config.ts so the relay's SDK transport builder and the host-agnostic `LocalMcpTransport` descriptor read a raw ~/.claude.json entry through one normalizer (carrying the stdio `env` the relay needs but the descriptor drops), instead of duplicating the branching and drifting on legacy bare-url/type-less entries. - Move the IPv6-literal private-address kernel into `@posthog/shared` (`isPrivateIpv6Literal`), shared by `isPrivateHostname` and web-fetch's `isBlockedHost`. This also fixes a latent gap: `isPrivateHostname` only decoded the dotted `::ffff:1.2.3.4` form, so an IPv4-mapped loopback in the hex-group form `URL#hostname` normalizes to (`::ffff:7f00:1`) was misclassified as importable. - Add unit tests for the shared IPv6 kernel (incl. the hex-group mapped case), the `mcp_response` exactly-one-of-payload/error refine, and `relayMcpServerNamesSchema`. Generated-By: PostHog Code Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
1 parent 311ba5c commit 1efbb02

8 files changed

Lines changed: 242 additions & 84 deletions

File tree

packages/agent/src/adapters/claude/session/mcp-config.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,33 +95,70 @@ export function sanitizeHeaders(
9595
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
9696
}
9797

98-
function toTransport(config: McpServerConfig): LocalMcpTransport {
98+
/**
99+
* A raw ~/.claude.json entry parsed into a normalized transport, including the
100+
* stdio `env` that the relay executor needs to spawn the process (the
101+
* host-agnostic {@link LocalMcpTransport} deliberately drops it as secrets).
102+
* Both the descriptor normalizer ({@link toTransport}) and the relay's SDK
103+
* transport builder read entries through here, so they can't drift on how a
104+
* legacy bare-`url` or type-less entry is interpreted.
105+
*/
106+
export type ParsedClaudeJsonTransport =
107+
| { kind: "http" | "sse"; url: string; headers?: Record<string, string> }
108+
| {
109+
kind: "stdio";
110+
command: string;
111+
args?: string[];
112+
env?: Record<string, string>;
113+
}
114+
| { kind: "unknown" };
115+
116+
export function parseClaudeJsonTransport(
117+
config: McpServerConfig,
118+
): ParsedClaudeJsonTransport {
99119
// ~/.claude.json is hand-editable, so treat the parsed config as untyped.
100120
const raw = config as Record<string, unknown>;
101121
const type = typeof raw.type === "string" ? raw.type : undefined;
122+
const url = typeof raw.url === "string" ? raw.url : undefined;
123+
const command = typeof raw.command === "string" ? raw.command : undefined;
102124

103-
if ((type === "http" || type === "sse") && typeof raw.url === "string") {
104-
return { type, url: raw.url, headers: sanitizeHeaders(raw.headers) };
125+
if ((type === "http" || type === "sse") && url) {
126+
return { kind: type, url, headers: sanitizeHeaders(raw.headers) };
105127
}
106-
if (
107-
(type === undefined || type === "stdio") &&
108-
typeof raw.command === "string"
109-
) {
128+
if ((type === undefined || type === "stdio") && command) {
110129
const args = Array.isArray(raw.args)
111130
? raw.args.filter((arg): arg is string => typeof arg === "string")
112131
: undefined;
113-
return { type: "stdio", command: raw.command, args };
132+
return { kind: "stdio", command, args, env: sanitizeHeaders(raw.env) };
114133
}
115134
// Legacy entries can carry a bare `url` with no `type`; streamable HTTP is
116135
// the current default transport, so read them as http.
117-
if (type === undefined && typeof raw.url === "string") {
118-
return {
119-
type: "http",
120-
url: raw.url,
121-
headers: sanitizeHeaders(raw.headers),
122-
};
136+
if (type === undefined && url) {
137+
return { kind: "http", url, headers: sanitizeHeaders(raw.headers) };
138+
}
139+
return { kind: "unknown" };
140+
}
141+
142+
function toTransport(config: McpServerConfig): LocalMcpTransport {
143+
const transport = parseClaudeJsonTransport(config);
144+
switch (transport.kind) {
145+
case "http":
146+
case "sse":
147+
return {
148+
type: transport.kind,
149+
url: transport.url,
150+
headers: transport.headers,
151+
};
152+
case "stdio":
153+
// Drop `env` — the descriptor shape excludes stdio secrets.
154+
return {
155+
type: "stdio",
156+
command: transport.command,
157+
args: transport.args,
158+
};
159+
default:
160+
return { type: "unknown" };
123161
}
124-
return { type: "unknown" };
125162
}
126163

127164
/**

packages/agent/src/server/schemas.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { mcpServersSchema, validateCommandParams } from "./schemas";
2+
import {
3+
mcpServersSchema,
4+
relayMcpServerNamesSchema,
5+
validateCommandParams,
6+
} from "./schemas";
37

48
describe("mcpServersSchema", () => {
59
it("accepts a valid HTTP server", () => {
@@ -234,4 +238,85 @@ describe("validateCommandParams", () => {
234238

235239
expect(result.success).toBe(false);
236240
});
241+
242+
it("accepts an mcp_response carrying only a payload", () => {
243+
const result = validateCommandParams("_posthog/mcp_response", {
244+
requestId: "req-1",
245+
server: "slack",
246+
payload: { jsonrpc: "2.0", id: 1, result: {} },
247+
});
248+
249+
expect(result.success).toBe(true);
250+
});
251+
252+
it("accepts an mcp_response carrying only an error", () => {
253+
const result = validateCommandParams("mcp_response", {
254+
requestId: "req-1",
255+
server: "slack",
256+
error: { code: -32000, message: "boom" },
257+
});
258+
259+
expect(result.success).toBe(true);
260+
});
261+
262+
it("rejects an mcp_response with both payload and error", () => {
263+
const result = validateCommandParams("mcp_response", {
264+
requestId: "req-1",
265+
server: "slack",
266+
payload: { ok: true },
267+
error: { code: -32000, message: "boom" },
268+
});
269+
270+
expect(result.success).toBe(false);
271+
});
272+
273+
it("rejects an mcp_response with neither payload nor error", () => {
274+
const result = validateCommandParams("mcp_response", {
275+
requestId: "req-1",
276+
server: "slack",
277+
});
278+
279+
expect(result.success).toBe(false);
280+
});
281+
282+
it("rejects an mcp_response missing requestId or server", () => {
283+
expect(
284+
validateCommandParams("mcp_response", {
285+
server: "slack",
286+
payload: { ok: true },
287+
}).success,
288+
).toBe(false);
289+
expect(
290+
validateCommandParams("mcp_response", {
291+
requestId: "req-1",
292+
payload: { ok: true },
293+
}).success,
294+
).toBe(false);
295+
});
296+
});
297+
298+
describe("relayMcpServerNamesSchema", () => {
299+
it("accepts a list of names and an empty list", () => {
300+
expect(
301+
relayMcpServerNamesSchema.safeParse(["slack", "linear"]).success,
302+
).toBe(true);
303+
expect(relayMcpServerNamesSchema.safeParse([]).success).toBe(true);
304+
});
305+
306+
it("rejects an empty-string name", () => {
307+
expect(relayMcpServerNamesSchema.safeParse(["slack", ""]).success).toBe(
308+
false,
309+
);
310+
});
311+
312+
it("rejects a name longer than 64 chars", () => {
313+
expect(relayMcpServerNamesSchema.safeParse(["a".repeat(65)]).success).toBe(
314+
false,
315+
);
316+
});
317+
318+
it("rejects more than 20 names", () => {
319+
const names = Array.from({ length: 21 }, (_, i) => `s${i}`);
320+
expect(relayMcpServerNamesSchema.safeParse(names).success).toBe(false);
321+
});
237322
});

packages/core/src/local-mcp/localMcpImport.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {
22
CloudMcpServerImport,
33
LocalMcpServerDescriptor,
44
} from "@posthog/shared";
5-
import { isPrivateIpv4Octets } from "@posthog/shared";
5+
import { isPrivateIpv4Octets, isPrivateIpv6Literal } from "@posthog/shared";
66
import { inject, injectable } from "inversify";
77
import { LOCAL_MCP_WORKSPACE_CLIENT } from "./identifiers";
88

@@ -73,17 +73,9 @@ export function isPrivateHostname(hostname: string): boolean {
7373
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
7474
if (host === "" || host === "localhost") return true;
7575

76-
if (host.includes(":")) {
77-
// IPv6
78-
if (host === "::" || host === "::1") return true;
79-
const v4Mapped = host.match(/^::ffff:(.+)$/)?.[1];
80-
if (v4Mapped) {
81-
const octets = parseIpv4(v4Mapped);
82-
return octets ? isPrivateIpv4Octets(octets[0], octets[1]) : false;
83-
}
84-
// fc00::/7 unique-local, fe80::/10 link-local
85-
return /^(f[cd]|fe[89ab])/.test(host);
86-
}
76+
// IPv6 literal: the shared kernel covers loopback/unspecified, link-local,
77+
// unique-local, and IPv4-mapped (incl. the hex-group form URL normalizes to).
78+
if (host.includes(":")) return isPrivateIpv6Literal(host);
8779

8880
const octets = parseIpv4(host);
8981
if (octets) return isPrivateIpv4Octets(octets[0], octets[1]);

packages/harness/src/extensions/web-access/web-fetch.ts

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { isIP } from "node:net";
22
import { defineTool } from "@earendil-works/pi-coding-agent";
3-
import { isPrivateIpv4Octets } from "@posthog/shared";
3+
import { isPrivateIpv4Octets, isPrivateIpv6Literal } from "@posthog/shared";
44
import { LRUCache } from "lru-cache";
55
import TurndownService from "turndown";
66
import { Type } from "typebox";
@@ -31,28 +31,6 @@ function ipv4Octets(hostname: string): number[] | undefined {
3131
return octets.length === 4 ? octets : undefined;
3232
}
3333

34-
function hexGroupToBytes(group: string): [number, number] {
35-
const value = Number.parseInt(group, 16);
36-
return [(value >> 8) & 0xff, value & 0xff];
37-
}
38-
39-
/**
40-
* `URL#hostname` normalizes an IPv4-mapped IPv6 literal's embedded address
41-
* into hex groups (`::ffff:127.0.0.1` becomes `::ffff:7f00:1`), so recovering
42-
* the embedded IPv4 address means decoding those two hex groups back into
43-
* bytes rather than pattern-matching a dotted-quad that will never appear.
44-
*/
45-
function ipv4MappedAddress(normalized: string): string | undefined {
46-
const hexMatch = normalized.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
47-
if (hexMatch) {
48-
const [b0, b1] = hexGroupToBytes(hexMatch[1]);
49-
const [b2, b3] = hexGroupToBytes(hexMatch[2]);
50-
return `${b0}.${b1}.${b2}.${b3}`;
51-
}
52-
const dottedMatch = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
53-
return dottedMatch?.[1];
54-
}
55-
5634
/**
5735
* Blocks loopback, private, link-local, and other non-public IPv4/IPv6
5836
* literal addresses — the classic SSRF-to-internal-services vector (e.g. the
@@ -61,7 +39,9 @@ function ipv4MappedAddress(normalized: string): string | undefined {
6139
* does not resolve hostnames, so it does not protect against DNS rebinding
6240
* (a public hostname whose DNS record later resolves to a private address).
6341
* That would require enforcing the check at connection time, not URL-parse
64-
* time — out of scope here, but worth remembering as a residual gap.
42+
* time — out of scope here, but worth remembering as a residual gap. The
43+
* IPv4-range and IPv6-literal kernels are shared with the other private-host
44+
* classifiers via `@posthog/shared`.
6545
*/
6646
function isBlockedHost(rawHostname: string): boolean {
6747
// `URL#hostname` keeps IPv6 literals bracketed ("[::1]"); `net.isIP`/our
@@ -73,15 +53,7 @@ function isBlockedHost(rawHostname: string): boolean {
7353
const v4 = ipv4Octets(hostname);
7454
if (v4) return isPrivateIpv4Octets(v4[0], v4[1]);
7555

76-
if (isIP(hostname) === 6) {
77-
if (lower === "::1") return true; // loopback
78-
if (lower === "::" || lower === "::0") return true; // unspecified
79-
if (lower.startsWith("fe80:")) return true; // link-local (fe80::/10)
80-
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // unique local (fc00::/7)
81-
const mapped = ipv4MappedAddress(lower);
82-
if (mapped && isBlockedHost(mapped)) return true;
83-
return false;
84-
}
56+
if (isIP(hostname) === 6) return isPrivateIpv6Literal(lower);
8557

8658
return false;
8759
}

packages/shared/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,17 @@ export {
165165
pathToFileUri,
166166
toRelativePath,
167167
} from "./path";
168-
export { isPrivateIpv4Octets } from "./private-network";
169168
export {
170169
buildPrOutput,
171170
mergePrUrls,
172171
promotePrUrl,
173172
readPrSummaries,
174173
readPrUrls,
175174
} from "./pr-urls";
175+
export {
176+
isPrivateIpv4Octets,
177+
isPrivateIpv6Literal,
178+
} from "./private-network";
176179
export {
177180
type CloudRegion,
178181
formatRegionBadge,

packages/shared/src/private-network.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { isPrivateIpv4Octets } from "./private-network";
2+
import { isPrivateIpv4Octets, isPrivateIpv6Literal } from "./private-network";
33

44
describe("isPrivateIpv4Octets", () => {
55
it.each([
@@ -31,3 +31,30 @@ describe("isPrivateIpv4Octets", () => {
3131
expect(isPrivateIpv4Octets(a, b)).toBe(false);
3232
});
3333
});
34+
35+
describe("isPrivateIpv6Literal", () => {
36+
it.each([
37+
["::1", "loopback"],
38+
["::", "unspecified"],
39+
["::0", "unspecified"],
40+
["fe80::1", "link-local"],
41+
["feb0::1", "link-local upper bound"],
42+
["fc00::1", "unique-local"],
43+
["fd12:3456::1", "unique-local"],
44+
// URL#hostname normalizes ::ffff:127.0.0.1 to the hex-group form.
45+
["::ffff:7f00:1", "IPv4-mapped loopback (hex-group form)"],
46+
["::ffff:127.0.0.1", "IPv4-mapped loopback (dotted form)"],
47+
["::ffff:c0a8:1", "IPv4-mapped 192.168.0.1 (hex-group form)"],
48+
])("treats %s (%s) as private", (host) => {
49+
expect(isPrivateIpv6Literal(host)).toBe(true);
50+
});
51+
52+
it.each([
53+
["2001:db8::1", "documentation range"],
54+
["2606:4700::1", "public (Cloudflare)"],
55+
["::ffff:8.8.8.8", "IPv4-mapped public DNS (dotted form)"],
56+
["::ffff:808:808", "IPv4-mapped 8.8.8.8 (hex-group form)"],
57+
])("treats %s (%s) as public", (host) => {
58+
expect(isPrivateIpv6Literal(host)).toBe(false);
59+
});
60+
});

packages/shared/src/private-network.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,47 @@ export function isPrivateIpv4Octets(a: number, b: number): boolean {
1616
if (a === 192 && b === 168) return true;
1717
return a === 198 && (b === 18 || b === 19);
1818
}
19+
20+
/**
21+
* The IPv4 address embedded in an IPv4-mapped IPv6 literal (`::ffff:…`), or
22+
* undefined for any other host. `URL#hostname` normalizes the embedded address
23+
* into hex groups (`::ffff:127.0.0.1` becomes `::ffff:7f00:1`), so both the
24+
* hex-group form and the raw dotted form are decoded.
25+
*/
26+
function ipv4MappedOctets(
27+
host: string,
28+
): [number, number, number, number] | undefined {
29+
const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
30+
if (hex) {
31+
const hi = Number.parseInt(hex[1], 16);
32+
const lo = Number.parseInt(hex[2], 16);
33+
return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
34+
}
35+
const dotted = host.match(
36+
/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
37+
);
38+
if (dotted) {
39+
const octets = dotted.slice(1).map(Number);
40+
if (octets.some((octet) => octet > 255)) return undefined;
41+
return octets as [number, number, number, number];
42+
}
43+
return undefined;
44+
}
45+
46+
/**
47+
* Whether a bracket-free, lowercased IPv6 literal is non-public: loopback
48+
* (`::1`), unspecified (`::`/`::0`), link-local (fe80::/10), unique-local
49+
* (fc00::/7), or an IPv4-mapped address whose embedded IPv4 is private. Shared
50+
* by `@posthog/core`'s `isPrivateHostname` and the web-fetch tool's
51+
* `isBlockedHost` so the IPv6-literal kernel can't drift between them; each
52+
* still layers its own non-literal rules (bare intranet names, `.local`
53+
* suffixes) on top.
54+
*/
55+
export function isPrivateIpv6Literal(host: string): boolean {
56+
if (host === "::1") return true;
57+
if (host === "::" || host === "::0") return true;
58+
if (/^fe[89ab]/.test(host)) return true; // link-local fe80::/10
59+
if (/^f[cd]/.test(host)) return true; // unique-local fc00::/7
60+
const octets = ipv4MappedOctets(host);
61+
return octets ? isPrivateIpv4Octets(octets[0], octets[1]) : false;
62+
}

0 commit comments

Comments
 (0)