Merge branch 'fix/v125-mcp-egress' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 15:19:23 +02:00
commit 3a84a07423
16 files changed
+608 -47

No files matched your search

@@ -1,5 +1,20 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// safeFetch's requirePublicHost path runs through undici's own `fetch`
// (version-locked with its dispatcher). Delegate it to the global `fetch`
// stub these tests install so the existing interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
+44 -3
View File
@@ -118,16 +118,42 @@ interface ValidatedRequest {
resource: string;
}
/**
* The gate a request failed, pinned to a stable enum so the
* `mcp.oauth.authorize.invalid` annotation stays dashboard-friendly. The
* swallowed-reason gap (a bare `invalid` event) is exactly why the prod CIMD
* failure was undiagnosable.
*/
type ValidateFailure =
| "invalid_params"
| "client_unresolved"
| "redirect_mismatch"
| "bad_response_type"
| "pkce_required"
| "audience_mismatch";
type ValidateMeta = {
reason: ValidateFailure;
/** For `client_unresolved`: the `resolveClient` reason. */
clientReason?: string;
/** For `client_unresolved`: a short, non-secret transport diagnostic. */
detail?: string;
};
/** Shared validation for GET + POST. Returns a ready error Response on failure. */
async function validate(
params: Record<string, string | undefined>,
requestUrl: string,
): Promise<{ ok: true; v: ValidatedRequest } | { ok: false; res: Response }> {
): Promise<
| { ok: true; v: ValidatedRequest }
| { ok: false; res: Response; meta: ValidateMeta }
> {
const parsed = z.object(baseParams).safeParse(params);
if (!parsed.success) {
return {
ok: false,
res: badRequest("invalid_request", "Missing or malformed parameters"),
meta: { reason: "invalid_params" },
};
}
const p = parsed.data;
@@ -139,6 +165,11 @@ async function validate(
return {
ok: false,
res: badRequest("invalid_client", "Unknown or unresolvable client"),
meta: {
reason: "client_unresolved",
clientReason: resolved.reason,
...(resolved.detail ? { detail: resolved.detail } : {}),
},
};
}
@@ -150,6 +181,7 @@ async function validate(
"invalid_request",
"redirect_uri does not match the registered client",
),
meta: { reason: "redirect_mismatch" },
};
}
@@ -161,6 +193,7 @@ async function validate(
"unsupported_response_type",
"Only response_type=code is supported",
),
meta: { reason: "bad_response_type" },
};
}
@@ -175,6 +208,7 @@ async function validate(
"invalid_request",
"PKCE with code_challenge_method=S256 is required",
),
meta: { reason: "pkce_required" },
};
}
@@ -186,6 +220,7 @@ async function validate(
"invalid_target",
"resource must equal this server's MCP endpoint",
),
meta: { reason: "audience_mismatch" },
};
}
@@ -280,7 +315,10 @@ export async function GET(request: NextRequest): Promise<Response> {
const params = Object.fromEntries(url.searchParams.entries());
const result = await validate(params, request.url);
if (!result.ok) {
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
annotate({
action: { name: "mcp.oauth.authorize.invalid" },
meta: result.meta,
});
return result.res;
}
const { v } = result;
@@ -409,7 +447,10 @@ export async function POST(request: NextRequest): Promise<Response> {
const result = await validate(params, request.url);
if (!result.ok) {
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
annotate({
action: { name: "mcp.oauth.authorize.invalid" },
meta: result.meta,
});
return result.res;
}
const { v } = result;
+15
View File
@@ -1,6 +1,21 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// safeFetch's requirePublicHost path runs through undici's own `fetch`
// (version-locked with its dispatcher). Delegate it to the global `fetch`
// stub these tests install so the existing interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));