IntelliJ Plugin Hot-Reload: HTTP 413

185 MB. That’s where my IntelliJ plugin stopped hot-reloading.

I’ve been working on MCP Steroid, an IntelliJ plugin that exposes IDE APIs to LLM agents via an MCP server. It bundles a Kotlin compiler, an OCR engine, and enough other dependencies to reach 185 MB as a ZIP.

That size mattered because MCP Steroid changes fast. An agent tries a skill, hits an edge case, I fix the handler, redeploy, and the agent tries again. Restarting the IDE for every build kills that feedback loop. That is why I built intellij-plugin-hot-reload (background post): an HTTP endpoint on IntelliJ’s built-in server on port 63342 that deploys plugins without a restart. My Gradle deployPlugin task finds running IDEs via marker files, POSTs the ZIP, and the IDE reloads the plugin on the fly.

Then I got this:

→ http://localhost:63342/api/plugin-hot-reload
  ✗ HTTP 413

The Investigation

I first assumed the bug was in my handler. It wasn’t. IntelliJ returned 413 before my code ever ran. curl -v showed the same thing:

curl -v -X POST "http://localhost:63342/api/plugin-hot-reload" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary "@mcp-steroid-185mb.zip"
> Content-Length: 194059789
> Expect: 100-continue
>
< HTTP/1.1 413 Request Entity Too Large
< content-length: 0

An empty body. No hint. Just 413.

IntelliJ’s built-in HTTP server uses Netty. The HttpRequestHandler extension point receives a FullHttpRequest. That word matters. Netty’s HttpObjectAggregator builds the full request body before the handler runs, and it returns 413 when the request exceeds its limit.

The aggregator is added in NettyUtil.addHttpServerCodec():

pipeline.addLast("httpObjectAggregator", new HttpObjectAggregator(MAX_CONTENT_LENGTH));

And MAX_CONTENT_LENGTH comes from NettyUtil.java:

public static final int MAX_CONTENT_LENGTH;

static {
  int maxContentLength = 180;
  try {
    maxContentLength = Integer.parseInt(
      System.getProperty("ide.netty.max.frame.size.in.mb", "180")
    ) * 1024 * 1024;
  } catch (NumberFormatException ignore) {
  }
  MAX_CONTENT_LENGTH = maxContentLength;
}

180 MB. My ZIP is 185 MB.

The pipeline is wired in PortUnificationServerHandler:

else if (isHttp(magic1, magic2)) {
  NettyUtil.addHttpServerCodec(pipeline);
  pipeline.addLast("delegatingHttpHandler", delegatingHttpRequestHandler);
}

Every request on IntelliJ’s built-in server goes through that path. There is no per-handler override. Once I saw that, the shape of the problem changed: fight the limit, or stop uploading the body.

First Idea: Another HTTP Server

My first workaround was obvious: spin up a separate Netty or Ktor server with a larger body limit. It was the idea that my AI Agent suggested as a too straightforward solution that I rejected. Too much work for that simple use-case.

It would work, but it would also mean:

  • Managing a second server lifecycle, plus port allocation and conflicts
  • Teaching every client a second endpoint convention
  • Debugging one more moving part when reload fails
  • Reimplementing authentication, TLS, and shutdown concerns the built-in server already handles

I rejected building it. Then I ask to find a better solution.

Better Solution: Pass a Local Path

The hot-reload plugin and the Gradle build run on the same machine. The ZIP already exists on disk. I was uploading 185 MB over loopback so the server could write it to a temp file and hand that path to PluginInstaller.installAndLoadDynamicPlugin().

The file was already local. I only needed to pass the path.

On the server side, the handler checks a query parameter before touching the request body:

val localDiskFile = urlDecoder.parameters()["local-disk-file"]?.firstOrNull()
if (localDiskFile != null) {
  val file = Path.of(localDiskFile)
  if (!Files.exists(file)) {
    return sendError(
      context,
      request,
      HttpResponseStatus.BAD_REQUEST,
      "File not found: $localDiskFile"
    )
  }

  return executeReload(context) { reloadService, progress ->
    reloadService.reloadPluginFromZipFile(file, progress)
  }
}

reloadPluginFromZipFile reads the ZIP from disk, extracts the plugin ID, and passes the path to IntelliJ’s installAndLoadDynamicPlugin(). My code never creates a 185 MB byte array.

On the client side, the Gradle task got smaller too:

val encodedPath = URLEncoder.encode(zip.absolutePath, Charsets.UTF_8)
val conn = (
  URI("$url?local-disk-file=$encodedPath").toURL().openConnection() as HttpURLConnection
).apply {
  requestMethod = "POST"
  doOutput = false
  doInput = true
  setRequestProperty("Authorization", token)
  connectTimeout = 5_000
  readTimeout = 300_000
}

check(conn.responseCode in 200..299) { "HTTP ${conn.responseCode}" }
conn.inputStream.bufferedReader().lineSequence().forEach { println("  $it") }

No body. No Content-Type. Just a short authenticated POST with a local path.

Why This Is Better

This wasn’t just a workaround. It was the design I should’ve used from the start.

The body-upload path forces Netty to aggregate the entire ZIP before my handler runs. At 185 MB, that’s a large allocation even on the happy path. If someone gets the Bearer token, it is also an easy memory amplifier.

The file-path flow avoids that:

  • No 185 MB buffer. The HTTP layer sees an empty body and hands a Path to the plugin installer.
  • The ZIP stays on disk instead of moving through a socket and an in-memory aggregator.
  • The handler can verify existence, size, and, if needed, a checksum before the reload starts.
  • More secure: you need a plugin file on the disk, even if you can reach the localhost-bound web-server and somehow know the token.

This is not a dramatic security boundary change. The caller is already authenticated and trusted to supply a local path. But it replaces “accept arbitrary bytes over the network” with “read this specific file.” That’s a better default.

I kept the body-upload path for plugins under 180 MB and for the rare case where client and server do not share a filesystem. Backward compatibility was just an early if in the handler.

The Result

→ http://localhost:63342/api/plugin-hot-reload?local-disk-file=%2Fpath%2Fto%2Fplugin.zip
  Authorization: Bearer $TOKEN
  Starting plugin hot reload, zip size: 193,995,401 bytes
  Plugin ID: com.jonnyzzz.mcp-steroid
  Unloading existing plugin: MCP Steroid
  Plugin unloaded successfully
  Installing and loading plugin: MCP Steroid (0.92.0)
  Plugin MCP Steroid reloaded successfully
  SUCCESS

185 MB plugin. Zero bytes in the request body. Full hot reload in under 10 seconds. Better security.

Takeaways

  1. When an error is opaque, read the source. The 413 had zero diagnostic information. Reading IntelliJ’s NettyUtil.java made the limit obvious.

  2. Question the transport, not just the limit. My first instinct was “make the limit bigger.” The better question was “why am I transferring this data at all?”

  3. Localhost is still a network hop. Even loopback HTTP goes through TCP, Netty codecs, and aggregation. A local path skips that entire stack.

  4. Global system properties are not a great API. ide.netty.max.frame.size.in.mb is global and resolved at class load time. Not something I want users editing in idea.vmoptions.

The hot-reload plugin with ?local-disk-file= support is at jonnyzzz/intellij-plugin-hot-reload (v1.0.0). If your IntelliJ plugin is creeping toward 180 MB — or you just want a tighter plugin development loop — give it a try.


I’m looking forward to seeing that functionality supported natively in IntelliJ and IntelliJ SDK. That will make IntelliJ plugin development much easier. Promote that post, and I’ll be happy to contribute the fixes.

Related posts: Agentic Experience and Tools, MCP Steroid Is Now Open Source, and IntelliJ as a Skill Factory.

Support my work