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
7 changes: 4 additions & 3 deletions docs/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ and most stdio clients:
| Tool | Arguments | Returns |
|---|---|---|
| `list_connections` | — | shared connections: `id`, `name`, `type`, `environment`, `database` |
| `list_tables` | `connection_id` | tables with their column count |
| `list_tables` | `connection_id` | tables and views with their column count |
| `describe_table` | `connection_id`, `table` | columns, types, primary key, foreign key targets, indexes |
| `sample_rows` | `connection_id`, `table`, `rows` (1-100, default 20) | first rows of the table |
| `run_query` | `connection_id`, `sql` | `columns`, `rows`, `row_count`, `truncated` |
Expand All @@ -97,8 +97,9 @@ can fix its query and retry.
- **Rows:** at most 1000 per `run_query` (`truncated: true` when cut), 100 per
`sample_rows`. **Cells** longer than 4 KB are cut with a marker.
- **Time:** 15 s per statement.
- **Tables:** base tables of the current schema / database; views are not
listed yet.
- **Tables:** base tables and views of the current schema / database
(`kind: "view"`; PostgreSQL materialized views included). A view has no
primary key or index information.

## Activity log

Expand Down
31 changes: 31 additions & 0 deletions lib/core/erd/erd_catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,37 @@ SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type,
CASE WHEN p."notnull" = 0 THEN 1 ELSE 0 END AS is_nullable
FROM sqlite_master m JOIN pragma_table_info(m.name) p
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
ORDER BY m.name, p.cid''',
};

/// Columns of the views (and PostgreSQL materialized views) of the current
/// schema or database, in the shape of [columnsSql]: no primary key, every
/// column nullable. MCP lists them next to the tables.
static String viewColumnsSql(SqlDialect d) => switch (d) {
SqlDialect.postgres => '''
SELECT ${_pgQualified('c', 'ns')} AS table_name, a.attname AS column_name,
format_type(a.atttypid, a.atttypmod) AS data_type,
0 AS is_pk, 1 AS is_nullable
FROM pg_catalog.pg_attribute a
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace ns ON ns.oid = c.relnamespace
WHERE ns.nspname = current_schema()
AND c.relkind IN ('v', 'm')
AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY table_name, a.attnum''',
SqlDialect.mysql => '''
SELECT c.table_name, c.column_name, c.column_type, 0, 1
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
AND t.table_type = 'VIEW'
WHERE c.table_schema = DATABASE()
ORDER BY c.table_name, c.ordinal_position''',
SqlDialect.sqlite => '''
SELECT m.name AS table_name, p.name AS column_name, p.type AS data_type,
0 AS is_pk, 1 AS is_nullable
FROM sqlite_master m JOIN pragma_table_info(m.name) p
WHERE m.type = 'view' AND m.name NOT LIKE 'sqlite_%'
ORDER BY m.name, p.cid''',
};

Expand Down
61 changes: 53 additions & 8 deletions lib/core/mcp/mcp_query_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,22 @@ class McpQueryService {
}

Future<List<McpTableSummary>> listTables(int connectionId) async {
final schema = await _withDelegate(connectionId, _loadSchema);
final cat = await _withDelegate(connectionId, _loadCatalog);
return [
for (final t in schema.tables)
McpTableSummary(name: t.name, columnCount: t.columns.length),
for (final t in cat.schema.tables)
McpTableSummary(
name: t.name,
columnCount: t.columns.length,
isView: cat.views.contains(t.name),
),
];
}

/// All tables with columns and keys in one catalog pass (no indexes); backs
/// the `schema://` resource.
Future<List<McpTableDescription>> schemaOverview(int connectionId) {
return _withDelegate(connectionId, (delegate, dialect) async {
final schema = await _loadSchema(delegate, dialect);
final schema = (await _loadCatalog(delegate, dialect)).schema;
return [for (final t in schema.tables) _describe(schema, t, const [])];
});
}
Expand All @@ -103,8 +107,10 @@ class McpQueryService {
String table,
) {
return _withDelegate(connectionId, (delegate, dialect) async {
final schema = await _loadSchema(delegate, dialect);
final cat = await _loadCatalog(delegate, dialect);
final schema = cat.schema;
final t = _findTable(schema, table);
if (cat.views.contains(t.name)) return _describe(schema, t, const []);
final indexes = await _run(
() => delegate.executeQuery(
_indexesSql(dialect, t.name),
Expand Down Expand Up @@ -150,7 +156,8 @@ class McpQueryService {
}) {
final n = rows.clamp(1, maxSampleRows);
return _withDelegate(connectionId, (delegate, dialect) async {
final t = _findTable(await _loadSchema(delegate, dialect), table);
final t =
_findTable((await _loadCatalog(delegate, dialect)).schema, table);
final result = await _run(
() => delegate.executeQuery(
'SELECT * FROM ${quoteIdentifier(t.name, dialect)}',
Expand Down Expand Up @@ -227,6 +234,35 @@ class McpQueryService {
}
}

/// Tables and keys, then the views: a name that is already a table stays a
/// table. Views have no keys and no indexes.
Future<({ErdSchema schema, Set<String> views})> _loadCatalog(
SqlQueryRunner delegate,
SqlDialect dialect,
) async {
final base = await _loadSchema(delegate, dialect);
final viewRows = await _run(
() => delegate.executeQuery(ErdCatalog.viewColumnsSql(dialect),
limit: _catalogRows, timeout: timeout),
dialect,
);
final known = {for (final t in base.tables) t.name};
final views = [
for (final t in ErdSchema.fromCatalog(
columnRows: viewRows.rows, fkRows: const [])
.tables)
if (!known.contains(t.name)) t,
];
return (
schema: ErdSchema(
tables: [...base.tables, ...views],
relations: base.relations,
truncated: base.truncated,
),
views: {for (final v in views) v.name},
);
}

Future<ErdSchema> _loadSchema(
SqlQueryRunner delegate,
SqlDialect dialect,
Expand Down Expand Up @@ -365,12 +401,21 @@ class McpConnectionInfo {
}

class McpTableSummary {
const McpTableSummary({required this.name, required this.columnCount});
const McpTableSummary({
required this.name,
required this.columnCount,
this.isView = false,
});

final String name;
final int columnCount;
final bool isView;

Map<String, Object?> toJson() => {'name': name, 'columns': columnCount};
Map<String, Object?> toJson() => {
'name': name,
'columns': columnCount,
if (isView) 'kind': 'view',
};
}

class McpColumnInfo {
Expand Down
3 changes: 2 additions & 1 deletion lib/core/mcp/querya_mcp_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ base class QueryaMcpServer extends MCPServer with ToolsSupport, ResourcesSupport

static final _listTables = Tool(
name: 'list_tables',
description: 'Lists the tables of a connection with their column count.',
description: 'Lists the tables and views of a connection with their column '
'count; views carry "kind": "view".',
inputSchema: Schema.object(
properties: {'connection_id': _connectionId},
required: ['connection_id'],
Expand Down
40 changes: 40 additions & 0 deletions test/core/mcp/mcp_query_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
import 'package:querya_desktop/core/erd/erd_catalog.dart';
import 'package:querya_desktop/core/mcp/mcp_access_store.dart';
import 'package:querya_desktop/core/mcp/mcp_query_service.dart';
import 'package:querya_desktop/core/security/connection_environment.dart';
Expand Down Expand Up @@ -222,6 +223,45 @@ void main() {
]);
});

group('views', () {
setUp(() {
db.onExecute = (sql) {
if (sql == ErdCatalog.viewColumnsSql(SqlDialect.postgres)) {
return const SqlExecutionResult(columns: ['t', 'c', 'ty', 'pk'], rows: [
['active_users', 'id', 'integer', '0'],
['active_users', 'name', 'text', '0'],
// Already a table: stays a table.
['users', 'id', 'integer', '0'],
]);
}
return _catalog(sql);
};
});

test('listTables names the views next to the tables', () async {
final tables = await service().listTables(1);
expect([for (final t in tables) t.toJson()], [
{'name': 'users', 'columns': 2},
{'name': 'orders', 'columns': 2},
{'name': 'active_users', 'columns': 2, 'kind': 'view'},
]);
});

test('describeTable of a view has columns and no index query', () async {
final view = await service().describeTable(1, 'active_users');
expect(view.columns.map((c) => c.name), ['id', 'name']);
expect(view.columns.any((c) => c.primaryKey), isFalse);
expect(view.indexes, isEmpty);
expect(db.executed.where((s) => s.contains('pg_indexes')), isEmpty);
});

test('sampleRows reads a view through its quoted name', () async {
await service().sampleRows(1, 'active_users', rows: 5);
expect(
db.executed.last, contains('"active_users"'));
});
});

test('describeTable has keys, references and indexes', () async {
final users = await service().describeTable(1, 'users');
expect(users.columns.first.primaryKey, isTrue);
Expand Down
Loading