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
6 changes: 5 additions & 1 deletion src/__tests__/integration/db-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,11 @@ describeForEachProvider('MCP Hub servers + audit logs', (getDb) => {
errorMessage: 'nope',
});

const logs = await db.listMcpRequestLogs('srv-a');
const logs = await db.listMcpRequestLogs({
serverId: 'nonexistent-server-id',
serverKey: 'srv-a',
projectId: 'proj-1',
});
expect(logs).toHaveLength(1);
expect(logs[0].callerType).toBe('public');
expect(logs[0].transport).toBe('jsonrpc');
Expand Down
9 changes: 7 additions & 2 deletions src/__tests__/integration/mcp-composite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ describe('composite execution — real routed call, member-scoped log, self-refe
});

it('writes a member-scoped log row tagged with the composite key, separate from the composite\'s own log', async () => {
const memberLogs = await listMcpRequestLogs(TENANT_DB_NAME, memberA.key, { limit: 10 });
const memberLogs = await listMcpRequestLogs(TENANT_DB_NAME, memberA, { limit: 10 });
const viaComposite = memberLogs.filter((l) => l.viaServerKey === composite.key);
expect(viaComposite.length).toBeGreaterThan(0);
expect(viaComposite[0].transport).toBe('internal');
Expand Down Expand Up @@ -357,7 +357,12 @@ describe('sqlite viaServerKey column', () => {
it('is persisted and queryable end to end', async () => {
const db = await getDatabase();
await db.switchToTenant(TENANT_DB_NAME);
const logs = await db.listMcpRequestLogs(memberA.key, { limit: 50 });
const logs = await db.listMcpRequestLogs({
serverId: String(memberA._id),
serverKey: memberA.key,
projectId: memberA.projectId,
createdAt: memberA.createdAt,
}, { limit: 50 });
expect(logs.some((l) => l.viaServerKey)).toBe(true);
});
});
11 changes: 9 additions & 2 deletions src/__tests__/integration/mcp-log-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ import { reloadConfig } from '@/lib/core/config';
import { disconnectDatabase } from '@/lib/database';
import { logMcpRequest, listMcpRequestLogs } from '@/lib/services/mcp';
import { LOG_SECRET_MASK } from '@/lib/services/logRedaction';
import type { IMcpServer } from '@/lib/database';

const TENANT_DB_NAME = 'mcp_logredact_tenant';
const TENANT_ID = 'tenant-logredact';
const SERVER_KEY = 'srv-logredact';
const SECRET = 'Bearer sk-live-echoed-credential-1234567890';
// These logs are written with no real MCP server record backing them (only
// `logMcpRequest`'s legacy serverKey field) — listMcpRequestLogs now scopes
// by serverId primarily, falling back to serverKey for pre-migration rows.
// A synthetic server stub with no serverId/createdAt match exercises that
// fallback path the same way a real legacy row would.
const SERVER_STUB = { _id: 'nonexistent-server-id', key: SERVER_KEY } as IMcpServer;

beforeAll(() => {
reloadConfig();
Expand Down Expand Up @@ -62,7 +69,7 @@ describe('logMcpRequest — secret scrubbing at the persistence boundary', () =>
[SECRET],
);

const logs = await listMcpRequestLogs(TENANT_DB_NAME, SERVER_KEY, { limit: 10 });
const logs = await listMcpRequestLogs(TENANT_DB_NAME, SERVER_STUB, { limit: 10 });
expect(logs.length).toBe(1);

const stored = JSON.stringify(logs[0].responsePayload);
Expand Down Expand Up @@ -91,7 +98,7 @@ describe('logMcpRequest — secret scrubbing at the persistence boundary', () =>
[SECRET],
);

const errorLog = (await listMcpRequestLogs(TENANT_DB_NAME, SERVER_KEY, { limit: 10 }))
const errorLog = (await listMcpRequestLogs(TENANT_DB_NAME, SERVER_STUB, { limit: 10 }))
.find((l) => l.toolName === 'failing');
expect(errorLog?.errorMessage).toBeDefined();
expect(errorLog?.errorMessage).not.toContain('sk-live-echoed-credential-1234567890');
Expand Down
1 change: 1 addition & 0 deletions src/lib/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ export type {
IMcpToolAnnotations,
IMcpRequestLog,
IMcpRequestAggregate,
McpLogServerScope,
IMcpAuditLog,
McpAuditAction,
McpServerStatus,
Expand Down
5 changes: 4 additions & 1 deletion src/lib/database/mongodb/indexManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,10 @@ export const TENANT_DB_INDEXES: Record<string, IndexDef[]> = {
websearch_run_logs: [{ key: { searchKey: 1, createdAt: -1 }, options: { name: 'idx_search_createdAt' } }],
agent_conversations: [{ key: { agentKey: 1, updatedAt: -1 }, options: { name: 'idx_agent_updatedAt' } }],
agent_versions: [{ key: { agentId: 1, version: -1 }, options: { name: 'idx_agent_version' } }],
mcp_request_logs: [{ key: { serverKey: 1, createdAt: -1 }, options: { name: 'idx_server_createdAt' } }],
mcp_request_logs: [
{ key: { serverKey: 1, createdAt: -1 }, options: { name: 'idx_server_createdAt' } },
{ key: { serverId: 1, createdAt: -1 }, options: { name: 'idx_serverId_createdAt' } },
],
tool_request_logs: [{ key: { toolKey: 1, createdAt: -1 }, options: { name: 'idx_tool_createdAt' } }],
ocr_job_items: [{ key: { jobId: 1, index: 1 }, options: { name: 'idx_job_index' } }],
batch_job_items: [{ key: { batchId: 1, index: 1 }, options: { name: 'idx_batch_index' } }],
Expand Down
86 changes: 46 additions & 40 deletions src/lib/database/mongodb/mcp-server.mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,50 @@ import type {
IMcpAuditLog,
IMcpRequestLog,
IMcpRequestAggregate,
McpLogServerScope,
McpServerStatus,
} from '../provider.interface';
import type { Constructor } from './types';
import { MongoDBProviderBase, COLLECTIONS } from './base';

/**
* Match a server's request logs by durable `serverId` primarily. The second
* `$or` branch only extends the match to rows written before `serverId`
* existed (`serverId` absent) — scoped to this server's own `serverKey` +
* `projectId`, and no older than this server's own `createdAt`, so a
* deleted-and-recreated server can never adopt its predecessor's logs just
* because they share the same name-derived key.
*/
function mcpLogScopeFilter(scope: McpLogServerScope): Record<string, unknown> {
const legacy: Record<string, unknown> = {
serverId: { $exists: false },
serverKey: scope.serverKey,
};
if (scope.projectId !== undefined) legacy.projectId = scope.projectId;
if (scope.createdAt) legacy.createdAt = { $gte: scope.createdAt };
return { $or: [{ serverId: scope.serverId }, legacy] };
}

/** Combine the scope filter with the caller's own query options via `$and` (the scope filter already uses `$or`, which can't be merged by object-spread without colliding keys). */
function buildMcpLogFilter(
scope: McpLogServerScope,
options?: { status?: string; from?: Date; to?: Date; keyword?: string },
): Record<string, unknown> {
const clauses: Record<string, unknown>[] = [mcpLogScopeFilter(scope)];
if (options?.status) clauses.push({ status: options.status });
if (options?.from || options?.to) {
const range: Record<string, unknown> = {};
if (options.from) range.$gte = options.from;
if (options.to) range.$lte = options.to;
clauses.push({ createdAt: range });
}
if (options?.keyword?.trim()) {
const keywordRegex = new RegExp(options.keyword.trim(), 'i');
clauses.push({ $or: [{ toolName: keywordRegex }, { errorMessage: keywordRegex }] });
}
return clauses.length === 1 ? clauses[0] : { $and: clauses };
}

export function McpServerMixin<TBase extends Constructor<MongoDBProviderBase>>(Base: TBase) {
return class McpServerOps extends Base {
// ── MCP Server CRUD ──────────────────────────────────────────────
Expand Down Expand Up @@ -129,7 +168,7 @@ export function McpServerMixin<TBase extends Constructor<MongoDBProviderBase>>(B
}

async listMcpRequestLogs(
serverKey: string,
scope: McpLogServerScope,
options?: {
limit?: number;
skip?: number;
Expand All @@ -140,20 +179,7 @@ export function McpServerMixin<TBase extends Constructor<MongoDBProviderBase>>(B
},
): Promise<IMcpRequestLog[]> {
const db = this.getTenantDb();
const filter: Record<string, unknown> = { serverKey };
if (options?.status) filter.status = options.status;
if (options?.from || options?.to) {
filter.createdAt = {};
if (options.from) (filter.createdAt as Record<string, unknown>).$gte = options.from;
if (options.to) (filter.createdAt as Record<string, unknown>).$lte = options.to;
}
if (options?.keyword?.trim()) {
const keywordRegex = new RegExp(options.keyword.trim(), 'i');
filter.$or = [
{ toolName: keywordRegex },
{ errorMessage: keywordRegex },
];
}
const filter = buildMcpLogFilter(scope, options);
const docs = await db
.collection(COLLECTIONS.mcpRequestLogs)
.find(filter)
Expand Down Expand Up @@ -183,40 +209,20 @@ export function McpServerMixin<TBase extends Constructor<MongoDBProviderBase>>(B
}

async countMcpRequestLogs(
serverKey: string,
scope: McpLogServerScope,
options?: { from?: Date; to?: Date; status?: string; keyword?: string },
): Promise<number> {
const db = this.getTenantDb();
const filter: Record<string, unknown> = { serverKey };

if (options?.status) filter.status = options.status;
if (options?.from || options?.to) {
filter.createdAt = {};
if (options.from) (filter.createdAt as Record<string, unknown>).$gte = options.from;
if (options.to) (filter.createdAt as Record<string, unknown>).$lte = options.to;
}
if (options?.keyword?.trim()) {
const keywordRegex = new RegExp(options.keyword.trim(), 'i');
filter.$or = [
{ toolName: keywordRegex },
{ errorMessage: keywordRegex },
];
}

const filter = buildMcpLogFilter(scope, options);
return db.collection(COLLECTIONS.mcpRequestLogs).countDocuments(filter);
}

async aggregateMcpRequestLogs(
serverKey: string,
scope: McpLogServerScope,
options?: { from?: Date; to?: Date; groupBy?: 'hour' | 'day' | 'month' },
): Promise<IMcpRequestAggregate> {
const db = this.getTenantDb();
const match: Record<string, unknown> = { serverKey };
if (options?.from || options?.to) {
match.createdAt = {};
if (options.from) (match.createdAt as Record<string, unknown>).$gte = options.from;
if (options.to) (match.createdAt as Record<string, unknown>).$lte = options.to;
}
const match = buildMcpLogFilter(scope, { from: options?.from, to: options?.to });

const pipeline = [
{ $match: match },
Expand Down Expand Up @@ -284,7 +290,7 @@ export function McpServerMixin<TBase extends Constructor<MongoDBProviderBase>>(B
}

return {
serverKey,
serverKey: scope.serverKey,
totalRequests: (agg?.totalRequests as number) ?? 0,
successCount: (agg?.successCount as number) ?? 0,
errorCount: (agg?.errorCount as number) ?? 0,
Expand Down
7 changes: 4 additions & 3 deletions src/lib/database/provider/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
IMcpAuditLog,
IMcpRequestAggregate,
IMcpRequestLog,
McpLogServerScope,
IMcpServer,
IModel,
IModelUsageAggregate,
Expand Down Expand Up @@ -1343,7 +1344,7 @@ export interface DatabaseProvider extends EnterpriseDbMethods {
log: Omit<IMcpRequestLog, '_id' | 'createdAt'>,
): Promise<IMcpRequestLog>;
listMcpRequestLogs(
serverKey: string,
scope: McpLogServerScope,
options?: {
limit?: number;
skip?: number;
Expand All @@ -1354,11 +1355,11 @@ export interface DatabaseProvider extends EnterpriseDbMethods {
},
): Promise<IMcpRequestLog[]>;
countMcpRequestLogs(
serverKey: string,
scope: McpLogServerScope,
options?: { from?: Date; to?: Date; status?: string; keyword?: string },
): Promise<number>;
aggregateMcpRequestLogs(
serverKey: string,
scope: McpLogServerScope,
options?: { from?: Date; to?: Date; groupBy?: 'hour' | 'day' | 'month' },
): Promise<IMcpRequestAggregate>;
listRecentMcpRequestLogs(options?: {
Expand Down
22 changes: 22 additions & 0 deletions src/lib/database/provider/types.extended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,14 @@ export interface IMcpRequestLog extends IUsageAttributionFields {
_id?: ObjectId | string;
tenantId: string;
projectId?: string;
/**
* Durable reference to the server that wrote this row. Always stamped on
* new rows (see `logMcpRequest`); absent only on rows written before this
* field existed. `serverKey` is a slug of the server's *name* and gets
* recycled if the server is deleted and a new one is created with the
* same name — `serverId` is what actually scopes a server's Logs tab.
*/
serverId?: string;
serverKey: string;
toolName: string;
status: 'success' | 'error';
Expand Down Expand Up @@ -383,6 +391,20 @@ export interface IMcpAuditLog {
createdAt?: Date;
}

/**
* Scopes an MCP request-log query to one server. `serverId` is the primary
* match; `serverKey`/`projectId`/`createdAt` only extend the match to rows
* written before `serverId` existed, and only up to this server's own
* `createdAt` — so a deleted-and-recreated server's recycled key can't pull
* in the predecessor's history. See `mcpLogScope` in mcpService.ts.
*/
export interface McpLogServerScope {
serverId: string;
serverKey: string;
projectId?: string;
createdAt?: Date;
}

export interface IMcpRequestAggregate {
serverKey: string;
totalRequests: number;
Expand Down
17 changes: 17 additions & 0 deletions src/lib/database/sqlite/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,13 @@ export class SQLiteProviderBase {
// records the composite's key here so the member's own Logs tab can show
// "via <composite>" alongside calls that hit it directly.
this.ensureTableColumn(db, TABLES.mcpRequestLogs, 'viaServerKey', 'viaServerKey TEXT');
// serverId: durable FK to mcp_servers.id, replacing serverKey (a slug of
// the server's name) as the log-query scope. serverKey is recycled when
// a server is deleted and recreated under the same name, which used to
// make a new server's Logs tab show its predecessor's history — see
// mcpLogScopeClause in mcp-server.mixin.ts. Rows written before this
// column existed are matched by the legacy fallback in that clause.
this.ensureTableColumn(db, TABLES.mcpRequestLogs, 'serverId', 'serverId TEXT');

// Evaluation: dataset items moved to their own table; the denormalised
// itemCount rides on the dataset row. Suites were missing the
Expand Down Expand Up @@ -938,6 +945,16 @@ export class SQLiteProviderBase {
ON ${TABLES.modelUsageLogs}(tenantId, userId, createdAt DESC);
`);

// mcp_request_logs.serverId index — must be created here (after
// applyTenantMigrations), same ordering constraint as above: serverId
// reaches legacy DBs via ensureTableColumn, and referencing it in
// TENANT_SCHEMA_SQL directly aborted the whole schema exec on tenants
// created before the column existed.
db.exec(`
CREATE INDEX IF NOT EXISTS idx_mcp_request_logs_serverId
ON ${TABLES.mcpRequestLogs}(serverId);
`);

// usage_daily unique dimension index v3 (adds metadataKey, on top of v2's
// agentKey). Same ordering constraint as above: agentKey/metadataKey
// reach legacy DBs via ensureTableColumn. v2 would reject rows differing
Expand Down
Loading
Loading