diff --git a/src/mcp-server/tools.ts b/src/mcp-server/tools.ts index 14c05aa..a4d23d4 100644 --- a/src/mcp-server/tools.ts +++ b/src/mcp-server/tools.ts @@ -51,13 +51,15 @@ export async function formatResult( const contentType = response?.headers.get("content-type") ?? ""; let content: CallToolResult["content"] = []; - if (contentType.search(/\bjson\b/g)) { - content = [{ type: "text", text: JSON.stringify(value) }]; + const normalizedValue = normalizeLaunchDarklyLinks(value, response?.url); + + if (/\bjson\b/g.test(contentType)) { + content = [{ type: "text", text: JSON.stringify(normalizedValue) }]; } else if ( contentType.startsWith("text/event-stream") - && isAsyncIterable(value) + && isAsyncIterable(normalizedValue) ) { - content = await consumeSSE(value); + content = await consumeSSE(normalizedValue); } else if (contentType.startsWith("text/") && typeof value === "string") { content = [{ type: "text", text: value }]; } else if (isBinaryData(value) && contentType.startsWith("image/")) { @@ -78,6 +80,78 @@ export async function formatResult( return { content }; } +const canonicalLDHosts = new Set([ + "app.launchdarkly.com", + "app.launchdarkly.us", +]); + +function normalizeLaunchDarklyLinks( + value: unknown, + responseURL?: string, +): unknown { + if (!responseURL) { + return value; + } + + let origin: string; + try { + origin = new URL(responseURL).origin; + } catch { + return value; + } + + return deepMap(value, (key, val) => { + if (key !== "href" || typeof val !== "string") { + return val; + } + + if (val.startsWith("/")) { + try { + return new URL(val, origin).toString(); + } catch { + return val; + } + } + + try { + const parsed = new URL(val); + if (canonicalLDHosts.has(parsed.host) && parsed.origin !== origin) { + return `${origin}${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } catch { + // Leave invalid URL-ish strings unchanged. + } + + return val; + }); +} + +function deepMap( + value: unknown, + transform: (key: string | null, value: unknown) => unknown, + key: string | null = null, +): unknown { + const transformed = transform(key, value); + + if (Array.isArray(transformed)) { + return transformed.map((item) => deepMap(item, transform)); + } + + if ( + transformed !== null + && typeof transformed === "object" + && Object.getPrototypeOf(transformed) === Object.prototype + ) { + const output: Record = {}; + for (const [k, v] of Object.entries(transformed as Record)) { + output[k] = deepMap(v, transform, k); + } + return output; + } + + return transformed; +} + async function consumeSSE( value: AsyncIterable, ): Promise { diff --git a/tests/tools.test.js b/tests/tools.test.js new file mode 100644 index 0000000..d635430 --- /dev/null +++ b/tests/tools.test.js @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { formatResult } from "../src/mcp-server/tools.js"; + +describe("formatResult link normalization", () => { + test("rewrites app.launchdarkly.com href to response origin", async () => { + const origin = "https://random-domain.example.test"; + const input = { + _links: { + site: { + href: "https://app.launchdarkly.com/default/production/features/flag-a", + }, + }, + }; + + const response = { + headers: new Headers({ "content-type": "application/json" }), + url: `${origin}/api/v2/flags/default/flag-a`, + }; + + const result = await formatResult(input, { response }); + const text = result.content[0]; + expect(text?.type).toBe("text"); + const parsed = JSON.parse(text.text); + expect(parsed._links.site.href).toBe( + `${origin}/default/production/features/flag-a`, + ); + }); + + test("rewrites app.launchdarkly.us href to response origin", async () => { + const origin = "https://federal-alt.example.test"; + const input = { + _links: { + site: { + href: "https://app.launchdarkly.us/default/production/features/flag-c", + }, + }, + }; + + const response = { + headers: new Headers({ "content-type": "application/json" }), + url: `${origin}/api/v2/flags/default/flag-c`, + }; + + const result = await formatResult(input, { response }); + const text = result.content[0]; + expect(text?.type).toBe("text"); + const parsed = JSON.parse(text.text); + expect(parsed._links.site.href).toBe( + `${origin}/default/production/features/flag-c`, + ); + }); + + test("expands relative href against response origin", async () => { + const origin = "https://another-random.example.test"; + const input = { + _links: { + site: { + href: "/default/production/features/flag-b", + }, + }, + }; + + const response = { + headers: new Headers({ "content-type": "application/json" }), + url: `${origin}/api/v2/flags/default/flag-b`, + }; + + const result = await formatResult(input, { response }); + const text = result.content[0]; + expect(text?.type).toBe("text"); + const parsed = JSON.parse(text.text); + expect(parsed._links.site.href).toBe( + `${origin}/default/production/features/flag-b`, + ); + }); +});