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
+
McpPlugin
Plugin 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.
+
McpToolSource
The 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.
+
McpToolCatalog
What 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.
+
McpSession
One 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.
+
McpToolText
Sanitises server-supplied names and
+ descriptions — untrusted remote text that would otherwise land verbatim in a
+ prompt assembled inside a third-party backend plugin.
+
SecureTokenStore
AES/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 00000000..1268a891
Binary files /dev/null and b/ai-agent-mcp/src/main/assets/icon_day.png differ
diff --git a/ai-agent-mcp/src/main/assets/icon_night.png b/ai-agent-mcp/src/main/assets/icon_night.png
new file mode 100644
index 00000000..200eab31
Binary files /dev/null and b/ai-agent-mcp/src/main/assets/icon_night.png differ
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