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:
A dependency on
com.intellij.mcpServer.One or more classes implementing
com.intellij.mcpserver.McpToolset.@McpTool+@McpDescriptionon each method you want exposed.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:
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:
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.
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_casetool 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, andsnake_caseviolates Kotlin naming.@McpDescriptionis 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:
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:
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:
Helpers in AbstractOps.kt:
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
Every tool body runs inside withMpsProject(...). Benefits:
The tool never throws to the framework; the agent always gets a JSON envelope.
toolFailuremaps 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:
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:
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
McpUserExceptionraised inside the command and re-throws it outside the command boundary — otherwise MPS'sActionDispatcherlogs ordinary validation failures asLOG.error("Action dispatch failed"). If your writes can fail on bad input, copy that trick.
A typical read-only tool then reads:
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:
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:
Four framework behaviors to design around:
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/toolFailurenever see it. If it's optional in behaviour, give it a default in the signature.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.SerializationExceptionduring binding — an opaque stack trace to the agent, logged as an internal error. Instead declare the parameter asStringand resolve it in the body:val op = resolveOperationOrNull<MyOp>(operation) ?: return unknownOperation<MyOp>(operation) // clean, coded errJson listing valid valuesKeep the enum internally for type safety; don't put it on the wire.
Accept a scalar where you accept a list. Agents frequently send
"models": "ref"instead of["ref"]. The example'sparseStringOrJsonArray(and its nullable siblingparseNullableStringOrJsonArray) accept a JSON array, a JSON-encoded string, or a bare string. Be liberal in what you accept.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
executeShortCommandOnEdtcommand, so resolution and mutation share one lock.Validate project membership before mutating.
Validate editability: the example's
resolveEditableModel/EditableNodeResolutionreturn a typedOk/Errand reject read-only libraries, stubs, and cross-project targets withNOT_EDITABLEerrors.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:
Usage:
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.xmlhas<depends>com.intellij.mcpServer</depends>and one<mcpServer.mcpToolset>per toolset class.Toolsets extend a shared base (envelope,
withMpsProjectboundary, 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 (useStringand resolve); list params accept scalar-or-array.Reads in
computeReadAction; writes inexecuteCommand; 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
runToolharness.
Reference map
In plugins/mcp-tools:
Concern | Where to look |
|---|---|
Plugin registration |
|
Module dependencies |
|
Shared base: envelope, boundary, threading, errors |
|
Node-editing base |
|
Simple toolset example |
|
Read-only and headless-editor tools |
|
Parameter and schema parsing helpers |
|
Integration test harness ( |
|
Pure unit tests |
|