From 343f6f888ddb7e2f0b8a30f0ccdffd1a1bfb7894 Mon Sep 17 00:00:00 2001
From: John Trujillo
Date: Fri, 14 Aug 2026 16:19:48 -0500
Subject: [PATCH] feat(ai): add MCP client plugin and route contributed tools
in the agent
New server settings, secure token storage and JSON-RPC/SSE transport; agent namespaces, budgets and approves contributed tool calls.
---
README.md | 1 +
ai-agent-mcp/.gitignore | 3 +
ai-agent-mcp/README.md | 88 ++++
ai-agent-mcp/ai-agent-mcp.html | 184 +++++++
ai-agent-mcp/build.gradle.kts | 91 ++++
ai-agent-mcp/gradle.properties | 10 +
ai-agent-mcp/proguard-rules.pro | 19 +
ai-agent-mcp/settings.gradle.kts | 35 ++
ai-agent-mcp/src/main/AndroidManifest.xml | 63 +++
ai-agent-mcp/src/main/assets/docs/index.html | 108 ++++
ai-agent-mcp/src/main/assets/icon_day.png | Bin 0 -> 597 bytes
ai-agent-mcp/src/main/assets/icon_night.png | Bin 0 -> 599 bytes
.../aiagentmcp/client/McpConnections.kt | 136 +++++
.../aiagentmcp/client/McpCredentials.kt | 18 +
.../plugins/aiagentmcp/client/McpSession.kt | 337 +++++++++++++
.../plugins/aiagentmcp/client/McpTool.kt | 27 +
.../aiagentmcp/errors/McpErrorFormatter.kt | 138 +++++
.../plugins/aiagentmcp/logging/LogTags.kt | 8 +
.../plugins/aiagentmcp/plugin/McpPlugin.kt | 467 +++++++++++++++++
.../aiagentmcp/security/SecureTokenStore.kt | 175 +++++++
.../plugins/aiagentmcp/settings/McpServer.kt | 28 ++
.../aiagentmcp/settings/McpServerStore.kt | 295 +++++++++++
.../settings/McpSettingsFragment.kt | 475 ++++++++++++++++++
.../settings/McpSettingsViewModel.kt | 265 ++++++++++
.../aiagentmcp/tools/McpToolCatalog.kt | 76 +++
.../plugins/aiagentmcp/tools/McpToolSource.kt | 207 ++++++++
.../plugins/aiagentmcp/tools/McpToolText.kt | 84 ++++
.../plugins/aiagentmcp/transport/JsonRpc.kt | 104 ++++
.../aiagentmcp/transport/McpHeaders.kt | 134 +++++
.../aiagentmcp/transport/McpHttpClient.kt | 203 ++++++++
.../aiagentmcp/transport/McpHttpException.kt | 18 +
.../plugins/aiagentmcp/transport/SseChunk.kt | 50 ++
.../main/res/drawable/bg_mcp_header_row.xml | 18 +
.../main/res/drawable/bg_mcp_icon_button.xml | 19 +
ai-agent-mcp/src/main/res/drawable/ic_add.xml | 12 +
.../src/main/res/drawable/ic_arrow_back.xml | 14 +
.../src/main/res/drawable/ic_close.xml | 12 +
.../src/main/res/layout/dialog_mcp_server.xml | 223 ++++++++
.../main/res/layout/fragment_mcp_settings.xml | 97 ++++
.../src/main/res/layout/item_mcp_header.xml | 81 +++
.../src/main/res/layout/item_mcp_server.xml | 53 ++
.../src/main/res/layout/item_mcp_tool.xml | 23 +
.../src/main/res/values-night/colors.xml | 36 ++
ai-agent-mcp/src/main/res/values/colors.xml | 41 ++
ai-agent-mcp/src/main/res/values/dimens.xml | 13 +
ai-agent-mcp/src/main/res/values/strings.xml | 84 ++++
ai-agent-mcp/src/main/res/values/styles.xml | 36 ++
.../errors/McpErrorFormatterTest.kt | 82 +++
.../settings/McpServerStoreMergeTest.kt | 65 +++
.../settings/McpTokenFieldConventionTest.kt | 29 ++
.../aiagentmcp/tools/McpToolTextTest.kt | 78 +++
.../aiagentmcp/transport/JsonRpcTest.kt | 76 +++
.../aiagentmcp/transport/McpHeadersTest.kt | Bin 0 -> 5471 bytes
.../aiagentmcp/transport/SseChunkTest.kt | 49 ++
ai-core/README.md | 48 ++
ai-core/ai-core.html | 50 +-
ai-core/src/main/AndroidManifest.xml | 4 +-
ai-core/src/main/assets/docs/index.html | 28 ++
.../fragments/ApprovalDialogFragment.kt | 11 +-
.../plugins/aicore/plugin/AiCorePlugin.kt | 37 ++
.../aicore/services/ToolSourceRegistryImpl.kt | 166 ++++++
.../plugins/aicore/tool/AgentTools.kt | 63 +++
.../plugins/aicore/tool/Executor.kt | 20 +-
.../aicore/tool/ToolApprovalManager.kt | 30 +-
.../plugins/aicore/tool/ToolCallGrammar.kt | 33 ++
.../plugins/aicore/tool/ToolHandler.kt | 30 ++
.../plugins/aicore/tool/ToolRouter.kt | 48 +-
.../aicore/tool/sources/ContributedTool.kt | 36 ++
.../tool/sources/ContributedToolHandler.kt | 155 ++++++
.../tool/sources/ContributedToolNames.kt | 70 +++
.../tool/sources/ContributedToolSource.kt | 41 ++
.../aicore/tool/sources/PromptToolBudget.kt | 88 ++++
.../aicore/tool/sources/ToolSourceStore.kt | 158 ++++++
.../plugins/aicore/viewmodel/ChatViewModel.kt | 148 ++++--
ai-core/src/main/res/values/strings.xml | 1 +
.../aicore/tool/ToolApprovalManagerTest.kt | 38 ++
.../plugins/aicore/tool/ToolRouterTest.kt | 100 ++--
.../sources/ContributedToolHandlerTest.kt | 150 ++++++
.../tool/sources/ContributedToolNamesTest.kt | 76 +++
.../aicore/tool/sources/FakeToolSource.kt | 73 +++
.../tool/sources/PromptToolBudgetTest.kt | 91 ++++
.../tool/sources/ToolSourceStoreTest.kt | 153 ++++++
82 files changed, 6835 insertions(+), 99 deletions(-)
create mode 100644 ai-agent-mcp/.gitignore
create mode 100644 ai-agent-mcp/README.md
create mode 100644 ai-agent-mcp/ai-agent-mcp.html
create mode 100644 ai-agent-mcp/build.gradle.kts
create mode 100644 ai-agent-mcp/gradle.properties
create mode 100644 ai-agent-mcp/proguard-rules.pro
create mode 100644 ai-agent-mcp/settings.gradle.kts
create mode 100644 ai-agent-mcp/src/main/AndroidManifest.xml
create mode 100644 ai-agent-mcp/src/main/assets/docs/index.html
create mode 100644 ai-agent-mcp/src/main/assets/icon_day.png
create mode 100644 ai-agent-mcp/src/main/assets/icon_night.png
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpConnections.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpCredentials.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpSession.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpTool.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatter.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/logging/LogTags.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/SecureTokenStore.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServer.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolCatalog.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolSource.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolText.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/JsonRpc.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHeaders.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHttpClient.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHttpException.kt
create mode 100644 ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/SseChunk.kt
create mode 100644 ai-agent-mcp/src/main/res/drawable/bg_mcp_header_row.xml
create mode 100644 ai-agent-mcp/src/main/res/drawable/bg_mcp_icon_button.xml
create mode 100644 ai-agent-mcp/src/main/res/drawable/ic_add.xml
create mode 100644 ai-agent-mcp/src/main/res/drawable/ic_arrow_back.xml
create mode 100644 ai-agent-mcp/src/main/res/drawable/ic_close.xml
create mode 100644 ai-agent-mcp/src/main/res/layout/dialog_mcp_server.xml
create mode 100644 ai-agent-mcp/src/main/res/layout/fragment_mcp_settings.xml
create mode 100644 ai-agent-mcp/src/main/res/layout/item_mcp_header.xml
create mode 100644 ai-agent-mcp/src/main/res/layout/item_mcp_server.xml
create mode 100644 ai-agent-mcp/src/main/res/layout/item_mcp_tool.xml
create mode 100644 ai-agent-mcp/src/main/res/values-night/colors.xml
create mode 100644 ai-agent-mcp/src/main/res/values/colors.xml
create mode 100644 ai-agent-mcp/src/main/res/values/dimens.xml
create mode 100644 ai-agent-mcp/src/main/res/values/strings.xml
create mode 100644 ai-agent-mcp/src/main/res/values/styles.xml
create mode 100644 ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatterTest.kt
create mode 100644 ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStoreMergeTest.kt
create mode 100644 ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpTokenFieldConventionTest.kt
create mode 100644 ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolTextTest.kt
create mode 100644 ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/JsonRpcTest.kt
create mode 100644 ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHeadersTest.kt
create mode 100644 ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/SseChunkTest.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/services/ToolSourceRegistryImpl.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentTools.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallGrammar.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ContributedTool.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ContributedToolHandler.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ContributedToolNames.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ContributedToolSource.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/PromptToolBudget.kt
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ToolSourceStore.kt
create mode 100644 ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ContributedToolHandlerTest.kt
create mode 100644 ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ContributedToolNamesTest.kt
create mode 100644 ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/FakeToolSource.kt
create mode 100644 ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/PromptToolBudgetTest.kt
create mode 100644 ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ToolSourceStoreTest.kt
diff --git a/README.md b/README.md
index 2dd2a5d2..a926ec67 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego
| [`ai-core/`](ai-core/) | The **Agent** chat (tool-calling assistant) plus the shared LLM inference **router** other plugins consume. Ships no model — install at least one backend plugin below. Mandatory for every AI feature. |
| [`ai-agent-local/`](ai-agent-local/) | On-device `.gguf` inference backend for `ai-core` (bundled llama.cpp AAR). Registers as `local`; needs no network. |
| [`ai-agent-gemini/`](ai-agent-gemini/) | Google Gemini API inference backend for `ai-core`. Registers as `gemini`; needs an API key and network access. |
+| [`ai-agent-mcp/`](ai-agent-mcp/) | Connects the Agent to Model Context Protocol servers, contributing their tools through `ai-core`. Needs network access; tools are off until enabled. |
| [`flutter-template/`](flutter-template/) | Adds Flutter starter project templates (Basic, BLoC, Provider, GetX, Riverpod) to the New Project screen. |
| [`code-suggestions-plugin/`](code-suggestions-plugin/) | Inline ghost-text code completions powered by AI. |
| [`speech-to-text-plugin/`](speech-to-text-plugin/) | Voice-to-code: converts speech to code with AI generation. |
diff --git a/ai-agent-mcp/.gitignore b/ai-agent-mcp/.gitignore
new file mode 100644
index 00000000..5380c6d5
--- /dev/null
+++ b/ai-agent-mcp/.gitignore
@@ -0,0 +1,3 @@
+**/.cxx/
+build-output.log
+**/.kotlin/
diff --git a/ai-agent-mcp/README.md b/ai-agent-mcp/README.md
new file mode 100644
index 00000000..9b2eda34
--- /dev/null
+++ b/ai-agent-mcp/README.md
@@ -0,0 +1,88 @@
+# AI Agent MCP plugin for CodeOnTheGo
+
+Connects CodeOnTheGo's Agent to **Model Context Protocol** servers. Tools a
+configured server advertises are contributed to
+[`ai-core`](../ai-core/)'s agent through the host's `ToolSourceRegistry`, so they
+appear beside the Agent's own tools with no change to `ai-core`.
+
+This is a *tool* plugin, not a model backend: it adds no inference. Install
+`ai-core` and at least one backend (`ai-agent-local`, `ai-agent-gemini`) as well.
+
+## Why a separate plugin
+
+`ai-core` declares the filesystem, shell and project permissions its own tools
+need, and **no network access**. MCP is the mirror image: it declares
+`network.access` and nothing else. Keeping them apart is what lets a user install
+the Agent without granting it the network, or add remote tools without widening
+what the Agent itself may touch.
+
+## Building
+
+Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with
+`sdk.dir=...`. This plugin uses the shared wrapper at the repo root:
+
+```bash
+cd ai-agent-mcp
+../gradlew assemblePlugin # release -> build/plugin/ai-agent-mcp.cgp
+../gradlew assemblePluginDebug # debug variant
+../gradlew testDebugUnitTest # JVM tests: framing, error classification, sanitising
+```
+
+## Using it
+
+Install the `.cgp` through the Plugin Manager, then open **Preferences →
+Configuration → MCP servers**:
+
+1. **Add server** — name, endpoint URL, optional token.
+2. **Test connection** — performs the MCP handshake and reports one sentence.
+3. **Refresh tools** — lists what the server offers.
+4. Switch on the tools you want. **They start off**: one popular GitHub server
+ advertises around ninety tools, which would fill a phone-sized context window
+ on its own.
+
+Every remote tool asks for approval on every call, and there is no "always
+allow" for contributed tools — they run outside the Agent's own path
+containment, so the dialog is the only gate.
+
+## Transport
+
+MCP's **Streamable HTTP** transport over `HttpURLConnection`:
+
+- one POST per JSON-RPC call, reading either a JSON body or the SSE stream
+ carrying the same document;
+- `Mcp-Session-Id` echoed when the server is stateful, dropped when it is not
+ (the 2026-07-28 revision is stateless), with one clean re-handshake when a
+ session expires;
+- the bearer token as an `Authorization` header, never a query string.
+
+Two dependencies were deliberately not taken:
+
+- **No OkHttp.** Plugins run in the host IDE's classloader, where `okhttp3`
+ resolves to the host's older copy; a bundled SDK crashes with
+ `NoSuchMethodError`.
+- **No official MCP Kotlin SDK.** It is KMP with no stated Android target, ships
+ no HTTP engine, and required pinning Kotlin 2.4.10 when it was tried
+ (ADFA-5083) against the 2.3.0 these plugins standardise on.
+
+## Layout
+
+- `plugin/McpPlugin.kt` — entry point; registers the tool source with `ai-core`
+ and re-registers from a `PluginLifecycleListener`, since plugins load in
+ parallel with no ordering
+- `transport/` — `JsonRpc` framing, `SseChunk` line parsing, `McpHttpClient`
+- `client/` — `McpSession` (handshake, `tools/list`, `tools/call`),
+ `McpConnections` (one session per server)
+- `tools/` — `McpToolSource` (the `ToolSourceRegistry.ToolSource`),
+ `McpToolCatalog` (what each server last advertised), `McpToolText` (sanitising)
+- `settings/` — server CRUD, per-tool toggles, the settings pane
+- `errors/` — HTTP and JSON-RPC failures reduced to one translated sentence
+- `security/` — Keystore-backed token encryption
+
+## Security notes
+
+- Tool names and descriptions from a server are **untrusted remote text**. They
+ are reduced to `[a-z0-9_]` and to a single capped line before they can reach a
+ system prompt assembled inside a backend plugin.
+- Tokens are encrypted with an AES/GCM key held in the Android Keystore under
+ this plugin's own alias; only ciphertext is written to disk.
+- Error bodies stay in logcat. The transcript gets one sentence.
diff --git a/ai-agent-mcp/ai-agent-mcp.html b/ai-agent-mcp/ai-agent-mcp.html
new file mode 100644
index 00000000..14f8e43a
--- /dev/null
+++ b/ai-agent-mcp/ai-agent-mcp.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+AI Agent MCP Plugin
+
+
+
+ AI Agent MCP Plugin
+
+ Executive overview
+ AI Agent MCP connects CodeOnTheGo's Agent to Model Context
+ Protocol servers. A server you configure advertises tools — search a
+ documentation index, open a ticket, query an internal API — and those tools
+ appear in the Agent's tool list beside its built-in ones, so the assistant can
+ reach systems that live outside the device.
+ This plugin adds tools, not a model. Install AI Core and at
+ least one backend (AI Agent Local or AI Agent Gemini ) as well;
+ on its own this plugin has no assistant to contribute to. Install order does
+ not matter — it registers with AI Core whenever AI Core activates.
+ It is deliberately a plugin of its own rather than part of AI Core. AI Core
+ declares the filesystem, shell and project permissions its own tools need and
+ no network access ; this plugin is the mirror image, declaring
+ network.access and nothing else . That split is what lets you run the
+ Agent without granting it the network, or add remote tools without widening
+ what the Agent itself may touch.
+
+ Core functionality
+
+ Server management — add, edit, disable and remove MCP servers from
+ one screen under Preferences → Configuration → MCP
+ servers .
+ Test connection — performs the MCP handshake and reports the
+ result in one sentence, so a wrong URL or a stale token is caught while you
+ are looking at the settings rather than mid-conversation.
+ Tool discovery — reads a server's tool catalogue, including
+ paginated catalogues, and remembers what it advertised.
+ Per-tool switches, off by default — you choose which of a server's
+ tools the Agent may see. One popular GitHub server advertises around ninety
+ tools, enough to fill a phone-sized context window on its own.
+ Approval on every call — a remote tool always asks, showing the
+ arguments it would send, and can never be granted "always allow".
+ Encrypted tokens — bearer tokens are encrypted with an Android
+ Keystore key and sent only to the server they belong to.
+ Legible failures — an unreachable server, a refused token or a URL
+ that is not an MCP endpoint each produce one plain sentence, never a raw
+ error body in the chat transcript.
+
+
+ Technical architecture
+
+ Component Role
+ McpPluginPlugin entry point. Contributes the MCP
+ servers settings row, registers the tool source with AI Core on activation
+ and withdraws it on deactivation. A PluginLifecycleListener
+ re-registers if AI Core activates later, since plugins load in parallel with
+ no ordering.
+ McpToolSourceThe tool source AI Core sees.
+ Publishes the enabled tools of the enabled servers, runs a call on its own
+ thread pool, and cancels in flight when an Agent run is stopped.
+ McpToolCatalogWhat each server last advertised,
+ held in memory. The Agent reads its tool list on a UI-adjacent path, so that
+ list is answered from memory and never blocks on the network.
+ McpSessionOne protocol conversation per server:
+ initialize, tools/list, tools/call,
+ session lifetime, and one clean re-handshake when a server drops a
+ session.
+ McpHttpClient / JsonRpc / SseChunk
+ The transport: one POST per JSON-RPC call, reading either a JSON body or
+ the server-sent-events stream carrying the same document. Connections are
+ closed on every path.
+ McpToolTextSanitises server-supplied names and
+ descriptions — untrusted remote text that would otherwise land verbatim in a
+ prompt assembled inside a third-party backend plugin.
+ SecureTokenStoreAES/GCM encryption under a
+ Keystore alias owned by this plugin, so only ciphertext reaches disk.
+
+ The transport is MCP's Streamable HTTP revision over
+ HttpURLConnection. Two dependencies were deliberately not taken:
+ no OkHttp , because plugins run in the host IDE's class loader where
+ okhttp3 resolves to the host's older copy and a bundled SDK crashes
+ with NoSuchMethodError; and no official MCP Kotlin SDK ,
+ which is a Kotlin Multiplatform library with no stated Android target, ships no
+ HTTP engine, and required a Kotlin version these plugins do not standardise
+ on.
+ The plugin declares network.access and nothing else. It holds no
+ filesystem, shell or project permission, so it cannot read your project;
+ project content reaches a server only if the model puts it in a tool argument
+ and you approve that call.
+
+ Usage
+
+ Install AI Core and a backend, then install AI Agent MCP via
+ the Plugin Manager.
+ Open Preferences → Configuration → MCP servers and tap
+ Add server .
+ Enter a name — it is also the prefix on that server's tool names,
+ so two servers offering search stay distinguishable — the
+ endpoint URL (usually ending in /mcp), and an
+ access token if the server needs one.
+ Tap Test connection , then Refresh tools to list what the
+ server offers.
+ Switch on the tools you want, and save. Ask the Agent for something those
+ tools cover; it will request your approval before each call.
+
+
+ New tools always arrive switched off , including after a refresh that
+ discovers them. Nothing a server adds later becomes visible to the model
+ without your say-so.
+
+
+ Key benefits
+
+ Reaches systems the IDE cannot — issue trackers, documentation
+ indexes and internal APIs become things the Agent can query, without
+ bundling an integration for each one.
+ Least privilege — network access is declared by this plugin alone;
+ installing it does not widen what the Agent's own tools may touch, and
+ uninstalling it removes the network capability entirely.
+ Context stays affordable — per-tool switches, off by default, keep
+ a ninety-tool server from crowding out the conversation on a phone.
+ Nothing runs unattended — every remote call is approved
+ individually, with its arguments visible, and no "always allow" exists for
+ contributed tools.
+ Extensible without code — supporting a new service means adding its
+ URL in settings; neither this plugin nor AI Core changes.
+
+
+ Limitations
+
+ Only the Streamable HTTP transport is supported. A server offering
+ just the local stdio transport, or the deprecated HTTP+SSE
+ transport, cannot be used.
+ Only tools are consumed. MCP prompts, resources and sampling are
+ not used.
+ The tool list is answered from memory, so a server that was unreachable at
+ startup needs a Refresh tools before its tools appear.
+
+
+ Troubleshooting
+
+ Message What to do
+ "has no MCP endpoint at that URL" The path is wrong; try
+ adding /mcp.
+ "does not accept this request" The server is probably not a
+ Streamable HTTP MCP server.
+ "refused the token" Re-enter it. The field shows
+ Stored rather than the value, so typing replaces it.
+ Tools missing from the Agent Check the server is enabled, at
+ least one tool is switched on, and AI Core is installed and active.
+
+
+
diff --git a/ai-agent-mcp/build.gradle.kts b/ai-agent-mcp/build.gradle.kts
new file mode 100644
index 00000000..cf5a3325
--- /dev/null
+++ b/ai-agent-mcp/build.gradle.kts
@@ -0,0 +1,91 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "ai-agent-mcp"
+}
+
+android {
+ namespace = "com.itsaky.androidide.plugins.aiagentmcp"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.itsaky.androidide.plugins.aiagentmcp"
+ minSdk = 33
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildFeatures {
+ viewBinding = false
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ isShrinkResources = false
+ signingConfig = signingConfigs.getByName("debug")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/DEPENDENCIES",
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE",
+ "META-INF/NOTICE.txt",
+ "META-INF/INDEX.LIST"
+ )
+ }
+ }
+}
+
+dependencies {
+ compileOnly(files("../libs/plugin-api.jar"))
+
+ // 'implementation' (not 'compileOnly') for the androidx/Material libraries: AAPT2 needs them
+ // at compile time to process the settings pane's layouts, as in every CoGo plugin with XML.
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.fragment:fragment-ktx:1.8.8")
+ implementation("com.google.android.material:material:1.10.0")
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+
+ testImplementation(files("../libs/plugin-api.jar"))
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.json:json:20240303")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
+}
+
+// No MCP SDK dependency on purpose: the official Kotlin SDK is KMP with no stated Android target,
+// ships no HTTP engine, and needed Kotlin 2.4.10 when it was tried (ADFA-5083) against the 2.3.0
+// these plugins standardise on. The transport here is HttpURLConnection and org.json, both of
+// which the platform already provides — and never OkHttp, which resolves to the host's older copy.
+
+// AAR metadata checks are disabled by convention for these application-as-library plugins.
+tasks.matching {
+ it.name.contains("checkDebugAarMetadata") ||
+ it.name.contains("checkReleaseAarMetadata")
+}.configureEach { enabled = false }
diff --git a/ai-agent-mcp/gradle.properties b/ai-agent-mcp/gradle.properties
new file mode 100644
index 00000000..fcd58cda
--- /dev/null
+++ b/ai-agent-mcp/gradle.properties
@@ -0,0 +1,10 @@
+android.enableJetifier=false
+android.jetifier.ignorelist=common-30.2.2.jar
+android.nonTransitiveRClass=false
+android.useAndroidX=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
+org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M"
+org.gradle.parallel=true
+
+kotlin.code.style=official
diff --git a/ai-agent-mcp/proguard-rules.pro b/ai-agent-mcp/proguard-rules.pro
new file mode 100644
index 00000000..8d83ec18
--- /dev/null
+++ b/ai-agent-mcp/proguard-rules.pro
@@ -0,0 +1,19 @@
+# AI Agent MCP Plugin ProGuard Rules
+
+# Keep plugin entry point
+-keep public class com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin {
+ public ;
+}
+
+# Keep the tool source: AI Core calls it across the plugin classloader boundary.
+-keep public class com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolSource {
+ public ;
+}
+
+# Keep the settings fragment: it is instantiated by name from getSettingsEntries().
+-keep public class com.itsaky.androidide.plugins.aiagentmcp.settings.McpSettingsFragment {
+ public (...);
+}
+
+# Keep plugin-api interfaces
+-keep interface com.itsaky.androidide.plugins.** { *; }
diff --git a/ai-agent-mcp/settings.gradle.kts b/ai-agent-mcp/settings.gradle.kts
new file mode 100644
index 00000000..245e232f
--- /dev/null
+++ b/ai-agent-mcp/settings.gradle.kts
@@ -0,0 +1,35 @@
+@file:Suppress("UnstableApiUsage")
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath(files("../libs/plugin-api.jar"))
+ classpath(files("../libs/gradle-plugin.jar"))
+ classpath("com.android.tools.build:gradle:8.11.0")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0")
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "ai-agent-mcp"
diff --git a/ai-agent-mcp/src/main/AndroidManifest.xml b/ai-agent-mcp/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..6c235909
--- /dev/null
+++ b/ai-agent-mcp/src/main/AndroidManifest.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/assets/docs/index.html b/ai-agent-mcp/src/main/assets/docs/index.html
new file mode 100644
index 00000000..710ef3e3
--- /dev/null
+++ b/ai-agent-mcp/src/main/assets/docs/index.html
@@ -0,0 +1,108 @@
+
+
+
+
+
+AI Agent MCP — Guide
+
+
+
+ AI Agent MCP — Remote Tools for the Agent
+
+ AI Agent MCP connects CodeOnTheGo's Agent to
+ Model Context Protocol servers. A server you configure advertises tools —
+ search a documentation index, open a ticket, query an internal API — and the
+ Agent can call them the same way it calls its built-in tools.
+
+
+ Install AI Core as well, plus a model backend. This plugin adds tools;
+ it does not add a model, and on its own it does nothing.
+
+
+ Adding a server
+
+ Open Preferences → Configuration → MCP servers and tap
+ Add server .
+ Give it a name — it is also the prefix on that server's tool names,
+ so two servers offering search stay distinguishable.
+ Enter the endpoint URL . It usually ends in /mcp.
+ Add an access token if the server needs one. It is encrypted with a
+ key held in the Android Keystore, and sent only to that server, as an
+ Authorization: Bearer header.
+ Tap Test connection to check it now, then Refresh tools to
+ list what the server offers.
+
+
+ Choosing tools
+ Every tool starts switched off . That is deliberate: each enabled tool
+ costs prompt space on every message you send, and a large server can advertise
+ ninety of them — enough to fill a phone-sized context window on its own. Switch
+ on the handful you actually want.
+ The Agent reads this list from memory, so use Refresh tools after the
+ server changes; a chat that is already open picks the new list up.
+
+ Approval
+ Every remote tool asks for your approval before it runs, showing the
+ arguments it would send, and there is no "always allow" for them. The Agent's
+ own file tools are confined to the open project; a remote tool is confined by
+ nothing this device controls, so the dialog is the only gate — read it.
+
+ What is sent
+
+ The tool's arguments, as the model composed them, to that server only.
+ Nothing else: this plugin has network access and no other permission .
+ It cannot read your project or your files. Anything from your project reaches
+ a server only if the model puts it in an argument and you approve the call.
+ Server names and tool descriptions are stripped to a single line before
+ they reach the model's prompt, so a server cannot forge instructions with
+ formatting.
+
+
+ Which servers work
+ Servers speaking MCP's Streamable HTTP transport, which is the one
+ reachable from a phone. A server offering only the local stdio
+ transport cannot be used, and the deprecated HTTP+SSE transport is not
+ supported.
+
+ Troubleshooting
+
+ "has no MCP endpoint at that URL" — the path is wrong; try adding
+ /mcp.
+ "does not accept this request" — the server is probably not a
+ Streamable HTTP MCP server.
+ "refused the token" — re-enter it; the field shows
+ Stored rather than the value, so typing replaces it.
+ Tools do not appear in the Agent — check the server is enabled, at
+ least one tool is switched on, and AI Core is installed and active.
+ Everything times out on mobile data — some servers are slow to
+ handshake; try again on Wi-Fi before assuming the URL is wrong.
+
+
+
diff --git a/ai-agent-mcp/src/main/assets/icon_day.png b/ai-agent-mcp/src/main/assets/icon_day.png
new file mode 100644
index 0000000000000000000000000000000000000000..1268a8918a64f7e035708280dd45e0a3397aeda2
GIT binary patch
literal 597
zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q4M;wBd$farfl0>G#WAGf*4w*=e1{AKSRDR0
z%n$lhe(3ThXYXgW-~C1CNwaMG3FcVO9>gy>b&9omeI?En#JpRB>T=DdoVn
zqF&L+hSh`dQd5Hv`^Mb&^Y@;v3hb~tV7cq#=IhKH3JpPwZ|}a@JKL_>wt;~O$S0X-
zf5jN}>&nhm433w@f=w1=RcI-7v4)8Ho4QPFSoL^iiw0i+*F3F`MGQ+Hu55r>P|>AC
zmR-19r!-xgfknW9Vdo=0xrUfcoB}TEewJReu!$>W;CG1I#uUJD;G8kziRuGD-z}K&
z-j{(9q#B7RQFgd1z-S`L4;2+}iFtGMOqWZ}b?vsCIPrsRUWhOAWFnGH9xvX4?Mou|ihm_3@
zP3%eqTR2pt83kJ3)ldzHJ-dJd!&0VOyKY9C*M6&FU}ORE
zNhbKNGH>~^vN(*fX_@Y_42P>9LR>_+S7^=4>QG_~Js!*%D6>GsCRD(c(d%$9qaehB
z4E_&e%910B#MMq_A7;c~E5WE3e|wo7LL
zUk8JgfIqv-zq5q~KpjmS3$|8F5@ak9lJi&em+xSR>SkA}P|#x1e3g89BftHWrX|;#
wnFVbW8kr}uJ#6HZns9)TOF|F~PM#Exye3~=QYLgCm|z$@UHx3vIVCg!05BP$>Hq)$
literal 0
HcmV?d00001
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpConnections.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpConnections.kt
new file mode 100644
index 00000000..6f49f151
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpConnections.kt
@@ -0,0 +1,136 @@
+package com.itsaky.androidide.plugins.aiagentmcp.client
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX
+import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServer
+import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServerStore
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+
+private const val TAG = "$LOG_PREFIX.McpConnections"
+
+/**
+ * The live [McpSession] per configured server.
+ *
+ * Sessions are reused so the handshake is paid once rather than per tool call, and keyed by url
+ * *and* token: editing either has to produce a new session, or the old credentials keep working
+ * until the IDE restarts, which reads as "my change did nothing".
+ */
+object McpConnections {
+
+ /**
+ * What a session was built from.
+ *
+ * The credentials are held as SHA-256 digests rather than as `hashCode`: a 32-bit hash of two
+ * distinct tokens can coincide, and the cost of that coincidence is one server's credential
+ * being reused after the user replaced it.
+ */
+ private data class Key(
+ val serverId: String,
+ val url: String,
+ val tokenFingerprint: String,
+ val headerFingerprint: String,
+ )
+
+ private val sessions = ConcurrentHashMap>()
+
+ /**
+ * The session for [server], created if needed.
+ *
+ * Reads the token and the headers, so call this off the main thread.
+ *
+ * @param server the server to connect to.
+ * @return its session, not yet initialized.
+ */
+ fun session(server: McpServer): McpSession {
+ val key = keyFor(server)
+
+ // Locked, so two tool calls arriving together cannot each build a session and leave one of
+ // them unreachable and unclosed. The stale one is closed outside, where its DELETE cannot
+ // hold the next caller behind a socket.
+ var stale: McpSession? = null
+ val session = synchronized(this) {
+ val current = sessions[server.id]
+ if (current != null && current.first == key) return current.second
+ stale = current?.second
+ // A supplier, not the values: the session is kept for the life of the process, and a
+ // token in one of its fields would be readable in a heap dump for just as long.
+ McpSession(server.url, { credentialsFor(server.id) })
+ .also { sessions[server.id] = key to it }
+ }
+
+ stale?.let {
+ Log.i(TAG, "Server '${server.name}' changed; dropping its session")
+ runCatching { it.close() }
+ }
+ return session
+ }
+
+ /** Drops the session for [serverId], ending it server-side when it had one. */
+ fun invalidate(serverId: String) {
+ sessions.remove(serverId)?.second?.let { runCatching { it.close() } }
+ }
+
+ /** Cancels whatever every session has in flight, for a stopped agent run. */
+ fun cancelAll() {
+ sessions.values.forEach { (_, session) -> runCatching { session.cancel() } }
+ }
+
+ /** Ends every session, for the plugin shutting down. */
+ fun closeAll() {
+ sessions.values.forEach { (_, session) -> runCatching { session.close() } }
+ sessions.clear()
+ }
+
+ /**
+ * Decrypts one server's credentials, for a request about to go out.
+ *
+ * Keystore work on every call rather than once per session; against a network round trip it
+ * does not register, and it is what keeps the plaintext from outliving the request.
+ *
+ * @param serverId the server being called.
+ * @return its token and headers; either may be empty.
+ */
+ private fun credentialsFor(serverId: String): McpCredentials =
+ McpCredentials(McpServerStore.token(serverId), McpServerStore.headers(serverId))
+
+ /**
+ * What [server]'s session was built from, credentials included as digests.
+ *
+ * Kept to its own function so the decrypted values are unreachable the moment it returns; the
+ * session that follows re-reads them per request rather than holding these.
+ *
+ * @param server the server to key.
+ * @return the key to compare against the cached one.
+ */
+ private fun keyFor(server: McpServer): Key {
+ // Headers join the key for the same reason the token does: edit one and the old session
+ // would keep sending the old value until the IDE restarts.
+ val credentials = credentialsFor(server.id)
+ return Key(
+ serverId = server.id,
+ url = server.url,
+ tokenFingerprint = fingerprint(credentials.token),
+ headerFingerprint = fingerprint(
+ credentials.headers.entries.joinToString("\n") { "${it.key}: ${it.value}" }
+ ),
+ )
+ }
+
+ /**
+ * A digest of a credential, for comparing one against another without keeping it here.
+ * @param value the credential, or empty when there is none.
+ * @return its hex SHA-256.
+ */
+ private fun fingerprint(value: String): String {
+ val bytes = value.toByteArray(Charsets.UTF_8)
+ return try {
+ MessageDigest.getInstance("SHA-256")
+ .digest(bytes)
+ .joinToString("") { byte -> "%02x".format(byte) }
+ } finally {
+ // The digest is kept, the credential it was taken from is not.
+ bytes.fill(0)
+ }
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpCredentials.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpCredentials.kt
new file mode 100644
index 00000000..931df238
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpCredentials.kt
@@ -0,0 +1,18 @@
+package com.itsaky.androidide.plugins.aiagentmcp.client
+
+/**
+ * What one request to an MCP server authenticates with.
+ *
+ * Supplied per request rather than held in a field, because a session outlives the call that made
+ * it: [McpConnections] keeps one per configured server for the life of the process, so a token
+ * stored on the session is a plaintext credential readable in a heap dump until the IDE exits.
+ * Nothing about a session needs it between calls, so nothing keeps it between calls.
+ *
+ * The last hop is still a `String` — [java.net.HttpURLConnection.setRequestProperty] takes one and
+ * retains it for the connection — so this bounds how long the plaintext lives rather than
+ * eliminating it. That is the part that was actually costing something.
+ *
+ * @property token bearer token, or blank for a server that needs none.
+ * @property headers the user's own headers, sent on every request to this server.
+ */
+class McpCredentials(val token: String, val headers: Map)
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpSession.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpSession.kt
new file mode 100644
index 00000000..2a556731
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpSession.kt
@@ -0,0 +1,337 @@
+package com.itsaky.androidide.plugins.aiagentmcp.client
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX
+import com.itsaky.androidide.plugins.aiagentmcp.transport.JsonRpc
+import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHttpClient
+import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHttpException
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.util.concurrent.atomic.AtomicLong
+import org.json.JSONArray
+import org.json.JSONObject
+
+private const val TAG = "$LOG_PREFIX.McpSession"
+
+/**
+ * One client connection to one MCP server: initialize, list tools, call a tool.
+ *
+ * Every call is blocking and belongs off the main thread; the callers are a background executor
+ * (tool invocation) and the settings pane's IO dispatcher (Test Connection).
+ *
+ * @param endpoint the server's MCP URL.
+ * @param credentials read once per request; see [McpCredentials] for why they are not held here.
+ * @param http the transport; injectable so the protocol can be exercised without a socket.
+ */
+class McpSession(
+ private val endpoint: String,
+ private val credentials: () -> McpCredentials,
+ private val http: McpHttpClient = McpHttpClient(),
+) {
+
+ companion object {
+ /** The revision this client implements and asks for; a server may negotiate one down. */
+ const val PREFERRED_PROTOCOL_VERSION = "2025-06-18"
+
+ /**
+ * From this revision on the transport is stateless: the server assigns no session and
+ * expects no lifecycle notification, so sending one earns a 4xx rather than a 202.
+ */
+ const val STATELESS_FROM_VERSION = "2026-07-28"
+
+ /**
+ * MCP revisions are ISO dates, which compare correctly as strings. Anything else — a
+ * semantic version, a server's own label — does not, so it is not compared at all.
+ */
+ private val DATED_VERSION = Regex("""\d{4}-\d{2}-\d{2}""")
+
+ private const val CLIENT_NAME = "CodeOnTheGo"
+ private const val CLIENT_VERSION = "1.0.0"
+
+ /** `tools/list` pages followed before giving up, so a huge server cannot loop forever. */
+ private const val MAX_TOOL_PAGES = 10
+
+ /** Tools kept from one server; the agent's prompt budget caps far lower than this. */
+ private const val MAX_TOOLS = 200
+ }
+
+ private val nextId = AtomicLong(1)
+
+ @Volatile private var sessionId: String? = null
+
+ @Volatile private var negotiatedVersion: String? = null
+
+ @Volatile private var initialized = false
+
+ /** The connection a call is currently using, so [cancel] can drop it mid-flight. */
+ @Volatile private var liveConnection: HttpURLConnection? = null
+
+ /** What the server called itself in `initialize`, for the settings pane's verdict. */
+ @Volatile var serverName: String? = null
+ private set
+
+ /**
+ * Performs the MCP handshake, unless it has already been performed.
+ *
+ * @throws IOException when the server cannot be reached or refuses the handshake.
+ */
+ @Synchronized
+ fun initialize() {
+ if (initialized) return
+
+ val params = JSONObject().apply {
+ put("protocolVersion", PREFERRED_PROTOCOL_VERSION)
+ put("capabilities", JSONObject())
+ put(
+ "clientInfo",
+ JSONObject().apply {
+ put("name", CLIENT_NAME)
+ put("version", CLIENT_VERSION)
+ }
+ )
+ }
+
+ val result = call("initialize", params)
+ negotiatedVersion = result.optString("protocolVersion").takeIf { it.isNotBlank() }
+ ?: PREFERRED_PROTOCOL_VERSION
+ serverName = result.optJSONObject("serverInfo")?.optString("name")?.takeIf { it.isNotBlank() }
+ initialized = true
+ Log.i(TAG, "Initialized $endpoint (revision $negotiatedVersion, session=${sessionId != null})")
+
+ if (!isStateless()) notifyInitialized()
+ }
+
+ /**
+ * Every tool the server advertises, following `nextCursor` pagination.
+ *
+ * @return the tools, capped at [MAX_TOOLS].
+ * @throws IOException when the server cannot be reached or answers an error.
+ */
+ fun listTools(): List {
+ initialize()
+
+ val tools = mutableListOf()
+ var cursor: String? = null
+ var page = 0
+ do {
+ val params = cursor?.let { JSONObject().put("cursor", it) }
+ val result = call("tools/list", params)
+ result.optJSONArray("tools")?.let { tools += toolsFrom(it) }
+ cursor = result.optString("nextCursor").takeIf { it.isNotBlank() }
+ page++
+ } while (cursor != null && page < MAX_TOOL_PAGES && tools.size < MAX_TOOLS)
+
+ if (cursor != null) {
+ Log.w(TAG, "Stopped listing tools after $page page(s); the server has more")
+ }
+ return tools.take(MAX_TOOLS)
+ }
+
+ /**
+ * Runs one tool.
+ *
+ * A tool that fails is not an exception: MCP reports it as a normal reply with `isError`, and
+ * the agent shows the model that text so it can try something else.
+ *
+ * @param name the tool's own name, as the server listed it.
+ * @param arguments the call arguments.
+ * @return the outcome, successful or not.
+ * @throws IOException when the server cannot be reached.
+ */
+ fun callTool(name: String, arguments: Map): McpCallResult {
+ initialize()
+
+ val params = JSONObject().apply {
+ put("name", name)
+ put("arguments", JSONObject(arguments.filterValues { it != null }))
+ }
+
+ val result = try {
+ call("tools/call", params)
+ } catch (e: McpProtocolException) {
+ // A protocol-level error for a single call is the tool failing, not the session dying.
+ return McpCallResult(false, "", e.message ?: "The server rejected the call.")
+ }
+
+ val text = flattenContent(result.optJSONArray("content"))
+ return if (result.optBoolean("isError", false)) {
+ McpCallResult(false, text, text.takeIf { it.isNotBlank() } ?: "The tool reported an error.")
+ } else {
+ McpCallResult(true, text)
+ }
+ }
+
+ /** Drops the connection a call is blocked on, so a stopped agent run does not wait it out. */
+ fun cancel() {
+ runCatching { liveConnection?.disconnect() }
+ }
+
+ /** Ends the server-side session, if there is one, and forgets the handshake. */
+ @Synchronized
+ fun close() {
+ sessionId?.let { id ->
+ runCatching {
+ val current = credentials()
+ http.deleteSession(endpoint, current.token, id, current.headers)
+ }
+ }
+ sessionId = null
+ initialized = false
+ negotiatedVersion = null
+ }
+
+ /**
+ * Whether the negotiated revision keeps no server-side session.
+ *
+ * Two signals rather than one: the revision string says what the server implements, and an
+ * absent session header says what it actually did. Either is enough to skip the lifecycle
+ * notification, which a stateless server answers with a 4xx.
+ *
+ * A server that reported an unorderable revision is judged on the session header alone, so a
+ * semantic version cannot be mistaken for a date old enough to look stateful.
+ */
+ private fun isStateless(): Boolean {
+ if (sessionId == null) return true
+ val version = negotiatedVersion ?: return false
+ return DATED_VERSION.matches(version) && version >= STATELESS_FROM_VERSION
+ }
+
+ /** Tells a stateful server the handshake is complete; failure here is not fatal. */
+ private fun notifyInitialized() {
+ try {
+ send(JsonRpc.notification("notifications/initialized"))
+ } catch (e: IOException) {
+ Log.d(TAG, "Server did not accept notifications/initialized: ${e.message}")
+ }
+ }
+
+ /**
+ * Sends one request and unwraps its reply, re-initializing once if the session expired.
+ *
+ * @param method the MCP method.
+ * @param params its parameters, or null.
+ * @return the `result` object.
+ * @throws McpProtocolException when the server answers a JSON-RPC error.
+ * @throws IOException on transport failure or a reply that is not one.
+ */
+ private fun call(method: String, params: JSONObject? = null): JSONObject {
+ val envelope = JsonRpc.request(nextId.getAndIncrement().toString(), method, params)
+
+ val response = try {
+ send(envelope)
+ } catch (e: McpHttpException) {
+ // 404 on an established session means the server dropped it; one clean retry.
+ if (e.statusCode == HttpURLConnection.HTTP_NOT_FOUND && sessionId != null) {
+ Log.i(TAG, "Session expired; re-initializing before retrying $method")
+ sessionId = null
+ initialized = false
+ initialize()
+ send(envelope)
+ } else {
+ throw e
+ }
+ }
+
+ val document = response.document
+ ?: throw IOException("The server accepted '$method' but sent no reply.")
+
+ val reply = try {
+ JsonRpc.parseReply(document)
+ } catch (e: Exception) {
+ throw IOException("The server's reply to '$method' was not JSON-RPC: ${e.message}")
+ } ?: throw IOException("The server's reply to '$method' carried no result.")
+
+ if (reply.isError) {
+ throw McpProtocolException(reply.errorCode ?: 0, reply.errorMessage.orEmpty())
+ }
+ return reply.result ?: JSONObject()
+ }
+
+ /** POSTs one envelope, tracking the connection so [cancel] can reach it. */
+ private fun send(envelope: JSONObject): McpHttpClient.Response {
+ // Read here rather than held on the session, so the plaintext lives for one request.
+ val current = credentials()
+ // Cleared in a finally: a call that threw would otherwise leave [cancel] holding a
+ // connection that is already closed, and the next cancel would reach nothing live.
+ val response = try {
+ http.post(
+ url = endpoint,
+ token = current.token,
+ body = envelope,
+ sessionId = sessionId,
+ protocolVersion = negotiatedVersion,
+ extraHeaders = current.headers,
+ onConnected = { liveConnection = it },
+ )
+ } finally {
+ liveConnection = null
+ }
+ response.sessionId?.let { sessionId = it }
+ return response
+ }
+
+ /**
+ * Reads the tools out of one `tools/list` page.
+ * @param array the `tools` array.
+ * @return the tools it described, skipping any entry with no name.
+ */
+ private fun toolsFrom(array: JSONArray): List =
+ (0 until array.length()).mapNotNull { index ->
+ val entry = array.optJSONObject(index) ?: return@mapNotNull null
+ val name = entry.optString("name").trim()
+ if (name.isEmpty()) return@mapNotNull null
+ McpTool(
+ name = name,
+ description = entry.optString("description"),
+ inputSchema = entry.optJSONObject("inputSchema")?.let(::toMap).orEmpty(),
+ )
+ }
+
+ /**
+ * Flattens a `content` array into the text the model reads.
+ * @param content the array, or null when the server sent none.
+ * @return the text, with non-text parts named rather than dropped silently.
+ */
+ private fun flattenContent(content: JSONArray?): String {
+ if (content == null) return ""
+ return (0 until content.length()).mapNotNull { index ->
+ val part = content.optJSONObject(index) ?: return@mapNotNull null
+ when (val type = part.optString("type")) {
+ "text" -> part.optString("text")
+ "" -> null
+ else -> "[$type content omitted]"
+ }
+ }.filter { it.isNotBlank() }.joinToString("\n")
+ }
+
+ /** Converts a JSON object to plain JDK types, which is all that may cross to another plugin. */
+ private fun toMap(json: JSONObject): Map =
+ json.keys().asSequence().mapNotNull { key ->
+ when (val value = json.get(key)) {
+ is JSONObject -> key to toMap(value)
+ is JSONArray -> key to toList(value)
+ JSONObject.NULL -> null
+ else -> key to value
+ }
+ }.toMap()
+
+ private fun toList(array: JSONArray): List =
+ (0 until array.length()).mapNotNull { index ->
+ when (val value = array.get(index)) {
+ is JSONObject -> toMap(value)
+ is JSONArray -> toList(value)
+ JSONObject.NULL -> null
+ else -> value
+ }
+ }
+}
+
+/**
+ * A JSON-RPC error answer, as opposed to a transport failure.
+ *
+ * @param code the JSON-RPC error code.
+ * @param detail the server's message.
+ */
+class McpProtocolException(val code: Int, detail: String) : IOException(
+ detail.ifBlank { "the server reported error $code" }
+)
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpTool.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpTool.kt
new file mode 100644
index 00000000..9b6b5a62
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpTool.kt
@@ -0,0 +1,27 @@
+package com.itsaky.androidide.plugins.aiagentmcp.client
+
+/**
+ * One tool advertised by an MCP server.
+ *
+ * @property name the tool's own name, unique within its server.
+ * @property description what it does, as the server describes it — untrusted remote text.
+ * @property inputSchema JSON Schema for the arguments, empty when the server sends none.
+ */
+data class McpTool(
+ val name: String,
+ val description: String,
+ val inputSchema: Map = emptyMap(),
+)
+
+/**
+ * The outcome of one `tools/call`.
+ *
+ * @property success whether the server reported the call as succeeding.
+ * @property text the call's content, flattened to text for the model.
+ * @property errorMessage one user-facing sentence when it failed, else null.
+ */
+data class McpCallResult(
+ val success: Boolean,
+ val text: String,
+ val errorMessage: String? = null,
+)
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatter.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatter.kt
new file mode 100644
index 00000000..7451c37e
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatter.kt
@@ -0,0 +1,138 @@
+package com.itsaky.androidide.plugins.aiagentmcp.errors
+
+import android.content.Context
+import com.itsaky.androidide.plugins.aiagentmcp.R
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpProtocolException
+import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHttpException
+import java.io.InterruptedIOException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import javax.net.ssl.SSLException
+
+/**
+ * An MCP failure reduced to the thing the user needs to be told.
+ *
+ * Carries no text: the wording lives in `strings.xml`, which also lets every branch be unit-tested
+ * without a Context.
+ */
+sealed interface McpFailure {
+
+ /** The server answered a status with no more specific handling. */
+ data class Http(val status: Int) : McpFailure
+
+ /** The request was malformed as far as the server is concerned (HTTP 400). */
+ data object BadRequest : McpFailure
+
+ /** The token was refused or missing (HTTP 401). */
+ data object TokenRefused : McpFailure
+
+ /** The token is valid but not allowed here (HTTP 403). */
+ data object Forbidden : McpFailure
+
+ /** Nothing MCP answers at that URL (HTTP 404). */
+ data object NoEndpoint : McpFailure
+
+ /** The server does not accept this request shape (HTTP 405), i.e. not Streamable HTTP. */
+ data object WrongTransport : McpFailure
+
+ /** No response format both sides accept (HTTP 406). */
+ data object NoCommonFormat : McpFailure
+
+ /** Rate limited (HTTP 429). */
+ data object RateLimited : McpFailure
+
+ /** The server failed on its own side (HTTP 5xx). */
+ data class ServerError(val status: Int) : McpFailure
+
+ /** The server answered a JSON-RPC error, i.e. it understood and refused. */
+ data class Rejected(val detail: String) : McpFailure
+
+ /** The host does not resolve. */
+ data object UnknownHost : McpFailure
+
+ /** TLS failed — wrong scheme, or a certificate this device will not accept. */
+ data object TlsFailed : McpFailure
+
+ /** The server took longer than the read timeout. */
+ data object TimedOut : McpFailure
+
+ /** The connection was dropped deliberately, to stop an agent run. */
+ data object Cancelled : McpFailure
+
+ /** Everything else, including failures that never reached the network. */
+ data class Failed(val reason: String?) : McpFailure
+}
+
+/**
+ * Classifies an MCP failure so it can be reported as one translated sentence.
+ *
+ * The server's raw body never reaches the transcript: it can be a stack trace, an HTML error page
+ * or a token echoed back, and all three are worse than useless on a phone screen. The body stays in
+ * logcat, where it belongs.
+ */
+object McpErrorFormatter {
+
+ /**
+ * Reduces [error] to what the user needs to know.
+ * @param error the failure, from any layer.
+ * @return the classification.
+ */
+ fun classify(error: Throwable): McpFailure = when (error) {
+ is McpHttpException -> forStatus(error.statusCode)
+ is McpProtocolException -> McpFailure.Rejected(error.message.orEmpty())
+ is UnknownHostException -> McpFailure.UnknownHost
+ is SSLException -> McpFailure.TlsFailed
+ is SocketTimeoutException -> McpFailure.TimedOut
+ // Thrown when the connection is dropped to cancel a run, which is not a fault to report.
+ is InterruptedIOException -> McpFailure.Cancelled
+ else -> McpFailure.Failed(error.message)
+ }
+
+ /**
+ * The sentence to show for [error].
+ * @param context this plugin's context, for its own `strings.xml`; null falls back to the
+ * untranslated shape, which beats showing the user nothing.
+ * @param serverName the server's label, so a user with several knows which one failed.
+ * @param error the failure.
+ * @return one sentence.
+ */
+ fun format(context: Context?, serverName: String, error: Throwable): String {
+ val failure = classify(error)
+ if (context == null) return "$serverName: ${error.message ?: error.javaClass.simpleName}"
+
+ return when (failure) {
+ McpFailure.BadRequest -> context.getString(R.string.mcp_error_bad_request, serverName)
+ McpFailure.TokenRefused -> context.getString(R.string.mcp_error_token_refused, serverName)
+ McpFailure.Forbidden -> context.getString(R.string.mcp_error_forbidden, serverName)
+ McpFailure.NoEndpoint -> context.getString(R.string.mcp_error_no_endpoint, serverName)
+ McpFailure.WrongTransport -> context.getString(R.string.mcp_error_wrong_transport, serverName)
+ McpFailure.NoCommonFormat -> context.getString(R.string.mcp_error_no_common_format, serverName)
+ McpFailure.RateLimited -> context.getString(R.string.mcp_error_rate_limited, serverName)
+ McpFailure.UnknownHost -> context.getString(R.string.mcp_error_unknown_host, serverName)
+ McpFailure.TlsFailed -> context.getString(R.string.mcp_error_tls, serverName)
+ McpFailure.TimedOut -> context.getString(R.string.mcp_error_timeout, serverName)
+ McpFailure.Cancelled -> context.getString(R.string.mcp_error_cancelled, serverName)
+ is McpFailure.ServerError ->
+ context.getString(R.string.mcp_error_server_error, serverName, failure.status)
+ is McpFailure.Http -> context.getString(R.string.mcp_error_http, serverName, failure.status)
+ is McpFailure.Rejected ->
+ context.getString(R.string.mcp_error_rejected, serverName, failure.detail)
+ is McpFailure.Failed -> failure.reason
+ ?.let { context.getString(R.string.mcp_error_failed_reason, serverName, it) }
+ ?: context.getString(R.string.mcp_error_failed, serverName)
+ }
+ }
+
+ private fun forStatus(status: Int): McpFailure = when (status) {
+ 400 -> McpFailure.BadRequest
+ 401 -> McpFailure.TokenRefused
+ 403 -> McpFailure.Forbidden
+ 404 -> McpFailure.NoEndpoint
+ 405 -> McpFailure.WrongTransport
+ 406 -> McpFailure.NoCommonFormat
+ 408, 504 -> McpFailure.TimedOut
+ 429 -> McpFailure.RateLimited
+ in 500..599 -> McpFailure.ServerError(status)
+ else -> McpFailure.Http(status)
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/logging/LogTags.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/logging/LogTags.kt
new file mode 100644
index 00000000..0e91f948
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/logging/LogTags.kt
@@ -0,0 +1,8 @@
+package com.itsaky.androidide.plugins.aiagentmcp.logging
+
+/**
+ * Prefix on every logcat tag this plugin writes, so a line names the plugin that emitted it — every
+ * AI feature shares the host IDE's process, where a bare `McpSession` tag names no `.cgp`.
+ * Tags read `"$LOG_PREFIX.ClassName"`, so `adb logcat -s AiAgentMcp.*` is this plugin's log.
+ */
+internal const val LOG_PREFIX = "AiAgentMcp"
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt
new file mode 100644
index 00000000..3e37f4a5
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt
@@ -0,0 +1,467 @@
+package com.itsaky.androidide.plugins.aiagentmcp.plugin
+
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.PluginLifecycleListener
+import com.itsaky.androidide.plugins.aiagentmcp.R
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpConnections
+import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServerStore
+import com.itsaky.androidide.plugins.aiagentmcp.settings.McpSettingsFragment
+import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolCatalog
+import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolSource
+import com.itsaky.androidide.plugins.extensions.DocumentationExtension
+import com.itsaky.androidide.plugins.extensions.PluginSettingsEntry
+import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
+import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
+import com.itsaky.androidide.plugins.extensions.SettingsExtension
+import com.itsaky.androidide.plugins.services.SharedServices
+import com.itsaky.androidide.plugins.services.ToolSourceRegistry
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+/**
+ * Connects the Agent to Model Context Protocol servers.
+ *
+ * Deliberately a plugin of its own rather than part of AI Core: everything here is network work,
+ * so it declares `network.access` and nothing else, while AI Core declares the filesystem, shell
+ * and project permissions its own tools need and no network at all.
+ */
+class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension {
+
+ private lateinit var context: PluginContext
+ private var toolSource: McpToolSource? = null
+
+ /** True once [toolSource] is registered with AI Core, so re-registration is idempotent. */
+ @Volatile private var registered = false
+
+ /** Background work: listing tools is network work and never belongs on the main thread. */
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ companion object {
+ /** Must match `plugin.id` in AndroidManifest.xml; also this source's provider id. */
+ const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentmcp"
+
+ /** Provider of [ToolSourceRegistry]; this plugin contributes nothing without it. */
+ private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore"
+
+ /**
+ * Category the host registers this plugin's tooltips under. Must be `"plugin_"` + the full
+ * plugin id, or a long-press renders the literal string `n/a`.
+ */
+ const val TOOLTIP_CATEGORY = "plugin_$PLUGIN_ID"
+
+ const val TOOLTIP_TAG_PLUGIN = "plugin_ai_agent_mcp"
+
+ // Tags for the controls on the MCP settings screen (see McpSettingsFragment).
+ const val TOOLTIP_TAG_ADD_SERVER = "mcp_add_server"
+ const val TOOLTIP_TAG_SERVER_ROW = "mcp_server_row"
+ const val TOOLTIP_TAG_SERVER_ENABLED = "mcp_server_enabled"
+ const val TOOLTIP_TAG_TEST_CONNECTION = "mcp_test_connection"
+ const val TOOLTIP_TAG_REFRESH_TOOLS = "mcp_refresh_tools"
+ const val TOOLTIP_TAG_TOOL_TOGGLE = "mcp_tool_toggle"
+ const val TOOLTIP_TAG_SERVER_NAME = "mcp_server_name"
+ const val TOOLTIP_TAG_SERVER_URL = "mcp_server_url"
+ const val TOOLTIP_TAG_SERVER_TOKEN = "mcp_server_token"
+ const val TOOLTIP_TAG_ADD_HEADER = "mcp_add_header"
+ const val TOOLTIP_TAG_HEADER_NAME = "mcp_header_name"
+ const val TOOLTIP_TAG_HEADER_VALUE = "mcp_header_value"
+ const val TOOLTIP_TAG_HEADER_REMOVE = "mcp_header_remove"
+ const val TOOLTIP_TAG_BACK = "mcp_back"
+
+ @Volatile
+ private var pluginContext: PluginContext? = null
+
+ /** This plugin's context, for the settings pane the host constructs by name. */
+ fun getContext(): PluginContext? = pluginContext
+ }
+
+ /**
+ * Re-registers when AI Core activates. Plugins load in parallel with no ordering, so
+ * [activate] may run before AI Core has published its registry.
+ */
+ private val aiCoreLifecycle = object : PluginLifecycleListener {
+ override fun onPluginActivated(pluginId: String) {
+ if (pluginId == AI_CORE_PLUGIN_ID) registerToolSource()
+ }
+
+ override fun onPluginDeactivated(pluginId: String) {
+ // The registry went away and took the registration with it; allow a fresh one.
+ if (pluginId == AI_CORE_PLUGIN_ID) registered = false
+ }
+
+ override fun onPluginUninstalled(pluginId: String) {
+ if (pluginId == AI_CORE_PLUGIN_ID) registered = false
+ }
+ }
+
+ /** Tells the agent to re-read the tool list whenever a server or a toggle changes. */
+ private val settingsChanged: () -> Unit = {
+ resolveToolSourceRegistry()?.notifyToolsChanged(PLUGIN_ID)
+ }
+
+ override fun initialize(context: PluginContext): Boolean {
+ this.context = context
+ pluginContext = context
+ context.logger.info("McpPlugin: initialized")
+ return true
+ }
+
+ override fun activate(): Boolean = try {
+ toolSource = McpToolSource()
+ McpServerStore.addChangeListener(settingsChanged)
+ context.addPluginLifecycleListener(aiCoreLifecycle)
+
+ if (!registerToolSource()) {
+ context.logger.info("McpPlugin: AI Core is not active yet; will register when it is")
+ }
+
+ // Tool lists are answered from cache, so the cache has to be filled before the user opens
+ // the Agent — otherwise the first cold-start session sees no MCP tools at all.
+ scope.launch {
+ val refreshed = McpToolCatalog.refreshAll()
+ if (refreshed > 0) settingsChanged()
+ }
+ true
+ } catch (e: Exception) {
+ context.logger.error("McpPlugin: activation failed", e)
+ false
+ }
+
+ override fun deactivate(): Boolean = try {
+ context.removePluginLifecycleListener(aiCoreLifecycle)
+ McpServerStore.removeChangeListener(settingsChanged)
+ unregisterToolSource()
+ releaseConnections()
+ true
+ } catch (e: Exception) {
+ context.logger.error("McpPlugin: deactivation failed", e)
+ false
+ }
+
+ override fun dispose() {
+ runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) }
+ McpServerStore.removeChangeListener(settingsChanged)
+ unregisterToolSource()
+ releaseConnections()
+ scope.cancel()
+ pluginContext = null
+ context.logger.info("McpPlugin: disposed")
+ }
+
+ /**
+ * Registers this plugin's tools with AI Core, if the registry is reachable.
+ *
+ * Guarded against [Throwable] rather than [Exception]: on an IDE older than the release that
+ * ships the contract the class is simply absent, and a [NoClassDefFoundError] here would fail
+ * the whole plugin rather than the one thing it cannot do.
+ *
+ * @return true when registered (now or already), false when AI Core is absent.
+ */
+ private fun registerToolSource(): Boolean {
+ if (registered) return true
+ val source = toolSource ?: return false
+
+ return try {
+ val registry = resolveToolSourceRegistry() ?: return false
+ registry.registerToolSource(source)
+ registered = true
+ context.logger.info("McpPlugin: registered the MCP tool source with AI Core")
+ true
+ } catch (e: Throwable) {
+ context.logger.error("McpPlugin: could not register the MCP tool source", e)
+ false
+ }
+ }
+
+ /** Withdraws the tools, so a disabled plugin stops appearing in the agent's tool list. */
+ private fun unregisterToolSource() {
+ if (!registered) return
+ val source = toolSource
+ try {
+ if (source != null) {
+ resolveToolSourceRegistry()?.unregisterToolSource(source)
+ context.logger.info("McpPlugin: unregistered the MCP tool source")
+ }
+ } catch (e: Throwable) {
+ context.logger.warn("McpPlugin: could not unregister the MCP tool source", e)
+ }
+ registered = false
+ }
+
+ /** Closes every session and forgets the cached tool lists. */
+ private fun releaseConnections() {
+ toolSource?.close()
+ toolSource = null
+ McpConnections.closeAll()
+ McpToolCatalog.clear()
+ }
+
+ /**
+ * Resolves AI Core's tool registry, preferring the process-global registry and falling back to
+ * the provider-scoped lookup so a registry cleared by another plugin is not fatal.
+ * @return the registry, or null when AI Core is absent or too old to carry the contract.
+ */
+ private fun resolveToolSourceRegistry(): ToolSourceRegistry? = try {
+ SharedServices.get(ToolSourceRegistry::class.java)
+ ?: context.getPluginService(AI_CORE_PLUGIN_ID, ToolSourceRegistry::class.java)
+ } catch (e: Throwable) {
+ context.logger.warn("McpPlugin: could not resolve the tool registry: ${e.message}")
+ null
+ }
+
+ // --- SettingsExtension: the MCP servers row in Preferences -> Configuration ---
+
+ override fun getSettingsEntries(): List = listOf(
+ PluginSettingsEntry(
+ id = "mcp_servers",
+ title = string(R.string.pref_mcp_title, "MCP servers"),
+ summary = string(R.string.pref_mcp_summary, "Remote tools for the Agent"),
+ fragmentClassName = McpSettingsFragment::class.java.name
+ )
+ )
+
+ /**
+ * Resolves [resId] against this plugin's own resources.
+ * @param resId the string resource.
+ * @param fallback returned when the context is missing or the lookup fails; the host may build
+ * Preferences either side of a lifecycle edge and must never see an exception from here.
+ * @return the resolved string.
+ */
+ private fun string(resId: Int, fallback: String): String = try {
+ pluginContext?.androidContext?.getString(resId) ?: fallback
+ } catch (e: Exception) {
+ fallback
+ }
+
+ // --- DocumentationExtension: three-tier in-IDE help ---
+
+ override fun getTooltipCategory(): String = TOOLTIP_CATEGORY
+
+ override fun getTooltipEntries(): List = listOf(
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_PLUGIN,
+ summary = "Lets the Agent use tools from remote MCP servers you configure. Needs a network connection.",
+ detail = """
+ AI Agent MCP connects the Agent to
+ Model Context Protocol servers — issue trackers,
+ documentation indexes, internal APIs — so their tools appear
+ alongside the Agent's own.
+ Install AI Core as well, then add a server under
+ Preferences → Configuration → MCP servers . Tools
+ are off until you switch them on, and every remote tool asks for
+ your approval before it runs.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_ADD_SERVER,
+ summary = "Add an MCP server by URL, with an optional access token.",
+ detail = """
+ Asks for a name, the server's MCP endpoint URL and, when the
+ server needs one, a token.
+ The name is only a label, but it also prefixes that server's
+ tool names so the Agent can tell two servers apart. The token is
+ encrypted with the Android Keystore; only the ciphertext is
+ written to disk.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SERVER_ROW,
+ summary = "Tap to edit this server, its token and which of its tools the Agent may use.",
+ detail = """
+ Opens the server's details: name, URL, token, and the list of
+ tools it advertises with a switch for each.
+ The tool list comes from the last successful refresh. If it is
+ empty, use Refresh tools — the server may have been
+ unreachable when the IDE started.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SERVER_ENABLED,
+ summary = "Switch the whole server off without deleting it or losing your tool choices.",
+ detail = """
+ A disabled server contributes no tools to the Agent and is not
+ contacted. Its URL, token and per-tool switches are kept, so
+ turning it back on restores exactly what you had.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SERVER_NAME,
+ summary = "Your label for this server. It also prefixes the tool names the Agent sees.",
+ detail = """
+ Only a label, so name it whatever tells you which server this
+ is — Company GitHub , Staging , Docs .
+ It is also how the Agent tells two servers' tools apart: the
+ first few characters, lowercased and reduced to letters, digits
+ and _, are prefixed to every tool this server
+ offers. Renaming the server therefore renames its tools, so a
+ chat already open needs Refresh tools to see them.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SERVER_URL,
+ summary = "The server's MCP endpoint, usually an https:// URL ending in /mcp.",
+ detail = """
+ This plugin speaks MCP's Streamable HTTP transport: one
+ POST per call, answered with JSON or with a stream of it.
+ A server that offers only the older stdio transport cannot be
+ used from a phone at all, and one that offers the deprecated
+ HTTP+SSE transport is not supported here.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SERVER_TOKEN,
+ summary = "Optional bearer token, encrypted on this device and sent only to this server.",
+ detail = """
+ Sent as an Authorization: Bearer header, never in
+ the URL — query strings leak into logs and proxies.
+ It is encrypted with a key held in the Android Keystore, so a
+ copy of the settings file is useless on another device. Leave it
+ empty for a server that needs no token.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_ADD_HEADER,
+ summary = "Send an extra header with every request, for a server that needs more than a token.",
+ detail = """
+ Some servers route on something the URL and the token do
+ not carry — an API key, an environment, a client id. Add the
+ header's name and value here and every request to this server
+ carries it.
+ Values are encrypted with the token, since a header is as
+ often a credential. Names this plugin sets itself
+ (Accept, Content-Type and the two
+ Mcp- headers) are refused — overriding them would
+ break the protocol.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_HEADER_NAME,
+ summary = "The header's name, such as X-Api-Key. Letters, digits and - _ . only.",
+ detail = """
+ Must be a real header name: letters, digits and the
+ punctuation HTTP allows in one. A space or a colon is refused as
+ you save, with the reason shown under the row.
+ The four names this plugin sets itself — Accept,
+ Content-Type and the two Mcp- headers —
+ are refused too, because overriding them would break the protocol
+ and the failure would look like a broken server.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_HEADER_VALUE,
+ summary = "What to send for this header. Encrypted on this device, like the token.",
+ detail = """
+ Sent verbatim with every request to this server. An empty
+ value is allowed, since an empty header is occasionally
+ meaningful, but a line break is not — one would forge a second
+ header at the socket.
+ Stored encrypted with the token rather than beside the URL: a
+ header is as often a credential as the token is, and nothing here
+ can tell which is which.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_HEADER_REMOVE,
+ summary = "Drop this header. It is forgotten once you save.",
+ detail = """
+ Takes the row off the screen; the stored header goes when you
+ save, so leaving by Cancel keeps it.
+ A row left completely blank needs no removing — it is ignored
+ on save rather than stored empty.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_BACK,
+ summary = "Return to Preferences. Your servers and switches are already saved.",
+ detail = """
+ Nothing on this screen is held until you leave it: adding a
+ server, switching one on and switching a tool on each save as
+ you do them.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_TEST_CONNECTION,
+ summary = "Check the URL and token now, rather than finding out mid-conversation.",
+ detail = """
+ Performs the MCP handshake and asks for the tool list, then
+ reports what happened in one sentence.
+ A server that completes the handshake but offers no tool
+ catalogue is still reported as reachable — some servers expose
+ only prompts or resources, which this plugin does not use.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_REFRESH_TOOLS,
+ summary = "Ask the server what tools it offers now and update the list below.",
+ detail = """
+ Re-reads the server's tool catalogue. Tools that disappeared are
+ removed along with their switches; new ones arrive switched
+ off .
+ The Agent reads this list from memory, so a refresh is also what
+ makes a newly added tool visible to a chat that is already open.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_TOOL_TOGGLE,
+ summary = "Let the Agent use this one tool. Off by default — pick only what you need.",
+ detail = """
+ Every switched-on tool costs prompt space on every message, and
+ a large server can advertise ninety of them, so they start off and
+ the Agent only ever sees the ones you chose.
+ Switching one on does not let it run unattended: a remote tool
+ asks for approval on every call, showing the arguments it would
+ send.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Agent MCP guide", uri = "index.html", order = 0)
+ )
+ ),
+ )
+
+ override fun getTier3DocsAssetPath(): String = "docs"
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/SecureTokenStore.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/SecureTokenStore.kt
new file mode 100644
index 00000000..bd0a7ab5
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/SecureTokenStore.kt
@@ -0,0 +1,175 @@
+package com.itsaky.androidide.plugins.aiagentmcp.security
+
+import android.content.SharedPreferences
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyPermanentlyInvalidatedException
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import android.util.Log
+import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX
+import java.security.GeneralSecurityException
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+private const val TAG = "$LOG_PREFIX.SecureTokenStore"
+
+/**
+ * AES/GCM encryption for the bearer tokens of configured MCP servers, keyed by a hardware-backed
+ * Android Keystore secret. Only ciphertext is written to SharedPreferences, so a copied prefs file
+ * (root, `adb backup`, forensic dump) is useless without this device's Keystore.
+ *
+ * The [ALIAS] must stay stable across releases — a token encrypted under one alias cannot be read
+ * under another — and is deliberately this plugin's own: every plugin runs in the host's process
+ * and UID and therefore shares one Keystore, so a shared alias would let this plugin's recovery
+ * path destroy a backend plugin's stored key as a side effect.
+ */
+object SecureTokenStore {
+
+ private const val KEYSTORE = "AndroidKeyStore"
+ private const val ALIAS = "cotg_ai_mcp_token_v1"
+ private const val TRANSFORM = "AES/GCM/NoPadding"
+ private const val IV_LEN = 12
+ private const val TAG_BITS = 128
+
+ /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */
+ const val ENC_PREFIX = "enc:v1:"
+
+ /**
+ * Encrypts [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext).
+ *
+ * The key is not auth-bound, so a credential change does not invalidate it; an alias an OEM
+ * Keystore drops anyway is regenerated once before retrying.
+ *
+ * @param plain the value to encrypt.
+ * @return the ciphertext to store.
+ * @throws GeneralSecurityException on any other Keystore or cipher failure, so the caller can
+ * tell the user instead of crashing the IDE on Save.
+ */
+ @Throws(GeneralSecurityException::class)
+ fun encrypt(plain: String): String = try {
+ encryptWith(getOrCreateKey(), plain)
+ } catch (e: KeyPermanentlyInvalidatedException) {
+ Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e)
+ deleteKey()
+ encryptWith(getOrCreateKey(), plain)
+ }
+
+ /**
+ * Reads a stored value back.
+ * @param stored the stored string, ciphertext or legacy plaintext.
+ * @return the plaintext, or null when a ciphertext value cannot be decrypted — the Keystore key
+ * was lost, and the user has to enter the token again.
+ */
+ fun decrypt(stored: String?): String? {
+ if (stored == null) return null
+ if (!stored.startsWith(ENC_PREFIX)) return stored
+ return try {
+ val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP)
+ val iv = combined.copyOfRange(0, IV_LEN)
+ val ciphertext = combined.copyOfRange(IV_LEN, combined.size)
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
+ // Zeroed once the String is built; see the note in encryptWith about the String.
+ val plainBytes = cipher.doFinal(ciphertext)
+ try {
+ String(plainBytes, Charsets.UTF_8)
+ } finally {
+ plainBytes.fill(0)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to decrypt a stored MCP token", e)
+ null
+ }
+ }
+
+ /**
+ * Stores [plain] under [key], encrypted; an empty value removes the entry instead.
+ *
+ * Keystore IPC plus AES/GCM, so call this off the main thread.
+ *
+ * @param prefs where to store it.
+ * @param key the preference key.
+ * @param plain the token, or empty to forget it.
+ * @return true when the value was stored (or removed), false when encryption failed.
+ */
+ fun write(prefs: SharedPreferences?, key: String, plain: String): Boolean {
+ val editor = prefs?.edit() ?: return false
+ if (plain.isBlank()) {
+ editor.remove(key).apply()
+ return true
+ }
+ return try {
+ editor.putString(key, encrypt(plain)).apply()
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Could not encrypt a token for '$key'", e)
+ false
+ }
+ }
+
+ /**
+ * Reads [key] from [prefs], upgrading a legacy plaintext value to ciphertext in place.
+ * @param prefs where the value lives.
+ * @param key the preference key.
+ * @return the trimmed plaintext, or null when nothing is stored or decryption failed.
+ */
+ fun readAndMigrate(prefs: SharedPreferences?, key: String): String? {
+ val stored = prefs?.getString(key, null) ?: return null
+ if (stored.startsWith(ENC_PREFIX)) return decrypt(stored)
+ val plain = stored.trim()
+ if (plain.isEmpty()) return plain
+ try {
+ prefs.edit().putString(key, encrypt(plain)).apply()
+ Log.i(TAG, "Upgraded a legacy plaintext token to ciphertext")
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not upgrade a legacy plaintext token to ciphertext", e)
+ }
+ return plain
+ }
+
+ private fun getOrCreateKey(): SecretKey {
+ val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
+ (store.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build()
+ )
+ return generator.generateKey()
+ }
+
+ private fun deleteKey() {
+ try {
+ KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e)
+ }
+ }
+
+ private fun encryptWith(key: SecretKey, plain: String): String {
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.ENCRYPT_MODE, key)
+ val iv = cipher.iv
+ // Zeroed straight after the cipher reads it. The String itself cannot be: every API this
+ // token passes through — SharedPreferences, JSONObject, setRequestProperty — takes one, so
+ // a CharArray here would only move the immutable copy one frame away.
+ val plainBytes = plain.toByteArray(Charsets.UTF_8)
+ val ciphertext = try {
+ cipher.doFinal(plainBytes)
+ } finally {
+ plainBytes.fill(0)
+ }
+ val combined = ByteArray(iv.size + ciphertext.size)
+ System.arraycopy(iv, 0, combined, 0, iv.size)
+ System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
+ return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP)
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServer.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServer.kt
new file mode 100644
index 00000000..be60ecf2
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServer.kt
@@ -0,0 +1,28 @@
+package com.itsaky.androidide.plugins.aiagentmcp.settings
+
+/**
+ * One configured MCP server.
+ *
+ * The token is deliberately not a field: it lives encrypted in the Keystore-backed store, keyed by
+ * [id], so it is never carried around in a value object that gets logged or put in a Bundle.
+ *
+ * @property id stable identity, kept across renames so toggles and the token survive an edit.
+ * @property name the user's label; also the prefix the server's tools are namespaced with.
+ * @property url the server's MCP endpoint.
+ * @property enabled whether the server contributes tools at all.
+ * @property knownTools the tool names last seen on this server, for the toggle list.
+ * @property enabledTools the tools the user switched on; empty is the default, so a new server
+ * contributes nothing until its tools are chosen deliberately.
+ */
+data class McpServer(
+ val id: String,
+ val name: String,
+ val url: String,
+ val enabled: Boolean = true,
+ val knownTools: List = emptyList(),
+ val enabledTools: Set = emptySet(),
+) {
+ /** The tools that should actually be offered to the agent right now. */
+ val activeTools: List
+ get() = if (!enabled) emptyList() else knownTools.filter { it in enabledTools }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt
new file mode 100644
index 00000000..f759bf99
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt
@@ -0,0 +1,295 @@
+package com.itsaky.androidide.plugins.aiagentmcp.settings
+
+import android.content.SharedPreferences
+import android.util.Log
+import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX
+import com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin
+import com.itsaky.androidide.plugins.aiagentmcp.security.SecureTokenStore
+import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHeaders
+import java.util.UUID
+import java.util.concurrent.CopyOnWriteArrayList
+import org.json.JSONArray
+import org.json.JSONObject
+
+private const val TAG = "$LOG_PREFIX.McpServerStore"
+
+/**
+ * The configured servers, their per-tool toggles and their tokens.
+ *
+ * One JSON blob under one preference key rather than a key per field: the list is small, always
+ * read whole, and a partial write is what leaves a server with a URL and no toggles.
+ */
+object McpServerStore {
+
+ /** Preferences file holding this plugin's settings. */
+ const val PREFERENCE_FILE = "McpSettings"
+
+ private const val KEY_SERVERS = "servers"
+ private const val KEY_TOKEN_PREFIX = "token_"
+ private const val KEY_HEADERS_PREFIX = "headers_"
+
+ private const val FIELD_ID = "id"
+ private const val FIELD_NAME = "name"
+ private const val FIELD_URL = "url"
+ private const val FIELD_ENABLED = "enabled"
+ private const val FIELD_KNOWN_TOOLS = "knownTools"
+ private const val FIELD_ENABLED_TOOLS = "enabledTools"
+
+ private val listeners = CopyOnWriteArrayList<() -> Unit>()
+
+ /** Registers [listener], called after any change to the servers or their toggles. */
+ fun addChangeListener(listener: () -> Unit) {
+ listeners.add(listener)
+ }
+
+ /** Removes a listener added by [addChangeListener]. */
+ fun removeChangeListener(listener: () -> Unit) {
+ listeners.remove(listener)
+ }
+
+ /**
+ * Every configured server, in the order they were added.
+ * @return the servers; empty before any is configured, or when the stored blob is unreadable.
+ */
+ fun servers(): List {
+ val raw = prefs()?.getString(KEY_SERVERS, null) ?: return emptyList()
+ return try {
+ val array = JSONArray(raw)
+ (0 until array.length()).mapNotNull { index ->
+ array.optJSONObject(index)?.let(::toServer)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Stored server list is unreadable; treating it as empty", e)
+ emptyList()
+ }
+ }
+
+ /**
+ * Stores the two fields the add/edit dialog owns, leaving every other field as stored.
+ *
+ * The dialog holds a snapshot taken when it opened, while the tool switches write straight
+ * through as they are tapped. Saving that snapshot whole is what silently reverted them, so
+ * the merge happens here rather than in any caller that might forget it.
+ *
+ * @param server the candidate, carrying the id plus the edited name and URL.
+ * @return the record as it now stands, for a caller holding a snapshot to refresh from.
+ */
+ fun saveDetails(server: McpServer): McpServer {
+ val merged = mergeDetails(servers().firstOrNull { it.id == server.id }, server)
+ upsert(merged)
+ return merged
+ }
+
+ /**
+ * Merges a dialog's edits onto the stored record.
+ *
+ * Pure and separate from the write, so the rule this screen kept getting wrong — everything but
+ * the name and the URL comes from the store, never from the caller — is testable without a
+ * device.
+ *
+ * @param stored the record as it stands, or null for a server being added.
+ * @param edited the candidate the dialog built from its snapshot.
+ * @return the record to write.
+ */
+ internal fun mergeDetails(stored: McpServer?, edited: McpServer): McpServer =
+ stored?.copy(name = edited.name, url = edited.url) ?: edited
+
+ /**
+ * Switches a whole server on or off, by id, so a stale snapshot cannot carry its toggles back.
+ * @param id the server.
+ * @param enabled whether it may contribute tools.
+ */
+ fun setEnabled(id: String, enabled: Boolean) {
+ val server = servers().firstOrNull { it.id == id } ?: return
+ upsert(server.copy(enabled = enabled))
+ }
+
+ /**
+ * The stored record for [id].
+ * @param id the server.
+ * @return the record, or null when it was never saved or has since been removed.
+ */
+ fun server(id: String): McpServer? = servers().firstOrNull { it.id == id }
+
+ /**
+ * Adds or replaces [server], matched by [McpServer.id].
+ *
+ * Private on purpose: every field of the record is written, so a caller passing anything but a
+ * record it just read back would revert whatever changed in between.
+ *
+ * @param server the server to store.
+ */
+ private fun upsert(server: McpServer) {
+ val current = servers().toMutableList()
+ val index = current.indexOfFirst { it.id == server.id }
+ if (index >= 0) current[index] = server else current += server
+ write(current)
+ }
+
+ /**
+ * Removes a server and forgets its token.
+ * @param id the server to remove.
+ */
+ fun remove(id: String) {
+ prefs()?.edit()
+ ?.remove(KEY_TOKEN_PREFIX + id)
+ ?.remove(KEY_HEADERS_PREFIX + id)
+ ?.apply()
+ write(servers().filterNot { it.id == id })
+ }
+
+ /**
+ * Stores a server's bearer token, encrypted; blank forgets it.
+ *
+ * Keystore work, so call this off the main thread.
+ *
+ * @param id the server the token belongs to.
+ * @param token the token, or blank to remove it.
+ * @return true when it was stored.
+ */
+ fun setToken(id: String, token: String): Boolean =
+ SecureTokenStore.write(prefs(), KEY_TOKEN_PREFIX + id, token)
+
+ /**
+ * Reads a server's bearer token.
+ *
+ * Keystore work, so call this off the main thread.
+ *
+ * @param id the server.
+ * @return the token, or empty when the server needs none.
+ */
+ fun token(id: String): String =
+ SecureTokenStore.readAndMigrate(prefs(), KEY_TOKEN_PREFIX + id).orEmpty()
+
+ /** True when a token is stored for [id], without decrypting it. */
+ fun hasToken(id: String): Boolean = prefs()?.contains(KEY_TOKEN_PREFIX + id) == true
+
+ /**
+ * The extra headers configured for a server.
+ *
+ * Encrypted with the token, not stored beside the URL: a header is as often a credential as the
+ * token is — an API key, a signed claim — and the store cannot tell which is which.
+ *
+ * Keystore work, so call this off the main thread.
+ *
+ * @param id the server.
+ * @return the headers in the order they were entered; empty when there are none.
+ */
+ fun headers(id: String): Map {
+ val raw = SecureTokenStore.readAndMigrate(prefs(), KEY_HEADERS_PREFIX + id) ?: return emptyMap()
+ return try {
+ val json = JSONObject(raw)
+ val parsed = LinkedHashMap()
+ json.keys().forEach { name -> parsed[name] = json.optString(name) }
+ McpHeaders.sanitize(parsed)
+ } catch (e: Exception) {
+ Log.e(TAG, "Stored headers for '$id' are unreadable; treating them as absent", e)
+ emptyMap()
+ }
+ }
+
+ /**
+ * Stores a server's extra headers, encrypted; an empty map forgets them.
+ *
+ * Keystore work, so call this off the main thread.
+ *
+ * @param id the server the headers belong to.
+ * @param headers the headers to store; unusable pairs are dropped.
+ * @return true when they were stored.
+ */
+ fun setHeaders(id: String, headers: Map): Boolean {
+ val clean = McpHeaders.sanitize(headers)
+ val key = KEY_HEADERS_PREFIX + id
+ if (clean.isEmpty()) {
+ prefs()?.edit()?.remove(key)?.apply()
+ fireChanged()
+ return true
+ }
+ val json = JSONObject()
+ clean.forEach { (name, value) -> json.put(name, value) }
+ val stored = SecureTokenStore.write(prefs(), key, json.toString())
+ fireChanged()
+ return stored
+ }
+
+ /** How many extra headers are configured for [id], without decrypting them. */
+ fun hasHeaders(id: String): Boolean = prefs()?.contains(KEY_HEADERS_PREFIX + id) == true
+
+ /**
+ * Records the tools a server advertised, dropping toggles for tools it no longer has.
+ * @param id the server.
+ * @param toolNames the tools it just listed.
+ */
+ fun setKnownTools(id: String, toolNames: List) {
+ val server = servers().firstOrNull { it.id == id } ?: return
+ upsert(
+ server.copy(
+ knownTools = toolNames,
+ enabledTools = server.enabledTools.filterTo(mutableSetOf()) { it in toolNames },
+ )
+ )
+ }
+
+ /**
+ * Switches one tool on or off for a server.
+ * @param id the server.
+ * @param toolName the tool.
+ * @param enabled whether the agent may see it.
+ */
+ fun setToolEnabled(id: String, toolName: String, enabled: Boolean) {
+ val server = servers().firstOrNull { it.id == id } ?: return
+ val tools = server.enabledTools.toMutableSet()
+ if (enabled) tools += toolName else tools -= toolName
+ upsert(server.copy(enabledTools = tools))
+ }
+
+ /** A server with a fresh id, ready to be edited and stored. */
+ fun newServer(name: String, url: String): McpServer =
+ McpServer(id = UUID.randomUUID().toString(), name = name, url = url)
+
+ private fun write(servers: List) {
+ val array = JSONArray()
+ servers.forEach { array.put(toJson(it)) }
+ prefs()?.edit()?.putString(KEY_SERVERS, array.toString())?.apply()
+ fireChanged()
+ }
+
+ private fun toJson(server: McpServer): JSONObject = JSONObject().apply {
+ put(FIELD_ID, server.id)
+ put(FIELD_NAME, server.name)
+ put(FIELD_URL, server.url)
+ put(FIELD_ENABLED, server.enabled)
+ put(FIELD_KNOWN_TOOLS, JSONArray(server.knownTools))
+ put(FIELD_ENABLED_TOOLS, JSONArray(server.enabledTools.toList()))
+ }
+
+ private fun toServer(json: JSONObject): McpServer? {
+ val id = json.optString(FIELD_ID).takeIf { it.isNotBlank() } ?: return null
+ return McpServer(
+ id = id,
+ name = json.optString(FIELD_NAME).ifBlank { json.optString(FIELD_URL) },
+ url = json.optString(FIELD_URL),
+ enabled = json.optBoolean(FIELD_ENABLED, true),
+ knownTools = json.optJSONArray(FIELD_KNOWN_TOOLS).toStringList(),
+ enabledTools = json.optJSONArray(FIELD_ENABLED_TOOLS).toStringList().toSet(),
+ )
+ }
+
+ private fun JSONArray?.toStringList(): List {
+ if (this == null) return emptyList()
+ return (0 until length()).mapNotNull { optString(it).takeIf { name -> name.isNotBlank() } }
+ }
+
+ private fun prefs(): SharedPreferences? =
+ McpPlugin.getContext()?.getPluginSharedPreferences(PREFERENCE_FILE)
+
+ private fun fireChanged() {
+ for (listener in listeners) {
+ try {
+ listener()
+ } catch (e: Throwable) {
+ Log.e(TAG, "A settings change listener threw", e)
+ }
+ }
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt
new file mode 100644
index 00000000..28e3de34
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt
@@ -0,0 +1,475 @@
+package com.itsaky.androidide.plugins.aiagentmcp.settings
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.EditText
+import android.widget.ImageButton
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.switchmaterial.SwitchMaterial
+import com.itsaky.androidide.plugins.aiagentmcp.R
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpTool
+import com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin
+import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolCatalog
+import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHeaders
+import com.itsaky.androidide.plugins.base.PluginFragmentHelper
+import com.itsaky.androidide.plugins.services.IdeTooltipService
+import kotlinx.coroutines.launch
+
+/**
+ * The MCP settings screen, opened from Preferences → Configuration → MCP servers.
+ *
+ * Loaded by name with this plugin's own classloader and inflated against this plugin's own
+ * resources, so the host needs to know nothing about MCP.
+ */
+class McpSettingsFragment : Fragment() {
+
+ private lateinit var viewModel: McpSettingsViewModel
+ private var tooltipService: IdeTooltipService? = null
+
+ /**
+ * The dialogs this screen owns, tracked so a rotation takes them down with the view they were
+ * anchored to; an untracked one outlives it as a leaked window.
+ */
+ private var serverDialog: AlertDialog? = null
+ private var deleteDialog: AlertDialog? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ try {
+ tooltipService = PluginFragmentHelper.getServiceRegistry(McpPlugin.PLUGIN_ID)
+ ?.get(IdeTooltipService::class.java)
+ } catch (e: Exception) {
+ // Tooltip help is optional; long-press simply shows nothing when it's unavailable.
+ McpPlugin.getContext()?.logger?.warn("McpSettingsFragment: no tooltip service", e)
+ }
+ }
+
+ /**
+ * Routes inflation through the host so this screen resolves against *this* plugin's resources
+ * and a Context whose Configuration tracks the IDE's day/night setting.
+ */
+ override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
+ val inflater = super.onGetLayoutInflater(savedInstanceState)
+ return PluginFragmentHelper.getPluginInflater(McpPlugin.PLUGIN_ID, inflater)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View? = inflater.inflate(R.layout.fragment_mcp_settings, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel = ViewModelProvider(
+ this,
+ McpSettingsViewModelFactory { McpPlugin.getContext() }
+ )[McpSettingsViewModel::class.java]
+
+ val backButton = view.findViewById(R.id.mcpBack)
+ wireTooltip(backButton, McpPlugin.TOOLTIP_TAG_BACK)
+ backButton.setOnClickListener { leaveScreen() }
+
+ val addButton = view.findViewById(R.id.mcpAddServer)
+ wireTooltip(addButton, McpPlugin.TOOLTIP_TAG_ADD_SERVER)
+ addButton.setOnClickListener { showServerDialog(null) }
+
+ // viewLifecycleOwner, not the fragment: the collector captures views and must stop with them.
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.servers.collect { servers -> renderServers(view, servers) }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.reload()
+ }
+
+ /**
+ * Leaves this screen.
+ *
+ * The host mounts a plugin settings pane either on its own back stack or as the whole activity,
+ * so both are handled rather than assuming the one this plugin happens to get today.
+ */
+ private fun leaveScreen() {
+ if (parentFragmentManager.backStackEntryCount > 0) {
+ parentFragmentManager.popBackStack()
+ } else {
+ requireActivity().finish()
+ }
+ }
+
+ /**
+ * Rebuilds the server list.
+ * @param root the screen's root view.
+ * @param servers the servers to show.
+ */
+ private fun renderServers(root: View, servers: List) {
+ val list = root.findViewById(R.id.mcpServerList)
+ val empty = root.findViewById(R.id.mcpEmptyState)
+ list.removeAllViews()
+ empty.visibility = if (servers.isEmpty()) View.VISIBLE else View.GONE
+
+ for (server in servers) {
+ val row = layoutInflater.inflate(R.layout.item_mcp_server, list, false)
+ row.findViewById(R.id.mcpServerName).text = server.name
+ row.findViewById(R.id.mcpServerUrl).text = server.url
+ row.findViewById(R.id.mcpServerTools).text = getString(
+ R.string.mcp_server_tools_summary,
+ server.enabledTools.size,
+ server.knownTools.size,
+ )
+
+ val enabled = row.findViewById(R.id.mcpServerEnabled)
+ // Set before the listener, or restoring state counts as a user change.
+ enabled.isChecked = server.enabled
+ enabled.setOnCheckedChangeListener { _, isChecked ->
+ viewModel.setEnabled(server.id, isChecked)
+ }
+ wireTooltip(enabled, McpPlugin.TOOLTIP_TAG_SERVER_ENABLED)
+
+ row.setOnClickListener { showServerDialog(server) }
+ wireTooltip(row, McpPlugin.TOOLTIP_TAG_SERVER_ROW)
+ list.addView(row)
+ }
+ }
+
+ /**
+ * Opens the add/edit dialog.
+ * @param existing the server to edit, or null to add one.
+ */
+ private fun showServerDialog(existing: McpServer?) {
+ val view = layoutInflater.inflate(R.layout.dialog_mcp_server, null)
+ val nameField = view.findViewById(R.id.mcpName)
+ val urlField = view.findViewById(R.id.mcpUrl)
+ val tokenField = view.findViewById(R.id.mcpToken)
+ val status = view.findViewById(R.id.mcpStatus)
+
+ wireTooltip(nameField, McpPlugin.TOOLTIP_TAG_SERVER_NAME)
+ wireTooltip(urlField, McpPlugin.TOOLTIP_TAG_SERVER_URL)
+ wireTooltip(tokenField, McpPlugin.TOOLTIP_TAG_SERVER_TOKEN)
+
+ var server = existing ?: viewModel.newServer()
+ nameField.setText(server.name)
+ urlField.setText(server.url)
+
+ renderTools(view, server.id, McpToolCatalog.tools(server.id))
+
+ view.findViewById(R.id.mcpAddHeader).also { button ->
+ wireTooltip(button, McpPlugin.TOOLTIP_TAG_ADD_HEADER)
+ button.setOnClickListener { addHeaderRow(view) }
+ }
+
+ // Reading either costs a Keystore decrypt, so both arrive a moment after the dialog does.
+ renderHeaders(view, emptyMap())
+ if (existing != null) {
+ viewModel.loadForm(server.id) { form ->
+ // The stored token is never shown: it is decrypted only to be sent. An empty field
+ // on an existing server means "leave it alone", which the placeholder says aloud.
+ if (form.hasToken) tokenField.hint = getString(R.string.mcp_hint_token_stored)
+ renderHeaders(view, form.headers)
+ }
+ }
+
+ view.findViewById(R.id.mcpTestConnection).also { button ->
+ wireTooltip(button, McpPlugin.TOOLTIP_TAG_TEST_CONNECTION)
+ button.setOnClickListener {
+ val candidate = server.copy(
+ name = nameField.text.toString().trim(),
+ url = urlField.text.toString().trim(),
+ )
+ val validation = validate(candidate, tokenField.text.toString())
+ if (validation != null) {
+ status.text = validation
+ return@setOnClickListener
+ }
+ val headers = collectHeaders(view)
+ if (headers == null) {
+ status.text = getString(R.string.mcp_headers_invalid)
+ return@setOnClickListener
+ }
+ status.text = getString(R.string.mcp_status_testing)
+ viewLifecycleOwner.lifecycleScope.launch {
+ val token = viewModel.tokenToSend(server.id, tokenField.text.toString())
+ viewModel.testConnection(candidate, token, headers) { message ->
+ status.text = message
+ }
+ }
+ }
+ }
+
+ view.findViewById(R.id.mcpRefreshTools).also { button ->
+ wireTooltip(button, McpPlugin.TOOLTIP_TAG_REFRESH_TOOLS)
+ button.setOnClickListener {
+ val candidate = server.copy(
+ name = nameField.text.toString().trim(),
+ url = urlField.text.toString().trim(),
+ )
+ val validation = validate(candidate, tokenField.text.toString())
+ if (validation != null) {
+ status.text = validation
+ return@setOnClickListener
+ }
+ val headers = collectHeaders(view)
+ if (headers == null) {
+ status.text = getString(R.string.mcp_headers_invalid)
+ return@setOnClickListener
+ }
+ // Stored first: refreshing reads the token, headers and URL from the store, so an
+ // unsaved edit would otherwise be refreshed against the old server.
+ status.text = getString(R.string.mcp_status_refreshing)
+ viewModel.save(
+ candidate,
+ viewModel.tokenToStore(tokenField.text.toString()),
+ headers,
+ ) { saved, _ ->
+ server = saved
+ viewModel.refreshTools(saved) { message, tools ->
+ status.text = message
+ renderTools(view, saved.id, tools)
+ }
+ }
+ }
+ }
+
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(if (existing == null) R.string.mcp_dialog_add_title else R.string.mcp_dialog_edit_title)
+ .setView(view)
+ // Bound after show() instead: a positive button set here dismisses whatever the
+ // listener decides, which would close the dialog over an unsaved, invalid header.
+ .setPositiveButton(R.string.mcp_save, null)
+ .setNegativeButton(R.string.mcp_cancel, null)
+
+ if (existing != null) {
+ builder.setNeutralButton(R.string.mcp_delete) { _, _ -> confirmDelete(existing) }
+ }
+
+ val dialog = builder.create()
+ serverDialog = dialog
+ dialog.setOnDismissListener { serverDialog = null }
+ dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
+ val candidate = server.copy(
+ name = nameField.text.toString().trim(),
+ url = urlField.text.toString().trim(),
+ )
+ val problem = validate(candidate, tokenField.text.toString())
+ if (problem != null) {
+ status.text = problem
+ return@setOnClickListener
+ }
+ // A malformed header stops the save: storing it would drop it later in silence.
+ val headers = collectHeaders(view)
+ if (headers == null) {
+ status.text = getString(R.string.mcp_headers_invalid)
+ return@setOnClickListener
+ }
+ viewModel.save(
+ candidate,
+ viewModel.tokenToStore(tokenField.text.toString()),
+ headers,
+ ) { _, _ -> }
+ dialog.dismiss()
+ }
+ }
+ dialog.show()
+ }
+
+ /**
+ * Fills the dialog's header list.
+ * @param view the dialog's root view.
+ * @param headers the headers to show, in entry order.
+ */
+ private fun renderHeaders(view: View, headers: Map) {
+ val list = view.findViewById(R.id.mcpHeaderList)
+ list.removeAllViews()
+ headers.forEach { (name, value) -> addHeaderRow(view, name, value) }
+ updateHeadersEmptyState(view)
+ }
+
+ /**
+ * Appends one header row.
+ * @param view the dialog's root view.
+ * @param name the header name, empty for a row the user is about to fill.
+ * @param value the header value.
+ */
+ private fun addHeaderRow(view: View, name: String = "", value: String = "") {
+ val list = view.findViewById(R.id.mcpHeaderList)
+ val row = layoutInflater.inflate(R.layout.item_mcp_header, list, false)
+ row.findViewById(R.id.mcpHeaderName).also { field ->
+ field.setText(name)
+ wireTooltip(field, McpPlugin.TOOLTIP_TAG_HEADER_NAME)
+ }
+ row.findViewById(R.id.mcpHeaderValue).also { field ->
+ field.setText(value)
+ wireTooltip(field, McpPlugin.TOOLTIP_TAG_HEADER_VALUE)
+ }
+ row.findViewById(R.id.mcpHeaderRemove).also { button ->
+ wireTooltip(button, McpPlugin.TOOLTIP_TAG_HEADER_REMOVE)
+ button.setOnClickListener {
+ list.removeView(row)
+ updateHeadersEmptyState(view)
+ }
+ }
+ list.addView(row)
+ updateHeadersEmptyState(view)
+ }
+
+ /** Shows the empty note only while there is no row to look at. */
+ private fun updateHeadersEmptyState(view: View) {
+ val list = view.findViewById(R.id.mcpHeaderList)
+ view.findViewById(R.id.mcpHeadersEmpty).visibility =
+ if (list.childCount == 0) View.VISIBLE else View.GONE
+ }
+
+ /**
+ * Reads the header rows back, marking any the server could not be sent.
+ *
+ * A row left entirely blank is not an error — it is a row the user added and thought better of
+ * — so it is skipped silently; a half-filled or malformed one stops the save, because saving it
+ * would drop it later without telling anyone.
+ *
+ * @param view the dialog's root view.
+ * @return the headers, or null when a row is unusable and has been marked.
+ */
+ private fun collectHeaders(view: View): Map? {
+ val list = view.findViewById(R.id.mcpHeaderList)
+ val headers = LinkedHashMap()
+ var valid = true
+
+ for (index in 0 until list.childCount) {
+ val row = list.getChildAt(index)
+ val nameField = row.findViewById(R.id.mcpHeaderName)
+ val valueField = row.findViewById(R.id.mcpHeaderValue)
+ val error = row.findViewById(R.id.mcpHeaderError)
+ val name = nameField.text.toString().trim()
+ val value = valueField.text.toString()
+
+ error.visibility = View.GONE
+ if (name.isEmpty() && value.isEmpty()) continue
+
+ val problem = McpHeaders.rowProblem(name, value, headers.keys)
+ if (problem != null) {
+ error.text = getString(headerProblemText(problem))
+ error.visibility = View.VISIBLE
+ valid = false
+ continue
+ }
+ headers[name] = value
+ }
+ return if (valid) headers else null
+ }
+
+ /**
+ * The message for a rejected header row.
+ * @param problem what the validator found.
+ * @return the string resource to show.
+ */
+ private fun headerProblemText(problem: McpHeaders.Problem): Int = when (problem) {
+ McpHeaders.Problem.EMPTY -> R.string.mcp_hint_header_name
+ McpHeaders.Problem.RESERVED -> R.string.mcp_header_name_reserved
+ McpHeaders.Problem.ILLEGAL_CHARACTERS -> R.string.mcp_header_name_illegal
+ McpHeaders.Problem.TOO_LONG -> R.string.mcp_header_name_too_long
+ McpHeaders.Problem.ILLEGAL_VALUE -> R.string.mcp_header_value_illegal
+ McpHeaders.Problem.DUPLICATE -> R.string.mcp_header_name_duplicate
+ }
+
+ /**
+ * Fills the dialog's tool list.
+ *
+ * Takes the server's id rather than the record: the switches are read back from the store on
+ * every render and written straight through by id, so nothing here can hand a snapshot from
+ * before the last toggle back to the store.
+ *
+ * @param view the dialog's root view.
+ * @param serverId the server being edited.
+ * @param tools the tools it advertises.
+ */
+ private fun renderTools(view: View, serverId: String, tools: List) {
+ val list = view.findViewById(R.id.mcpToolList)
+ val empty = view.findViewById(R.id.mcpToolsEmpty)
+ list.removeAllViews()
+ empty.visibility = if (tools.isEmpty()) View.VISIBLE else View.GONE
+
+ val enabledTools = viewModel.enabledTools(serverId)
+
+ for (tool in tools) {
+ val row = layoutInflater.inflate(R.layout.item_mcp_tool, list, false)
+ val toggle = row.findViewById(R.id.mcpToolSwitch)
+ toggle.text = tool.name
+ // Set before the listener, or restoring state counts as a user change.
+ toggle.isChecked = tool.name in enabledTools
+ toggle.setOnCheckedChangeListener { _, isChecked ->
+ viewModel.setToolEnabled(serverId, tool.name, isChecked)
+ }
+ wireTooltip(toggle, McpPlugin.TOOLTIP_TAG_TOOL_TOGGLE)
+
+ row.findViewById(R.id.mcpToolDescription).text = tool.description
+ list.addView(row)
+ }
+ }
+
+ /**
+ * Asks before removing a server, since the token goes with it.
+ * @param server the server to remove.
+ */
+ private fun confirmDelete(server: McpServer) {
+ deleteDialog = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.mcp_delete_title)
+ .setMessage(getString(R.string.mcp_delete_message, server.name))
+ .setPositiveButton(R.string.mcp_delete) { _, _ -> viewModel.delete(server.id) }
+ .setNegativeButton(R.string.mcp_cancel, null)
+ .setOnDismissListener { deleteDialog = null }
+ .create()
+ .apply { show() }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ // Both are anchored to the view being destroyed; see the fields' comment.
+ serverDialog?.dismiss()
+ serverDialog = null
+ deleteDialog?.dismiss()
+ deleteDialog = null
+ }
+
+ /**
+ * Checks what the user typed.
+ *
+ * The token is checked here rather than at the socket: a pasted credential often arrives with a
+ * stray line break, and the transport refuses to send one, so catching it on Save is the
+ * difference between a marked field and a connection that fails for no visible reason.
+ *
+ * @param server the candidate.
+ * @param typedToken the current contents of the token field.
+ * @return the message to show, or null when it is usable.
+ */
+ private fun validate(server: McpServer, typedToken: String): String? = when {
+ server.name.isBlank() -> getString(R.string.mcp_name_required)
+ server.url.isBlank() -> getString(R.string.mcp_url_required)
+ !server.url.startsWith("http://") && !server.url.startsWith("https://") ->
+ getString(R.string.mcp_url_scheme_invalid)
+ !McpHeaders.isSendableToken(typedToken.trim()) -> getString(R.string.mcp_token_illegal)
+ else -> null
+ }
+
+ /** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
+ private fun wireTooltip(view: View, tag: String) {
+ view.setOnLongClickListener { anchor ->
+ val service = tooltipService ?: return@setOnLongClickListener false
+ service.showTooltip(anchor, McpPlugin.TOOLTIP_CATEGORY, tag)
+ true
+ }
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt
new file mode 100644
index 00000000..5a2ff284
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt
@@ -0,0 +1,265 @@
+package com.itsaky.androidide.plugins.aiagentmcp.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiagentmcp.R
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpConnections
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpCredentials
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpSession
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpTool
+import com.itsaky.androidide.plugins.aiagentmcp.errors.McpErrorFormatter
+import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolCatalog
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * State and background work for the MCP settings pane.
+ *
+ * Everything that touches the Keystore or the network happens here on [Dispatchers.IO]; the
+ * fragment only renders what this exposes.
+ *
+ * @param getContext this plugin's context, for preferences and its own strings.
+ */
+class McpSettingsViewModel(
+ private val getContext: () -> PluginContext?,
+) : ViewModel() {
+
+ private val _servers = MutableStateFlow(McpServerStore.servers())
+
+ /** The configured servers, re-read after every change. */
+ val servers: StateFlow> = _servers.asStateFlow()
+
+ /** Re-reads the stored servers, e.g. after returning to the screen. */
+ fun reload() {
+ _servers.value = McpServerStore.servers()
+ }
+
+ /**
+ * What the add/edit dialog needs from the store before it can show a server.
+ * @property hasToken whether a token is stored, so the field can say so without decrypting it.
+ * @property headers the extra headers configured for the server.
+ */
+ data class FormState(val hasToken: Boolean, val headers: Map)
+
+ /** A server with a fresh id, ready for the dialog to fill in. */
+ fun newServer(): McpServer = McpServerStore.newServer("", "")
+
+ /**
+ * The tools switched on for a server, for rendering its tool list.
+ * @param id the server.
+ * @return the enabled tool names; empty when the server is unknown.
+ */
+ fun enabledTools(id: String): Set = McpServerStore.server(id)?.enabledTools.orEmpty()
+
+ /**
+ * Reads what the dialog needs about an existing server.
+ *
+ * One load rather than two, and off the main thread: the headers are a Keystore decrypt, and
+ * the token check is a preferences read the dialog used to do on the UI thread.
+ *
+ * @param id the server being edited.
+ * @param onLoaded receives the state, on the main thread.
+ */
+ fun loadForm(id: String, onLoaded: (FormState) -> Unit) {
+ viewModelScope.launch {
+ val state = withContext(Dispatchers.IO) {
+ FormState(McpServerStore.hasToken(id), McpServerStore.headers(id))
+ }
+ onLoaded(state)
+ }
+ }
+
+ /**
+ * What an empty token field means when saving: leave whatever is stored alone.
+ * @param typed the current contents of the token field.
+ * @return the token to store, or null to keep the stored one.
+ */
+ fun tokenToStore(typed: String): String? = typed.trim().takeIf { it.isNotEmpty() }
+
+ /**
+ * What an empty token field means when testing: send whatever is stored.
+ *
+ * The mirror of [tokenToStore], and here rather than in the screen so the one convention the
+ * dialog's placeholder promises is written once.
+ *
+ * @param id the server being tested.
+ * @param typed the current contents of the token field.
+ * @return the token to send, empty when the server needs none.
+ */
+ suspend fun tokenToSend(id: String, typed: String): String =
+ tokenToStore(typed) ?: withContext(Dispatchers.IO) { McpServerStore.token(id) }
+
+ /**
+ * Stores the edited name and URL of a server and, when given, its token.
+ *
+ * Only those fields are written: the tool switches are saved as they are tapped, and the
+ * dialog's snapshot predates them.
+ *
+ * @param server the server to store.
+ * @param token the token to store, or null to leave the stored one alone.
+ * @param headers the extra headers to store, replacing whatever was there.
+ * @param onDone receives the merged record and a status sentence, null when everything worked.
+ */
+ fun save(
+ server: McpServer,
+ token: String?,
+ headers: Map = emptyMap(),
+ onDone: (McpServer, String?) -> Unit,
+ ) {
+ viewModelScope.launch {
+ val outcome = withContext(Dispatchers.IO) {
+ val merged = McpServerStore.saveDetails(server)
+ val tokenStored = token?.let { McpServerStore.setToken(server.id, it.trim()) } ?: true
+ val headersStored = McpServerStore.setHeaders(server.id, headers)
+ // A credential change has to invalidate the session, or the old one keeps working.
+ McpConnections.invalidate(server.id)
+ val failure = when {
+ !tokenStored -> string(R.string.mcp_token_save_failed)
+ !headersStored -> string(R.string.mcp_headers_save_failed)
+ else -> null
+ }
+ merged to failure
+ }
+ reload()
+ onDone(outcome.first, outcome.second)
+ }
+ }
+
+ /**
+ * Removes a server, its token, its session and its cached tools.
+ * @param id the server to remove.
+ */
+ fun delete(id: String) {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ McpConnections.invalidate(id)
+ McpToolCatalog.forget(id)
+ McpServerStore.remove(id)
+ }
+ reload()
+ }
+ }
+
+ /**
+ * Switches a whole server on or off.
+ * @param id the server.
+ * @param enabled whether it may contribute tools.
+ */
+ fun setEnabled(id: String, enabled: Boolean) {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) { McpServerStore.setEnabled(id, enabled) }
+ reload()
+ }
+ }
+
+ /**
+ * Switches one of a server's tools on or off.
+ * @param id the server.
+ * @param toolName the tool.
+ * @param enabled whether the agent may see it.
+ */
+ fun setToolEnabled(id: String, toolName: String, enabled: Boolean) {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ McpServerStore.setToolEnabled(id, toolName, enabled)
+ }
+ reload()
+ }
+ }
+
+ /**
+ * Performs the handshake and asks for the tool list, without storing anything.
+ *
+ * A server that handshakes but exposes no tool catalogue is still reported as reachable — some
+ * expose only prompts or resources, which this plugin does not use.
+ *
+ * @param server the server to test, with the values currently in the form.
+ * @param token the token currently in the form.
+ * @param headers the extra headers currently in the form, so a test exercises what a real call
+ * would send rather than what was last saved.
+ * @param onResult receives the sentence to show.
+ */
+ fun testConnection(
+ server: McpServer,
+ token: String,
+ headers: Map = emptyMap(),
+ onResult: (String) -> Unit,
+ ) {
+ viewModelScope.launch {
+ val message = withContext(Dispatchers.IO) {
+ // The form's own values, not the store's: a test has to exercise the unsaved edit.
+ val typed = McpCredentials(token.trim(), headers)
+ val session = McpSession(server.url.trim(), { typed })
+ try {
+ session.initialize()
+ val tools = try {
+ session.listTools()
+ } catch (e: Exception) {
+ // A missing catalogue is not a failed connection.
+ emptyList()
+ }
+ val name = session.serverName ?: server.name
+ if (tools.isEmpty()) {
+ string(R.string.mcp_status_connected_no_tools, name)
+ } else {
+ string(R.string.mcp_status_connected, name, tools.size)
+ }
+ } catch (e: Exception) {
+ McpErrorFormatter.format(getContext()?.androidContext, server.name, e)
+ } finally {
+ runCatching { session.close() }
+ }
+ }
+ onResult(message)
+ }
+ }
+
+ /**
+ * Re-reads one server's tool catalogue and stores it.
+ * @param server the server to refresh.
+ * @param onResult receives the sentence to show and the tools now known.
+ */
+ fun refreshTools(server: McpServer, onResult: (String, List) -> Unit) {
+ viewModelScope.launch {
+ val outcome = withContext(Dispatchers.IO) {
+ try {
+ val tools = McpToolCatalog.refresh(server)
+ string(R.string.mcp_status_tools_refreshed, tools.size) to tools
+ } catch (e: Exception) {
+ McpErrorFormatter.format(getContext()?.androidContext, server.name, e) to
+ McpToolCatalog.tools(server.id)
+ }
+ }
+ reload()
+ onResult(outcome.first, outcome.second)
+ }
+ }
+
+ /**
+ * Resolves a string against this plugin's own resources.
+ * @param resId the string resource.
+ * @param args format arguments.
+ * @return the resolved string, or empty when the context is gone.
+ */
+ private fun string(resId: Int, vararg args: Any?): String =
+ getContext()?.androidContext?.getString(resId, *args).orEmpty()
+}
+
+/**
+ * Builds [McpSettingsViewModel] with this plugin's context.
+ * @param getContext supplier of the plugin context.
+ */
+class McpSettingsViewModelFactory(
+ private val getContext: () -> PluginContext?,
+) : ViewModelProvider.Factory {
+
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T =
+ McpSettingsViewModel(getContext) as T
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolCatalog.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolCatalog.kt
new file mode 100644
index 00000000..1a030061
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolCatalog.kt
@@ -0,0 +1,76 @@
+package com.itsaky.androidide.plugins.aiagentmcp.tools
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpConnections
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpTool
+import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX
+import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServer
+import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServerStore
+import java.util.concurrent.ConcurrentHashMap
+
+private const val TAG = "$LOG_PREFIX.McpToolCatalog"
+
+/**
+ * What each configured server last said its tools are.
+ *
+ * The agent reads the tool list on a UI-adjacent path and the contract forbids blocking on the
+ * network there, so the list has to be answered from memory. This is that memory: filled by an
+ * explicit [refresh] — on activation, on Refresh tools, after an edit — and read by the source.
+ */
+object McpToolCatalog {
+
+ private val toolsByServer = ConcurrentHashMap>()
+
+ /**
+ * The cached tools for a server.
+ * @param serverId the server.
+ * @return its tools, empty before the first successful refresh.
+ */
+ fun tools(serverId: String): List = toolsByServer[serverId].orEmpty()
+
+ /**
+ * Re-reads one server's tool list over the network and caches it.
+ *
+ * Blocking, so call it off the main thread. The stored known-tool names are updated too, which
+ * is what drops toggles for tools the server no longer offers.
+ *
+ * @param server the server to ask.
+ * @return the tools it listed.
+ * @throws java.io.IOException when the server cannot be reached or refuses.
+ */
+ fun refresh(server: McpServer): List {
+ val tools = McpConnections.session(server).listTools()
+ toolsByServer[server.id] = tools
+ McpServerStore.setKnownTools(server.id, tools.map { it.name })
+ Log.i(TAG, "Server '${server.name}' listed ${tools.size} tool(s)")
+ return tools
+ }
+
+ /**
+ * Refreshes every enabled server, tolerating the ones that fail.
+ * @return how many servers answered.
+ */
+ fun refreshAll(): Int {
+ var refreshed = 0
+ for (server in McpServerStore.servers().filter { it.enabled }) {
+ try {
+ refresh(server)
+ refreshed++
+ } catch (e: Exception) {
+ // One unreachable server must not cost the user the tools of the others.
+ Log.w(TAG, "Could not list tools for '${server.name}': ${e.message}")
+ }
+ }
+ return refreshed
+ }
+
+ /** Forgets a server's tools, for one that was removed or edited. */
+ fun forget(serverId: String) {
+ toolsByServer.remove(serverId)
+ }
+
+ /** Forgets everything, for the plugin shutting down. */
+ fun clear() {
+ toolsByServer.clear()
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolSource.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolSource.kt
new file mode 100644
index 00000000..3d843737
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolSource.kt
@@ -0,0 +1,207 @@
+package com.itsaky.androidide.plugins.aiagentmcp.tools
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpConnections
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpTool
+import com.itsaky.androidide.plugins.aiagentmcp.errors.McpErrorFormatter
+import com.itsaky.androidide.plugins.aiagentmcp.R
+import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX
+import com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin
+import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServer
+import com.itsaky.androidide.plugins.aiagentmcp.settings.McpServerStore
+import com.itsaky.androidide.plugins.services.ToolSourceRegistry
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+private const val TAG = "$LOG_PREFIX.McpToolSource"
+
+/**
+ * Contributes the tools of every configured MCP server to the IDE's AI agent.
+ *
+ * Only tools the user switched on are offered: one popular GitHub server advertises around ninety,
+ * which would exhaust a phone-sized context window on its own, so the toggle defaults to off and
+ * this class never widens it.
+ */
+class McpToolSource : ToolSourceRegistry.ToolSource {
+
+ /** Calls in flight, so a stopped agent run can drop the socket instead of waiting it out. */
+ private val inFlight = ConcurrentHashMap>()
+
+ /**
+ * Remote calls run here rather than on the common pool: an MCP call blocks on a socket for as
+ * long as the read timeout, and the common pool is sized for CPU work.
+ */
+ private val executor: ExecutorService = Executors.newCachedThreadPool { runnable ->
+ Thread(runnable, "mcp-tool-call").apply { isDaemon = true }
+ }
+
+ override fun getProviderId(): String = McpPlugin.PLUGIN_ID
+
+ override fun getDisplayName(): String = DISPLAY_NAME
+
+ override fun listTools(): List = exposedTools().map { exposed ->
+ Spec(
+ name = exposed.name,
+ description = McpToolText.description(exposed.tool.description),
+ parametersSchema = exposed.tool.inputSchema,
+ )
+ }
+
+ override fun invoke(
+ invocation: ToolSourceRegistry.ToolInvocation,
+ ): CompletableFuture {
+ val target = resolve(invocation.toolName)
+ ?: return CompletableFuture.completedFuture(
+ Outcome(false, "", string(R.string.mcp_error_tool_gone, invocation.toolName))
+ )
+
+ val future = CompletableFuture.supplyAsync(
+ { runTool(target.first, target.second, invocation.arguments) },
+ executor,
+ )
+ inFlight[invocation.callId] = future
+ return future.whenComplete { _, _ -> inFlight.remove(invocation.callId) }
+ }
+
+ override fun cancel(callId: String) {
+ inFlight.remove(callId)?.cancel(true)
+ // The worker is blocked on a socket read, where an interrupt does nothing; dropping the
+ // connection is what actually ends it.
+ McpConnections.cancelAll()
+ }
+
+ /** Ends the executor, for the plugin shutting down. */
+ fun close() {
+ executor.shutdownNow()
+ inFlight.clear()
+ }
+
+ /**
+ * Runs one remote tool.
+ * @param server the server that owns it.
+ * @param tool the tool, as the server described it.
+ * @param arguments the agent's arguments.
+ * @return the outcome; a failure carries one sentence, never the server's raw body.
+ */
+ private fun runTool(
+ server: McpServer,
+ tool: McpTool,
+ arguments: Map,
+ ): ToolSourceRegistry.ToolOutcome = try {
+ val result = McpConnections.session(server).callTool(tool.name, arguments)
+ Outcome(result.success, result.text, result.errorMessage)
+ } catch (e: Throwable) {
+ Log.w(TAG, "Tool '${tool.name}' on '${server.name}' failed", e)
+ val context = McpPlugin.getContext()?.androidContext
+ Outcome(false, "", McpErrorFormatter.format(context, server.name, e))
+ }
+
+ /**
+ * Resolves a string against this plugin's own resources.
+ * @param resId the string resource.
+ * @param args format arguments.
+ * @return the resolved string, or the raw arguments when the plugin context is already gone.
+ */
+ private fun string(resId: Int, vararg args: Any?): String =
+ McpPlugin.getContext()?.androidContext?.getString(resId, *args)
+ ?: args.joinToString(" ")
+
+ /**
+ * Every enabled tool of every enabled server, paired with its server.
+ * @return the pairs, in configured order.
+ */
+ private fun enabledTools(): List> =
+ McpServerStore.servers()
+ .filter { it.enabled }
+ .flatMap { server ->
+ McpToolCatalog.tools(server.id)
+ .filter { it.name in server.enabledTools }
+ .map { server to it }
+ }
+
+ /**
+ * Finds the server and tool behind an exposed name.
+ * @param exposedName the name this source published.
+ * @return the pair, or null when the tool has since been switched off or removed.
+ */
+ private fun resolve(exposedName: String): Pair? =
+ exposedTools().firstOrNull { it.name == exposedName }?.let { it.server to it.tool }
+
+ /**
+ * Every enabled tool paired with the name this source publishes it under.
+ *
+ * The one place a name is decided, so listing and resolving cannot disagree: both walk the same
+ * servers in the same order and hand a truncation collision to the same numbering rule.
+ *
+ * @return the tools this source offers, in configured order.
+ */
+ private fun exposedTools(): List {
+ val exposed = mutableListOf()
+ val taken = mutableSetOf()
+
+ for ((server, tool) in enabledTools()) {
+ val base = McpToolText.exposedName(server.name, tool.name)
+ if (base == null) {
+ Log.w(TAG, "Dropping a tool from '${server.name}': its name has nothing usable")
+ continue
+ }
+ val name = McpToolText.disambiguate(base, taken)
+ if (name == null) {
+ Log.w(TAG, "Dropping '$base' from '${server.name}': that name is already taken")
+ continue
+ }
+ if (name != base) {
+ Log.i(TAG, "'${tool.name}' on '${server.name}' shares a name; offering it as '$name'")
+ }
+ taken += name
+ exposed += Exposed(server, tool, name)
+ }
+ return exposed
+ }
+
+ /**
+ * One tool and the name it is published under.
+ * @property server the server that owns it.
+ * @property tool the tool, as the server described it.
+ * @property name the name the agent and the model see.
+ */
+ private class Exposed(val server: McpServer, val tool: McpTool, val name: String)
+
+ /**
+ * One tool, as the host contract describes it.
+ *
+ * Every remote tool asks for approval and declares itself non-read-only: this plugin cannot
+ * know what a server's tool does, and guessing on the permissive side is the one guess that
+ * cannot be undone. The contract is a Java interface, so its accessors are implemented as
+ * functions: Kotlin synthesises properties for *reading* a Java getter, never for overriding one.
+ */
+ private class Spec(
+ private val name: String,
+ private val description: String,
+ private val parametersSchema: Map,
+ ) : ToolSourceRegistry.ToolSpec {
+ override fun getName(): String = name
+ override fun getDescription(): String = description
+ override fun getParametersSchema(): Map = parametersSchema
+ override fun requiresApproval(): Boolean = true
+ override fun isReadOnly(): Boolean = false
+ }
+
+ /** One outcome, as the host contract describes it. */
+ private class Outcome(
+ private val success: Boolean,
+ private val output: String,
+ private val errorMessage: String? = null,
+ ) : ToolSourceRegistry.ToolOutcome {
+ override fun isSuccess(): Boolean = success
+ override fun getOutput(): String = output
+ override fun getErrorMessage(): String? = errorMessage
+ }
+
+ private companion object {
+ /** Shown wherever tool provenance is surfaced. */
+ const val DISPLAY_NAME = "MCP servers"
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolText.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolText.kt
new file mode 100644
index 00000000..3df9773c
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolText.kt
@@ -0,0 +1,84 @@
+package com.itsaky.androidide.plugins.aiagentmcp.tools
+
+/**
+ * Sanitises the text an MCP server supplies before it leaves this plugin.
+ *
+ * Tool names and descriptions are untrusted remote strings that end up verbatim in a system prompt
+ * assembled inside a third-party backend plugin. A newline is enough to forge prompt structure
+ * there, and a name outside `[a-z0-9_]` is enough to make a tool the model can read but never call.
+ */
+object McpToolText {
+
+ /** Max characters kept from a server label used as a tool-name prefix. */
+ private const val MAX_PREFIX_LENGTH = 10
+
+ /** Max characters kept from a tool name; the agent caps the namespaced form again. */
+ private const val MAX_NAME_LENGTH = 24
+
+ /** Max characters kept from a description; the agent's prompt budget caps it again, lower. */
+ const val MAX_DESCRIPTION_LENGTH = 200
+
+ /** Numbered variants tried for a name two tools truncated onto; past this the tool is dropped. */
+ private const val MAX_VARIANTS = 20
+
+ /**
+ * The exposed name for one remote tool: a short server prefix plus the tool's own name.
+ * @param serverName the user's label for the server.
+ * @param toolName the tool's own name.
+ * @return the exposed name, or null when nothing usable survives sanitising.
+ */
+ fun exposedName(serverName: String, toolName: String): String? {
+ val tool = identifier(toolName).take(MAX_NAME_LENGTH).trim('_')
+ if (tool.isEmpty()) return null
+ val prefix = identifier(serverName).take(MAX_PREFIX_LENGTH).trim('_')
+ return if (prefix.isEmpty()) tool else "${prefix}_$tool"
+ }
+
+ /**
+ * A free variant of [name], numbered when two tools ended up sharing one exposed name.
+ *
+ * Truncation is what makes this necessary: `get_pull_request_comments` and
+ * `get_pull_request_committers` are distinct tools that both survive [MAX_NAME_LENGTH] as the
+ * same string, and dropping the second would silently take away a tool the user switched on.
+ *
+ * The numbered form still fits the agent's own cap: the longest name this builds is
+ * [MAX_PREFIX_LENGTH] + [MAX_NAME_LENGTH] + a two-digit suffix.
+ *
+ * @param name the name [exposedName] produced.
+ * @param taken the names already published by this source.
+ * @return the name to publish, or null when even the numbered forms are spoken for.
+ */
+ fun disambiguate(name: String, taken: Set): String? {
+ if (name !in taken) return name
+ return (2..MAX_VARIANTS).map { "${name}_$it" }.firstOrNull { it !in taken }
+ }
+
+ /**
+ * A description safe to put in a prompt.
+ * @param description the server's own text.
+ * @return the text, flattened to one line and capped.
+ */
+ fun description(description: String): String {
+ val flattened = description
+ .map { if (it.isWhitespace() || it.isISOControl()) ' ' else it }
+ .joinToString("")
+ .replace(Regex(" +"), " ")
+ .trim()
+ return if (flattened.length > MAX_DESCRIPTION_LENGTH) {
+ flattened.take(MAX_DESCRIPTION_LENGTH).trimEnd() + "…"
+ } else {
+ flattened
+ }
+ }
+
+ /** Lowercases and reduces to `[a-z0-9_]`, collapsing runs of separators into one `_`. */
+ private fun identifier(raw: String): String {
+ val mapped = raw.lowercase().map { if (it in 'a'..'z' || it in '0'..'9') it else '_' }
+ return buildString {
+ for (char in mapped) {
+ if (char == '_' && endsWith("_")) continue
+ append(char)
+ }
+ }.trim('_')
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/JsonRpc.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/JsonRpc.kt
new file mode 100644
index 00000000..90472528
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/JsonRpc.kt
@@ -0,0 +1,104 @@
+package com.itsaky.androidide.plugins.aiagentmcp.transport
+
+import org.json.JSONObject
+
+/**
+ * JSON-RPC 2.0 framing, which is all MCP puts on the wire.
+ *
+ * Pure and separate from the socket work, so the framing — where a mis-shaped envelope silently
+ * becomes "the server returned nothing" — is unit-testable without a server.
+ */
+object JsonRpc {
+
+ private const val VERSION = "2.0"
+
+ /** JSON-RPC's own code for a method the server does not implement. */
+ const val METHOD_NOT_FOUND = -32601
+
+ /**
+ * One reply, already unwrapped.
+ * @property id the request id it answers, or null for a malformed envelope.
+ * @property result the result object, or null when the reply is an error.
+ * @property errorCode the JSON-RPC error code, or null on success.
+ * @property errorMessage the server's error text, or null on success.
+ */
+ data class Reply(
+ val id: String?,
+ val result: JSONObject?,
+ val errorCode: Int? = null,
+ val errorMessage: String? = null,
+ ) {
+ val isError: Boolean get() = errorCode != null
+ }
+
+ /**
+ * Builds a request envelope.
+ * @param id this call's id, echoed by the server.
+ * @param method the MCP method, e.g. `tools/list`.
+ * @param params the method's parameters, or null for none.
+ * @return the envelope to POST.
+ */
+ fun request(id: String, method: String, params: JSONObject? = null): JSONObject =
+ JSONObject().apply {
+ put("jsonrpc", VERSION)
+ put("id", id)
+ put("method", method)
+ params?.let { put("params", it) }
+ }
+
+ /**
+ * Builds a notification envelope, which carries no id and expects no reply.
+ * @param method the MCP method, e.g. `notifications/initialized`.
+ * @param params the method's parameters, or null for none.
+ * @return the envelope to POST.
+ */
+ fun notification(method: String, params: JSONObject? = null): JSONObject =
+ JSONObject().apply {
+ put("jsonrpc", VERSION)
+ put("method", method)
+ params?.let { put("params", it) }
+ }
+
+ /**
+ * Reads a reply envelope.
+ *
+ * A batch — which the spec allows and some servers send even for a single request — is reduced
+ * to its first non-notification member, since this client only ever has one call in flight.
+ *
+ * @param payload one JSON-RPC document, object or array.
+ * @return the reply, or null when the payload is not a reply at all (a server-initiated
+ * request or notification, which this client does not answer).
+ */
+ fun parseReply(payload: String): Reply? {
+ val trimmed = payload.trim()
+ val json = when {
+ trimmed.startsWith("{") -> JSONObject(trimmed)
+ trimmed.startsWith("[") -> org.json.JSONArray(trimmed).let { array ->
+ (0 until array.length())
+ .mapNotNull { array.optJSONObject(it) }
+ .firstOrNull { it.has("result") || it.has("error") }
+ ?: return null
+ }
+ else -> throw IllegalArgumentException("not a JSON-RPC document")
+ }
+
+ if (!json.has("result") && !json.has("error")) return null
+
+ val id = when {
+ json.isNull("id") -> null
+ else -> json.get("id").toString()
+ }
+
+ json.optJSONObject("error")?.let { error ->
+ return Reply(
+ id = id,
+ result = null,
+ errorCode = error.optInt("code", 0),
+ errorMessage = error.optString("message").takeIf { it.isNotBlank() }
+ ?: "the server reported an unspecified error",
+ )
+ }
+
+ return Reply(id = id, result = json.optJSONObject("result") ?: JSONObject())
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHeaders.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHeaders.kt
new file mode 100644
index 00000000..306739f0
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHeaders.kt
@@ -0,0 +1,134 @@
+package com.itsaky.androidide.plugins.aiagentmcp.transport
+
+/**
+ * The rules for the extra headers a user may attach to a server.
+ *
+ * Some MCP servers need more than a bearer token to route a request — an API key, an environment
+ * selector, a client id — so the settings screen lets the user add their own. That text goes
+ * straight onto the wire, which makes two checks mandatory rather than cosmetic: a name or value
+ * carrying CR or LF would let one field forge another (request splitting), and a name this plugin
+ * sets itself would silently break the protocol.
+ *
+ * Pure and free of Android types, so every rule is unit-testable without a device.
+ */
+object McpHeaders {
+
+ /**
+ * Headers the transport owns. A user-supplied `Accept` or `Mcp-Session-Id` would break the
+ * session or the response negotiation, and the failure would look like a broken server.
+ * `Authorization` is deliberately absent: it has its own field, but a server wanting a scheme
+ * other than `Bearer` has no other way to say so.
+ */
+ val RESERVED = setOf(
+ "content-type",
+ "accept",
+ "mcp-session-id",
+ "mcp-protocol-version",
+ )
+
+ /** Max characters kept from a name; longer is a paste accident, not a header. */
+ const val MAX_NAME_LENGTH = 128
+
+ /** Max characters kept from a value. Generous: some gateways take a signed blob here. */
+ const val MAX_VALUE_LENGTH = 2048
+
+ /** RFC 7230 token characters — what a header name is actually allowed to contain. */
+ private val NAME_PATTERN = Regex("""^[A-Za-z0-9!#$%&'*+.^_`|~-]+$""")
+
+ /**
+ * Whether [name] is a usable header name this plugin does not already own.
+ * @param name the name as typed.
+ * @return true when it can be sent.
+ */
+ fun isValidName(name: String): Boolean {
+ val trimmed = name.trim()
+ return trimmed.isNotEmpty() &&
+ trimmed.length <= MAX_NAME_LENGTH &&
+ NAME_PATTERN.matches(trimmed) &&
+ trimmed.lowercase() !in RESERVED
+ }
+
+ /**
+ * Whether [value] can be sent as typed.
+ *
+ * Empty is allowed — an empty header is legal and occasionally meaningful — but a line break
+ * or any other control character is not.
+ *
+ * @param value the value as typed.
+ * @return true when it can be sent.
+ */
+ fun isValidValue(value: String): Boolean =
+ value.length <= MAX_VALUE_LENGTH && value.none { it.isISOControl() }
+
+ /**
+ * Whether [token] can travel in an `Authorization` header.
+ *
+ * The same rule as a header value, because that is what it becomes: a token pasted with a
+ * trailing line break would forge a header of its own at the socket, exactly as a header value
+ * would.
+ *
+ * @param token the bearer token as stored or typed.
+ * @return true when it can be sent.
+ */
+ fun isSendableToken(token: String): Boolean = isValidValue(token)
+
+ /**
+ * Keeps only the pairs that can be sent, in the order given.
+ *
+ * A row the user left half-filled is dropped rather than reported: the settings screen already
+ * marks a bad name as it is typed, and a silent drop here is the last line of defence, not the
+ * first.
+ *
+ * @param headers the pairs as entered.
+ * @return the pairs safe to put on the wire, later duplicates of a name discarded.
+ */
+ fun sanitize(headers: Map): Map {
+ val clean = LinkedHashMap()
+ for ((name, value) in headers) {
+ val trimmed = name.trim()
+ if (!isValidName(trimmed) || !isValidValue(value)) continue
+ if (clean.keys.none { it.equals(trimmed, ignoreCase = true) }) {
+ clean[trimmed] = value
+ }
+ }
+ return clean
+ }
+
+ /**
+ * Why [name] cannot be used, for the settings screen to show against the offending row.
+ * @param name the name as typed.
+ * @return a reason key, or null when the name is fine.
+ */
+ fun nameProblem(name: String): Problem? {
+ val trimmed = name.trim()
+ return when {
+ trimmed.isEmpty() -> Problem.EMPTY
+ trimmed.lowercase() in RESERVED -> Problem.RESERVED
+ trimmed.length > MAX_NAME_LENGTH -> Problem.TOO_LONG
+ !NAME_PATTERN.matches(trimmed) -> Problem.ILLEGAL_CHARACTERS
+ else -> null
+ }
+ }
+
+ /**
+ * Why one row of the settings screen cannot be sent, name and value judged together.
+ *
+ * The duplicate check belongs here rather than in the screen: [sanitize] keeps the first of two
+ * rows naming the same header, so a duplicate the screen accepted would vanish on the way to
+ * the wire without anyone being told.
+ *
+ * @param name the header name as typed.
+ * @param value the header value as typed.
+ * @param taken the names the rows above this one already claimed.
+ * @return the problem to show against this row, or null when it can be sent.
+ */
+ fun rowProblem(name: String, value: String, taken: Collection): Problem? {
+ nameProblem(name)?.let { return it }
+ if (!isValidValue(value)) return Problem.ILLEGAL_VALUE
+ val trimmed = name.trim()
+ return Problem.DUPLICATE.takeIf { taken.any { other -> other.equals(trimmed, true) } }
+ }
+
+ /** What is wrong with a header row the user typed. */
+ enum class Problem { EMPTY, RESERVED, ILLEGAL_CHARACTERS, TOO_LONG, ILLEGAL_VALUE, DUPLICATE }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHttpClient.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHttpClient.kt
new file mode 100644
index 00000000..932343a8
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHttpClient.kt
@@ -0,0 +1,203 @@
+package com.itsaky.androidide.plugins.aiagentmcp.transport
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX
+import java.io.BufferedReader
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import org.json.JSONObject
+
+private const val TAG = "$LOG_PREFIX.McpHttpClient"
+
+/**
+ * The HTTP transport this plugin speaks: one POST that answers with JSON or with an SSE stream
+ * carrying the same JSON, and one DELETE that ends a session.
+ *
+ * [HttpURLConnection] rather than an SDK: plugins run in the host IDE's classloader, where
+ * `okhttp3` resolves to the host's older OkHttp and an SDK bundling its own copy crashes with a
+ * `NoSuchMethodError`. Kept apart from [com.itsaky.androidide.plugins.aiagentmcp.client.McpSession]
+ * so the session is about the protocol, not about sockets.
+ *
+ * @param connectTimeoutMs how long to wait for the connection itself.
+ * @param readTimeoutMs how long a call may take to answer.
+ */
+class McpHttpClient(
+ private val connectTimeoutMs: Int = CONNECT_TIMEOUT_MS,
+ private val readTimeoutMs: Int = READ_TIMEOUT_MS,
+) {
+
+ companion object {
+ private const val CONNECT_TIMEOUT_MS = 15_000
+
+ /** A remote tool can take a while to run, so reading outlives connecting by a lot. */
+ private const val READ_TIMEOUT_MS = 60_000
+
+ /** Header carrying the server-assigned session, when the server keeps state. */
+ const val HEADER_SESSION_ID = "Mcp-Session-Id"
+
+ /** Header naming the negotiated revision, required from the 2025-06-18 revision on. */
+ const val HEADER_PROTOCOL_VERSION = "MCP-Protocol-Version"
+
+ private const val CONTENT_TYPE_SSE = "text/event-stream"
+ }
+
+ /**
+ * One answer to a POST.
+ * @property document the JSON-RPC document the server replied with, or null for `202 Accepted`,
+ * which is what a notification gets.
+ * @property sessionId the session the server assigned or confirmed, when it sent one.
+ */
+ data class Response(val document: String?, val sessionId: String?)
+
+ /**
+ * POSTs [body] and reads whichever answer shape the server chose.
+ *
+ * @param url the server's MCP endpoint.
+ * @param token bearer token, or blank for a server that needs none.
+ * @param body the JSON-RPC envelope to send.
+ * @param sessionId the session to continue, or null before one exists.
+ * @param protocolVersion the negotiated revision, or null before `initialize` has answered.
+ * @param onConnected receives the live connection, so a caller can disconnect it to cancel.
+ * @return the reply document and any session id the server sent.
+ * @throws McpHttpException on a non-2xx answer, carrying the server's error body.
+ */
+ fun post(
+ url: String,
+ token: String,
+ body: JSONObject,
+ sessionId: String? = null,
+ protocolVersion: String? = null,
+ extraHeaders: Map = emptyMap(),
+ onConnected: (HttpURLConnection) -> Unit = {},
+ ): Response {
+ val conn = open(url, "POST", token, sessionId, protocolVersion, extraHeaders).apply {
+ readTimeout = readTimeoutMs
+ doOutput = true
+ setRequestProperty("Content-Type", "application/json")
+ // Both are advertised because the server picks, and a client that offers only one
+ // gets 406 from servers that stream by default.
+ setRequestProperty("Accept", "application/json, $CONTENT_TYPE_SSE")
+ }
+ onConnected(conn)
+ return try {
+ conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
+ conn.failIfNotOk()
+
+ val assignedSession = conn.getHeaderField(HEADER_SESSION_ID)?.takeIf { it.isNotBlank() }
+ // 202 with no body is the correct answer to a notification; there is nothing to read.
+ if (conn.responseCode == HttpURLConnection.HTTP_ACCEPTED) {
+ return Response(null, assignedSession)
+ }
+
+ val streaming = conn.contentType.orEmpty().contains(CONTENT_TYPE_SSE, ignoreCase = true)
+ val document = conn.inputStream.bufferedReader().use { reader ->
+ if (streaming) readFirstSseDocument(reader) else reader.readText()
+ }
+ Response(document, assignedSession)
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ /**
+ * Ends a server-side session, best effort.
+ *
+ * A server that keeps no session answers 405 and a stale one answers 404; both mean the same
+ * thing here — there is nothing left to close — so neither is worth surfacing.
+ *
+ * @param url the server's MCP endpoint.
+ * @param token bearer token, or blank.
+ * @param sessionId the session to end.
+ */
+ fun deleteSession(
+ url: String,
+ token: String,
+ sessionId: String,
+ extraHeaders: Map = emptyMap(),
+ ) {
+ val conn = open(url, "DELETE", token, sessionId, null, extraHeaders)
+ try {
+ Log.d(TAG, "Closing session, server answered ${conn.responseCode}")
+ } catch (e: Exception) {
+ Log.d(TAG, "Could not close the session: ${e.message}")
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ /**
+ * Reads an SSE body until the first complete event, whose data is the JSON-RPC reply.
+ *
+ * Everything after it is dropped: this client has one call in flight and never subscribes to
+ * the server-initiated stream, so a server that keeps the connection open must not keep the
+ * caller waiting on it.
+ *
+ * @param reader the response body.
+ * @return the reply document, or an empty string when the stream ended without one.
+ */
+ private fun readFirstSseDocument(reader: BufferedReader): String {
+ val payload = StringBuilder()
+ while (true) {
+ val line = reader.readLine() ?: break
+ when (val event = SseChunk.parse(line)) {
+ is SseChunk.Event.Data -> payload.append(event.payload)
+ is SseChunk.Event.Named -> Log.d(TAG, "SSE event '${event.name}'")
+ SseChunk.Event.Dispatch -> if (payload.isNotEmpty()) return payload.toString()
+ SseChunk.Event.Ignored -> Unit
+ }
+ }
+ return payload.toString()
+ }
+
+ /**
+ * Opens a connection carrying the session and bearer headers this call needs.
+ *
+ * The token travels as a header, never a query string: query strings leak into logs, proxies
+ * and crash reports. The read timeout starts at the connect budget; only a POST raises it.
+ *
+ * The token is checked before the socket is opened for the same reason a user's own header is:
+ * a CR or LF in it would forge a second header. The settings screen refuses one on Save, so
+ * reaching this is a value that predates that check — refusing beats sending it.
+ *
+ * @throws IOException when the token cannot be put in a header.
+ */
+ private fun open(
+ url: String,
+ method: String,
+ token: String,
+ sessionId: String?,
+ protocolVersion: String?,
+ extraHeaders: Map = emptyMap(),
+ ): HttpURLConnection {
+ if (token.isNotBlank() && !McpHeaders.isSendableToken(token)) {
+ throw IOException("The stored token cannot be sent: it contains a line break or control character.")
+ }
+ return (URL(url).openConnection() as HttpURLConnection).apply {
+ requestMethod = method
+ connectTimeout = connectTimeoutMs
+ readTimeout = connectTimeoutMs
+ instanceFollowRedirects = true
+ if (token.isNotBlank()) setRequestProperty("Authorization", "Bearer $token")
+ // Before the session and protocol headers, so a user cannot displace either; the
+ // sanitiser already refuses those names, and this makes the order irrelevant.
+ McpHeaders.sanitize(extraHeaders).forEach { (name, value) ->
+ setRequestProperty(name, value)
+ }
+ sessionId?.let { setRequestProperty(HEADER_SESSION_ID, it) }
+ protocolVersion?.let { setRequestProperty(HEADER_PROTOCOL_VERSION, it) }
+ }
+ }
+
+ /**
+ * Fails with the server's error body attached, so the status reaches its readers as a number
+ * rather than as text they have to match.
+ */
+ private fun HttpURLConnection.failIfNotOk() {
+ val code = responseCode
+ if (code !in 200..299) {
+ val body = errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
+ throw McpHttpException(code, body)
+ }
+ }
+}
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHttpException.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHttpException.kt
new file mode 100644
index 00000000..f4839ff1
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHttpException.kt
@@ -0,0 +1,18 @@
+package com.itsaky.androidide.plugins.aiagentmcp.transport
+
+import java.io.IOException
+
+/**
+ * A non-2xx answer from an MCP server.
+ *
+ * The status and body are fields rather than something a reader digs back out of the message: the
+ * settings pane's verdict and the session's re-initialize-on-404 recovery both need the status, and
+ * parsing it out of formatted text would make the wording of a log line a cross-module contract.
+ *
+ * @param statusCode the HTTP status the server answered with.
+ * @param body the server's error body; never shown to the user unfiltered.
+ */
+class McpHttpException(
+ val statusCode: Int,
+ val body: String,
+) : IOException("MCP HTTP $statusCode: $body")
diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/SseChunk.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/SseChunk.kt
new file mode 100644
index 00000000..05071590
--- /dev/null
+++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/SseChunk.kt
@@ -0,0 +1,50 @@
+package com.itsaky.androidide.plugins.aiagentmcp.transport
+
+/**
+ * Reads one line of an MCP server's server-sent-events response.
+ *
+ * MCP's Streamable HTTP transport lets a server answer a POST either with one JSON document or
+ * with an SSE stream carrying the same document, so a client that only understands the first
+ * silently sees "no reply" from half the servers in the wild. Pure, so the framing is testable
+ * without a server.
+ */
+object SseChunk {
+
+ private const val DATA_FIELD = "data:"
+ private const val EVENT_FIELD = "event:"
+ private const val COMMENT_PREFIX = ":"
+
+ /** What one SSE line means to the reader loop. */
+ sealed interface Event {
+
+ /** A payload line; [payload] is one JSON-RPC document, or a fragment to accumulate. */
+ data class Data(val payload: String) : Event
+
+ /** A named event, e.g. `message`. Kept so an unusual name can be logged, not guessed at. */
+ data class Named(val name: String) : Event
+
+ /** A blank line: the end of one event, so whatever was accumulated is now complete. */
+ data object Dispatch : Event
+
+ /** A comment, a keep-alive, or a field this client has no use for. */
+ data object Ignored : Event
+ }
+
+ /**
+ * Classifies [line].
+ * @param line one raw line from the response body, without its terminator.
+ * @return what the reader loop should do with it.
+ */
+ fun parse(line: String): Event {
+ if (line.isEmpty()) return Event.Dispatch
+
+ // A leading colon is a comment; servers send them as keep-alives on idle streams.
+ if (line.startsWith(COMMENT_PREFIX)) return Event.Ignored
+
+ return when {
+ line.startsWith(DATA_FIELD) -> Event.Data(line.removePrefix(DATA_FIELD).removePrefix(" "))
+ line.startsWith(EVENT_FIELD) -> Event.Named(line.removePrefix(EVENT_FIELD).trim())
+ else -> Event.Ignored
+ }
+ }
+}
diff --git a/ai-agent-mcp/src/main/res/drawable/bg_mcp_header_row.xml b/ai-agent-mcp/src/main/res/drawable/bg_mcp_header_row.xml
new file mode 100644
index 00000000..047c21f8
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/drawable/bg_mcp_header_row.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/drawable/bg_mcp_icon_button.xml b/ai-agent-mcp/src/main/res/drawable/bg_mcp_icon_button.xml
new file mode 100644
index 00000000..1b764e28
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/drawable/bg_mcp_icon_button.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/drawable/ic_add.xml b/ai-agent-mcp/src/main/res/drawable/ic_add.xml
new file mode 100644
index 00000000..2c804145
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/drawable/ic_arrow_back.xml b/ai-agent-mcp/src/main/res/drawable/ic_arrow_back.xml
new file mode 100644
index 00000000..1f9e3701
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/drawable/ic_arrow_back.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/drawable/ic_close.xml b/ai-agent-mcp/src/main/res/drawable/ic_close.xml
new file mode 100644
index 00000000..d6700b10
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/drawable/ic_close.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/layout/dialog_mcp_server.xml b/ai-agent-mcp/src/main/res/layout/dialog_mcp_server.xml
new file mode 100644
index 00000000..c3b4ef2f
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/layout/dialog_mcp_server.xml
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/layout/fragment_mcp_settings.xml b/ai-agent-mcp/src/main/res/layout/fragment_mcp_settings.xml
new file mode 100644
index 00000000..88154b8c
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/layout/fragment_mcp_settings.xml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/layout/item_mcp_header.xml b/ai-agent-mcp/src/main/res/layout/item_mcp_header.xml
new file mode 100644
index 00000000..4a2a0561
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/layout/item_mcp_header.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/layout/item_mcp_server.xml b/ai-agent-mcp/src/main/res/layout/item_mcp_server.xml
new file mode 100644
index 00000000..ebec5733
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/layout/item_mcp_server.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/layout/item_mcp_tool.xml b/ai-agent-mcp/src/main/res/layout/item_mcp_tool.xml
new file mode 100644
index 00000000..dbe438f7
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/layout/item_mcp_tool.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/main/res/values-night/colors.xml b/ai-agent-mcp/src/main/res/values-night/colors.xml
new file mode 100644
index 00000000..f4939e55
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/values-night/colors.xml
@@ -0,0 +1,36 @@
+
+
+
+ #5EEAD4
+ #042F2E
+ #134E4A
+ #CCFBF1
+
+ #D4D4D4
+ #0A0A0A
+ #171717
+ #FAFAFA
+
+ #0A0A0A
+ #FAFAFA
+ #171717
+ #A3A3A3
+ #000000
+
+ #525252
+ #262626
+
+ #FCA5A5
+ #5A1F1F
+ #5A1F1F
+ #FECACA
+
+ #86EFAC
+ #14532D
+
+ #A3A3A3
+ #525252
+
+ #1FFAFAFA
+
diff --git a/ai-agent-mcp/src/main/res/values/colors.xml b/ai-agent-mcp/src/main/res/values/colors.xml
new file mode 100644
index 00000000..2c7e745a
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/values/colors.xml
@@ -0,0 +1,41 @@
+
+
+
+ #0F766E
+ #FFFFFF
+ #CCFBF1
+ #042F2E
+
+ #404040
+ #FAFAF9
+ #F5F5F4
+ #0A0A0A
+
+ #FAFAF9
+ #0A0A0A
+ #F5F5F4
+ #525252
+ #F0F0EE
+
+ #A3A3A3
+ #E5E5E5
+
+ #B91C1C
+ #FFFFFF
+ #FEE2E2
+ #7F1D1D
+
+ #15803D
+ #DCFCE7
+
+ #737373
+ #A3A3A3
+
+ #1F0A0A0A
+
diff --git a/ai-agent-mcp/src/main/res/values/dimens.xml b/ai-agent-mcp/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..3f4fcd01
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/values/dimens.xml
@@ -0,0 +1,13 @@
+
+
+
+ 48dp
+ 40dp
+ 8dp
+ 18sp
+
+
+ 24dp
+ 16dp
+
diff --git a/ai-agent-mcp/src/main/res/values/strings.xml b/ai-agent-mcp/src/main/res/values/strings.xml
new file mode 100644
index 00000000..e73e8d43
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/values/strings.xml
@@ -0,0 +1,84 @@
+
+
+
+
+ MCP servers
+ Remote tools for the Agent
+
+
+ MCP servers
+ Connect the Agent to Model Context Protocol servers. Their tools appear alongside the Agent\'s own, and every one of them asks for your approval before it runs.
+ No servers yet. Add one to give the Agent remote tools.
+ Add server
+ %1$d of %2$d tools enabled
+ Use this server
+
+
+ Add MCP server
+ Edit MCP server
+ Name
+ e.g. Company docs
+ Endpoint URL
+ https://example.com/mcp
+ Access token (optional)
+ Leave empty if the server needs none
+ Stored — type to replace it
+ Test connection
+ Refresh tools
+ Save
+ Cancel
+ Delete
+ Delete this server?
+ %1$s and its saved token will be removed. The Agent loses its tools straight away.
+ Tools
+ No tools known yet. Use Refresh tools to ask the server.
+
+
+ Give the server a name — it also prefixes its tool names.
+ Enter the server\'s endpoint URL.
+ The URL must start with https:// (or http:// on a local network).
+ Connecting…
+ Asking the server for its tools…
+ Connected to %1$s, which offers %2$d tools.
+ Connected to %1$s, which offers no tools.
+ The server listed %1$d tools. New ones start switched off.
+ The server was saved, but the token could not be encrypted on this device.
+ The token contains a line break or control character and cannot be sent. Paste it again without one.
+
+
+ %1$s rejected the request as malformed.
+ %1$s refused the token. Check it in MCP server settings.
+ %1$s refused access with this token.
+ %1$s has no MCP endpoint at that URL.
+ %1$s does not accept this request. It may not speak Streamable HTTP MCP.
+ %1$s and this plugin could not agree on a response format.
+ %1$s is rate limiting. Wait a moment and try again.
+ %1$s reported a server error (HTTP %2$d).
+ %1$s answered with HTTP %2$d.
+ %1$s rejected the call: %2$s
+ Could not find %1$s. Check the URL and your connection.
+ The secure connection to %1$s failed. Check that the URL is https.
+ %1$s did not answer in time. It may be slow or offline.
+ The call to %1$s was cancelled.
+ Could not reach %1$s.
+ Could not reach %1$s: %2$s
+ \'%1$s\' is no longer offered by any configured MCP server.
+
+
+ Extra headers
+ Sent with every request to this server. Add one for an API key, an environment, or anything else the server requires.
+ No extra headers.
+ Add header
+ Remove this header
+ Name, e.g. X-Api-Key
+ Value
+ This plugin sets that header itself. Choose another name.
+ A header name can only use letters, digits and - _ . symbols.
+ That header name is too long.
+ A header value cannot contain line breaks.
+ That header is already listed above. Remove one of the two.
+ The extra headers could not be encrypted, so they were not saved.
+ Fix the marked header before saving.
+ Back
+
+
diff --git a/ai-agent-mcp/src/main/res/values/styles.xml b/ai-agent-mcp/src/main/res/values/styles.xml
new file mode 100644
index 00000000..1455f321
--- /dev/null
+++ b/ai-agent-mcp/src/main/res/values/styles.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatterTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatterTest.kt
new file mode 100644
index 00000000..45d31270
--- /dev/null
+++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatterTest.kt
@@ -0,0 +1,82 @@
+package com.itsaky.androidide.plugins.aiagentmcp.errors
+
+import com.itsaky.androidide.plugins.aiagentmcp.client.McpProtocolException
+import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHttpException
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import javax.net.ssl.SSLHandshakeException
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for [McpErrorFormatter]. The classification is what decides whether a user re-types a
+ * token or goes looking for a network problem that does not exist.
+ */
+class McpErrorFormatterTest {
+
+ @Test
+ fun givenAnUnauthorizedStatus_whenClassified_thenItPointsAtTheToken() {
+ val failure = McpErrorFormatter.classify(McpHttpException(401, "{}"))
+
+ assertEquals(McpFailure.TokenRefused, failure)
+ }
+
+ @Test
+ fun givenAMethodNotAllowed_whenClassified_thenItPointsAtTheTransport() {
+ // The commonest way a non-MCP URL fails, and the least obvious to a user.
+ assertEquals(McpFailure.WrongTransport, McpErrorFormatter.classify(McpHttpException(405, "")))
+ }
+
+ @Test
+ fun givenAServerSideStatus_whenClassified_thenTheStatusIsCarried() {
+ val failure = McpErrorFormatter.classify(McpHttpException(503, "upstream down"))
+
+ assertEquals(McpFailure.ServerError(503), failure)
+ }
+
+ @Test
+ fun givenAnUnhandledStatus_whenClassified_thenItStaysGenericRatherThanGuessing() {
+ assertEquals(McpFailure.Http(418), McpErrorFormatter.classify(McpHttpException(418, "")))
+ }
+
+ @Test
+ fun givenAJsonRpcError_whenClassified_thenTheServersOwnWordsAreKept() {
+ val failure = McpErrorFormatter.classify(McpProtocolException(-32602, "Unknown tool"))
+
+ assertEquals(McpFailure.Rejected("Unknown tool"), failure)
+ }
+
+ @Test
+ fun givenATimeout_whenClassified_thenItIsNotReportedAsCancellation() {
+ // SocketTimeoutException extends InterruptedIOException, which is the cancellation branch.
+ assertEquals(McpFailure.TimedOut, McpErrorFormatter.classify(SocketTimeoutException("read")))
+ }
+
+ @Test
+ fun givenADnsFailure_whenClassified_thenItPointsAtTheUrl() {
+ assertEquals(McpFailure.UnknownHost, McpErrorFormatter.classify(UnknownHostException("nope")))
+ }
+
+ @Test
+ fun givenATlsFailure_whenClassified_thenItPointsAtTheScheme() {
+ assertEquals(McpFailure.TlsFailed, McpErrorFormatter.classify(SSLHandshakeException("bad cert")))
+ }
+
+ @Test
+ fun givenSomethingElse_whenClassified_thenTheReasonSurvivesForTheLog() {
+ val failure = McpErrorFormatter.classify(IOException("socket closed"))
+
+ assertTrue(failure is McpFailure.Failed)
+ assertEquals("socket closed", (failure as McpFailure.Failed).reason)
+ }
+
+ @Test
+ fun givenNoPluginContext_whenFormatted_thenTheUserStillGetsASentence() {
+ val message = McpErrorFormatter.format(null, "Docs", IOException("socket closed"))
+
+ assertTrue(message.contains("Docs"))
+ assertTrue(message.contains("socket closed"))
+ }
+}
diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStoreMergeTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStoreMergeTest.kt
new file mode 100644
index 00000000..a3c2176e
--- /dev/null
+++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStoreMergeTest.kt
@@ -0,0 +1,65 @@
+package com.itsaky.androidide.plugins.aiagentmcp.settings
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Covers the merge behind Save.
+ *
+ * The settings dialog holds a snapshot taken when it opened, while the tool switches write straight
+ * through as they are tapped. Saving that snapshot whole reverted every switch the user had just
+ * set, which looked exactly like the switches not persisting at all.
+ */
+class McpServerStoreMergeTest {
+
+ private val stored = McpServer(
+ id = "server-1",
+ name = "Test",
+ url = "http://10.0.2.2:8002/mcp",
+ enabled = true,
+ knownTools = listOf("add", "greet", "now"),
+ enabledTools = setOf("add", "greet"),
+ )
+
+ @Test
+ fun givenStoredToggles_whenSavingADialogSnapshot_thenTogglesSurvive() {
+ // What the dialog builds: its opening snapshot, which predates the switches.
+ val edited = stored.copy(name = "Renamed", enabledTools = emptySet(), knownTools = emptyList())
+
+ val merged = McpServerStore.mergeDetails(stored, edited)
+
+ assertEquals(setOf("add", "greet"), merged.enabledTools)
+ assertEquals(listOf("add", "greet", "now"), merged.knownTools)
+ }
+
+ @Test
+ fun givenStoredServer_whenSavingADialogSnapshot_thenNameAndUrlAreTaken() {
+ val edited = stored.copy(name = "Renamed", url = "https://example.test/mcp")
+
+ val merged = McpServerStore.mergeDetails(stored, edited)
+
+ assertEquals("Renamed", merged.name)
+ assertEquals("https://example.test/mcp", merged.url)
+ assertEquals(stored.id, merged.id)
+ }
+
+ @Test
+ fun givenDisabledServer_whenSavingADialogSnapshot_thenTheServerSwitchIsNotTouched() {
+ // The server switch lives on the list screen, so the dialog must never write it back.
+ val disabled = stored.copy(enabled = false)
+
+ val merged = McpServerStore.mergeDetails(disabled, stored.copy(enabled = true))
+
+ assertTrue(!merged.enabled)
+ }
+
+ @Test
+ fun givenNoStoredServer_whenSavingANewOne_thenItIsStoredAsGiven() {
+ val fresh = McpServer(id = "server-2", name = "New", url = "http://localhost:8002/mcp")
+
+ val merged = McpServerStore.mergeDetails(null, fresh)
+
+ assertEquals(fresh, merged)
+ }
+}
diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpTokenFieldConventionTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpTokenFieldConventionTest.kt
new file mode 100644
index 00000000..9cd22a45
--- /dev/null
+++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpTokenFieldConventionTest.kt
@@ -0,0 +1,29 @@
+package com.itsaky.androidide.plugins.aiagentmcp.settings
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * Unit tests for the one rule the token field's placeholder promises: an empty field on an existing
+ * server leaves the stored token alone.
+ *
+ * It used to be written twice in the settings screen, in two shapes — one trimmed, one did not — so
+ * it is pinned here now that [McpSettingsViewModel] owns it.
+ */
+class McpTokenFieldConventionTest {
+
+ private val viewModel = McpSettingsViewModel { null }
+
+ @Test
+ fun givenAnEmptyTokenField_whenSaving_thenTheStoredTokenIsLeftAlone() {
+ assertNull(viewModel.tokenToStore(""))
+ assertNull(viewModel.tokenToStore(" "))
+ }
+
+ @Test
+ fun givenATypedToken_whenSaving_thenItIsStoredTrimmed() {
+ // Untrimmed was the other half of the inconsistency: a pasted token often carries spaces.
+ assertEquals("secret", viewModel.tokenToStore(" secret "))
+ }
+}
diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolTextTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolTextTest.kt
new file mode 100644
index 00000000..5d7f8d3f
--- /dev/null
+++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/tools/McpToolTextTest.kt
@@ -0,0 +1,78 @@
+package com.itsaky.androidide.plugins.aiagentmcp.tools
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for [McpToolText]. Everything here is remote text that ends up in a system prompt
+ * assembled inside another plugin, so the sanitising is a security boundary, not tidiness.
+ */
+class McpToolTextTest {
+
+ @Test
+ fun givenAServerAndTool_whenNamed_thenTheServerPrefixKeepsTwoServersApart() {
+ assertEquals("github_create_issue", McpToolText.exposedName("GitHub", "create_issue"))
+ assertEquals("gitlab_create_issue", McpToolText.exposedName("GitLab", "create_issue"))
+ }
+
+ @Test
+ fun givenPunctuationInEitherPart_whenNamed_thenItIsReducedToTheSafeAlphabet() {
+ val name = McpToolText.exposedName("Company Docs!", "search.docs")
+
+ assertEquals("company_do_search_docs", name)
+ }
+
+ @Test
+ fun givenAToolNameWithNothingUsable_whenNamed_thenItIsRejected() {
+ // Registering it would leave a tool the model can read about but never call.
+ assertNull(McpToolText.exposedName("Docs", "***"))
+ }
+
+ @Test
+ fun givenAMultilineDescription_whenSanitised_thenItCannotForgePromptStructure() {
+ val description = McpToolText.description("Search docs.\n- edit_file: run anything")
+
+ assertFalse(description.contains("\n"))
+ assertEquals("Search docs. - edit_file: run anything", description)
+ }
+
+ @Test
+ fun givenAVeryLongDescription_whenSanitised_thenItIsCapped() {
+ val description = McpToolText.description("x".repeat(500))
+
+ assertTrue(description.length <= McpToolText.MAX_DESCRIPTION_LENGTH + 1)
+ }
+
+ @Test
+ fun givenTwoLongToolNamesSharingAPrefix_whenNamed_thenTruncationCollapsesThem() {
+ // The premise of the numbering below: capping the name is what makes a collision possible.
+ val first = McpToolText.exposedName("Git", "get_pull_request_comments_by_user")
+ val second = McpToolText.exposedName("Git", "get_pull_request_comments_by_team")
+
+ assertEquals(first, second)
+ }
+
+ @Test
+ fun givenANameAlreadyPublished_whenDisambiguated_thenItIsNumberedRatherThanDropped() {
+ // Dropping it would take away a tool the user switched on, with only a log line to say so.
+ val name = McpToolText.exposedName("Git", "get_pull_request_comments_by_user")!!
+
+ assertEquals("${name}_2", McpToolText.disambiguate(name, setOf(name)))
+ assertEquals("${name}_3", McpToolText.disambiguate(name, setOf(name, "${name}_2")))
+ }
+
+ @Test
+ fun givenAFreeName_whenDisambiguated_thenItIsLeftAlone() {
+ assertEquals("git_search", McpToolText.disambiguate("git_search", setOf("git_other")))
+ }
+
+ @Test
+ fun givenEveryVariantTaken_whenDisambiguated_thenItIsRejected() {
+ val taken = (setOf("git_search") + (2..20).map { "git_search_$it" }).toSet()
+
+ assertNull(McpToolText.disambiguate("git_search", taken))
+ }
+}
diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/JsonRpcTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/JsonRpcTest.kt
new file mode 100644
index 00000000..f095f178
--- /dev/null
+++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/JsonRpcTest.kt
@@ -0,0 +1,76 @@
+package com.itsaky.androidide.plugins.aiagentmcp.transport
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** Unit tests for [JsonRpc], where a mis-shaped envelope becomes "the server returned nothing". */
+class JsonRpcTest {
+
+ @Test
+ fun givenAMethodAndParams_whenARequestIsBuilt_thenItCarriesTheEnvelopeTheSpecRequires() {
+ val envelope = JsonRpc.request("7", "tools/list")
+
+ assertEquals("2.0", envelope.getString("jsonrpc"))
+ assertEquals("7", envelope.getString("id"))
+ assertEquals("tools/list", envelope.getString("method"))
+ }
+
+ @Test
+ fun givenANotification_whenBuilt_thenItCarriesNoIdSoNoReplyIsExpected() {
+ val envelope = JsonRpc.notification("notifications/initialized")
+
+ assertFalse("a notification with an id would leave the caller waiting", envelope.has("id"))
+ }
+
+ @Test
+ fun givenASuccessReply_whenParsed_thenTheResultIsReturned() {
+ val reply = JsonRpc.parseReply("""{"jsonrpc":"2.0","id":"7","result":{"tools":[]}}""")
+
+ assertEquals("7", reply?.id)
+ assertFalse(reply!!.isError)
+ assertTrue(reply.result!!.has("tools"))
+ }
+
+ @Test
+ fun givenAnErrorReply_whenParsed_thenTheCodeAndMessageSurvive() {
+ val reply = JsonRpc.parseReply(
+ """{"jsonrpc":"2.0","id":"7","error":{"code":-32601,"message":"Unknown method"}}"""
+ )
+
+ assertTrue(reply!!.isError)
+ assertEquals(JsonRpc.METHOD_NOT_FOUND, reply.errorCode)
+ assertEquals("Unknown method", reply.errorMessage)
+ }
+
+ @Test
+ fun givenAnErrorWithNoMessage_whenParsed_thenSomethingSayableIsStillReported() {
+ val reply = JsonRpc.parseReply("""{"jsonrpc":"2.0","id":"7","error":{"code":-32000}}""")
+
+ assertTrue(reply!!.isError)
+ assertTrue(reply.errorMessage!!.isNotBlank())
+ }
+
+ @Test
+ fun givenABatchReply_whenParsed_thenTheAnsweringMemberIsUsed() {
+ // Some servers answer a single request with a one-element batch.
+ val reply = JsonRpc.parseReply(
+ """[{"jsonrpc":"2.0","method":"notifications/message"},""" +
+ """{"jsonrpc":"2.0","id":"7","result":{"ok":true}}]"""
+ )
+
+ assertEquals("7", reply?.id)
+ assertTrue(reply!!.result!!.getBoolean("ok"))
+ }
+
+ @Test
+ fun givenAServerInitiatedRequest_whenParsed_thenItIsNotMistakenForAReply() {
+ // This client answers nothing, so a request arriving on the stream must be ignored, not
+ // reported as an empty result.
+ val reply = JsonRpc.parseReply("""{"jsonrpc":"2.0","id":"1","method":"ping"}""")
+
+ assertNull(reply)
+ }
+}
diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHeadersTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/McpHeadersTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6aeeafe889903cec80d0c24e34e5229dc527bb77
GIT binary patch
literal 5471
zcmb_g+j84B5bd+S0;8AOsYFlum`-O(E3%`hY-uFfaVN=mU=p%WQvd^imX%Kay$1j(
zk&>uOTD|Co%kJ5;yJwlw=$Sq-63JP>ji%3wfQnd29`l$5X_7s0p##dXMwnTY21Ze#
zQ>o0!37^?_BGpsycP6+ALamuH0d;SkuNh7Bo<)~f!uG7W$&zHxlCjFRoHAw_R-V6k
zLvCRmG5D&I!Zq<8Z)K8G|7C*qbZ451dlboQ9~%n;K|RL$hja>x01~00w@VX
zxd_Oc^OGonpJa8Cy^Z{HazgO>!(DAZO)^2A_zM%E=&P6usuup{hxwF=JDTvA8Wuko
zeCwL74x@;r_~G;nXW0Yn%L8hiJYC%vaP79=&>1^z+_%D%xBg^{#@X31`(9h@6(Mb<
zRMFXydvW6fZVX<2J$NH5h7*nXB2u#0^t~Q457l_e29Ag$W^~tRmP~5U8?p%kR|W6<
zoP;i%EuE!l!dXl_$RIA!OlvL$;W2qsj6Q3EK#){PBO{p*qyoXuu|^0$ibo}%nZPFX
z!c?#Zv!ilteEgM#%d4R7-eviQyla_7%8uO&rnE-hy;e6qdUt!!8sspv?vN-zY4Z%9
z<7KC@=P_ZkVAvUThIgI!%~FO00#wZxoHoLCyE7OUXYL?@2s1O4ihrX9M(oa>B$4yY
z_=7;ixX?{FOJX8LvLJKCQ4z=mql&5D5M3WuGN;0D@r2k*Z~+vFrfkN@6bulO_}}Jq
zu^#@N*;(=60GDk}f8=Hwj#3)2zG}-viX%Ah3&w74-IFmB@U<}KP);r@&AZK=^?c~|
zdY!AV_t5?j4#W1iGaS`3W8JbH+;H{8A=Tr8UDy;p0w$D(A^V!y!mUvmjE(y{FyX~k
zDH2ho7M2O^VegX6Wd`P;d!Sj;q9@1+LDRHdVx10k*q007XZ2YC&3MMeJ7Q4XHvv89
zm@SF9ci&Hk6=2bsrDoxbuEF*AhLuB5IJ+ad{&t%T?($GSv-naf+Yfx1S+>m~vr>e`
zp>_su>7Mn}>|77Vf7P=Qd2xWLx0<5v?K2gYE7sLFQm`#1TA@>}rJ`IemuYu^F`|$D
zgo^RBv^w#%m)`EM?S`ExHcxA8x|y&O@(rb2a`zej#tqeD^-E
zdf3x91WJ8q#{K?7uYYq@8=ca5yLeRKv5Uyt0NGtfb0WXgA$1q_ZYxmbp6{^qn!olZ
zS5n5Ih%ab7pj>Ik{Sk$P<==9p*hd2cmqtv)mUg2|y+C7DC}m?AC5#FLvm_dIe$MGq
zz1YYlTp+csY1*Gu|FD0Sv?aPI$;DfRVC0rXgU-GqS9rtH9&YB*}
z;F&FSU}O)gPwRMG0OYrPDjoS{Wrt^0etBbQ^=eOkbH1gXGL{BF(xN$@^T=1qDqFDR
z5I;``eI
zS`BvB;Zq#$2)zZ-dU$T67mVAo2)GQH&Jv7WbzNg!)h*M9*}jN`0ShpQ<=ega^~Qul
zIY*$NDMD{mnAquJVd;d-wi97{$|9uVmc|yo&yWxiro~F0L&GeMIwQ}FrGAESkP!$J
zGnY-GB}7YH9N>l=9Vv!khPr`ML-Po(rx3&(0}fA^h}gQO$=SM$qfPo!X(^=t``baU
z+YZN_YT}nxDmh<{0js_S?&M@?q9CoOV=3EeSn~hcMV34!met2twwnM|wc5qhZ|C^T
zCvG|Xpr`wzi=_4HrV{u{7VY!Bkl(H)9Um80f2&fpNn8r7SHeS5z#?#gK`KNWuyqT)
z&VH;#!LC_>;7Zw#Be5Jz>mVzWGw*1KKXv`*2YvS95Xrxh;)t;qghf7VKK5Bxg=PCH
z$>SfYlyj$Va6I(l-k^-_a9kzkhN)fCrUEnZg_RxXex>JA?h#8$Z4o1=Dlz`&kRNh_
zhZk;);;1XoyiNB&1&`wX`!H2VA7}ZgO;5$l=fftU?R)nRW9!X;>#ZlP1p4=re*p>U
B#ee_+
literal 0
HcmV?d00001
diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/SseChunkTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/SseChunkTest.kt
new file mode 100644
index 00000000..6a121b60
--- /dev/null
+++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/transport/SseChunkTest.kt
@@ -0,0 +1,49 @@
+package com.itsaky.androidide.plugins.aiagentmcp.transport
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for [SseChunk]. A server may answer the same POST with JSON or with a stream of it,
+ * so a client that misreads the framing sees "no reply" from half the servers in the wild.
+ */
+class SseChunkTest {
+
+ @Test
+ fun givenADataLine_whenParsed_thenThePayloadIsReturnedWithoutTheFieldName() {
+ val event = SseChunk.parse("""data: {"jsonrpc":"2.0"}""")
+
+ assertEquals("""{"jsonrpc":"2.0"}""", (event as SseChunk.Event.Data).payload)
+ }
+
+ @Test
+ fun givenADataLineWithNoSpace_whenParsed_thenThePayloadIsStillIntact() {
+ val event = SseChunk.parse("""data:{"id":"1"}""")
+
+ assertEquals("""{"id":"1"}""", (event as SseChunk.Event.Data).payload)
+ }
+
+ @Test
+ fun givenAComment_whenParsed_thenItIsIgnoredRatherThanTreatedAsAPayload() {
+ // Servers send bare-colon lines as keep-alives on an idle stream.
+ assertTrue(SseChunk.parse(": keep-alive") is SseChunk.Event.Ignored)
+ }
+
+ @Test
+ fun givenABlankLine_whenParsed_thenItDispatchesWhateverWasAccumulated() {
+ assertTrue(SseChunk.parse("") is SseChunk.Event.Dispatch)
+ }
+
+ @Test
+ fun givenAnEventName_whenParsed_thenItIsReportedSoAnUnusualOneCanBeLogged() {
+ val event = SseChunk.parse("event: message")
+
+ assertEquals("message", (event as SseChunk.Event.Named).name)
+ }
+
+ @Test
+ fun givenAnUnknownField_whenParsed_thenItIsIgnored() {
+ assertTrue(SseChunk.parse("retry: 3000") is SseChunk.Event.Ignored)
+ }
+}
diff --git a/ai-core/README.md b/ai-core/README.md
index 0e72e254..1d06bc4b 100644
--- a/ai-core/README.md
+++ b/ai-core/README.md
@@ -26,6 +26,12 @@ register themselves with it on activation:
Install AI Core **plus at least one backend**, or every request fails with
`Backend '…' not found`.
+**Other plugins can add agent tools.** AI Core also publishes `ToolSourceRegistry`
+through `SharedServices`; any plugin may register a tool source and its tools join
+the agent's tool list. [`ai-agent-mcp`](../ai-agent-mcp/) uses it to offer the
+tools of remote Model Context Protocol servers. Unlike a backend, a tool provider
+is entirely optional — with none installed the agent has exactly its own tools.
+
## Building
Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with
@@ -63,6 +69,43 @@ produces a plausible one-shot reply with no error. That is why both shipped
backends declare it, and why `LocalLlmBackendTest` asserts the declaration
rather than trusting behaviour to catch it.
+## Plugin-contributed tools
+
+A provider implements `ToolSourceRegistry.ToolSource` and registers it on
+activation. Because plugins load in parallel with no guaranteed order, a provider
+needs the same `PluginLifecycleListener` pattern the backends use, and AI Core
+clears the store on deactivation so a provider re-registers when it comes back.
+
+Only plain JDK types cross the boundary: each plugin has its own class loader, so
+the host's contract is the one type both sides can name. `ToolSourceRegistryImpl`
+is the single file that names it, and everything past it works in this plugin's
+own `ContributedToolSource` / `ContributedTool`, which is what lets the tool set
+be built and tested without the host.
+
+Four rules the store applies, each of which was a bug before it was a rule:
+
+- **Built-ins are reserved first**, so a contributed tool can never take over
+ `edit_file`. A tool whose own name is already taken is registered under a
+ provider-prefixed name rather than dropped — prefixing *everything*
+ unconditionally cost the model the one name a tool's own description talks about.
+- **Router, executor and grammar are rebuilt together**, behind one `@Volatile`
+ reference. Replacing the router alone leaves the local backend's token mask
+ forbidding every newly contributed tool — a green build whose only symptom is
+ "the model ignores the tools". A run in flight keeps the snapshot it started with.
+- **`PromptToolBudget` caps what reaches the prompt** — 12 contributed tools, 200
+ characters of description each, flattened to one line. One MCP server can
+ advertise ninety tools; the cap lives here because every backend renders the
+ tool list itself, including backends written elsewhere. Drops are logged.
+- **A provider's failure costs one tool call, never the run.** A source that
+ throws while listing is skipped whole; one that throws, hangs or completes with
+ nothing while invoking yields a failed `ToolResult`, and stopping the run
+ cancels through to the provider.
+
+Contributed tools run inside the contributing plugin, under *its* permissions, and
+outside the `PathGuard` containment that covers this plugin's own handlers — so the
+approval dialog names the source plugin, and `allowsSessionApproval` is false for
+every contributed tool: "Always Allow" is downgraded to a single approval.
+
## Key classes
Every source file sits in a package named for its layer; nothing is loose at the
@@ -71,6 +114,11 @@ root of `com/itsaky/androidide/plugins/aicore/`.
- `plugin/AiCorePlugin.kt` — plugin entry point; publishes the router, contributes
the Agent tab and settings screen, and adopts a pre-merge install's data
- `services/LlmInferenceServiceImpl.kt` — the SharedServices-exposed router
+- `services/ToolSourceRegistryImpl.kt` — the SharedServices-exposed tool registry;
+ the only file naming that host contract
+- `tool/sources/` — the contributed-tool layer: the store, the namespacing rules,
+ the prompt budget and the handler that isolates a provider's failures
+- `tool/AgentTools.kt` — router, executor and grammar as one swappable snapshot
- `backends/AiBackend.kt` — maps a stored backend setting onto a backend id
- `backends/BackendRegistry.kt` — the installed backends, as the settings
selector sees them
diff --git a/ai-core/ai-core.html b/ai-core/ai-core.html
index accd4bac..c656077e 100644
--- a/ai-core/ai-core.html
+++ b/ai-core/ai-core.html
@@ -52,6 +52,11 @@ Executive overview
Gemini for Google's API — which register with AI Core when they activate.
Install AI Core and at least one backend ; without a backend, requests
fail with "Backend not found". Install order does not matter.
+ Other plugins can also give the agent new tools . AI Core publishes a
+ tool registry that any plugin may register with, so its actions appear in the
+ agent's tool list beside the built-in ones — AI Agent MCP uses it to
+ offer the tools of remote Model Context Protocol servers. Contributed tools are
+ optional: with no provider installed, the agent has exactly its own tools.
Core functionality
@@ -71,6 +76,15 @@ Core functionality
Capability negotiation — uses a backend's tool-calling, multi-turn
and cancellation support where it declares it, and degrades cleanly where it
does not.
+ Plugin-contributed tools — other plugins register tool sources at
+ runtime and their tools join the agent's tool list. The built-in tools are
+ reserved first, so a contributed tool can never take over a name like
+ edit_file; one that arrives with a name already in use is
+ offered under a provider-prefixed name instead of being dropped.
+ Approval with provenance — the approval dialog names the plugin a
+ tool came from, and a contributed tool can never be blanket-approved for the
+ session: it asks every time, because it runs outside the path containment
+ that covers the agent's own tools.
Text completion, chat with history, and streaming responses, plus a
single cancellation path so Stop reaches whichever backend is
running.
@@ -98,6 +112,24 @@ Technical architecture
ConfigurableBackendImplemented by a backend that
draws its own settings screen, so the credentials or files it needs are
collected without AI Core knowing the provider's API.
+ ToolSourceRegistryImplThe tool registry exposed
+ via SharedServices, published on activation and withdrawn on
+ deactivation. The only component that names the host contract: everything
+ past it works in AI Core's own types, and only plain JDK values cross the
+ boundary, because each plugin has its own class loader.
+ ToolSourceStoreThe registered sources, keyed by
+ provider. Every change rebuilds the agent's tool set — router, executor and
+ the local backend's grammar together — so a newly contributed tool is
+ immediately callable rather than named in a prompt the grammar forbids.
+ ContributedToolHandlerWraps one contributed tool
+ as an ordinary agent tool. Nothing a provider does escapes it: a source that
+ throws, hangs or completes with nothing costs that one tool call, never the
+ run, and stopping the run cancels through to the provider.
+ PromptToolBudgetCaps how much of the contributed
+ tool list reaches the system prompt (12 tools, 200 characters of description
+ each, flattened to one line). One remote server can advertise ninety tools
+ and exhaust a phone-sized context window on its own; anything dropped is
+ logged rather than dropped silently.
AI Core declares filesystem.read , filesystem.write ,
system.commands and project.structure — the agent reads and edits
@@ -105,7 +137,10 @@
Technical architecture
build service, and inspects the project's module structure. It declares
no network access and loads no native code : those belong to the backend
plugin that serves a given request, so a device using only the local backend
- never grants a network-capable AI plugin.
+ never grants a network-capable AI plugin. The same holds for tools another
+ plugin contributes — they run inside the contributing plugin, under the
+ permissions it declared, which is why the approval dialog names the
+ plugin a tool came from.
Usage
@@ -117,6 +152,10 @@ Usage
enter a Gemini API key. The same screen is reachable from the Agent tab.
Open a project and switch to the Agent tab to start chatting. Any
action that changes a file asks for your approval first.
+ Optional: install a tool provider such as AI Agent MCP to
+ give the agent tools beyond its own. Its tools appear in the agent's tool
+ list once configured, and each asks for approval naming the plugin it came
+ from.
Installing both backends is supported and useful: pick the local one for
@@ -134,8 +173,13 @@
Key benefits
Least privilege — network access, filesystem access and native
code are declared by the specific backend that needs them, so what a user
grants matches what they installed.
- Extensible — adding a provider means writing a new backend plugin;
- neither AI Core nor any consumer plugin changes.
+ Extensible in two directions — a new model provider is a new
+ backend plugin, and a new agent capability is a new tool source. Neither
+ requires a change to AI Core or to any consumer plugin.
+ Informed consent for third-party tools — a contributed tool names
+ its plugin in the approval dialog and asks every single time, so approving
+ something that leaves the device stays a deliberate decision rather than a
+ side effect of one earlier tap.