fix(apps): guide generic MCP connection failures

Map OAuth origin, duplicate-name, and DNS failures to stable operator guidance, and derive collision-resistant defaults from host, port, and path.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent 048c3578e3
commit ba603abace
8 files changed
+225 -9

No files matched your search

+25 -1
View File
@@ -32,6 +32,7 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
* pass `responseTimeoutMs` so this default never truncates it.
*/
const DEFAULT_RESPONSE_TIMEOUT_MS = 30_000;
const DNS_RESOLUTION_ERROR_CODES = new Set(["ENODATA", "ENOTFOUND", "EAI_AGAIN"]);
/** How a verified socket is opened. Overridable so tests can simulate a rebind. */
export type RemoteHttpSocketFactory = (target: {
@@ -103,11 +104,34 @@ export async function guardedRemoteHttpFetch(
const literalHost = isIP(endpoint.hostname.replace(/^\[|\]$/g, "")) !== 0;
const platformFetch = options.unpinnedFetch ?? fetch;
if (approved.length === 0 || literalHost) {
return platformFetch(endpoint.toString(), { ...init, redirect: "manual" });
try {
return await platformFetch(endpoint.toString(), { ...init, redirect: "manual" });
} catch (error) {
if (isDnsResolutionError(error)) {
throw options.error(
"Remote MCP connection hostname could not be resolved",
"remote_http_dns_failed",
);
}
throw error;
}
}
return pinnedRequest(endpoint, approved, init, options);
}
/** Node's platform fetch wraps DNS failures in TypeError.cause. */
function isDnsResolutionError(error: unknown): boolean {
const seen = new Set<unknown>();
let current = error;
while (typeof current === "object" && current !== null && !seen.has(current)) {
seen.add(current);
const record = current as { code?: unknown; cause?: unknown };
if (typeof record.code === "string" && DNS_RESOLUTION_ERROR_CODES.has(record.code)) return true;
current = record.cause;
}
return false;
}
async function pinnedRequest(
endpoint: URL,
approved: string[],
+14 -3
View File
@@ -1655,6 +1655,9 @@ function healthFailureHttpStatus(failure: { status: ToolConnectionHealthStatus;
function sanitizeHttpFailure(error: unknown): { status: ToolConnectionHealthStatus; message: string; code: string } {
if (error instanceof HttpError) {
const code = asRecord(error.details).code;
if (typeof code === "string" && code.startsWith("remote_http_")) {
return { status: "error", message: error.message, code };
}
if (isOAuthEndpointRejection(error)) {
return { status: "error", message: error.message, code: String(code) };
}
@@ -4120,7 +4123,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
function defaultLinkName(link: string): string {
try {
const url = new URL(link);
return url.hostname.replace(/^www\./, "") || "MCP app";
const host = url.host.replace(/^www\./, "");
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
return `${host}${path}`.slice(0, 160) || "MCP app";
} catch {
return "MCP app";
}
@@ -7285,7 +7290,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
),
)
.limit(1);
if (duplicate) throw conflict("A tool access record with that name already exists");
if (duplicate) {
throw conflict("A tool access record with that name already exists", {
code: "tool_access_name_conflict",
});
}
}
const [row] = await db
.update(toolApplications)
@@ -8863,7 +8872,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
constraint?.includes("tool_applications") ||
/duplicate key value|unique constraint|tool_applications_company_id_name_unique/i.test(message)
) {
throw conflict("A tool access record with that name already exists");
throw conflict("A tool access record with that name already exists", {
code: "tool_access_name_conflict",
});
}
throw error;
},