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
15 changes: 9 additions & 6 deletions lib/core/mcp/mcp_mongo_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,7 @@ class McpMongoService {

Future<List<String>> listCollections(int connectionId) {
return _withSession(connectionId, (session) async {
final names = await session.listCollections();
return [
for (final n in names)
if (!n.startsWith('system.')) n,
]..sort();
return (await _visibleCollections(session)).toList()..sort();
});
}

Expand Down Expand Up @@ -243,9 +239,16 @@ class McpMongoService {
}
}

/// The collections the model may see and name; `system.*` ones (profile,
/// views) hold past queries and stay hidden from every tool.
Future<Iterable<String>> _visibleCollections(McpMongoSession session) async {
final names = await session.listCollections();
return names.where((n) => !n.startsWith('system.'));
}

/// The collection must exist, so model input never names one unchecked.
Future<String> _knownCollection(McpMongoSession session, String name) async {
final names = await session.listCollections();
final names = (await _visibleCollections(session)).toList();
if (names.contains(name)) return name;
final lower = name.toLowerCase();
final loose = names.where((n) => n.toLowerCase() == lower).toList();
Expand Down
15 changes: 14 additions & 1 deletion lib/core/mcp/querya_mcp_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,20 @@ base class QueryaMcpServer extends MCPServer with ToolsSupport, ResourcesSupport
if (!request.uri.startsWith(prefix)) return null;
final id = int.tryParse(request.uri.substring(prefix.length));
if (id == null) return null;
final tables = await service.schemaOverview(id);
final started = DateTime.now();
final sw = Stopwatch()..start();
final List<McpTableDescription> tables;
try {
tables = await service.schemaOverview(id);
} on McpToolException catch (e) {
_report('schema', started, sw.elapsed, id, null, null, e.message, e.rule);
rethrow;
} catch (e) {
final message = McpRedaction.redact('Internal error: $e');
_report('schema', started, sw.elapsed, id, null, null, message, null);
throw McpToolException(message);
}
_report('schema', started, sw.elapsed, id, null, tables.length, null, null);
return ReadResourceResult(contents: [
TextResourceContents(
uri: request.uri,
Expand Down
17 changes: 17 additions & 0 deletions test/core/mcp/mcp_mongo_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ void main() {
opened = 0;
});

test('system.* collections are neither listed nor readable (#1774)',
() async {
final svc = service();
expect(await svc.listCollections(1), ['orders', 'users']);
await expectLater(
svc.find(1, 'system.views'),
throwsA(isA<McpToolException>()
.having((e) => e.message, 'message', contains('Collection not found'))),
);
await expectLater(
svc.count(1, 'system.views'),
throwsA(isA<McpToolException>()
.having((e) => e.message, 'message', contains('Collection not found'))),
);
expect(session.calls, isEmpty);
});

group('McpMongoGuard', () {
test('plain filters pass', () {
expect(
Expand Down
61 changes: 59 additions & 2 deletions test/core/mcp/mcp_security_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,17 @@ SqlExecutionResult _catalog(String sql) {
}

/// Client and server over an in-memory channel.
Future<(ServerConnection, QueryaMcpServer)> _connect(McpQueryService service) async {
Future<(ServerConnection, QueryaMcpServer)> _connect(
McpQueryService service, {
void Function(McpCallRecord)? onCall,
}) async {
final pair = StreamChannelController<String>();
final server = QueryaMcpServer(pair.local, service: service, version: 't');
final server = QueryaMcpServer(
pair.local,
service: service,
version: 't',
onCall: onCall,
);
final client = MCPClient(Implementation(name: 'attacker', version: '1'));
final conn = client.connectServer(pair.foreign);
await conn.initialize(InitializeRequest(
Expand Down Expand Up @@ -371,6 +379,55 @@ void main() {
});
});

group('the schema resource (#1775)', () {
test('is logged like a tool call', () async {
final calls = <McpCallRecord>[];
final (conn, server) = await _connect(
McpQueryService(
createDelegate: (row, dialect) =>
FakeSqlExecutionDelegate()..onExecute = _catalog,
access: _Access({1}),
loadConnections: () async => [_row(1, 'postgresql')],
),
onCall: calls.add,
);
addTearDown(server.shutdown);

await conn.readResource(ReadResourceRequest(uri: 'schema://1'));

expect(calls.single.tool, 'schema');
expect(calls.single.connectionId, 1);
expect(calls.single.error, isNull);
});

test('masks secrets in a failing driver error and logs it', () async {
final calls = <McpCallRecord>[];
final (conn, server) = await _connect(
McpQueryService(
createDelegate: (row, dialect) => throw StateError(
'connect postgresql://admin:$_pw@db.internal/shop failed'),
access: _Access({1}),
loadConnections: () async => [_row(1, 'postgresql')],
),
onCall: calls.add,
);
addTearDown(server.shutdown);

Object? caught;
try {
await conn.readResource(ReadResourceRequest(uri: 'schema://1'));
} catch (e) {
caught = e;
}

expect(caught, isNotNull);
expect('$caught', isNot(contains(_pw)));
expect(calls.single.tool, 'schema');
expect(calls.single.error, isNotNull);
expect(calls.single.error, isNot(contains(_pw)));
});
});

test('a connection that is not shared is invisible to every tool', () async {
var created = 0;
final (conn, server) = await _connect(McpQueryService(
Expand Down
Loading