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

+ + +

Technical architecture

+ + + + + + + + + + +
ComponentRole
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 / SseChunkThe 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

+
    +
  1. Install AI Core and a backend, then install AI Agent MCP via + the Plugin Manager.
  2. +
  3. Open Preferences → Configuration → MCP servers and tap + Add server.
  4. +
  5. 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.
  6. +
  7. Tap Test connection, then Refresh tools to list what the + server offers.
  8. +
  9. Switch on the tools you want, and save. Ask the Agent for something those + tools cover; it will request your approval before each call.
  10. +
+
+ 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

+ + +

Limitations

+ + +

Troubleshooting

+ + + + + + +
MessageWhat 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 AgentCheck 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