mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
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:
1 parent
048c3578e3
commit
ba603abace
8 files changed
+225
-9
No files matched your search
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user