Skip to content

ondrsh/mcp4k

Repository files navigation

mcp4k banner

Maven Central License

mcp4k is a compiler-driven framework that handles all the Model Context Protocol details behind the scenes.

You just annotate your functions and mcp4k takes care of JSON-RPC messages, schema generation and protocol lifecycle:

/**
 * Sends a notification to multiple recipients at once.
 *
 * @param recipients List of recipient IDs. Must be non-empty.
 * @param message The notification body to send
 */
@McpTool
suspend fun sendBulkNotification(
  recipients: List<String>,
  message: String,
): ToolContent {
  require(recipients.isNotEmpty()) {
    "At least one recipient must be specified"
  }
  require(message.isNotBlank()) {
    "Message body must not be empty"
  }
  delay(2000) // actually send the notification here
  return "Sent notification to ${recipients.size} recipients!".toTextContent()
}

fun main() = runBlocking {
  val server = Server.Builder()
    .withTool(::sendBulkNotification)
    .withTransport(StdioTransport())
    .build()

  server.start()

  while(true) {
    delay(1000)
  }
}

That's it! To use the above tool in Claude Desktop, add the compiled binary to your claude_desktop_config.json file.

mcp4k will do the following for you:

  • Generate the required JSON schemas (including tool and parameter descriptions)
  • Handle incoming tool requests
  • Convert thrown Kotlin exceptions to MCP error codes

In the above case, if no recipients are specified or the message is blank, clients will receive a -32602 (INVALID_PARAMS) with the error message you specified.


Installation

Add the mcp4k plugin to your multiplatform (or jvm) project:

plugins {
  kotlin("multiplatform") version "2.1.0" // or kotlin("jvm")
  kotlin("plugin.serialization") version "2.1.0"
  
  id("sh.ondr.mcp4k") version "0.3.3" // <-- Add this
}

Examples

Tools

@JsonSchema @Serializable
enum class Priority {
  LOW, NORMAL, HIGH
}

/**
 * @property title The email's title
 * @property body The email's body
 * @property priority The email's priority
 */
@JsonSchema @Serializable
data class Email(
  val title: String,
  val body: String?,
  val priority: Priority = Priority.NORMAL,
)

/**
 * Sends an email
 * @param recipients The email addresses of the recipients
 * @param email The email to send
 */
@McpTool
fun sendEmail(
  recipients: List<String>,
  email: Email,
) = buildString {
  append("Email sent to ${recipients.joinToString()} with ")
  append("title '${email.title}' and ")
  append("body '${email.body}' and ")
  append("priority ${email.priority}")
}.toTextContent()

When clients call tools/list, they see a JSON schema describing the tool's input:

{
  "type": "object",
  "description": "Sends an email",
  "properties": {
    "recipients": {
      "type": "array",
      "description": "The email addresses of the recipients",
      "items": {
        "type": "string"
      }
    },
    "email": {
      "type": "object",
      "description": "The email to send",
      "properties": {
        "title": {
          "type": "string",
          "description": "The email's title"
        },
        "body": {
          "type": "string",
          "description": "The email's body"
        },
        "priority": {
          "type": "string",
          "description": "The email's priority",
          "enum": [
            "LOW",
            "NORMAL",
            "HIGH"
          ]
        }
      },
      "required": [
        "title"
      ]
    }
  },
  "required": [
    "recipients",
    "email"
  ]
}

KDoc parameter descriptions are type-safe and will throw a compile-time error if you specify a non-existing property.

Clients can now send a tools/call request with a JSON object describing the above schema. Invocation and type-safe deserialization will be handled by mcp4k.


Prompts

Annotate functions with @McpPrompt and return a GetPromptResult. Arguments must be Strings:

@McpPrompt
fun codeReviewPrompt(code: String) = buildPrompt {
  user("Please review the following code:")
  user("'''\n$code\n'''")
}

Resources

Resources are provided by a ResourceProvider. You can either create your own ResourceProvider or use one of the 2 default implementations:

  • DiscreteFileProvider

    • Let's you add/remove a discrete set of files that will be exposed to the client.
    • Handles resources/list requests.
    • Handles resources/read requests by reading contents from disk via okio.
    • Sends notifications/resources/list_changed when files are added or removed.
    • Supports subscriptions (but changes to files have to be marked manually for now).
  • TemplateFileProvider

    • Exposes a given rootDir through a URI template.
    • Handles resources/templates/list.
    • Handles resources/read requests by reading contents from disk via okio.
    • Supports subscriptions (but changes to files have to be marked manually for now).

Use those providers only in a sand-boxed environment. They are NOT production-ready.

DiscreteFileProvider

Let's say you want to expose 2 files:

  • /app/resources/cpp/my_program.h
  • /app/resources/cpp/my_program.cpp

You would first create the following provider:

val fileProvider = DiscreteFileProvider(
  fileSystem = FileSystem.SYSTEM,
  rootDir = "/app/resources".toPath(),
  initialFiles = listOf(
    File(
      relativePath = "cpp/my_program.h",
      mimeType = "text/x-c++",
    ),
    File(
      relativePath = "cpp/my_program.cpp",
      mimeType = "text/x-c++",
    ),
  )
)

And add it when building the server:

val server = Server.Builder()
  .withResourceProvider(fileProvider)
  .withTransport(StdioTransport())
  .build()

A client calling resources/list will then receive:

{
  "resources": [
    {
      "uri": "file://cpp/my_program.h",
      "name": "my_program.h",
      "description": "File at cpp/my_program.h",
      "mimeType": "text/x-c++"
    },
    {
      "uri": "file://cpp/my_program.cpp",
      "name": "my_program.cpp",
      "description": "File at cpp/my_program.cpp",
      "mimeType": "text/x-c++"
    }
  ]
}

A client sending a resources/read request to fetch the contents of the source file would receive:

{
  "contents": [
    {
      "uri": "file://cpp/my_program.cpp",
      "mimeType": "text/x-c++",
      "text": "int main(){}"
    }
  ]
}

You can also add or remove files at runtime via

fileProvider.addFile(
  File(
    relativePath = "cpp/README.txt",
    mimeType = "text/plain",
  )
)

fileProvider.removeFile("cpp/my_program.h")

Both addFile and removeFile will send a notifications/resources/list_changed notification.


When making changes to a file, always call

fileProvider.onResourceChange("cpp/my_program.h")

If (and only if) the client subscribed to this resource, this will send a notifications/resources/updated notification to the client.


TemplateFileProvider

If you want to expose a whole directory, you can do:

val templateFileProvider = TemplateFileProvider(
  fileSystem = FileSystem.SYSTEM,
  rootDir = "/app/resources".toPath(),
)

A client calling resources/templates/list will receive:

{
  "resourceTemplates": [
    {
      "uriTemplate": "file:///{path}",
      "name": "Arbitrary local file access",
      "description": "Allows reading any file by specifying {path}"
    }
  ]
}

The client can then issue a resources/read request by providing the path:

{
  "method": "resources/read",
  "params": {
    "uri": "file:///cpp/my_program.cpp"
  }
}

This will read from /app/resources/cpp/my_program.cpp and return the result:

{
  "contents": [
    {
      "uri": "file:///cpp/my_program.cpp",
      "mimeType": "text/plain",
      "text": "int main(){}"
    }
  ]
}

Note the incorrect text/plain here - proper MIME detection will be added at some point.

Similarly to DiscreteFileProvider, when modifying a resource, call

templateFileProvider.onResourceChange("cpp/my_program.h")

to trigger the notification in case a client is subscribed to this resource.


Sampling

Clients can fulfill server-initiated LLM requests by providing a SamplingProvider.

In a real application, you would call your favorite LLM API (e.g., OpenAI, Anthropic) inside the provider. Here’s a simplified example that always returns a dummy completion:

// 1) Define a sampling provider
val samplingProvider = SamplingProvider { params: CreateMessageParams ->
  CreateMessageResult(
    model = "dummy-model",
    role = Role.ASSISTANT,
    content = TextContent("Dummy completion result"),
    stopReason = "endTurn",
  )
}

// 2) Build the client with sampling support
val client = Client.Builder()
  .withTransport(StdioTransport())
  .withPermissionCallback { userApprovable -> 
    // Prompt the user for confirmation here
    true 
  }
  .withSamplingProvider(samplingProvider) // Register the provider
  .build()

runBlocking {
  client.start()
  client.initialize()

  // Now, if a server sends a "sampling/createMessage" request, 
  // the samplingProvider will be invoked to generate a response.
}

Request Cancellations

mcp4k uses Kotlin coroutines for cooperative cancellation. For example, suppose you have a long-running tool operation on the server:

@McpTool
suspend fun slowToolOperation(iterations: Int = 10): ToolContent {
  for (i in 1..iterations) {
    delay(1000)
  }
  return "Operation completed successfully after $iterations iterations".toTextContent()
}

From the client side, after you invoked the tool, you can simply cancel the coroutine job:

val requestJob = launch {
  client.sendRequest { id ->
    CallToolRequest(
      id = id,
      params = CallToolRequest.CallToolParams(
        name = "slowToolOperation",
        arguments = mapOf("iterations" to 20),
      ),
    )
  }
}

// Let the server do partial work
delay(600)

// Now cancel
requestJob.cancel("User doesn't want to wait anymore")

Under the hood, mcp4k automatically sends a JSON-RPC notifications/cancelled message to the server, and your suspended tool operation will be aborted:

{
  "method": "notifications/cancelled",
  "jsonrpc": "2.0",
  "params": {
    "requestId": "2",
    "reason": "Client doesn't want to wait anymore"
  }
}

This gives you straightforward cancellations across the entire client-server flow.

TODO

✅ Add resource capability
✅ Suspendable logger, @McpTool and @McpPrompt functions
✅ Request cancellations
✅ Pagination
✅ Sampling (client-side)
✅ Roots
⬜ Completions
⬜ Support logging levels
⬜ Proper version negotiation
⬜ Emit progress notifications from @McpTool functions
⬜ Proper MIME detection
⬜ Add FileWatcher to automate resources/updated nofications
⬜ HTTP-SSE transport
⬜ Add references, property descriptions and validation keywords to the JSON schemas

How mcp4k Works

  • Annotated @McpTool and @McpPrompt functions are processed at compile time.
  • mcp4k generates schemas, handlers, and registrations automatically.
  • Generated code is injected during the IR phase.
  • If you mess something up, you (hopefully) get a compile-time error.

Contributing

Issues and pull requests are welcome.

License

Licensed under the Apache License 2.0.