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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ jobs:
- name: Analyze code
run: flutter analyze

# packages/ is excluded above: the bridge is its own dependency-free
# package and must keep compiling to a single static binary.
- name: Analyze and build querya-mcp
run: |
cd packages/querya_mcp_bridge
dart pub get
dart analyze --fatal-infos
dart compile exe bin/querya_mcp.dart -o "$RUNNER_TEMP/querya-mcp"
"$RUNNER_TEMP/querya-mcp" --help | grep -q "Usage: querya-mcp"

# Release smoke build: runs after tests + analyze only when the workflow was
# triggered by a version tag (semver X.Y.Z or legacy v*), not on branch/PR pushes.
build-linux-release:
Expand Down
44 changes: 43 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ jobs:
- name: Build Windows (release)
run: flutter build windows --release

- name: Bundle querya-mcp (MCP stdio bridge)
run: |
cd packages\querya_mcp_bridge
dart pub get
dart compile exe bin\querya_mcp.dart -o ..\..\build\windows\x64\runner\Release\querya-mcp.exe

- name: Zip Windows artifact (portable)
run: |
$v = "${{ steps.version.outputs.version }}"
Expand Down Expand Up @@ -143,6 +149,12 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/linux/apply_patched_engine.sh

- name: Bundle querya-mcp (MCP stdio bridge)
run: |
cd packages/querya_mcp_bridge
dart pub get
dart compile exe bin/querya_mcp.dart -o ../../build/linux/x64/release/bundle/querya-mcp

- name: Zip Linux artifact (portable)
run: |
cd build/linux/x64/release/bundle
Expand Down Expand Up @@ -363,6 +375,12 @@ jobs:
EXE=querya-ext-tester
if [ "${{ runner.os }}" = "Windows" ]; then EXE=querya-ext-tester.exe; fi
dart compile exe bin/querya_ext_tester.dart -o "build/ext-tester/$EXE"
MCP=querya-mcp
if [ "${{ runner.os }}" = "Windows" ]; then MCP=querya-mcp.exe; fi
mkdir -p build/mcp
# Own dependency-free package: one static binary, no build hooks.
(cd packages/querya_mcp_bridge && dart pub get && \
dart compile exe bin/querya_mcp.dart -o "../../build/mcp/$MCP")

- name: Smoke test
shell: bash
Expand All @@ -380,23 +398,40 @@ jobs:
cat smoke.log
test "$code" -eq 1
grep -q "manifest.json not found" smoke.log
MCP=build/mcp/querya-mcp
if [ "${{ runner.os }}" = "Windows" ]; then MCP=build/mcp/querya-mcp.exe; fi
"$MCP" --help | grep -q "Usage: querya-mcp"
# No endpoint file: initialize must be answered with a clear error.
set +e
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
| QUERYA_MCP_ENDPOINT="$PWD/build/no-such-endpoint.json" "$MCP" > mcp-smoke.log
code=$?
set -e
cat mcp-smoke.log
test "$code" -eq 1
grep -q "Querya Desktop is not running" mcp-smoke.log

- name: Package
shell: bash
run: |
set -euo pipefail
NAME="querya-ext-tester-v${{ steps.version.outputs.version }}-${{ matrix.target }}"
MCP_NAME="querya-mcp-v${{ steps.version.outputs.version }}-${{ matrix.target }}"
cd build/ext-tester
if [ "${{ matrix.archive }}" = "zip" ]; then
7z a -tzip "../../$NAME.zip" querya-ext-tester.exe
cd ../mcp && 7z a -tzip "../../$MCP_NAME.zip" querya-mcp.exe
else
tar -czf "../../$NAME.tar.gz" querya-ext-tester
cd ../mcp && tar -czf "../../$MCP_NAME.tar.gz" querya-mcp
fi

- uses: actions/upload-artifact@v4
with:
name: tester-${{ matrix.target }}
path: querya-ext-tester-v${{ steps.version.outputs.version }}-${{ matrix.target }}.${{ matrix.archive }}
path: |
querya-ext-tester-v${{ steps.version.outputs.version }}-${{ matrix.target }}.${{ matrix.archive }}
querya-mcp-v${{ steps.version.outputs.version }}-${{ matrix.target }}.${{ matrix.archive }}
if-no-files-found: error

publish:
Expand Down Expand Up @@ -439,6 +474,9 @@ jobs:
find tester-artifacts -type f \( -name 'querya-ext-tester-*.tar.gz' -o -name 'querya-ext-tester-*.zip' \) \
-exec mv -t dist/ {} +
test "$(find dist -name 'querya-ext-tester-*' | wc -l)" -eq 4
find tester-artifacts -type f \( -name 'querya-mcp-*.tar.gz' -o -name 'querya-mcp-*.zip' \) \
-exec mv -t dist/ {} +
test "$(find dist -name 'querya-mcp-*' | wc -l)" -eq 4
cd dist
sha256sum *.* | tee SHA256SUMS.txt

Expand Down Expand Up @@ -473,6 +511,9 @@ jobs:
echo "#### Extension developers"
echo "- **querya-ext-tester** (headless extension test runner, no Flutter needed): \`querya-ext-tester-v${VERSION}-linux-x64.tar.gz\`, \`-macos-arm64.tar.gz\`, \`-macos-x64.tar.gz\`, \`-windows-x64.zip\` — see docs/extension-testing.md"
echo ""
echo "#### MCP (AI clients)"
echo "- **querya-mcp** (stdio bridge for Claude Desktop, Cursor, VS Code and other MCP clients; also bundled with the Linux and Windows builds): \`querya-mcp-v${VERSION}-linux-x64.tar.gz\`, \`-macos-arm64.tar.gz\`, \`-macos-x64.tar.gz\`, \`-windows-x64.zip\`"
echo ""
echo "Verify checksums: \`SHA256SUMS.txt\`"
echo ""
echo "### Build info"
Expand Down Expand Up @@ -500,6 +541,7 @@ jobs:
dist/Querya-Desktop-*.zip
dist/*.dmg
dist/querya-ext-tester-*
dist/querya-mcp-*
dist/*.deb
dist/*.rpm
dist/*.flatpak
Expand Down
2 changes: 2 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "third_party/**"
# Own package with its own analysis_options (no Flutter lints).
- "packages/**"
- "build/**"
- android/**
- ios/**
Expand Down
13 changes: 11 additions & 2 deletions lib/app/app_lifecycle_cleanup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import 'dart:async';

import 'package:flutter/widgets.dart';

import 'package:querya_desktop/core/mcp/mcp_server_controller.dart';

import 'app_shutdown.dart';

/// Closes pooled TCP connections when the app is shutting down.
Expand All @@ -28,17 +30,24 @@ class _AppLifecycleCleanupState extends State<AppLifecycleCleanup>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(disconnectAllExternalServices());
unawaited(_shutdown());
super.dispose();
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.detached) {
unawaited(disconnectAllExternalServices());
unawaited(_shutdown());
}
}

/// Stops the MCP server first, so no client starts a query on a pool that
/// is being closed, and removes its endpoint file.
Future<void> _shutdown() async {
await McpServerController.instance.stop();
await disconnectAllExternalServices();
}

@override
Widget build(BuildContext context) => widget.child;
}
30 changes: 23 additions & 7 deletions lib/core/mcp/mcp_query_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ class McpQueryService {
];
}

/// 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);
return [for (final t in schema.tables) _describe(schema, t, const [])];
});
}

Future<McpTableDescription> describeTable(
int connectionId,
String table,
Expand All @@ -100,7 +109,19 @@ class McpQueryService {
),
dialect,
);
return McpTableDescription(
return _describe(schema, t, [
for (final r in indexes.rows)
if (r.length >= 2) McpIndexInfo(name: r[0], definition: r[1]),
]);
});
}

static McpTableDescription _describe(
ErdSchema schema,
ErdTable t,
List<McpIndexInfo> indexes,
) =>
McpTableDescription(
name: t.name,
columns: [
for (final c in t.columns)
Expand All @@ -115,13 +136,8 @@ class McpQueryService {
].firstOrNull,
),
],
indexes: [
for (final r in indexes.rows)
if (r.length >= 2) McpIndexInfo(name: r[0], definition: r[1]),
],
indexes: indexes,
);
});
}

Future<McpQueryResult> sampleRows(
int connectionId,
Expand Down
152 changes: 152 additions & 0 deletions lib/core/mcp/mcp_server_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:querya_mcp_bridge/querya_mcp_bridge.dart';
import 'package:querya_desktop/core/mcp/mcp_query_service.dart';
import 'package:querya_desktop/core/mcp/mcp_socket_host.dart';
import 'package:querya_desktop/core/mcp/mcp_sql_delegates.dart';
import 'package:querya_desktop/core/mcp/querya_mcp_server.dart';
import 'package:querya_desktop/core/storage/local_db.dart';

/// Owns the in-app MCP server: starts it when enabled in settings, stops it
/// on shutdown, and reports status and tool calls to the UI.
class McpServerController {
McpServerController({
McpQueryService? service,
File? endpointFile,
String? version,
}) : _service = service ??
McpQueryService(createDelegate: createReadOnlyMcpDelegate),
_endpointFile = endpointFile ?? McpEndpoint.defaultFile(),
_versionOverride = version;

static final McpServerController instance = McpServerController();

/// `app_settings` key; the server is off unless the user enables it.
static const enabledKey = 'mcp_server_enabled';

final McpQueryService _service;
final File _endpointFile;
final String? _versionOverride;

Future<String> _version() async {
if (_versionOverride != null) return _versionOverride;
try {
return (await PackageInfo.fromPlatform()).version;
} catch (_) {
return 'unknown';
}
}

McpSocketHost? _host;
final _servers = <QueryaMcpServer>{};

/// Running state and client count, for the settings page.
final status = ValueNotifier<McpServerStatus>(McpServerStatus.stopped);

final _calls = StreamController<McpCallRecord>.broadcast();

/// Every finished tool call (activity log, audit).
Stream<McpCallRecord> get calls => _calls.stream;

File get endpointFile => _endpointFile;

Future<bool> isEnabled() async {
final v = await LocalDb.instance.getAppSetting(enabledKey);
return v == 'true';
}

Future<void> setEnabled(bool enabled) async {
await LocalDb.instance.setAppSetting(enabledKey, enabled.toString());
enabled ? await start() : await stop();
}

/// Called once at startup.
Future<void> startIfEnabled() async {
try {
if (await isEnabled()) await start();
} catch (e) {
debugPrint('MCP server did not start: $e');
status.value = McpServerStatus.failed(e.toString());
}
}

Future<void> start() async {
if (_host != null) return;
final version = await _version();
final host = McpSocketHost(
endpointFile: _endpointFile,
version: version,
onSession: (channel) async {
final server = QueryaMcpServer(
channel,
service: _service,
version: version,
onCall: _calls.add,
);
_servers.add(server);
await server.done;
_servers.remove(server);
},
);
host.changes.listen((_) => _publish(host));
await host.start();
_host = host;
_publish(host);
}

/// Disconnects clients and removes the endpoint file.
Future<void> stop() async {
final host = _host;
_host = null;
for (final s in _servers.toList()) {
await s.shutdown();
}
_servers.clear();
await host?.stop();
status.value = McpServerStatus.stopped;
}

void _publish(McpSocketHost host) {
if (_host != host && _host != null) return;
status.value = host.isRunning
? McpServerStatus(
running: true, port: host.port, clients: host.sessionCount)
: McpServerStatus.stopped;
}
}

@immutable
class McpServerStatus {
const McpServerStatus({
required this.running,
this.port,
this.clients = 0,
this.error,
});

const McpServerStatus.failed(String this.error)
: running = false,
port = null,
clients = 0;

static const stopped = McpServerStatus(running: false);

final bool running;
final int? port;
final int clients;
final String? error;

@override
bool operator ==(Object other) =>
other is McpServerStatus &&
other.running == running &&
other.port == port &&
other.clients == clients &&
other.error == error;

@override
int get hashCode => Object.hash(running, port, clients, error);
}
Loading
Loading