Skip to content

feat: add MCP server for AI tool integration#226

Open
sarim2000 wants to merge 1 commit into
mainfrom
feat/mcp-server
Open

feat: add MCP server for AI tool integration#226
sarim2000 wants to merge 1 commit into
mainfrom
feat/mcp-server

Conversation

@sarim2000
Copy link
Copy Markdown
Owner

Summary

  • Adds an embedded MCP (Model Context Protocol) server using the official kotlin-sdk-server (v0.9.0)
  • Runs as a foreground service on port 8765 with Ktor CIO + Streamable HTTP transport
  • Exposes 6 read-only tools: get_transactions, get_spending_summary, get_categories, search_transactions, get_balance_overview, get_subscriptions
  • Developer toggle in Settings > Developer > MCP Server
  • Upgrades Ktor from 2.3.12 to 3.2.3 for SDK compatibility

Usage

  1. Enable Developer Mode in Settings
  2. Toggle MCP Server on
  3. From desktop: adb forward tcp:8765 tcp:8765
  4. Connect any MCP client to http://localhost:8765/mcp

Test plan

  • Toggle MCP server on/off, verify foreground notification appears/disappears
  • curl http://localhost:8765/health returns OK
  • MCP Inspector connects and lists all 6 tools
  • Test each tool returns valid JSON results
  • Turning off Developer Mode auto-stops MCP server
  • Verify existing Ktor client (ExchangeRateProvider) still works after Ktor 3 upgrade

Embeds an MCP (Model Context Protocol) server in the app using the
official kotlin-sdk. Developers can toggle it on via Settings > Developer
> MCP Server, which starts a foreground service with a Ktor CIO server
on port 8765 exposing 6 read-only tools: get_transactions,
get_spending_summary, get_categories, search_transactions,
get_balance_overview, and get_subscriptions.

Also upgrades Ktor from 2.3.12 to 3.2.3 for SDK compatibility.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 28, 2026

Greptile Summary

This PR adds an embedded MCP (Model Context Protocol) server as a developer-only feature, exposing 6 read-only financial data tools over Streamable HTTP via Ktor CIO. The server runs as a foreground service on port 8765 and is gated behind a developer mode toggle in Settings.

Key issues found:

  • Security (P0): The Ktor server binds to 0.0.0.0 instead of 127.0.0.1. Since there is no authentication on any MCP endpoint, any device on the same WiFi network can query the user's complete transaction history, account balances, and subscriptions — not just through ADB tunnelling as the usage docs intend.
  • Logic bug (P1): total_matching in get_transactions is computed from the paginated slice (transactions.size after .drop(offset).take(limit)) rather than the pre-pagination total, so it always reports at most limit (50). AI clients relying on this field to drive pagination will think there are no further pages.
  • ANR risk (P1): McpServerService.onDestroy() calls the blocking McpServer.stop() (up to 2 s) directly on the main thread; this should be offloaded to a coroutine.
  • Performance (P2): get_transactions fetches the entire date-range result set into memory before applying category/type/merchant/amount filters in Kotlin sequences. Pushing these to the DAO query would avoid large intermediate allocations for heavy users.
  • Semantics (P2): get_spending_summary includes TransactionType.CREDIT in spending totals; in Indian banking SMS context "credit" typically means money received, which would inflate the spending figure shown to AI tools.

Confidence Score: 3/5

Not safe to merge as-is — the 0.0.0.0 binding is a security hole for a financial app, and the total_matching logic bug breaks the primary pagination contract for AI clients.

Two concrete issues block a clean merge: the server binding exposes sensitive financial data on the local network without any auth, and the total_matching field is structurally wrong making pagination unusable for AI tools. The main thread blocking in onDestroy is also a reliability concern. The overall architecture is solid and the remaining non-mcp changes (preferences, settings UI, manifest) are correct.

McpServer.kt (0.0.0.0 binding), McpToolHandler.kt (total_matching, in-memory filtering), McpServerService.kt (blocking stop on main thread)

Important Files Changed

Filename Overview
app/src/main/java/com/pennywiseai/tracker/mcp/McpServer.kt Creates the embedded Ktor/MCP server; binds to 0.0.0.0 which exposes sensitive financial data on all network interfaces without authentication — should be 127.0.0.1.
app/src/main/java/com/pennywiseai/tracker/mcp/McpToolHandler.kt Implements 6 read-only MCP tools; contains a logic bug where total_matching reports page size instead of full result count, and loads all transactions into memory before filtering.
app/src/main/java/com/pennywiseai/tracker/mcp/McpServerService.kt Android foreground service hosting the MCP server; onDestroy() calls the blocking stop() on the main thread risking UI jank.
app/src/main/java/com/pennywiseai/tracker/ui/screens/settings/SettingsViewModel.kt Adds MCP server toggle logic; correctly auto-stops server when developer mode is disabled.
app/src/main/java/com/pennywiseai/tracker/ui/screens/settings/SettingsScreen.kt Adds MCP server toggle UI under developer mode section; correctly hides when developer mode is off.
app/src/main/AndroidManifest.xml Registers McpServerService as a foreground service with specialUse type; permissions and service declaration look correct.
app/src/main/java/com/pennywiseai/tracker/data/preferences/UserPreferencesRepository.kt Adds MCP server enabled preference; follows existing DataStore patterns correctly.
gradle/libs.versions.toml Upgrades Ktor from 2.x to 3.2.3 (major version bump) and adds MCP SDK 0.9.0 and slf4j-nop dependencies; Ktor 3 upgrade needs verification against existing client usage.
app/build.gradle.kts Adds Ktor server and MCP SDK dependencies; changes look correct.

Sequence Diagram

sequenceDiagram
    participant UI as SettingsScreen
    participant VM as SettingsViewModel
    participant Prefs as UserPreferencesRepository
    participant Svc as McpServerService
    participant Server as McpServer (Ktor/CIO)
    participant Tool as McpToolHandler
    participant DB as Room DAOs
    participant Client as MCP Client

    UI->>VM: toggleMcpServer(true)
    VM->>Prefs: setMcpServerEnabled(true)
    VM->>Svc: start(context, port=8765)
    Svc->>Svc: startForeground(notification)
    Svc->>Server: McpServer(toolHandler, port).start()
    Server->>Server: embeddedServer(CIO, host="0.0.0.0", port=8765)
    Note over Server: ⚠️ binds all interfaces

    Client->>Server: POST /mcp (tool call)
    Server->>Tool: invoke registered tool
    Tool->>DB: query DAO (getTransactionsBetweenDatesList etc.)
    DB-->>Tool: List<Entity>
    Tool-->>Server: CallToolResult(JSON)
    Server-->>Client: JSON response

    UI->>VM: toggleDeveloperMode(false)
    VM->>VM: toggleMcpServer(false)
    VM->>Prefs: setMcpServerEnabled(false)
    VM->>Svc: stop(context)
    Svc->>Svc: onDestroy() [main thread]
    Svc->>Server: stop() ⚠️ blocks up to 2s on main thread
Loading

Comments Outside Diff (5)

  1. app/src/main/java/com/pennywiseai/tracker/mcp/McpServer.kt, line 120 (link)

    P0 Server binds to all interfaces, exposing financial data on WiFi

    The server is bound to 0.0.0.0, making it reachable from any device on the same WiFi network — not just via ADB forwarding. Since the MCP endpoint exposes raw transaction history, account balances, and subscription data with no authentication, any device on the same network can query it while the toggle is on.

    The intended usage ("adb forward tcp:8765 tcp:8765") only requires loopback binding. Change to 127.0.0.1:

    This ensures traffic only arrives through ADB tunnelling, matching the documented developer workflow.

  2. app/src/main/java/com/pennywiseai/tracker/mcp/McpToolHandler.kt, line 449-453 (link)

    P1 total_matching reports page size, not total result count

    transactions is the already-paginated list (after .drop(offset).take(limit)), so transactions.size will always be at most limit (50). The field name total_matching implies the total count of items that pass the filters — the value an AI client needs to decide whether to request another page. With the current code, the client can never detect that more results exist.

    The fix is to compute the total count before applying pagination:

    val allFiltered = transactionDao.getTransactionsBetweenDatesList(startDateTime, endDateTime)
        .asSequence()
        .filter { category == null || it.category.equals(category, ignoreCase = true) }
        .filter { type == null || it.transactionType == type }
        .filter { merchant == null || it.merchantName.contains(merchant, ignoreCase = true) }
        .filter { minAmount == null || it.amount.toDouble() >= minAmount }
        .filter { maxAmount == null || it.amount.toDouble() <= maxAmount }
        .toList()
    
    val transactions = allFiltered.drop(offset).take(limit)
    
    val result = buildJsonObject {
        put("total_matching", allFiltered.size)   // ← correct total
        put("page_size", transactions.size)
        ...
    }
  3. app/src/main/java/com/pennywiseai/tracker/mcp/McpServerService.kt, line 209-213 (link)

    P1 stop() blocks the main thread for up to 2 seconds in onDestroy()

    onDestroy() is called on the main thread. McpServer.stop() calls server?.stop(gracePeriodMillis = 1000, timeoutMillis = 2000), which is a synchronous blocking call that can take up to 2 seconds. While it is within Android's 5-second ANR budget, it will visibly stall the UI and is bad practice for a foreground service teardown.

    Move the stop to a coroutine:

    override fun onDestroy() {
        serviceScope.launch {
            mcpServer?.stop()
            mcpServer = null
        }
        serviceScope.cancel()
        super.onDestroy()
    }
  4. app/src/main/java/com/pennywiseai/tracker/mcp/McpToolHandler.kt, line 439-448 (link)

    P2 All transactions in the date range are loaded into memory before filtering

    getTransactionsBetweenDatesList fetches the entire date range into a List, then all filters (category, type, merchant, min_amount, max_amount) are applied in-memory. For a user with thousands of transactions this allocates a large intermediate collection on every tool call.

    Consider pushing the high-cardinality filters down to the DAO query (category, type, merchant via SQL LIKE, amount range) so only matching rows are fetched. The existing searchTransactions DAO method shows that parameterised queries are already used elsewhere.

  5. app/src/main/java/com/pennywiseai/tracker/mcp/McpToolHandler.kt, line 496 (link)

    P2 CREDIT transactions included in spending summary may inflate totals

    .filter { it.transactionType == TransactionType.EXPENSE || it.transactionType == TransactionType.CREDIT }

    In Indian banking SMS context (which this app parses), "CREDIT" typically means money received into the account. Including it in total_spent and the spending breakdown would give an AI tool an overstated (and semantically incorrect) spending figure. If CREDIT really does represent outgoing spend (e.g. credit-card purchases), a brief code comment explaining this would prevent future confusion.

Reviews (1): Last reviewed commit: "feat: add MCP server for exposing expens..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant