Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/backend/src/adapters/adapters.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,10 @@ describe('AdaptersService install — a base URL variable without https://', ()
// The import probe looks the connector up; nothing found = no probe.
findUnique: jest.fn().mockResolvedValue(null),
},
mcpTool: { create: jest.fn().mockResolvedValue({}) },
mcpTool: {
create: jest.fn().mockResolvedValue({}),
createMany: jest.fn(async ({ data }: any) => ({ count: data.length })),
},
};
const service = new AdaptersService(
prisma as any,
Expand Down
58 changes: 35 additions & 23 deletions packages/backend/src/adapters/adapters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,29 +464,41 @@ export class AdaptersService {
}
}

for (const tool of toolsToCreate) {
try {
await this.prisma.mcpTool.create({
data: {
connectorId: connector.id,
name: tool.name,
description: tool.description,
isEnabled: tool.enabled !== false,
// Seed the proxy preference from the adapter spec (default off).
useProxy: tool.useProxy === true,
parameters: tool.parameters as any,
endpointMapping: tool.endpointMapping as any,
responseMapping: tool.responseMapping as any,
outputSchema: ((tool as any).outputSchema ?? null) as any,
annotations: (tool.annotations ?? undefined) as any,
// A catalog tool: catalog updates may change or retire it.
origin: 'catalog',
},
});
toolsCreated++;
} catch (err: any) {
if (err.code !== 'P2002') {
this.logger.warn(`Failed to create tool ${tool.name}: ${err.message}`);
const toolRows = toolsToCreate.map((tool) => ({
connectorId: connector.id,
name: tool.name,
description: tool.description,
isEnabled: tool.enabled !== false,
// Seed the proxy preference from the adapter spec (default off).
useProxy: tool.useProxy === true,
parameters: tool.parameters as any,
endpointMapping: tool.endpointMapping as any,
responseMapping: tool.responseMapping as any,
outputSchema: ((tool as any).outputSchema ?? null) as any,
annotations: (tool.annotations ?? undefined) as any,
// A catalog tool: catalog updates may change or retire it.
origin: 'catalog',
}));

try {
// One insert for the whole adapter instead of one per tool
// (ANYTHINGMCP-CLOUD-BACKEND-5: 40-odd inserts for Telegram Bot). A
// name the connector already has is skipped, as before.
toolsCreated = (
await this.prisma.mcpTool.createMany({ data: toolRows, skipDuplicates: true })
).count;
} catch (err: any) {
// A row the batch cannot take would otherwise lose every tool: insert
// one by one so only that tool is left out, as the import always did.
this.logger.warn(`Batch insert of the "${slug}" tools failed (${err.message}); inserting one by one`);
for (const data of toolRows) {
try {
await this.prisma.mcpTool.create({ data });
toolsCreated++;
} catch (e: any) {
if (e.code !== 'P2002') {
this.logger.warn(`Failed to create tool ${data.name}: ${e.message}`);
}
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions packages/backend/src/adapters/mcp-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ describe('importAdapter for an MCP adapter (splunk)', () => {
created.push(data);
return data;
}),
createMany: jest.fn(async ({ data }: any) => {
created.push(...data);
return { count: data.length };
}),
},
};
const service = new AdaptersService(
Expand Down Expand Up @@ -131,4 +135,18 @@ describe('importAdapter for an MCP adapter (splunk)', () => {
expect(out.probe).toMatchObject({ ok: false, toolName: 'tools/list', status: 403 });
expect((out.probe as any).message).toContain('invalid token audience');
});

it('inserts the tools in one batch, and one by one only if the batch fails', async () => {
const discover = jest.fn().mockResolvedValue([remote('splunk_get_info'), remote('splunk_get_indexes')]);
const { service, prisma } = build(discover);
await service.importAdapter('splunk', 'u1', 'o1', creds);
expect(prisma.mcpTool.createMany).toHaveBeenCalledTimes(1);
expect(prisma.mcpTool.create).not.toHaveBeenCalled();

const fallback = build(jest.fn().mockResolvedValue([remote('splunk_get_info'), remote('splunk_get_indexes')]));
fallback.prisma.mcpTool.createMany.mockRejectedValueOnce(new Error('batch refused'));
const out = await fallback.service.importAdapter('splunk', 'u1', 'o1', creds);
expect(fallback.prisma.mcpTool.create).toHaveBeenCalledTimes(2);
expect(out.toolsCreated).toBe(2);
});
});
4 changes: 3 additions & 1 deletion packages/frontend/src/instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
BROWSER_CONFIG_GLOBAL,
BROWSER_DENY_URLS,
BROWSER_IGNORE_ERRORS,
beforeSendBrowser,
scrubBreadcrumb,
scrubEvent,
type BrowserSentryConfig,
Expand All @@ -32,7 +33,8 @@ function init(): void {
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
sendDefaultPii: false,
beforeSend: scrubEvent,
// Drops errors thrown entirely inside scripts the browser injected.
beforeSend: beforeSendBrowser,
beforeSendTransaction: scrubEvent,
beforeBreadcrumb: scrubBreadcrumb,
// Noise from extensions and link scanners: see lib/sentry-scrub.ts.
Expand Down
22 changes: 22 additions & 0 deletions packages/frontend/src/lib/sentry-scrub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ export const BROWSER_IGNORE_ERRORS: Array<string | RegExp> = [
/Invalid call to runtime\.sendMessage\(\)\. Tab not found/,
];

const SCRIPT_FILE = /\.[cm]?js(?:[?#]|$)/i;

/**
* True for an error whose stack runs entirely through code that is not a
* script file: what a browser injects into the page itself. Chrome on iOS
* runs its translate helpers that way; translating /login into Portuguese
* overflowed one of them (ANYTHINGMCP-CLOUD-FRONTEND-9, -A: frames at lines
* 191 and 425 of a 12-line document). None of our /_next/ chunks was on the
* stack. The site drops the same events (ANYTHINGMCP-WEBSITE-K). Errors
* without a stack are kept.
*/
export function isInjectedScriptError(event: Event): boolean {
const frames = (event.exception?.values ?? []).flatMap((v) => v.stacktrace?.frames ?? []);
if (frames.length === 0) return false;
return frames.every((f) => !SCRIPT_FILE.test(f.filename ?? f.abs_path ?? ''));
}

/** The browser's beforeSend: drop injected-script noise, scrub the rest. */
export function beforeSendBrowser<T extends Event>(event: T): T | null {
return isInjectedScriptError(event) ? null : scrubEvent(event);
}

/** Errors raised by scripts browser extensions inject into the page. */
export const BROWSER_DENY_URLS: RegExp[] = [
/^(chrome|moz|safari(-web)?|ms-browser)-extension:\/\//i,
Expand Down
32 changes: 32 additions & 0 deletions packages/frontend/tests/e2e/sentry-injected-scripts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
import type { Event } from '@sentry/nextjs';
import { beforeSendBrowser, isInjectedScriptError } from '../../src/lib/sentry-scrub';

/**
* Errors thrown entirely inside scripts the browser injected (Chrome on iOS
* translating /login, ANYTHINGMCP-CLOUD-FRONTEND-9 and -A) never reach Sentry.
* Anything with a frame in one of our chunks, or without a stack, still does.
*/

const withFrames = (...filenames: Array<string | undefined>): Event => ({
exception: { values: [{ type: 'RangeError', value: 'x', stacktrace: { frames: filenames.map((filename) => ({ filename })) } }] },
});

test.describe('injected-script errors', () => {
test('drops an error whose whole stack is the page itself', () => {
expect(isInjectedScriptError(withFrames('app:///login', 'app:///login'))).toBe(true);
expect(isInjectedScriptError(withFrames(undefined))).toBe(true);
expect(beforeSendBrowser(withFrames('app:///login'))).toBeNull();
});

test('keeps an error with a frame in one of our chunks', () => {
const ours = withFrames('app:///login', 'app:///_next/static/chunks/0abc.js');
expect(isInjectedScriptError(ours)).toBe(false);
expect(beforeSendBrowser(ours)).not.toBeNull();
});

test('keeps an error without a stack', () => {
expect(isInjectedScriptError({ exception: { values: [{ type: 'Error', value: 'La' }] } })).toBe(false);
expect(isInjectedScriptError({})).toBe(false);
});
});
Loading