MPS 2026.1 Help

Writing Your Own MCP-Tools Plugin for MPS

A hands-on tutorial for Java/Kotlin developers who already know the IntelliJ Platform plugin model and MPS. It uses the bundled mcp-tools plugin (plugins/mcp-tools, plugin ID jetbrains.mps.agents.mcp.tools, display name Projectional Agent Toolkit) as a worked example, and shows how to build a separate plugin that contributes your own MCP tools to the same com.intellij.mcpServer runtime.

The goal: expose your domain operations (MPS or plain platform) to an AI coding agent over the Model Context Protocol, safely and idiomatically.

What an MCP tool actually is

The IntelliJ Platform ships an MCP server plugin, com.intellij.mcpServer. It discovers toolsets through an extension point and exposes each annotated method as one MCP tool that an external agent (Claude Code, and similar) can call.

You don't implement the protocol, transport, JSON-RPC framing, or the tool registry. You contribute:

  1. A dependency on com.intellij.mcpServer.

  2. One or more classes implementing com.intellij.mcpserver.McpToolset.

  3. @McpTool + @McpDescription on each method you want exposed.

  4. An <mcpServer.mcpToolset> extension registration per class.

The framework:

  • reflects over your toolset methods,

  • derives an input JSON schema from each method's parameters,

  • decodes incoming arguments into your Kotlin parameters,

  • injects call context (the target Project, activity reporting) via the coroutine context,

  • ships your method's return value back to the agent as the tool result.

Project and module setup

Module dependencies

Model your module on plugins/mcp-tools/mcp-tools.iml. The load-bearing entries:

<orderEntry type="library" name="idea.mcpserver" level="project" /> <!-- the MCP server API --> <orderEntry type="library" name="Idea-platform" level="project" /> <orderEntry type="library" name="Annotations" level="project" /> <!-- MPS modules only if your tools touch MPS models: --> <orderEntry type="module" module-name="mps" /> <orderEntry type="module" module-name="model" /> <orderEntry type="module" module-name="kernel" /> <!-- ...plus whichever MPS runtimes your operations need --> <!-- Test scope: --> <orderEntry type="module" module-name="testbench" scope="TEST" /> <orderEntry type="module" module-name="environment" scope="TEST" /> <orderEntry type="module" module-name="startup" scope="TEST" /> <orderEntry type="library" scope="TEST" name="JUnit4" level="project" /> <orderEntry type="library" scope="TEST" name="hamcrest" level="project" />

idea.mcpserver is the only dependency strictly required to contribute tools. The MPS modules are needed only when the toolset manipulates MPS models; a pure-platform toolset can skip all of them.

The source layout mirrors the example: src/ for production code, test/ for JUnit tests, and an optional resources/ for bundled agent skills and docs.

plugin.xml

plugins/mcp-tools/META-INF/plugin.xml is the template. Your own plugin:

<idea-plugin> <id>com.example.my.mcp.tools</id> <name>My MCP Tools</name> <vendor>Example</vendor> <!-- REQUIRED: the MCP server host plugin --> <depends>com.intellij.mcpServer</depends> <!-- Only if you touch MPS. Drop these for pure-platform tools. --> <depends>com.intellij.modules.mps</depends> <depends>jetbrains.mps.core</depends> <extensions defaultExtensionNs="com.intellij"> <mcpServer.mcpToolset implementation="com.example.mcp.MyFirstMcpToolset" /> <!-- one line per toolset class --> </extensions> </idea-plugin>

Each <mcpServer.mcpToolset> line registers one toolset class. Group tools into toolsets by domain, not one giant class.

Your first toolset

A toolset is any class implementing com.intellij.mcpserver.McpToolset. Each public suspend fun annotated with @McpTool becomes a tool.

package com.example.mcp import com.intellij.mcpserver.McpToolset import com.intellij.mcpserver.annotations.McpDescription import com.intellij.mcpserver.annotations.McpTool import com.intellij.mcpserver.project import kotlinx.coroutines.currentCoroutineContext @Suppress("FunctionName", "unused") // tool names are the public MCP surface; invoked via reflection class MyFirstMcpToolset : McpToolset { @McpTool @McpDescription( """ Returns the name of the project this MCP call is routed to. Use this to confirm which open project the host resolved. """ ) suspend fun my_mcp_hello(): String { val project = currentCoroutineContext().project return "Hello from ${project.name}" } }

Register it in plugin.xml, build, restart the IDE with the MCP server enabled, and my_mcp_hello appears to any connected agent.

Follow these conventions from the example:

  • suspend fun. Tools are called from the MCP server's coroutine machinery. The call context (project, activity reporting) rides on the coroutine context, so a suspend function is the natural shape.

  • snake_case tool names, namespaced by a short prefix (mps_mcp_* in the example, my_mcp_* for you). The method name is the tool ID the agent sees.

  • @Suppress("FunctionName", "unused") — static analysis can't see the reflective invocation, and snake_case violates Kotlin naming.

  • @McpDescription is the agent's entire documentation. Write it for an LLM: say what the tool does, when to use it, what the return shape is, and how each parameter behaves. The example's descriptions are long on purpose.

Getting the target project

The MCP host resolves which open project a call targets (via its projectPath selector) and puts it on the coroutine context. Read it with the platform extension:

import com.intellij.mcpserver.project // the resolved target project import com.intellij.mcpserver.projectOrNull // nullable variant val project = currentCoroutineContext().project // com.intellij.openapi.project.Project val maybe = currentCoroutineContext().projectOrNull

project throws when no project is resolved. projectOrNull returns null when there is no open project, but still throws when called outside an MCP call context (IllegalStateException) or when several projects are open and none is singled out (McpExpectedError). Inside withMpsProject the toolFailure boundary maps the throw to a clean envelope, but if you read projectOrNull outside a tool boundary, wrap it in try/catch.

For MPS work, convert the IDE project to an MPSProject:

import jetbrains.mps.ide.project.ProjectHelper val mpsProject = ProjectHelper.fromIdeaProject(project) // null if not an MPS project

A shared base class pays off fast

The example never implements McpToolset directly in a tool class. Every toolset extends AbstractOps : McpToolset (and node-editing toolsets extend AbstractNodeOps : AbstractOps). This base concentrates the cross-cutting concerns so individual tools stay short. Build the equivalent early.

A uniform result envelope

Every tool returns a JSON string in one of two shapes:

{ "ok": true, "data": "<payload>", "warnings": [], "details": {} } { "ok": false, "error": "message", "code": "NOT_FOUND", "details": {} }

Helpers in AbstractOps.kt:

fun okJson(payload: String): String // payload must already be valid JSON fun okJson(payload: JsonElement, warnings, details): String fun okJsonString(payload: String): String // wraps a plain string as data fun errJson(message, code: McpErrorCode?, details, warnings): String

A stable, machine-readable envelope lets the agent branch on ok/code instead of scraping prose. Define an error-code enum like the example's McpErrorCode (INVALID_JSON, INVALID_REFERENCE, INVALID_REQUEST, NOT_FOUND, NOT_EDITABLE, MAKE_INPUT_INVALID, INTERNAL_ERROR) — INVALID_REQUEST is the catch-all for bad-argument validation failures.

One boundary that maps exceptions to responses

protected suspend inline fun withMpsProject(activity: String, block: (MPSProject) -> String): String { currentCoroutineContext().reportToolActivity(activity) val mpsProject = ProjectHelper.fromIdeaProject(currentCoroutineContext().project) ?: return errJson("No MPS project available", McpErrorCode.NOT_FOUND) return try { block(mpsProject) } catch (e: Throwable) { toolFailure(activity, e) } }

Every tool body runs inside withMpsProject(...). Benefits:

  • The tool never throws to the framework; the agent always gets a JSON envelope.

  • toolFailure maps known exception types to stable codes and logs only genuine internal errors.

  • Domain failures are raised as typed exceptions (McpNotFoundException, McpInvalidReferenceException, McpNotEditableException, McpInvalidRequestException) and come back as clean, coded responses rather than stack traces.

Always re-throw cancellation before doing anything else in a catch:

protected fun rethrowIfCancellation(e: Throwable) { if (e is CancellationException) throw e }

Swallowing CancellationException breaks structured concurrency.

Threading and model access

MPS model reads and writes need the right lock and thread. The base wraps the two common patterns:

protected suspend fun <T> executeShortReadOnEdt(mpsProject, action: () -> T): T = withContext(Dispatchers.EDT) { mpsProject.modelAccess.computeReadAction { action() } } protected suspend fun <T> executeBackgroundRead(mpsProject, action: () -> T): T = withContext(Dispatchers.Default) { mpsProject.modelAccess.computeReadAction { action() } } protected suspend fun <T> executeShortCommandOnEdt(mpsProject, action: () -> T): T = withContext(Dispatchers.EDT) { mpsProject.modelAccess.executeCommand { action() } }
  • Reads go in computeReadAction. Resolving a reference reads the model, so it must be inside a read action.

  • Writes/mutations go in executeCommand.

  • The command wrapper in the example additionally captures a McpUserException raised inside the command and re-throws it outside the command boundary — otherwise MPS's ActionDispatcher logs ordinary validation failures as LOG.error("Action dispatch failed"). If your writes can fail on bad input, copy that trick.

A typical read-only tool then reads:

suspend fun my_mcp_describe(nodeReference: String): String = withMpsProject("Describing node") { mpsProject -> executeShortReadOnEdt(mpsProject) { val ref = resolveNodeReference(mpsProject, nodeReference) ?: return@executeShortReadOnEdt invalidReference("Bad reference: $nodeReference") val node = ref.resolve(mpsProject.repository) ?: return@executeShortReadOnEdt errJson("Not found", McpErrorCode.NOT_FOUND) okJson(describe(node)) } }

Response-size escape hatch

MCP responses have practical size limits. For potentially large payloads the example writes the full JSON to a temp file and returns the path:

protected fun saveToTempFileResult(json: String): String // returns okJson with a file path in data

For output that is usually small but occasionally large, the example's finalizeResult offloads to a temp file only past a size threshold (~20,000 characters) and otherwise returns the JSON inline. Do the same for any tool whose output can be big.

Declaring tool parameters

Parameters become the tool's input schema. Annotate each with @McpDescription. Optional parameters get a Kotlin default:

suspend fun mps_mcp_get_project_structure( @McpDescription("Include models within modules.") includeModels: Boolean = false, @McpDescription("Optional starting point: a reference or a plain name.") startingPoint: String? = null, ): String

Four framework behaviors to design around:

  1. Optional means has a Kotlin default. A parameter the body treats as optional must declare a default value, or the framework's argument binder throws "No argument is passed for required parameter '<name>'" before your body runs — withMpsProject/toolFailure never see it. If it's optional in behaviour, give it a default in the signature.

  2. Never type a parameter as an enum. The framework decodes typed arguments before the tool body. An unknown enum value raises a raw kotlinx.serialization.SerializationException during binding — an opaque stack trace to the agent, logged as an internal error. Instead declare the parameter as String and resolve it in the body:

    val op = resolveOperationOrNull<MyOp>(operation) ?: return unknownOperation<MyOp>(operation) // clean, coded errJson listing valid values

    Keep the enum internally for type safety; don't put it on the wire.

  3. Accept a scalar where you accept a list. Agents frequently send "models": "ref" instead of ["ref"]. The example's parseStringOrJsonArray (and its nullable sibling parseNullableStringOrJsonArray) accept a JSON array, a JSON-encoded string, or a bare string. Be liberal in what you accept.

  4. Descriptions are contracts. Spell out reference formats, name-resolution order, defaults, and the exact return shape in @McpDescription. The agent has nothing else to go on.

Read-only vs. write tools

Read-only tools (inspection, navigation, search) are the safe majority. Wrap the work in executeShortReadOnEdt or executeBackgroundRead and return a JSON envelope. Prefer them; they compose freely and can't corrupt a model.

Write tools (create/update/delete nodes, edit modules, run make) need more care:

  • Resolve and mutate inside the same executeShortCommandOnEdt command, so resolution and mutation share one lock.

  • Validate project membership before mutating.

  • Validate editability: the example's resolveEditableModel/EditableNodeResolution return a typed Ok/Err and reject read-only libraries, stubs, and cross-project targets with NOT_EDITABLE errors.

  • After structural changes, drive the appropriate follow-up (re-check problems, make/rebuild, reload) so the agent's next read sees a consistent state.

Testing your tools

The example's test/ tree defines two tiers:

Pure unit tests for parsing and schema helpers

Input-schema parsers (McpToolInputSchemas.kt) and candidate-selection logic are plain functions with no IDE needed — test them directly. Push as many of the parameter-binding hazards as you can into this tier: McpToolInputSchemasTest exercises unknown enum text, missing required fields, malformed-JSON shapes, and the scalar-vs-array helper parseStringOrJsonArray.

Integration tests that invoke tools like the host does

McpIntegrationTestBase (extending ModuleInProjectTest) spins up a real MPSProject, creates a writable language and structure model, and provides runTool, which injects a stub call context so a suspend tool can be invoked straight from JUnit:

protected fun <T : McpToolset, R> runToolForProject(project: MPSProject, toolset: T, block: suspend (T) -> R): R { val element = McpCallAdditionalDataElement(stubMcpCallInfo(project)) return runBlocking(element) { block(toolset) } }

Usage:

val response = runTool(MyFirstMcpToolset()) { it.my_mcp_hello() } // then assert on the ok/err envelope with the shared helpers (expectOk, expectErr, ...)

The base also has envelope-assertion helpers — expectOk (asserts ok:true and returns data as a JsonObject), expectErr (asserts ok:false and returns the error message), and parseDataObject/parseDataArray that normalise the data field. Reuse them.

Wire an integration test suite (see McpToolsIntegrationTestSuite.java) into your build's test run.

Shipping agent-facing documentation

The example bundles skills (markdown workflow guides) under resources/.../skills/ and installs them into the user's repository via a dedicated tool (mps_mcp_initialize_project_for_agents). This is how the agent learns your tools' higher-level workflows beyond per-tool @McpDescription. If your toolset has non-obvious multi-step usage, consider the same pattern: ship the docs in resources/ and provide an init tool so agents can find them.

Checklist

  • Module depends on idea.mcpserver (plus MPS modules only if needed).

  • plugin.xml has <depends>com.intellij.mcpServer</depends> and one <mcpServer.mcpToolset> per toolset class.

  • Toolsets extend a shared base (envelope, withMpsProject boundary, model-access wrappers, error codes).

  • Every tool: suspend fun, @McpTool, @McpDescription, snake_case name.

  • Every parameter: @McpDescription; optional ones have a Kotlin default; no enum params (use String and resolve); list params accept scalar-or-array.

  • Reads in computeReadAction; writes in executeCommand; resolution and mutation share one command; cancellation always re-thrown.

  • Write tools validate project membership and editability.

  • Large payloads returned via temp-file path.

  • Unit tests for parsers and schema; integration tests via a runTool harness.

Reference map

In plugins/mcp-tools:

Concern

Where to look

Plugin registration

META-INF/plugin.xml

Module dependencies

mcp-tools.iml

Shared base: envelope, boundary, threading, errors

src/.../AbstractOps.kt

Node-editing base

src/.../AbstractNodeOps.kt

Simple toolset example

src/.../JetBrainsMPSProjectMcpToolset.kt

Read-only and headless-editor tools

src/.../JetBrainsMPSIntentionsMcpToolset.kt

Parameter and schema parsing helpers

src/.../McpToolInputSchemas.kt

Integration test harness (runTool)

test/.../McpIntegrationTestBase.kt

Pure unit tests

test/.../McpToolInputSchemasTest.kt

13 July 2026