Last active
January 14, 2026 10:39
-
-
Save rodydavis/583360695f48c9704dc59133bd9f4eee to your computer and use it in GitHub Desktop.
Flutter App as a MCP server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:convert'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/services.dart'; | |
| import 'package:provider/provider.dart'; | |
| import 'mcp_service.dart'; | |
| void main() async { | |
| WidgetsFlutterBinding.ensureInitialized(); | |
| runApp(const McpApp()); | |
| } | |
| /// The types of transport available for the MCP service. | |
| enum McpTransportType { | |
| /// Uses a local HTTP server with Server-Sent Events (SSE). | |
| http, | |
| /// Uses a Unix Domain Socket (UDS) for standard input/output bridging. | |
| stdio, | |
| } | |
| /// Manages the connection state and lifecycle of the active MCP service. | |
| /// | |
| /// This class handles: | |
| /// - Switching between transport types (HTTP <-> UDS). | |
| /// - Preserving application state (counter value) during switches. | |
| /// - Toggling the server on and off. | |
| class McpConnectionManager extends ChangeNotifier { | |
| McpConnectionManager() { | |
| _init(); | |
| } | |
| McpService _service = HttpMcpService(); | |
| /// The currently active MCP service instance. | |
| McpService get service => _service; | |
| McpTransportType _transportType = McpTransportType.http; | |
| /// The currently selected transport type. | |
| McpTransportType get transportType => _transportType; | |
| bool _isRunning = false; | |
| /// Whether the MCP server is currently running. | |
| bool get isRunning => _isRunning; | |
| // Preserve counter value across service switches | |
| int _lastCounterValue = 0; | |
| void _init() { | |
| // Initially not started | |
| _service.set(_lastCounterValue); | |
| } | |
| /// Sets the transport type, stopping the current service and starting a new one. | |
| Future<void> setTransport(McpTransportType type) async { | |
| if (_transportType == type) return; | |
| // Save state | |
| _lastCounterValue = _service.counter; | |
| // Stop current service if running | |
| if (_isRunning) { | |
| await _service.stopServer(); | |
| } | |
| _transportType = type; | |
| // Create new service | |
| switch (type) { | |
| case McpTransportType.http: | |
| _service = HttpMcpService(); | |
| break; | |
| case McpTransportType.stdio: | |
| _service = StdioMcpService(); | |
| break; | |
| } | |
| // Restore state | |
| _service.set(_lastCounterValue); | |
| // Restart if was running | |
| if (_isRunning) { | |
| try { | |
| await _service.startServer(); | |
| } catch (e) { | |
| print("Failed to restart server after switch: $e"); | |
| _isRunning = false; | |
| } | |
| } | |
| notifyListeners(); | |
| } | |
| /// Toggles the server running state. | |
| Future<void> toggleServer(bool value) async { | |
| if (_isRunning == value) return; | |
| _isRunning = value; | |
| notifyListeners(); | |
| try { | |
| if (_isRunning) { | |
| await _service.startServer(); | |
| } else { | |
| await _service.stopServer(); | |
| } | |
| } catch (e) { | |
| print("Error toggling server: $e"); | |
| _isRunning = false; | |
| notifyListeners(); | |
| } | |
| } | |
| } | |
| /// The root widget of the MCP Counter Application. | |
| class McpApp extends StatelessWidget { | |
| const McpApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return ChangeNotifierProvider( | |
| create: (_) => McpConnectionManager(), | |
| child: const MaterialApp( | |
| home: CounterPage(), | |
| debugShowCheckedModeBanner: false, | |
| ), | |
| ); | |
| } | |
| } | |
| /// The main page displaying the counter and MCP controls. | |
| class CounterPage extends StatelessWidget { | |
| const CounterPage({super.key}); | |
| void _showConfig(BuildContext context, Map<String, dynamic> config) { | |
| const encoder = JsonEncoder.withIndent(' '); | |
| final jsonConfig = encoder.convert(config); | |
| showDialog( | |
| context: context, | |
| builder: (context) => AlertDialog( | |
| title: const Text('MCP Configuration'), | |
| content: SingleChildScrollView( | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| const Text('Add this to your MCP config:'), | |
| const SizedBox(height: 10), | |
| Container( | |
| padding: const EdgeInsets.all(8), | |
| color: Colors.grey[200], | |
| child: Text( | |
| jsonConfig, | |
| style: const TextStyle(fontFamily: 'monospace', fontSize: 12), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| actions: [ | |
| TextButton( | |
| onPressed: () => Navigator.pop(context), | |
| child: const Text('Close'), | |
| ), | |
| ElevatedButton( | |
| onPressed: () { | |
| Clipboard.setData(ClipboardData(text: jsonConfig)); | |
| ScaffoldMessenger.of(context).showSnackBar( | |
| const SnackBar(content: Text('Config copied to clipboard!')), | |
| ); | |
| Navigator.pop(context); | |
| }, | |
| child: const Text('Copy'), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| // Access manager to get the current service | |
| final manager = context.watch<McpConnectionManager>(); | |
| // Provide the specific McpService to the subtree so Consumers work correctly | |
| return ChangeNotifierProvider<McpService>.value( | |
| value: manager.service, | |
| child: Scaffold( | |
| appBar: AppBar( | |
| title: const Text('MCP Counter'), | |
| actions: [ | |
| Builder( | |
| builder: (context) { | |
| return IconButton( | |
| icon: const Icon(Icons.info_outline), | |
| onPressed: () async { | |
| // Use the current service from the manager | |
| final config = await manager.service.getMcpServersConfig(); | |
| if (context.mounted) { | |
| _showConfig(context, config); | |
| } | |
| }, | |
| tooltip: 'Show MCP Config', | |
| ); | |
| }, | |
| ), | |
| ], | |
| ), | |
| body: Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| // Transport Selection | |
| Padding( | |
| padding: const EdgeInsets.all(16.0), | |
| child: SegmentedButton<McpTransportType>( | |
| segments: const [ | |
| ButtonSegment( | |
| value: McpTransportType.http, | |
| label: Text('HTTP'), | |
| icon: Icon(Icons.http), | |
| ), | |
| ButtonSegment( | |
| value: McpTransportType.stdio, | |
| label: Text('STDIO (UDS)'), | |
| icon: Icon(Icons.terminal), | |
| ), | |
| ], | |
| selected: {manager.transportType}, | |
| onSelectionChanged: (Set<McpTransportType> newSelection) { | |
| manager.setTransport(newSelection.first); | |
| }, | |
| ), | |
| ), | |
| const SizedBox(height: 20), | |
| // Server Toggle | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| const Text('Server Status: '), | |
| Switch( | |
| value: manager.isRunning, | |
| onChanged: (val) => manager.toggleServer(val), | |
| ), | |
| Text(manager.isRunning ? 'Running' : 'Stopped'), | |
| ], | |
| ), | |
| const Divider(), | |
| const SizedBox(height: 20), | |
| const Text('Current Value:', style: TextStyle(fontSize: 24)), | |
| Consumer<McpService>( | |
| builder: (context, mcp, child) { | |
| return Text( | |
| '${mcp.counter}', | |
| style: const TextStyle( | |
| fontSize: 48, | |
| fontWeight: FontWeight.bold, | |
| ), | |
| ); | |
| }, | |
| ), | |
| ], | |
| ), | |
| ), | |
| floatingActionButton: Column( | |
| mainAxisAlignment: MainAxisAlignment.end, | |
| children: [ | |
| Consumer<McpService>( | |
| builder: (context, mcp, _) => FloatingActionButton( | |
| onPressed: () => mcp.increment(), | |
| tooltip: 'Increment', | |
| child: const Icon(Icons.add), | |
| ), | |
| ), | |
| const SizedBox(height: 10), | |
| Consumer<McpService>( | |
| builder: (context, mcp, _) => FloatingActionButton( | |
| onPressed: () => mcp.decrement(), | |
| tooltip: 'Decrement', | |
| child: const Icon(Icons.remove), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:async'; | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:path/path.dart' as p; | |
| import 'package:path_provider/path_provider.dart'; | |
| /// Base class for the Model Context Protocol (MCP) service. | |
| /// | |
| /// This abstract class defines the core functionality for an MCP service, | |
| /// including state management (the counter) and message processing (JSON-RPC). | |
| /// It extends [ChangeNotifier] to allow the UI to react to state changes. | |
| abstract class McpService extends ChangeNotifier { | |
| McpService(); | |
| int _counter = 0; | |
| /// The current value of the counter. | |
| int get counter => _counter; | |
| // 1. Counter Logic | |
| /// Increments the counter by 1. | |
| void increment() { | |
| _counter++; | |
| notifyListeners(); | |
| } | |
| /// Decrements the counter by 1. | |
| void decrement() { | |
| _counter--; | |
| notifyListeners(); | |
| } | |
| /// Sets the counter to a specific value. | |
| void set(int val) { | |
| _counter = val; | |
| notifyListeners(); | |
| } | |
| /// Resets the counter to 0. | |
| void reset() { | |
| _counter = 0; | |
| notifyListeners(); | |
| } | |
| /// Returns the full configuration for this MCP server, including the "mcpServers" key. | |
| Future<Map<String, dynamic>> getMcpServersConfig() async { | |
| return { | |
| "mcpServers": {"CounterApp": await getConfig()}, | |
| }; | |
| } | |
| /// Returns the transport-specific configuration for this server. | |
| /// | |
| /// For HTTP servers, this includes the 'serverUrl'. | |
| /// For Stdio/UDS servers, this includes the 'command' and 'args'. | |
| Future<Map<String, dynamic>> getConfig(); | |
| /// Starts the MCP server. | |
| Future<void> startServer(); | |
| /// Stops the MCP server and cleans up resources. | |
| Future<void> stopServer(); | |
| /// Processes an incoming JSON-RPC request string and returns the JSON-RPC response string. | |
| /// | |
| /// This method handles: | |
| /// - JSON parsing | |
| /// - Method dispatching (initialize, ping, tools/list, tools/call) | |
| /// - Error handling | |
| Future<String?> processRequest(String jsonString) async { | |
| Map<String, dynamic>? request; | |
| try { | |
| request = jsonDecode(jsonString); | |
| } catch (e) { | |
| return jsonEncode({ | |
| "jsonrpc": "2.0", | |
| "id": null, | |
| "error": {"code": -32700, "message": "Parse error"}, | |
| }); | |
| } | |
| if (request == null) return null; | |
| final id = request['id']; | |
| final method = request['method']; | |
| final params = request['params'] ?? {}; | |
| try { | |
| switch (method) { | |
| case 'initialize': | |
| return _success(id, { | |
| "protocolVersion": "2024-11-05", | |
| "capabilities": {"tools": {}, "resources": {}, "prompts": {}}, | |
| "serverInfo": {"name": "flutter-counter-app", "version": "1.0.0"}, | |
| }); | |
| case 'notifications/initialized': | |
| return null; // No response needed | |
| case 'ping': | |
| return _success(id, {}); | |
| case 'tools/list': | |
| return _success(id, { | |
| "tools": [ | |
| { | |
| "name": "get_value", | |
| "description": "Get the current value of the counter", | |
| "inputSchema": {"type": "object", "properties": {}}, | |
| }, | |
| { | |
| "name": "increment", | |
| "description": "Increment the counter by 1", | |
| "inputSchema": {"type": "object", "properties": {}}, | |
| }, | |
| { | |
| "name": "decrement", | |
| "description": "Decrement the counter by 1", | |
| "inputSchema": {"type": "object", "properties": {}}, | |
| }, | |
| { | |
| "name": "reset", | |
| "description": "Reset the counter to 0", | |
| "inputSchema": {"type": "object", "properties": {}}, | |
| }, | |
| { | |
| "name": "set_value", | |
| "description": "Set the counter to a specific value", | |
| "inputSchema": { | |
| "type": "object", | |
| "properties": { | |
| "value": { | |
| "type": "integer", | |
| "description": "The new value", | |
| }, | |
| }, | |
| "required": ["value"], | |
| }, | |
| }, | |
| ], | |
| }); | |
| case 'tools/call': | |
| final name = params['name']; | |
| final args = params['arguments'] ?? {}; | |
| dynamic toolResult; | |
| switch (name) { | |
| case 'get_value': | |
| toolResult = _counter; | |
| break; | |
| case 'increment': | |
| increment(); | |
| toolResult = "Counter incremented. New value: $_counter"; | |
| break; | |
| case 'decrement': | |
| decrement(); | |
| toolResult = "Counter decremented. New value: $_counter"; | |
| break; | |
| case 'reset': | |
| reset(); | |
| toolResult = "Counter reset to 0"; | |
| break; | |
| case 'set_value': | |
| if (args['value'] is int) { | |
| set(args['value']); | |
| toolResult = "Counter set to $_counter"; | |
| } else { | |
| throw "Invalid arguments: value must be an integer"; | |
| } | |
| break; | |
| default: | |
| throw "Tool not found: $name"; | |
| } | |
| return _success(id, { | |
| "content": [ | |
| {"type": "text", "text": toolResult.toString()}, | |
| ], | |
| }); | |
| default: | |
| if (id != null) { | |
| return _error(id, -32601, "Method not found"); | |
| } | |
| return null; | |
| } | |
| } catch (e) { | |
| if (id != null) { | |
| return _error(id, -32603, "Internal error: $e"); | |
| } | |
| return null; | |
| } | |
| } | |
| String _success(dynamic id, dynamic result) { | |
| return jsonEncode({"jsonrpc": "2.0", "id": id, "result": result}); | |
| } | |
| String _error(dynamic id, int code, String message) { | |
| return jsonEncode({ | |
| "jsonrpc": "2.0", | |
| "id": id, | |
| "error": {"code": code, "message": message}, | |
| }); | |
| } | |
| } | |
| /// An MCP service implementation that uses HTTP (SSE) for transport. | |
| /// | |
| /// It binds to a local port (starting at 8080) and handles POST/OPTIONS/GET requests. | |
| class HttpMcpService extends McpService { | |
| HttpMcpService(); | |
| int? _port; | |
| HttpServer? _server; | |
| /// Returns the base URI of the running server. | |
| /// Throws [StateError] if server is not started. | |
| Future<String> get serverUri async { | |
| if (_port == null) throw StateError("Server not started"); | |
| return "http://localhost:$_port"; | |
| } | |
| @override | |
| Future<Map<String, dynamic>> getConfig() async { | |
| if (_port == null) { | |
| return {"error": "Server not running"}; | |
| } | |
| return {"serverUrl": await serverUri}; | |
| } | |
| Future<void> _handleIncomingHttpRequest(HttpRequest request) async { | |
| if (request.method == 'POST') { | |
| try { | |
| final content = await utf8.decoder.bind(request).join(); | |
| final response = await processRequest(content); | |
| request.response.headers.contentType = ContentType.json; | |
| // Add CORS headers | |
| request.response.headers.add('Access-Control-Allow-Origin', '*'); | |
| request.response.headers.add( | |
| 'Access-Control-Allow-Methods', | |
| 'POST, OPTIONS', | |
| ); | |
| request.response.headers.add( | |
| 'Access-Control-Allow-Headers', | |
| 'Content-Type', | |
| ); | |
| if (response != null) { | |
| request.response.statusCode = HttpStatus.ok; | |
| request.response.write(response); | |
| } else { | |
| request.response.statusCode = HttpStatus.accepted; | |
| } | |
| } catch (e) { | |
| request.response.statusCode = HttpStatus.internalServerError; | |
| request.response.write(jsonEncode({"error": e.toString()})); | |
| } | |
| } else if (request.method == 'OPTIONS') { | |
| // Handle pre-flight CORS requests | |
| request.response.headers.add('Access-Control-Allow-Origin', '*'); | |
| request.response.headers.add( | |
| 'Access-Control-Allow-Methods', | |
| 'POST, OPTIONS', | |
| ); | |
| request.response.headers.add( | |
| 'Access-Control-Allow-Headers', | |
| 'Content-Type', | |
| ); | |
| request.response.statusCode = HttpStatus.ok; | |
| } else if (request.method == 'GET' && request.uri.path == '/') { | |
| request.response.headers.contentType = ContentType.text; | |
| request.response.write('MCP Server Running'); | |
| } else { | |
| request.response.statusCode = HttpStatus.methodNotAllowed; | |
| } | |
| await request.response.close(); | |
| } | |
| @override | |
| Future<void> startServer() async { | |
| int port = 8080; | |
| // Try to find an available port starting from 8080 | |
| while (_server == null) { | |
| try { | |
| _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); | |
| _port = port; | |
| } catch (e) { | |
| if (e is SocketException) { | |
| port++; | |
| if (port > 65535) { | |
| throw Exception('Error: No available ports found.'); | |
| } | |
| } else { | |
| rethrow; | |
| } | |
| } | |
| } | |
| print('MCP Server listening on http://localhost:$_port'); | |
| _server!.listen(_handleIncomingHttpRequest); | |
| } | |
| @override | |
| Future<void> stopServer() async { | |
| await _server?.close(); | |
| _server = null; | |
| } | |
| } | |
| /// An MCP service implementation that uses Stdio over a Unix Domain Socket (UDS). | |
| /// | |
| /// It bridges standard input/output behavior for MCP clients by providing a | |
| /// socket that clients can connect to via `nc -U` or native UDS support. | |
| class StdioMcpService extends McpService { | |
| StdioMcpService(); | |
| String? _socketPath; | |
| ServerSocket? _server; | |
| @override | |
| Future<Map<String, dynamic>> getConfig() async { | |
| return { | |
| "command": "nc", | |
| "args": ["-U", _socketPath ?? ""], | |
| }; | |
| } | |
| @override | |
| Future<void> startServer() async { | |
| try { | |
| final dir = await getApplicationSupportDirectory(); | |
| _socketPath = p.join(dir.path, 'mcp.sock'); | |
| // Ensure directory exists | |
| await dir.create(recursive: true); | |
| // Clean up socket file using reliable type checking | |
| final type = FileSystemEntity.typeSync(_socketPath!); | |
| if (type != FileSystemEntityType.notFound) { | |
| try { | |
| File(_socketPath!).deleteSync(); | |
| } catch (e) { | |
| print("Warning: Failed to delete existing socket file: $e"); | |
| } | |
| } | |
| final address = InternetAddress( | |
| _socketPath!, | |
| type: InternetAddressType.unix, | |
| ); | |
| _server = await ServerSocket.bind(address, 0); | |
| // Set permissions to 600 (owner read/write only) | |
| try { | |
| if (!Platform.isWindows) { | |
| await Process.run('chmod', ['600', _socketPath!]); | |
| } | |
| } catch (e) { | |
| print('Error setting socket permissions: $e'); | |
| } | |
| print('MCP Server listening on unix socket: $_socketPath'); | |
| _server!.listen((Socket client) { | |
| print('Client connected: ${client.remoteAddress}'); | |
| client | |
| .cast<List<int>>() | |
| .transform(utf8.decoder) | |
| .transform(const LineSplitter()) | |
| .listen( | |
| (String line) async { | |
| if (line.trim().isEmpty) return; | |
| try { | |
| final response = await processRequest(line); | |
| if (response != null) { | |
| client.write('$response\n'); | |
| } | |
| } catch (e) { | |
| print('Error processing UDS request: $e'); | |
| // Optionally send JSON-RPC error back | |
| } | |
| }, | |
| onDone: () { | |
| print('Client disconnected'); | |
| client.destroy(); | |
| }, | |
| onError: (e) { | |
| print('Client connection error: $e'); | |
| client.destroy(); | |
| }, | |
| ); | |
| }); | |
| } catch (e) { | |
| print('Failed to start StdioMcpService: $e'); | |
| rethrow; | |
| } | |
| } | |
| @override | |
| Future<void> stopServer() async { | |
| await _server?.close(); | |
| _server = null; | |
| if (_socketPath != null) { | |
| final type = FileSystemEntity.typeSync(_socketPath!); | |
| if (type != FileSystemEntityType.notFound) { | |
| try { | |
| File(_socketPath!).deleteSync(); | |
| } catch (_) {} | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment