├── .cursor └── mcp.json ├── .github ├── labeler.yml ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── rebase-cmd-dispatch.yml │ ├── rebase-cmd.yml │ ├── scala-steward.yml │ └── test-report.yml ├── .gitignore ├── .scala-steward.conf ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── core └── src │ ├── main │ └── scala │ │ └── chimp │ │ ├── McpHandler.scala │ │ ├── mcpEndpoint.scala │ │ ├── protocol │ │ └── model.scala │ │ └── tool.scala │ └── test │ └── scala │ └── chimp │ ├── McpHandlerSpec.scala │ └── protocol │ └── ModelSpec.scala ├── examples └── src │ └── main │ ├── resources │ └── logback.xml │ └── scala │ └── chimp │ ├── adderMcp.scala │ ├── adderWithAuthMcp.scala │ ├── twoToolsMcp.scala │ └── weatherMcp.scala └── project ├── build.properties └── plugins.sbt /.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "chimp-metals": { 4 | "url": "http://localhost:57525/sse" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | labels: 3 | - label: "automerge" 4 | authors: ["softwaremill-ci"] 5 | files: 6 | - "build.sbt" 7 | - "project/build.properties" 8 | - "project/Versions.scala" 9 | - "project/plugins.sbt" 10 | - label: "dependency" 11 | authors: ["softwaremill-ci"] 12 | files: 13 | - "build.sbt" 14 | - "project/build.properties" 15 | - "project/Versions.scala" 16 | - "project/plugins.sbt" 17 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | branches: ['**'] 5 | push: 6 | branches: ['**'] 7 | tags: [v*] 8 | jobs: 9 | build: 10 | uses: softwaremill/github-actions-workflows/.github/workflows/build-scala.yml@main 11 | # run on 1) push, 2) external PRs, 3) softwaremill-ci PRs 12 | # do not run on internal, non-steward PRs since those will be run by push to branch 13 | if: | 14 | github.event_name == 'push' || 15 | github.event.pull_request.head.repo.full_name != github.repository || 16 | github.event.pull_request.user.login == 'softwaremill-ci' 17 | with: 18 | java-opts: '-Xmx4G' 19 | 20 | publish: 21 | uses: softwaremill/github-actions-workflows/.github/workflows/publish-release.yml@main 22 | needs: [build] 23 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 24 | secrets: inherit 25 | with: 26 | java-opts: "-Xmx4G" 27 | sttp-native: 1 28 | 29 | label: 30 | # only for PRs by softwaremill-ci 31 | if: github.event.pull_request.user.login == 'softwaremill-ci' 32 | uses: softwaremill/github-actions-workflows/.github/workflows/label.yml@main 33 | 34 | auto-merge: 35 | # only for PRs by softwaremill-ci 36 | if: github.event.pull_request.user.login == 'softwaremill-ci' 37 | needs: [ build, label ] 38 | uses: softwaremill/github-actions-workflows/.github/workflows/auto-merge.yml@main -------------------------------------------------------------------------------- /.github/workflows/rebase-cmd-dispatch.yml: -------------------------------------------------------------------------------- 1 | # On any comment, it will look for '/rebase' in the comment body and in case of hit, it dispatches rebase cmd 2 | # with event type 'rebase-command' which triggers 'rebase-command` WF that performs the rebase operation. 3 | name: Slash Command Dispatch 4 | on: 5 | issue_comment: 6 | types: [created] 7 | jobs: 8 | rebase-cmd-dispatch: 9 | uses: softwaremill/github-actions-workflows/.github/workflows/rebase-cmd-dispatch.yml@main 10 | secrets: 11 | repo-github-token: ${{ secrets.REPO_GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/rebase-cmd.yml: -------------------------------------------------------------------------------- 1 | name: rebase-command 2 | on: 3 | repository_dispatch: 4 | types: [rebase-command] 5 | jobs: 6 | rebase: 7 | uses: softwaremill/github-actions-workflows/.github/workflows/rebase-cmd.yml@main 8 | secrets: 9 | repo-github-token: ${{ secrets.REPO_GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/scala-steward.yml: -------------------------------------------------------------------------------- 1 | name: Scala Steward 2 | 3 | # This workflow will launch at 00:00 every day 4 | on: 5 | schedule: 6 | - cron: '0 0 * * *' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | scala-steward: 11 | uses: softwaremill/github-actions-workflows/.github/workflows/scala-steward.yml@main 12 | secrets: 13 | repo-github-token: ${{secrets.REPO_GITHUB_TOKEN}} 14 | with: 15 | java-version: '21' -------------------------------------------------------------------------------- /.github/workflows/test-report.yml: -------------------------------------------------------------------------------- 1 | name: 'Test Report' 2 | on: 3 | workflow_run: 4 | workflows: ['CI'] 5 | types: 6 | - completed 7 | 8 | permissions: 9 | contents: read 10 | actions: read 11 | checks: write 12 | 13 | jobs: 14 | test-report: 15 | uses: softwaremill/github-actions-workflows/.github/workflows/test-report.yml@main -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # sbt 2 | target/ 3 | project/target/ 4 | project/project/ 5 | project/.bloop/ 6 | project/metals.sbt 7 | .bloop/ 8 | .metals/ 9 | .scala-build/ 10 | 11 | # IDE 12 | .idea/ 13 | *.iml 14 | .vscode/ 15 | *.swp 16 | *.swo 17 | .bsp 18 | 19 | # OS 20 | .DS_Store 21 | Thumbs.db -------------------------------------------------------------------------------- /.scala-steward.conf: -------------------------------------------------------------------------------- 1 | updates.pin = [ 2 | { groupId = "org.scala-lang", artifactId = "scala3-library", version = "3.3." } 3 | ] 4 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.8.0 2 | maxColumn = 140 3 | rewrite.rules = [RedundantBraces, RedundantParens, SortImports] 4 | runner.dialect = scala3 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 SoftwareMill 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chimp MCP Server 2 | 3 | [![CI](https://github.com/softwaremill/chimp/actions/workflows/ci.yml/badge.svg)](https://github.com/softwaremill/chimp/actions/workflows/ci.yml) 4 | [![Scala 3](https://img.shields.io/badge/scala-3.3.6-blue.svg)](https://www.scala-lang.org/) 5 | 6 | A library for building [MCP](#mcp-protocol) (Model Context Protocol) servers in Scala 3, based on [Tapir](https://tapir.softwaremill.com/). Describe MCP tools with type-safe input, expose them over a JSON-RPC HTTP API. 7 | 8 | Integrates with any Scala stack, using any of the HTTP server implementations supported by Tapir. 9 | 10 | --- 11 | 12 | ## Quickstart 13 | 14 | Add the dependency to your `build.sbt`: 15 | 16 | ```scala 17 | libraryDependencies += "com.softwaremill.chimp" %% "core" % "0.1.4" 18 | ``` 19 | 20 | ### Example: the simplest MCP server 21 | 22 | Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example: 23 | 24 | ```scala 25 | //> using dep com.softwaremill.chimp::core:0.1.4 26 | 27 | import chimp.* 28 | import sttp.tapir.* 29 | import sttp.tapir.server.netty.sync.NettySyncServer 30 | 31 | // define the input type for your tool 32 | case class AdderInput(a: Int, b: Int) derives io.circe.Codec, Schema 33 | 34 | @main def mcpApp(): Unit = 35 | // describe the tool providing the name, description, and input type 36 | val adderTool = tool("adder").description("Adds two numbers").input[AdderInput] 37 | 38 | // combine the tool description with the server-side logic 39 | val adderServerTool = adderTool.handle(i => Right(s"The result is ${i.a + i.b}")) 40 | 41 | // create the MCP server endpoint; it will be available at http://localhost:8080/mcp 42 | val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) 43 | 44 | // start the server 45 | NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() 46 | ``` 47 | 48 | ### More examples 49 | 50 | Are available [here](https://github.com/softwaremill/chimp/tree/master/examples/src/main/scala/chimp). 51 | 52 | --- 53 | 54 | ## MCP Protocol 55 | 56 | Chimp implements the HTTP transport of the [MCP protocol](https://modelcontextprotocol.io/specification/2025-03-26) (version **2025-03-26**). Only tools are supported, via the following JSON-RPC commands: 57 | 58 | - Initialization and capabilities negotiation (`initialize`) 59 | - Listing available tools (`tools/list`) 60 | - Invoking a tool (`tools/call`) 61 | 62 | All requests and responses use JSON-RPC 2.0. Tool input schemas are described using JSON Schema, auto-generated from Scala types. 63 | 64 | --- 65 | 66 | ## Defining Tools and Server Logic 67 | 68 | - Use `tool(name)` to start defining a tool. 69 | - Add a description and annotations for metadata and hints. 70 | - Specify the input type (must have a Circe `Codec` and Tapir `Schema`). 71 | - Provide the server logic as a function from input to `Either[String, String]` (or a generic effect type). 72 | - Use `handle` to connect the tool definition with the server logic when the use of headers is not required. 73 | - Use `handleWithHeaders` to connect the tool definition with the server logic when headers are required. 74 | - Create a Tapir endpoint by providing your tools to `mcpEndpoint` 75 | - Start an HTTP server using your preferred Tapir server interpreter. 76 | 77 | --- 78 | 79 | ## Contributing 80 | 81 | Contributions are welcome! Please open issues or pull requests. 82 | 83 | --- 84 | 85 | ## Commercial Support 86 | 87 | We offer commercial support for Tapir and related technologies, as well as development services. [Contact us](https://softwaremill.com) to learn more about our offer! 88 | 89 | --- 90 | 91 | ## Copyright 92 | 93 | Copyright (C) 2025 SoftwareMill [https://softwaremill.com](https://softwaremill.com). -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings 2 | import com.softwaremill.Publish.{ossPublishSettings, updateDocs} 3 | import com.softwaremill.UpdateVersionInDocs 4 | 5 | // Version constants 6 | val scalaTestV = "3.2.19" 7 | val circeV = "0.14.14" 8 | val tapirV = "1.11.36" 9 | 10 | lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq( 11 | organization := "com.softwaremill.chimp", 12 | scalaVersion := "3.3.6", 13 | updateDocs := Def.taskDyn { 14 | val files = UpdateVersionInDocs(sLog.value, organization.value, version.value) 15 | Def.task { 16 | files 17 | } 18 | }.value, 19 | Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s", 20 | Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s" 21 | ) 22 | 23 | val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test 24 | 25 | lazy val rootProject = (project in file(".")) 26 | .settings(commonSettings: _*) 27 | .settings(publishArtifact := false, name := "chimp") 28 | .aggregate(core, examples) 29 | 30 | lazy val core: Project = (project in file("core")) 31 | .settings(commonSettings: _*) 32 | .settings( 33 | name := "core", 34 | libraryDependencies ++= Seq( 35 | scalaTest, 36 | "io.circe" %% "circe-core" % circeV, 37 | "io.circe" %% "circe-generic" % circeV, 38 | "io.circe" %% "circe-parser" % circeV, 39 | "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirV, 40 | "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirV, 41 | "com.softwaremill.sttp.tapir" %% "tapir-apispec-docs" % tapirV, 42 | "com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10", 43 | "org.slf4j" % "slf4j-api" % "2.0.17" 44 | ) 45 | ) 46 | 47 | lazy val examples = (project in file("examples")) 48 | .settings(commonSettings: _*) 49 | .settings( 50 | publishArtifact := false, 51 | name := "examples", 52 | libraryDependencies ++= Seq( 53 | "com.softwaremill.sttp.client4" %% "core" % "4.0.9", 54 | "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV, 55 | "ch.qos.logback" % "logback-classic" % "1.5.18" 56 | ) 57 | ) 58 | .dependsOn(core) 59 | -------------------------------------------------------------------------------- /core/src/main/scala/chimp/McpHandler.scala: -------------------------------------------------------------------------------- 1 | package chimp 2 | 3 | import chimp.protocol.* 4 | import io.circe.* 5 | import io.circe.syntax.* 6 | import org.slf4j.LoggerFactory 7 | import sttp.apispec.circe.* 8 | import sttp.model.Header 9 | import sttp.monad.MonadError 10 | import sttp.monad.syntax.* 11 | import sttp.tapir.* 12 | import sttp.tapir.docs.apispec.schema.TapirSchemaToJsonSchema 13 | 14 | /** The MCP server handles JSON-RPC requests for tool listing, invocation, and initialization. 15 | * 16 | * @param tools 17 | * The list of available server tools. 18 | * @param name 19 | * The server name (for protocol reporting). 20 | * @param version 21 | * The server version (for protocol reporting). 22 | * @param showJsonSchemaMetadata 23 | * Whether to include JSON Schema metadata (such as $schema) in the tool input schemas. Some agents do not recognize it, so it can be 24 | * disabled. 25 | */ 26 | class McpHandler[F[_]]( 27 | tools: List[ServerTool[?, F]], 28 | name: String, 29 | version: String, 30 | showJsonSchemaMetadata: Boolean 31 | ): 32 | private val logger = LoggerFactory.getLogger(classOf[McpHandler[_]]) 33 | private val ProtocolVersion = "2025-03-26" 34 | private val toolsByName = tools.map(t => t.name -> t).toMap 35 | 36 | /** Converts a ServerTool to its protocol definition. */ 37 | private def toolToDefinition(tool: ServerTool[?, F]): ToolDefinition = 38 | val jsonSchema = 39 | val base = TapirSchemaToJsonSchema(tool.inputSchema, markOptionsAsNullable = true) 40 | if showJsonSchemaMetadata then base 41 | else base.copy($schema = None) 42 | 43 | val json = jsonSchema.asJson 44 | ToolDefinition( 45 | name = tool.name, 46 | description = tool.description, 47 | inputSchema = json, 48 | annotations = tool.annotations 49 | .map(a => ToolAnnotations(a.title, a.readOnlyHint, a.destructiveHint, a.idempotentHint, a.openWorldHint)) 50 | ) 51 | 52 | private val toolDefs: List[ToolDefinition] = tools.map(toolToDefinition) 53 | 54 | private def protocolError(id: RequestId, code: Int, message: String): JSONRPCMessage.Error = 55 | logger.debug(s"Protocol error (id=$id, code=$code): $message") 56 | JSONRPCMessage.Error(id = id, error = JSONRPCErrorObject(code = code, message = message)) 57 | 58 | private def handleInitialize(id: RequestId): JSONRPCMessage.Response = 59 | val capabilities = ServerCapabilities(tools = Some(ServerToolsCapability(listChanged = Some(false)))) 60 | val result = 61 | InitializeResult(protocolVersion = ProtocolVersion, capabilities = capabilities, serverInfo = Implementation(name, version)) 62 | JSONRPCMessage.Response(id = id, result = result.asJson) 63 | 64 | /** Handles the 'tools/list' JSON-RPC method, returning the list of available tools. */ 65 | private def handleToolsList(id: RequestId): JSONRPCMessage.Response = 66 | JSONRPCMessage.Response(id = id, result = ListToolsResponse(toolDefs).asJson) 67 | 68 | /** Handles the 'tools/call' JSON-RPC method. Attempts to decode the tool name and arguments, then dispatches to the tool logic. Provides 69 | * detailed error messages for decode failures. 70 | */ 71 | private def handleToolsCall(params: Option[io.circe.Json], id: RequestId, headers: Seq[Header])(using 72 | MonadError[F] 73 | ): F[JSONRPCMessage] = 74 | // Extract tool name and arguments in a functional, idiomatic way 75 | val toolNameOpt = params.flatMap(_.hcursor.downField("name").as[String].toOption) 76 | val argumentsOpt = params.flatMap(_.hcursor.downField("arguments").focus) 77 | (toolNameOpt, argumentsOpt) match 78 | case (Some(toolName), Some(args)) => 79 | toolsByName.get(toolName) match 80 | case Some(tool) => 81 | def inputSnippet = args.noSpaces.take(200) // for error reporting 82 | // Use Circe's Decoder for argument decoding 83 | tool.inputDecoder.decodeJson(args) match 84 | case Right(decodedInput) => handleDecodedInput(tool, decodedInput, id, headers) 85 | case Left(decodingError) => 86 | protocolError( 87 | id, 88 | JSONRPCErrorCodes.InvalidParams.code, 89 | s"Invalid arguments: ${decodingError.getMessage}. Input: $inputSnippet" 90 | ).unit 91 | case None => protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown tool: $toolName").unit 92 | case (Some(toolName), None) => 93 | protocolError(id, JSONRPCErrorCodes.InvalidParams.code, s"Missing arguments for tool: $toolName").unit 94 | case (None, _) => 95 | protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Missing tool name").unit 96 | 97 | /** Handles a successfully decoded tool input, dispatching to the tool's logic. */ 98 | private def handleDecodedInput[T](tool: ServerTool[T, F], decodedInput: T, id: RequestId, headers: Seq[Header])(using 99 | MonadError[F] 100 | ): F[JSONRPCMessage] = 101 | tool 102 | .logic(decodedInput, headers) 103 | .map: 104 | case Right(result) => 105 | val callResult = ToolCallResult( 106 | content = List(ToolContent.Text(text = result)), 107 | isError = false 108 | ) 109 | JSONRPCMessage.Response(id = id, result = callResult.asJson) 110 | case Left(errorMsg) => 111 | val callResult = ToolCallResult( 112 | content = List(ToolContent.Text(text = errorMsg)), 113 | isError = true 114 | ) 115 | JSONRPCMessage.Response(id = id, result = callResult.asJson) 116 | 117 | /** Handles a JSON-RPC request, dispatching to the appropriate handler. Logs requests and responses. */ 118 | def handleJsonRpc(request: Json, headers: Seq[Header])(using MonadError[F]): F[Json] = 119 | logger.debug(s"Request: $request") 120 | val responseF: F[JSONRPCMessage] = request.as[JSONRPCMessage] match 121 | case Left(err) => protocolError(RequestId("null"), JSONRPCErrorCodes.ParseError.code, s"Parse error: ${err.message}").unit 122 | case Right(JSONRPCMessage.Request(_, method, params: Option[io.circe.Json], id)) => 123 | method match 124 | case "tools/list" => handleToolsList(id).unit 125 | case "tools/call" => handleToolsCall(params, id, headers) 126 | case "initialize" => handleInitialize(id).unit 127 | case other => protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown method: $other").unit 128 | case Right(JSONRPCMessage.BatchRequest(requests)) => 129 | // For each sub-request, process as a single request using flatMap/fold (no .sequence) 130 | def processBatch(reqs: List[JSONRPCMessage], acc: List[JSONRPCMessage]): F[List[JSONRPCMessage]] = 131 | reqs match 132 | case Nil => acc.reverse.unit 133 | case head :: tail => 134 | head match 135 | case JSONRPCMessage.Notification(_, _, _) => 136 | processBatch(tail, acc) // skip notifications 137 | case _ => 138 | handleJsonRpc(head.asJson, headers).flatMap { respJson => 139 | val msg = respJson 140 | .as[JSONRPCMessage] 141 | .getOrElse( 142 | protocolError(RequestId("null"), JSONRPCErrorCodes.InternalError.code, "Failed to decode sub-response") 143 | ) 144 | processBatch(tail, msg :: acc) 145 | } 146 | processBatch(requests, Nil).map { responses => 147 | // Per JSON-RPC spec, notifications (no id) should not be included in the response 148 | val filtered = responses.collect { 149 | case r @ JSONRPCMessage.Response(_, id, _) => r 150 | case e @ JSONRPCMessage.Error(_, id, _) => e 151 | } 152 | JSONRPCMessage.BatchResponse(filtered) 153 | } 154 | case Right(notification: JSONRPCMessage.Notification) => notification.unit 155 | case Right(_) => protocolError(RequestId("null"), JSONRPCErrorCodes.InvalidRequest.code, "Invalid request type").unit 156 | responseF.map: response => 157 | val responseJson = response.asJson 158 | logger.debug(s"Response: $responseJson") 159 | responseJson 160 | -------------------------------------------------------------------------------- /core/src/main/scala/chimp/mcpEndpoint.scala: -------------------------------------------------------------------------------- 1 | package chimp 2 | 3 | import io.circe.Json 4 | import org.slf4j.LoggerFactory 5 | import sttp.monad.MonadError 6 | import sttp.monad.syntax.* 7 | import sttp.tapir.* 8 | import sttp.tapir.json.circe.* 9 | import sttp.tapir.server.ServerEndpoint 10 | import sttp.model.Header 11 | 12 | private val logger = LoggerFactory.getLogger(classOf[McpHandler[_]]) 13 | 14 | /** Creates a Tapir endpoint description, which will handle MCP HTTP server requests, using the provided tools. 15 | * 16 | * @param tools 17 | * The list of tools to expose. 18 | * @param path 19 | * The path components at which to expose the MCP server. 20 | * 21 | * @tparam F 22 | * The effect type. Might be `Identity` for a endpoints with synchronous logic. 23 | */ 24 | def mcpEndpoint[F[_]]( 25 | tools: List[ServerTool[?, F]], 26 | path: List[String], 27 | name: String = "Chimp MCP server", 28 | version: String = "1.0.0", 29 | showJsonSchemaMetadata: Boolean = true 30 | ): ServerEndpoint[Any, F] = 31 | val mcpHandler = new McpHandler(tools, name, version, showJsonSchemaMetadata) 32 | val e = infallibleEndpoint.post 33 | .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) 34 | .in(extractFromRequest(_.headers)) 35 | .in(jsonBody[Json]) 36 | .out(jsonBody[Json]) 37 | 38 | ServerEndpoint.public( 39 | e, 40 | me => { (input: (Seq[Header], Json)) => 41 | val (headers, json) = input 42 | given MonadError[F] = me 43 | mcpHandler 44 | .handleJsonRpc(json, headers) 45 | .map(responseJson => Right(responseJson.deepDropNullValues)) 46 | } 47 | ) 48 | -------------------------------------------------------------------------------- /core/src/main/scala/chimp/protocol/model.scala: -------------------------------------------------------------------------------- 1 | // Combined MCP 2025-03-26 protocol and tool data model. 2 | // NOTE: RequestId and ProgressToken use newtype wrappers for spec accuracy and to avoid ambiguous implicits. 3 | package chimp.protocol 4 | 5 | import io.circe.syntax.* 6 | import io.circe.{Codec, Decoder, Encoder, Json} 7 | 8 | // --- JSON-RPC base types --- 9 | // Use newtype wrappers for union types to avoid ambiguous implicits 10 | opaque type RequestId = String | Int 11 | object RequestId { 12 | def apply(value: String | Int): RequestId = value 13 | def unapply(id: RequestId): Option[String | Int] = Some(id) 14 | given encoder: Encoder[RequestId] = Encoder.instance { 15 | case s: String => Json.fromString(s) 16 | case i: Int => Json.fromInt(i) 17 | } 18 | given decoder: Decoder[RequestId] = Decoder.instance { c => 19 | c.as[String].map(RequestId(_)).orElse(c.as[Int].map(RequestId(_))) 20 | } 21 | given codec: Codec[RequestId] = Codec.from(decoder, encoder) 22 | } 23 | opaque type ProgressToken = String | Int 24 | object ProgressToken { 25 | def apply(value: String | Int): ProgressToken = value 26 | def unapply(token: ProgressToken): Option[String | Int] = Some(token) 27 | given encoder: Encoder[ProgressToken] = Encoder.instance { 28 | case s: String => Json.fromString(s) 29 | case i: Int => Json.fromInt(i) 30 | } 31 | given decoder: Decoder[ProgressToken] = Decoder.instance { c => 32 | c.as[String].map(ProgressToken(_)).orElse(c.as[Int].map(ProgressToken(_))) 33 | } 34 | given codec: Codec[ProgressToken] = Codec.from(decoder, encoder) 35 | } 36 | // For pagination 37 | type Cursor = String 38 | 39 | // Note: JSONRPCMessage is a protocol sum type; custom codecs are needed for serialization. 40 | enum JSONRPCMessage: 41 | case Request(jsonrpc: String = "2.0", method: String, params: Option[Json] = None, id: RequestId) 42 | case Notification(jsonrpc: String = "2.0", method: String, params: Option[Json] = None) 43 | case Response(jsonrpc: String = "2.0", id: RequestId, result: Json) 44 | case Error(jsonrpc: String = "2.0", id: RequestId, error: JSONRPCErrorObject) 45 | case BatchRequest(requests: List[JSONRPCMessage]) 46 | case BatchResponse(responses: List[JSONRPCMessage]) 47 | 48 | object JSONRPCMessage { 49 | import io.circe.* 50 | import io.circe.syntax.* 51 | 52 | given Decoder[JSONRPCMessage] = Decoder.instance { c => 53 | val jsonrpc = c.downField("jsonrpc").as[String].getOrElse("2.0") 54 | val methodOpt = c.downField("method").as[String].toOption 55 | val idOpt = c.downField("id").as[RequestId].toOption 56 | val paramsOpt = c.downField("params").focus 57 | val resultOpt = c.downField("result").focus 58 | val errorOpt = c.downField("error").as[JSONRPCErrorObject].toOption 59 | val isBatchRequest = c.keys.exists(_.exists(_ == "requests")) 60 | val isBatchResponse = c.keys.exists(_.exists(_ == "responses")) 61 | 62 | (methodOpt, idOpt, paramsOpt, resultOpt, errorOpt, isBatchRequest, isBatchResponse) match { 63 | case (Some(method), Some(id), _, None, None, false, false) => 64 | // Request (with or without params) 65 | Right(JSONRPCMessage.Request(jsonrpc, method, paramsOpt, id)) 66 | case (Some(method), None, _, None, None, false, false) => 67 | // Notification (with or without params) 68 | Right(JSONRPCMessage.Notification(jsonrpc, method, paramsOpt)) 69 | case (None, Some(id), None, Some(result), None, false, false) => 70 | // Response 71 | Right(JSONRPCMessage.Response(jsonrpc, id, result)) 72 | case (None, Some(id), None, None, Some(error), false, false) => 73 | // Error 74 | Right(JSONRPCMessage.Error(jsonrpc, id, error)) 75 | case (None, None, None, None, None, true, false) => 76 | // BatchRequest 77 | c.downField("requests").as[List[JSONRPCMessage]].map(JSONRPCMessage.BatchRequest(_)) 78 | case (None, None, None, None, None, false, true) => 79 | // BatchResponse 80 | c.downField("responses").as[List[JSONRPCMessage]].map(JSONRPCMessage.BatchResponse(_)) 81 | case _ => 82 | Left(DecodingFailure("type JSONRPCMessage could not be decoded from JSON", c.history)) 83 | } 84 | } 85 | 86 | given Encoder[JSONRPCMessage] = Encoder.instance { 87 | case JSONRPCMessage.Request(jsonrpc, method, params, id) => 88 | Json 89 | .obj( 90 | "jsonrpc" -> Json.fromString(jsonrpc), 91 | "method" -> Json.fromString(method), 92 | "params" -> params.getOrElse(Json.Null), 93 | "id" -> id.asJson 94 | ) 95 | .dropNullValues 96 | case JSONRPCMessage.Notification(jsonrpc, method, params) => 97 | Json 98 | .obj( 99 | "jsonrpc" -> Json.fromString(jsonrpc), 100 | "method" -> Json.fromString(method), 101 | "params" -> params.getOrElse(Json.Null) 102 | ) 103 | .dropNullValues 104 | case JSONRPCMessage.Response(jsonrpc, id, result) => 105 | Json.obj( 106 | "jsonrpc" -> Json.fromString(jsonrpc), 107 | "id" -> id.asJson, 108 | "result" -> result 109 | ) 110 | case JSONRPCMessage.Error(jsonrpc, id, error) => 111 | Json.obj( 112 | "jsonrpc" -> Json.fromString(jsonrpc), 113 | "id" -> id.asJson, 114 | "error" -> error.asJson 115 | ) 116 | case JSONRPCMessage.BatchRequest(requests) => 117 | Json.obj( 118 | "requests" -> requests.asJson 119 | ) 120 | case JSONRPCMessage.BatchResponse(responses) => 121 | Json.obj( 122 | "responses" -> responses.asJson 123 | ) 124 | } 125 | } 126 | 127 | final case class JSONRPCErrorObject( 128 | code: Int, 129 | message: String, 130 | data: Option[Json] = None 131 | ) derives Codec 132 | 133 | enum JSONRPCErrorCodes(val code: Int) { 134 | case ParseError extends JSONRPCErrorCodes(-32700) 135 | case InvalidRequest extends JSONRPCErrorCodes(-32600) 136 | case MethodNotFound extends JSONRPCErrorCodes(-32601) 137 | case InvalidParams extends JSONRPCErrorCodes(-32602) 138 | case InternalError extends JSONRPCErrorCodes(-32603) 139 | 140 | // Optionally, a method to get enum from code 141 | def fromCode(code: Int): Option[JSONRPCErrorCodes] = code match { 142 | case -32700 => Some(ParseError) 143 | case -32600 => Some(InvalidRequest) 144 | case -32601 => Some(MethodNotFound) 145 | case -32602 => Some(InvalidParams) 146 | case -32603 => Some(InternalError) 147 | case _ => None 148 | } 149 | } 150 | 151 | // --- Capabilities --- 152 | final case class ClientCapabilities( 153 | experimental: Option[Map[String, Json]] = None, 154 | roots: Option[ClientRootsCapability] = None, 155 | sampling: Option[Json] = None 156 | ) derives Codec 157 | final case class ClientRootsCapability(listChanged: Option[Boolean] = None) derives Codec 158 | 159 | final case class ServerCapabilities( 160 | experimental: Option[Map[String, Json]] = None, 161 | logging: Option[Json] = None, 162 | completions: Option[Json] = None, 163 | prompts: Option[ServerPromptsCapability] = None, 164 | resources: Option[ServerResourcesCapability] = None, 165 | tools: Option[ServerToolsCapability] = None 166 | ) derives Codec 167 | final case class ServerPromptsCapability(listChanged: Option[Boolean] = None) derives Codec 168 | final case class ServerResourcesCapability(subscribe: Option[Boolean] = None, listChanged: Option[Boolean] = None) derives Codec 169 | final case class ServerToolsCapability(listChanged: Option[Boolean] = None) derives Codec 170 | 171 | final case class Implementation(name: String, version: String) derives Codec 172 | 173 | // --- Progress, Cancellation, Initialization, Ping --- 174 | final case class CancelledNotification( 175 | method: String = "notifications/cancelled", 176 | params: CancelledParams 177 | ) derives Codec 178 | final case class CancelledParams(requestId: RequestId, reason: Option[String] = None) derives Codec 179 | 180 | final case class InitializeRequest( 181 | method: String = "initialize", 182 | params: InitializeParams 183 | ) derives Codec 184 | final case class InitializeParams( 185 | protocolVersion: String, 186 | capabilities: ClientCapabilities, 187 | clientInfo: Implementation 188 | ) derives Codec 189 | final case class InitializeResult( 190 | protocolVersion: String, 191 | capabilities: ServerCapabilities, 192 | serverInfo: Implementation, 193 | instructions: Option[String] = None 194 | ) derives Codec 195 | final case class InitializedNotification(method: String = "notifications/initialized") derives Codec 196 | 197 | final case class PingRequest(method: String = "ping") derives Codec 198 | 199 | // --- Model selection --- 200 | final case class ModelPreferences( 201 | hints: Option[List[ModelHint]] = None, 202 | costPriority: Option[Double] = None, 203 | speedPriority: Option[Double] = None, 204 | intelligencePriority: Option[Double] = None 205 | ) derives Codec 206 | final case class ModelHint(name: Option[String] = None) derives Codec 207 | 208 | // --- Resource and prompt references --- 209 | final case class ResourceReference(`type`: String = "ref/resource", uri: String) derives Codec 210 | final case class PromptReference(`type`: String = "ref/prompt", name: String) derives Codec 211 | 212 | // --- Roots --- 213 | final case class ListRootsRequest(method: String = "roots/list") derives Codec 214 | final case class ListRootsResult(roots: List[Root]) derives Codec 215 | final case class Root(uri: String, name: Option[String] = None) derives Codec 216 | final case class RootsListChangedNotification(method: String = "notifications/roots/list_changed") derives Codec 217 | 218 | // --- Autocomplete --- 219 | // Use an enum for CompleteRef instead of Either 220 | enum CompleteRef derives Codec: 221 | case Prompt(prompt: PromptReference) 222 | case Resource(resource: ResourceReference) 223 | 224 | final case class CompleteRequest( 225 | method: String = "completion/complete", 226 | params: CompleteParams 227 | ) derives Codec 228 | final case class CompleteParams( 229 | ref: CompleteRef, 230 | argument: CompleteArgument 231 | ) derives Codec 232 | final case class CompleteArgument(name: String, value: String) derives Codec 233 | final case class CompleteResult(completion: Completion) derives Codec 234 | final case class Completion(values: List[String], total: Option[Int] = None, hasMore: Option[Boolean] = None) derives Codec 235 | 236 | // --- Tool model --- 237 | final case class ToolAnnotations( 238 | title: Option[String] = None, 239 | readOnlyHint: Option[Boolean] = None, 240 | destructiveHint: Option[Boolean] = None, 241 | idempotentHint: Option[Boolean] = None, 242 | openWorldHint: Option[Boolean] = None 243 | ) derives Codec 244 | 245 | final case class ToolDefinition( 246 | name: String, 247 | description: Option[String] = None, 248 | inputSchema: Json, 249 | annotations: Option[ToolAnnotations] = None 250 | ) derives Codec 251 | 252 | final case class ListToolsResponse( 253 | tools: List[ToolDefinition], 254 | nextCursor: Option[String] = None 255 | ) derives Codec 256 | 257 | // Tool result content types 258 | enum ToolContent: 259 | case Text( 260 | `type`: String = "text", 261 | text: String 262 | ) 263 | case Image( 264 | `type`: String = "image", 265 | data: String, // base64 266 | mimeType: String 267 | ) 268 | case Audio( 269 | `type`: String = "audio", 270 | data: String, // base64 271 | mimeType: String 272 | ) 273 | case ResourceContent( 274 | `type`: String = "resource", 275 | resource: Resource 276 | ) 277 | 278 | object ToolContent { 279 | import io.circe.{DecodingFailure, HCursor} 280 | given Encoder[ToolContent] = Encoder.instance { 281 | case ToolContent.Text(_, text) => 282 | Json.obj( 283 | "type" -> Json.fromString("text"), 284 | "text" -> Json.fromString(text) 285 | ) 286 | case ToolContent.Image(_, data, mimeType) => 287 | Json.obj( 288 | "type" -> Json.fromString("image"), 289 | "data" -> Json.fromString(data), 290 | "mimeType" -> Json.fromString(mimeType) 291 | ) 292 | case ToolContent.Audio(_, data, mimeType) => 293 | Json.obj( 294 | "type" -> Json.fromString("audio"), 295 | "data" -> Json.fromString(data), 296 | "mimeType" -> Json.fromString(mimeType) 297 | ) 298 | case ToolContent.ResourceContent(_, resource) => 299 | Json.obj( 300 | "type" -> Json.fromString("resource"), 301 | "resource" -> resource.asJson 302 | ) 303 | } 304 | 305 | given Decoder[ToolContent] = Decoder.instance { (c: HCursor) => 306 | c.downField("type").as[String].flatMap { 307 | case "text" => 308 | c.downField("text").as[String].map(ToolContent.Text("text", _)) 309 | case "image" => 310 | for { 311 | data <- c.downField("data").as[String] 312 | mimeType <- c.downField("mimeType").as[String] 313 | } yield ToolContent.Image("image", data, mimeType) 314 | case "audio" => 315 | for { 316 | data <- c.downField("data").as[String] 317 | mimeType <- c.downField("mimeType").as[String] 318 | } yield ToolContent.Audio("audio", data, mimeType) 319 | case "resource" => 320 | c.downField("resource").as[Resource].map(ToolContent.ResourceContent("resource", _)) 321 | case other => 322 | Left(DecodingFailure(s"Unknown ToolContent type: $other", c.history)) 323 | } 324 | } 325 | } 326 | 327 | final case class Resource( 328 | uri: String, 329 | mimeType: String, 330 | text: Option[String] = None 331 | ) derives Codec 332 | 333 | final case class ToolCallResult( 334 | content: List[ToolContent], 335 | isError: Boolean = false 336 | ) derives Codec 337 | 338 | // --- Tool call (request/response) --- 339 | final case class CallToolRequest( 340 | method: String = "tools/call", 341 | params: CallToolParams 342 | ) derives Codec 343 | final case class CallToolParams( 344 | name: String, 345 | arguments: Json 346 | ) derives Codec 347 | final case class CallToolResult( 348 | content: List[ToolContent], 349 | isError: Boolean = false 350 | ) derives Codec 351 | 352 | // --- List tools (request/response) --- 353 | final case class ListToolsRequest( 354 | method: String = "tools/list", 355 | params: Option[ListToolsParams] = None 356 | ) derives Codec 357 | final case class ListToolsParams(cursor: Option[Cursor] = None) derives Codec 358 | // ListToolsResponse is above 359 | 360 | // --- Notifications for list changes --- 361 | final case class ToolListChangedNotification(method: String = "notifications/tools/list_changed") derives Codec 362 | final case class PromptListChangedNotification(method: String = "notifications/prompts/list_changed") derives Codec 363 | final case class ResourceListChangedNotification(method: String = "notifications/resources/list_changed") derives Codec 364 | 365 | // --- Logging --- 366 | final case class LoggingMessageNotification( 367 | method: String = "notifications/logging/message", 368 | params: LoggingMessageParams 369 | ) derives Codec 370 | final case class LoggingMessageParams( 371 | level: String, 372 | message: String 373 | ) derives Codec 374 | 375 | // --- Progress notification --- 376 | final case class ProgressNotification( 377 | method: String = "notifications/progress", 378 | params: ProgressParams 379 | ) derives Codec 380 | final case class ProgressParams( 381 | requestId: RequestId, 382 | progressToken: Option[ProgressToken] = None, 383 | message: Option[String] = None, 384 | percent: Option[Double] = None 385 | ) derives Codec 386 | -------------------------------------------------------------------------------- /core/src/main/scala/chimp/tool.scala: -------------------------------------------------------------------------------- 1 | package chimp 2 | 3 | import sttp.tapir.Schema 4 | import io.circe.Decoder 5 | import sttp.model.Header 6 | import sttp.shared.Identity 7 | 8 | case class ToolAnnotations( 9 | title: Option[String] = None, 10 | readOnlyHint: Option[Boolean] = None, 11 | destructiveHint: Option[Boolean] = None, 12 | idempotentHint: Option[Boolean] = None, 13 | openWorldHint: Option[Boolean] = None 14 | ) 15 | 16 | /** Describes a tool before the input is specified. */ 17 | case class PartialTool( 18 | name: String, 19 | description: Option[String] = None, 20 | annotations: Option[ToolAnnotations] = None 21 | ): 22 | def description(desc: String): PartialTool = copy(description = Some(desc)) 23 | def withAnnotations(ann: ToolAnnotations): PartialTool = copy(annotations = Some(ann)) 24 | 25 | /** Specify the input type for the tool, providing both a Tapir Schema and a Circe Decoder. */ 26 | def input[I: Schema: Decoder]: Tool[I] = Tool[I](name, description, summon[Schema[I]], summon[Decoder[I]], annotations) 27 | 28 | /** Creates a new MCP tool description with the given name. */ 29 | def tool(name: String): PartialTool = PartialTool(name) 30 | 31 | // 32 | 33 | /** Describes a tool after the input is specified. */ 34 | case class Tool[I]( 35 | name: String, 36 | description: Option[String], 37 | inputSchema: Schema[I], 38 | inputDecoder: Decoder[I], 39 | annotations: Option[ToolAnnotations] 40 | ): 41 | /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, 42 | * should return either a tool execution error (`Left`), or a successful textual result (`Right`), using the F-effect. 43 | */ 44 | def serverLogic[F[_]](logic: (I, Seq[Header]) => F[Either[String, String]]): ServerTool[I, F] = 45 | ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) 46 | 47 | /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, 48 | * should return either a tool execution error (`Left`), or a successful textual result (`Right`). 49 | * 50 | * Same as [[serverLogic]], but using the identity "effect". 51 | */ 52 | def handleWithHeaders(logic: (I, Seq[Header]) => Either[String, String]): ServerTool[I, Identity] = 53 | ServerTool(name, description, inputSchema, inputDecoder, annotations, (i, t) => logic(i, t)) 54 | 55 | /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, 56 | * should return either a tool execution error (`Left`), or a successful textual result (`Right`). 57 | * 58 | * Same as [[handleWithHeaders]], but using no headers. 59 | */ 60 | def handle(logic: I => Either[String, String]): ServerTool[I, Identity] = 61 | handleWithHeaders((i, _) => logic(i)) 62 | 63 | /** A tool that can be executed by the MCP server. */ 64 | case class ServerTool[I, F[_]]( 65 | name: String, 66 | description: Option[String], 67 | inputSchema: Schema[I], 68 | inputDecoder: Decoder[I], 69 | annotations: Option[ToolAnnotations], 70 | logic: (I, Seq[Header]) => F[Either[String, String]] 71 | ) 72 | -------------------------------------------------------------------------------- /core/src/test/scala/chimp/McpHandlerSpec.scala: -------------------------------------------------------------------------------- 1 | package chimp 2 | 3 | import chimp.protocol.* 4 | import chimp.protocol.JSONRPCMessage.given 5 | import io.circe.* 6 | import io.circe.parser.* 7 | import io.circe.syntax.* 8 | import org.scalatest.flatspec.AnyFlatSpec 9 | import org.scalatest.matchers.should.Matchers 10 | import sttp.model.Header 11 | import sttp.monad.{IdentityMonad, MonadError} 12 | import sttp.shared.Identity 13 | import sttp.tapir.Schema 14 | 15 | class McpHandlerSpec extends AnyFlatSpec with Matchers: 16 | import JSONRPCMessage.* 17 | import chimp.protocol.JSONRPCErrorCodes.* 18 | 19 | // Simple test input types 20 | case class EchoInput(message: String) derives Schema, Codec 21 | case class AddInput(a: Int, b: Int) derives Schema, Codec 22 | 23 | // Test tools 24 | val echoTool = tool("echo") 25 | .description("Echoes the input message.") 26 | .input[EchoInput] 27 | .handle(in => Right(in.message)) 28 | 29 | val addTool = tool("add") 30 | .description("Adds two numbers.") 31 | .input[AddInput] 32 | .handle(in => Right((in.a + in.b).toString)) 33 | 34 | val errorTool = tool("fail") 35 | .description("Always fails.") 36 | .input[EchoInput] 37 | .handle(_ => Left("Intentional failure")) 38 | 39 | // Tool that echoes the header's value for testing 40 | case class HeaderEchoInput(dummy: String) derives Schema, Codec 41 | private val headerEchoTool = tool("headerEcho") 42 | .description("Echoes the header value if present.") 43 | .input[HeaderEchoInput] 44 | .handleWithHeaders { (in, headers) => 45 | if headers.isEmpty then Right("no header") 46 | else 47 | Right( 48 | headers 49 | .map(header => s"header name: ${header.name}, header value: ${header.value}") 50 | .mkString(", ") 51 | ) 52 | } 53 | 54 | val handler = McpHandler(List(echoTool, addTool, errorTool, headerEchoTool), "Chimp MCP server", "1.0.0", true) 55 | 56 | def parseJson(str: String): Json = parse(str).getOrElse(throw new RuntimeException("Invalid JSON")) 57 | 58 | given MonadError[Identity] = IdentityMonad 59 | 60 | "McpHandler" should "respond to initialize" in: 61 | // Given 62 | val req: JSONRPCMessage = Request(method = "initialize", id = RequestId("1")) 63 | val json = req.asJson 64 | // When 65 | val respJson = handler.handleJsonRpc(json, Seq.empty) 66 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 67 | // Then 68 | resp match 69 | case Response(_, _, result) => 70 | val resultObj = result.as[InitializeResult].getOrElse(fail("Failed to decode result")) 71 | resultObj.protocolVersion shouldBe "2025-03-26" 72 | resultObj.serverInfo.name should include("Chimp MCP server") 73 | case _ => fail("Expected Response") 74 | 75 | it should "list available tools" in: 76 | // Given 77 | val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("2")) 78 | val json = req.asJson 79 | // When 80 | val respJson = handler.handleJsonRpc(json, Seq.empty) 81 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 82 | // Then 83 | resp match 84 | case Response(_, _, result) => 85 | val resultObj = result.as[ListToolsResponse].getOrElse(fail("Failed to decode result")) 86 | resultObj.tools.map(_.name).toSet shouldBe Set("echo", "add", "fail", "headerEcho") 87 | case _ => fail("Expected Response") 88 | 89 | it should "call a tool successfully (echo)" in: 90 | // Given 91 | val params = Json.obj( 92 | "name" -> Json.fromString("echo"), 93 | "arguments" -> Json.obj("message" -> Json.fromString("hello")) 94 | ) 95 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("3")) 96 | val json = req.asJson 97 | // When 98 | val respJson = handler.handleJsonRpc(json, Seq.empty) 99 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 100 | // Then 101 | resp match 102 | case Response(_, _, result) => 103 | val resultObj = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 104 | resultObj.isError shouldBe false 105 | resultObj.content should have length 1 106 | resultObj.content.head shouldBe ToolContent.Text("text", "hello") 107 | case _ => fail("Expected Response") 108 | 109 | it should "call a tool successfully (add)" in: 110 | // Given 111 | val params = Json.obj( 112 | "name" -> Json.fromString("add"), 113 | "arguments" -> Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3)) 114 | ) 115 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("4")) 116 | val json = req.asJson 117 | // When 118 | val respJson = handler.handleJsonRpc(json, Seq.empty) 119 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 120 | // Then 121 | resp match 122 | case Response(_, _, result) => 123 | val resultObj = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 124 | resultObj.isError shouldBe false 125 | resultObj.content.head shouldBe ToolContent.Text("text", "5") 126 | case _ => fail("Expected Response") 127 | 128 | it should "accept notifications" in: 129 | // Given 130 | val req: JSONRPCMessage = Notification(method = "notifications/initialized") 131 | val json = req.asJson 132 | // When 133 | val respJson = handler.handleJsonRpc(json, Seq.empty) 134 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 135 | // Then 136 | resp match 137 | case Notification(_, methodName, _) => methodName shouldBe "notifications/initialized" 138 | case _ => fail("Expected Notification") 139 | 140 | it should "return an error for unknown tool" in: 141 | // Given 142 | val params = Json.obj( 143 | "name" -> Json.fromString("unknown"), 144 | "arguments" -> Json.obj("foo" -> Json.fromString("bar")) 145 | ) 146 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("5")) 147 | val json = req.asJson 148 | // When 149 | val respJson = handler.handleJsonRpc(json, Seq.empty) 150 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) 151 | // Then 152 | resp match 153 | case Error(_, _, error) => 154 | error.code shouldBe MethodNotFound.code 155 | error.message should include("Unknown tool") 156 | case _ => fail("Expected Error") 157 | 158 | it should "return an error for invalid arguments" in: 159 | // Given 160 | val params = Json.obj( 161 | "name" -> Json.fromString("add"), 162 | "arguments" -> Json.obj("a" -> Json.fromString("notAnInt"), "b" -> Json.fromInt(3)) 163 | ) 164 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("6")) 165 | val json = req.asJson 166 | // When 167 | val respJson = handler.handleJsonRpc(json, Seq.empty) 168 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) 169 | // Then 170 | resp match 171 | case Error(_, _, error) => 172 | error.code shouldBe InvalidParams.code 173 | error.message should include("Invalid arguments") 174 | case _ => fail("Expected Error") 175 | 176 | it should "return an error for missing arguments" in: 177 | // Given 178 | val params = Json.obj( 179 | "name" -> Json.fromString("add") 180 | // missing 'arguments' 181 | ) 182 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("7")) 183 | val json = req.asJson 184 | // When 185 | val respJson = handler.handleJsonRpc(json, Seq.empty) 186 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) 187 | // Then 188 | resp match 189 | case Error(_, _, error) => 190 | error.code shouldBe InvalidParams.code 191 | error.message should include("Missing arguments") 192 | case _ => fail("Expected Error") 193 | 194 | it should "return an error for missing tool name" in: 195 | // Given 196 | val params = Json.obj( 197 | // missing 'name' 198 | "arguments" -> Json.obj("message" -> Json.fromString("hello")) 199 | ) 200 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("8")) 201 | val json = req.asJson 202 | // When 203 | val respJson = handler.handleJsonRpc(json, Seq.empty) 204 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) 205 | // Then 206 | resp match 207 | case Error(_, _, error) => 208 | error.code shouldBe InvalidParams.code 209 | error.message should include("Missing tool name") 210 | case _ => fail("Expected Error") 211 | 212 | it should "return an error for tool logic failure" in: 213 | // Given 214 | val params = Json.obj( 215 | "name" -> Json.fromString("fail"), 216 | "arguments" -> Json.obj("message" -> Json.fromString("test")) 217 | ) 218 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("9")) 219 | val json = req.asJson 220 | // When 221 | val respJson = handler.handleJsonRpc(json, Seq.empty) 222 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 223 | // Then 224 | resp match 225 | case Response(_, _, result) => 226 | val resultObj = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 227 | resultObj.isError shouldBe true 228 | resultObj.content.head shouldBe ToolContent.Text("text", "Intentional failure") 229 | case _ => fail("Expected Response") 230 | 231 | it should "return an error for unknown method" in: 232 | // Given 233 | val req: JSONRPCMessage = Request(method = "not/a/real/method", id = RequestId("10")) 234 | val json = req.asJson 235 | // When 236 | val respJson = handler.handleJsonRpc(json, Seq.empty) 237 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) 238 | // Then 239 | resp match 240 | case Error(_, _, error) => 241 | error.code shouldBe MethodNotFound.code 242 | error.message should include("Unknown method") 243 | case _ => fail("Expected Error") 244 | 245 | it should "handle batch requests with mixed results" in: 246 | // Given 247 | val req1 = Request( 248 | method = "tools/call", 249 | params = Some( 250 | Json.obj( 251 | "name" -> Json.fromString("echo"), 252 | "arguments" -> Json.obj("message" -> Json.fromString("hi")) 253 | ) 254 | ), 255 | id = RequestId("b1") 256 | ) 257 | val req2 = Request( 258 | method = "tools/call", 259 | params = Some( 260 | Json.obj( 261 | "name" -> Json.fromString("add"), 262 | "arguments" -> Json.obj("a" -> Json.fromInt(1), "b" -> Json.fromInt(2)) 263 | ) 264 | ), 265 | id = RequestId("b2") 266 | ) 267 | val req3 = Request( 268 | method = "tools/call", 269 | params = Some( 270 | Json.obj( 271 | "name" -> Json.fromString("fail"), 272 | "arguments" -> Json.obj("message" -> Json.fromString("fail")) 273 | ) 274 | ), 275 | id = RequestId("b3") 276 | ) 277 | val req4 = Request( 278 | method = "tools/call", 279 | params = Some( 280 | Json.obj( 281 | "name" -> Json.fromString("unknown"), 282 | "arguments" -> Json.obj("foo" -> Json.fromString("bar")) 283 | ) 284 | ), 285 | id = RequestId("b4") 286 | ) 287 | val notification = Notification(method = "tools/list", params = None) 288 | val batch = BatchRequest(List(req1, req2, req3, req4, notification)) 289 | val json = batch.asJson 290 | // When 291 | val respJson = handler.handleJsonRpc(json, Seq.empty) 292 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode batch response")) 293 | // Then 294 | resp match 295 | case BatchResponse(responses) => 296 | // Should not include notification response 297 | responses.foreach { 298 | case Response(_, id, result) if id == RequestId("b1") => 299 | val r = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 300 | r.isError shouldBe false 301 | r.content.head shouldBe ToolContent.Text("text", "hi") 302 | case Response(_, id, result) if id == RequestId("b2") => 303 | val r = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 304 | r.isError shouldBe false 305 | r.content.head shouldBe ToolContent.Text("text", "3") 306 | case Response(_, id, result) if id == RequestId("b3") => 307 | val r = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 308 | r.isError shouldBe true 309 | r.content.head shouldBe ToolContent.Text("text", "Intentional failure") 310 | case Error(_, id, error) if id == RequestId("b4") => 311 | error.code shouldBe MethodNotFound.code 312 | error.message should include("Unknown tool") 313 | case other => fail(s"Unexpected response: $other") 314 | } 315 | responses.exists { 316 | case Response(_, id, _) if id == RequestId("b1") => true 317 | case Response(_, id, _) if id == RequestId("b2") => true 318 | case Response(_, id, _) if id == RequestId("b3") => true 319 | case Error(_, id, _) if id == RequestId("b4") => true 320 | case _ => false 321 | } shouldBe true 322 | responses.exists { 323 | case Notification(_, _, _) => true 324 | case _ => false 325 | } shouldBe false 326 | case _ => fail("Expected BatchResponse") 327 | 328 | it should "call a tool with a header and receive the header's value in the response" in: 329 | // Given 330 | val params = Json.obj( 331 | "name" -> Json.fromString("headerEcho"), 332 | "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) 333 | ) 334 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header1")) 335 | val json = req.asJson 336 | // When 337 | val respJson = handler.handleJsonRpc(json, Seq(Header("header-name", "my-secret-header"))) 338 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 339 | // Then 340 | resp match 341 | case Response(_, _, result) => 342 | val resultObj = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 343 | resultObj.isError shouldBe false 344 | resultObj.content.head shouldBe ToolContent.Text("text", "header name: header-name, header value: my-secret-header") 345 | case _ => fail("Expected Response") 346 | 347 | it should "call a tool with a header and receive multiple header's values in the response" in: 348 | // Given 349 | val params = Json.obj( 350 | "name" -> Json.fromString("headerEcho"), 351 | "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) 352 | ) 353 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header1")) 354 | val json = req.asJson 355 | // When 356 | val respJson = 357 | handler.handleJsonRpc(json, Seq(Header("header-name", "my-secret-header"), Header("another-header-name", "another-secret-header"))) 358 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 359 | // Then 360 | resp match 361 | case Response(_, _, result) => 362 | val resultObj = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 363 | resultObj.isError shouldBe false 364 | resultObj.content.head shouldBe ToolContent.Text( 365 | "text", 366 | "header name: header-name, header value: my-secret-header, header name: another-header-name, header value: another-secret-header" 367 | ) 368 | case _ => fail("Expected Response") 369 | 370 | it should "call a tool without a header value and receive 'no header' in the response" in: 371 | // Given 372 | val params = Json.obj( 373 | "name" -> Json.fromString("headerEcho"), 374 | "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) 375 | ) 376 | val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header2")) 377 | val json = req.asJson 378 | // When 379 | val respJson = handler.handleJsonRpc(json, Seq.empty) 380 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) 381 | // Then 382 | resp match 383 | case Response(_, _, result) => 384 | val resultObj = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 385 | resultObj.isError shouldBe false 386 | resultObj.content.head shouldBe ToolContent.Text("text", "no header") 387 | case _ => fail("Expected Response") 388 | 389 | it should "handle batch requests with mixed headers" in: 390 | // Given 391 | val req1 = Request( 392 | method = "tools/call", 393 | params = Some( 394 | Json.obj( 395 | "name" -> Json.fromString("headerEcho"), 396 | "arguments" -> Json.obj("dummy" -> Json.fromString("hi")) 397 | ) 398 | ), 399 | id = RequestId("bh1") 400 | ) 401 | val req2 = Request( 402 | method = "tools/call", 403 | params = Some( 404 | Json.obj( 405 | "name" -> Json.fromString("headerEcho"), 406 | "arguments" -> Json.obj("dummy" -> Json.fromString("yo")) 407 | ) 408 | ), 409 | id = RequestId("bh2") 410 | ) 411 | val batch = BatchRequest(List(req1, req2)) 412 | val json = batch.asJson 413 | // When 414 | val respJson = handler.handleJsonRpc(json, Seq(Header("header-name", "batch-header"))) 415 | val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode batch response")) 416 | // Then 417 | resp match 418 | case BatchResponse(responses) => 419 | responses.foreach { 420 | case Response(_, id, result) if id == RequestId("bh1") || id == RequestId("bh2") => 421 | val r = result.as[ToolCallResult].getOrElse(fail("Failed to decode result")) 422 | r.isError shouldBe false 423 | r.content.head shouldBe ToolContent.Text("text", "header name: header-name, header value: batch-header") 424 | case other => fail(s"Unexpected response: $other") 425 | } 426 | case _ => fail("Expected BatchResponse") 427 | -------------------------------------------------------------------------------- /core/src/test/scala/chimp/protocol/ModelSpec.scala: -------------------------------------------------------------------------------- 1 | package chimp.protocol 2 | 3 | import io.circe.Json 4 | import io.circe.parser.* 5 | import io.circe.syntax.* 6 | import org.scalatest.flatspec.AnyFlatSpec 7 | import org.scalatest.matchers.should.Matchers 8 | 9 | class ModelSpec extends AnyFlatSpec with Matchers { 10 | import JSONRPCMessage.* 11 | import chimp.protocol.JSONRPCErrorCodes.* 12 | 13 | // Helper function to parse JSON strings 14 | private def parseJson(str: String): Json = parse(str).getOrElse(throw new RuntimeException("Invalid JSON")) 15 | 16 | "JSONRPCMessage" should "handle Request messages according to JSON-RPC 2.0 spec" in { 17 | // Given 18 | val request: JSONRPCMessage = Request( 19 | method = "test/method", 20 | params = Some(Json.obj("param1" -> Json.fromString("value1"))), 21 | id = RequestId("123") 22 | ) 23 | val expectedJson = parseJson(""" 24 | { 25 | "jsonrpc": "2.0", 26 | "method": "test/method", 27 | "params": {"param1": "value1"}, 28 | "id": "123" 29 | } 30 | """) 31 | 32 | // When 33 | val json = request.asJson 34 | val decoded = json.as[JSONRPCMessage].getOrElse(fail("Failed to decode JSON")) 35 | 36 | // Then 37 | json shouldBe expectedJson 38 | decoded shouldBe request 39 | } 40 | 41 | it should "handle Notification messages according to JSON-RPC 2.0 spec" in { 42 | // Given 43 | val notification: JSONRPCMessage = Notification( 44 | method = "test/notification", 45 | params = Some(Json.obj("param1" -> Json.fromString("value1"))) 46 | ) 47 | val expectedJson = parseJson(""" 48 | { 49 | "jsonrpc": "2.0", 50 | "method": "test/notification", 51 | "params": {"param1": "value1"} 52 | } 53 | """) 54 | 55 | // When 56 | val json = notification.asJson 57 | val decoded = json.as[JSONRPCMessage].getOrElse(fail("Failed to decode JSON")) 58 | 59 | // Then 60 | json shouldBe expectedJson 61 | decoded shouldBe notification 62 | } 63 | 64 | it should "handle Response messages according to JSON-RPC 2.0 spec" in { 65 | // Given 66 | val response: JSONRPCMessage = Response( 67 | id = RequestId(123), 68 | result = Json.obj("result" -> Json.fromString("success")) 69 | ) 70 | val expectedJson = parseJson(""" 71 | { 72 | "jsonrpc": "2.0", 73 | "id": 123, 74 | "result": {"result": "success"} 75 | } 76 | """) 77 | 78 | // When 79 | val json = response.asJson 80 | val decoded = json.as[JSONRPCMessage].getOrElse(fail("Failed to decode JSON")) 81 | 82 | // Then 83 | json shouldBe expectedJson 84 | decoded shouldBe response 85 | } 86 | 87 | it should "handle Error messages according to JSON-RPC 2.0 spec" in { 88 | // Given 89 | val error: JSONRPCMessage = Error( 90 | id = RequestId("error-123"), 91 | error = JSONRPCErrorObject( 92 | code = InvalidRequest.code, 93 | message = "Invalid request", 94 | data = Some(Json.obj("details" -> Json.fromString("More info"))) 95 | ) 96 | ) 97 | val expectedJson = parseJson(""" 98 | { 99 | "jsonrpc": "2.0", 100 | "id": "error-123", 101 | "error": { 102 | "code": -32600, 103 | "message": "Invalid request", 104 | "data": {"details": "More info"} 105 | } 106 | } 107 | """) 108 | 109 | // When 110 | val json = error.asJson 111 | val decoded = json.as[JSONRPCMessage].getOrElse(fail("Failed to decode JSON")) 112 | 113 | // Then 114 | json shouldBe expectedJson 115 | decoded shouldBe error 116 | } 117 | 118 | it should "handle BatchRequest messages according to JSON-RPC 2.0 spec" in { 119 | // Given 120 | val batchRequest: JSONRPCMessage = BatchRequest( 121 | List( 122 | Request(method = "method1", id = RequestId(1)), 123 | Request(method = "method2", id = RequestId(2)) 124 | ) 125 | ) 126 | val expectedJson = parseJson(""" 127 | { 128 | "requests": [ 129 | { 130 | "jsonrpc": "2.0", 131 | "method": "method1", 132 | "id": 1 133 | }, 134 | { 135 | "jsonrpc": "2.0", 136 | "method": "method2", 137 | "id": 2 138 | } 139 | ] 140 | } 141 | """) 142 | 143 | // When 144 | val json = batchRequest.asJson 145 | val decoded = json.as[JSONRPCMessage].getOrElse(fail("Failed to decode JSON")) 146 | 147 | // Then 148 | json shouldBe expectedJson 149 | decoded shouldBe batchRequest 150 | } 151 | 152 | it should "handle BatchResponse messages according to JSON-RPC 2.0 spec" in { 153 | // Given 154 | val batchResponse: JSONRPCMessage = BatchResponse( 155 | List( 156 | Response(id = RequestId(1), result = Json.obj("result1" -> Json.fromString("value1"))), 157 | Response(id = RequestId(2), result = Json.obj("result2" -> Json.fromString("value2"))) 158 | ) 159 | ) 160 | val expectedJson = parseJson(""" 161 | { 162 | "responses": [ 163 | { 164 | "jsonrpc": "2.0", 165 | "id": 1, 166 | "result": {"result1": "value1"} 167 | }, 168 | { 169 | "jsonrpc": "2.0", 170 | "id": 2, 171 | "result": {"result2": "value2"} 172 | } 173 | ] 174 | } 175 | """) 176 | 177 | // When 178 | val json = batchResponse.asJson 179 | val decoded = json.as[JSONRPCMessage].getOrElse(fail("Failed to decode JSON")) 180 | 181 | // Then 182 | json shouldBe expectedJson 183 | decoded shouldBe batchResponse 184 | } 185 | 186 | it should "handle both numeric and string request IDs according to JSON-RPC 2.0 spec" in { 187 | // Given 188 | val numericRequest: JSONRPCMessage = Request(method = "test", id = RequestId(123)) 189 | val stringRequest: JSONRPCMessage = Request(method = "test", id = RequestId("abc")) 190 | 191 | // When 192 | val numericJson = numericRequest.asJson 193 | val stringJson = stringRequest.asJson 194 | val decodedNumeric = numericJson.as[JSONRPCMessage].getOrElse(fail()) 195 | val decodedString = stringJson.as[JSONRPCMessage].getOrElse(fail()) 196 | 197 | // Then 198 | decodedNumeric shouldBe numericRequest 199 | decodedString shouldBe stringRequest 200 | } 201 | 202 | it should "handle optional parameters according to JSON-RPC 2.0 spec" in { 203 | // Given 204 | val requestWithParams: JSONRPCMessage = Request( 205 | method = "test", 206 | params = Some(Json.obj("param" -> Json.fromString("value"))), 207 | id = RequestId(1) 208 | ) 209 | val requestWithoutParams: JSONRPCMessage = Request(method = "test", id = RequestId(2)) 210 | 211 | // When 212 | val jsonWithParams = requestWithParams.asJson 213 | val jsonWithoutParams = requestWithoutParams.asJson 214 | val decodedWithParams = jsonWithParams.as[JSONRPCMessage].getOrElse(fail()) 215 | val decodedWithoutParams = jsonWithoutParams.as[JSONRPCMessage].getOrElse(fail()) 216 | 217 | // Then 218 | decodedWithParams shouldBe requestWithParams 219 | decodedWithoutParams shouldBe requestWithoutParams 220 | } 221 | 222 | it should "handle all standard JSON-RPC error codes according to spec" in { 223 | // Given 224 | val errorCodes = List( 225 | ParseError, 226 | InvalidRequest, 227 | MethodNotFound, 228 | InvalidParams, 229 | InternalError 230 | ) 231 | 232 | // When/Then 233 | errorCodes.foreach { code => 234 | // Given 235 | val error: JSONRPCMessage = Error( 236 | id = RequestId("test"), 237 | error = JSONRPCErrorObject(code = code.code, message = "Test error") 238 | ) 239 | 240 | // When 241 | val json = error.asJson 242 | val decoded = json.as[JSONRPCMessage].getOrElse(fail()) 243 | 244 | // Then 245 | decoded shouldBe error 246 | } 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /examples/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/src/main/scala/chimp/adderMcp.scala: -------------------------------------------------------------------------------- 1 | package chimp 2 | 3 | //> using dep com.softwaremill.chimp::core:0.1.2 4 | //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.33 5 | //> using dep ch.qos.logback::logback-classic:1.5.18 6 | 7 | import sttp.tapir.* 8 | import io.circe.Codec 9 | import sttp.tapir.server.netty.sync.NettySyncServer 10 | 11 | case class Input(a: Int, b: Int) derives Codec, Schema 12 | 13 | @main def mcpApp(): Unit = 14 | val adderTool = tool("adder") 15 | .description("Adds two numbers") 16 | .withAnnotations(ToolAnnotations(idempotentHint = Some(true))) 17 | .input[Input] 18 | 19 | def logic(i: Input): Either[String, String] = Right(s"The result is ${i.a + i.b}") 20 | 21 | val adderServerTool = adderTool.handle(logic) 22 | 23 | val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) 24 | 25 | NettySyncServer() 26 | .port(8080) 27 | .addEndpoint(mcpServerEndpoint) 28 | .startAndWait() 29 | -------------------------------------------------------------------------------- /examples/src/main/scala/chimp/adderWithAuthMcp.scala: -------------------------------------------------------------------------------- 1 | package chimp 2 | 3 | //> using dep com.softwaremill.chimp::core:0.1.2 4 | //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.33 5 | //> using dep ch.qos.logback::logback-classic:1.5.18 6 | 7 | import sttp.model.Header 8 | import sttp.tapir.* 9 | import sttp.tapir.server.netty.sync.NettySyncServer 10 | 11 | @main def mcpAuthApp(): Unit = 12 | val adderTool = tool("adder") 13 | .description("Adds two numbers") 14 | .withAnnotations(ToolAnnotations(idempotentHint = Some(true))) 15 | .input[Input] 16 | 17 | def logic(i: Input, headers: Seq[Header]): Either[String, String] = 18 | val tokenMsg = 19 | headers.find(_.name == "test_header").map(t => s"token: ${t.value} (header name used: ${t.name})").getOrElse("no token provided") 20 | Right(s"The result is ${i.a + i.b} ($tokenMsg)") 21 | 22 | val adderServerTool = adderTool.handleWithHeaders(logic) 23 | 24 | val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) 25 | 26 | NettySyncServer() 27 | .port(8080) 28 | .addEndpoint(mcpServerEndpoint) 29 | .startAndWait() 30 | -------------------------------------------------------------------------------- /examples/src/main/scala/chimp/twoToolsMcp.scala: -------------------------------------------------------------------------------- 1 | package chimp 2 | 3 | //> using dep com.softwaremill.chimp::core:0.1.2 4 | //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.33 5 | //> using dep ch.qos.logback::logback-classic:1.5.18 6 | 7 | import io.circe.Codec 8 | import sttp.tapir.* 9 | import sttp.tapir.server.netty.sync.NettySyncServer 10 | 11 | case class IsPrimeInput(n: Int) derives Codec, Schema 12 | case class IsFibonacciInput(n: Int) derives Codec, Schema 13 | 14 | @main def twoToolsMcp(): Unit = 15 | val isPrimeTool = tool("isPrime") 16 | .description("Checks if a number is prime") 17 | .input[IsPrimeInput] 18 | .handle(i => 19 | if i.n <= 0 20 | then Left("Only positive numbers can be prime-checked") 21 | else Right(isPrimeWithDescription(i.n)) 22 | ) 23 | 24 | val isFibonacci = tool("isFibonacci") 25 | .description("Checks if a number is a Fibonacci number") 26 | .input[IsFibonacciInput] 27 | .handle(i => Right(isFibonacciWithDescription(i.n))) 28 | 29 | val mcpServerEndpoint = mcpEndpoint(List(isPrimeTool, isFibonacci), List("mcp")) 30 | NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() 31 | 32 | private def smallestDivisor(n: Int): Int = 33 | if n <= 1 then 1 34 | else if n % 2 == 0 then 2 35 | else if n % 3 == 0 then 3 36 | else 37 | var i = 5 38 | while i * i <= n do 39 | if n % i == 0 then return i 40 | if n % (i + 2) == 0 then return i + 2 41 | i += 6 42 | n 43 | 44 | private def isPrimeWithDescription(n: Int) = 45 | val sd = smallestDivisor(n) 46 | if sd == n then s"$n is prime" else s"$n is not prime, it is divisible by $sd" 47 | 48 | private def isFibonacciWithDescription(n: Int) = 49 | // check if n is a Fibonacci number by testing if 5*n^2 + 4 or 5*n^2 - 4 is a perfect square 50 | def isPerfectSquare(x: Long): Boolean = 51 | val sqrt = math.sqrt(x.toDouble).toLong 52 | sqrt * sqrt == x 53 | 54 | val nSquared = n.toLong * n.toLong 55 | val test1 = 5 * nSquared + 4 56 | val test2 = 5 * nSquared - 4 57 | 58 | if isPerfectSquare(test1) || isPerfectSquare(test2) then s"$n is a Fibonacci number" 59 | else s"$n is not a Fibonacci number" 60 | -------------------------------------------------------------------------------- /examples/src/main/scala/chimp/weatherMcp.scala: -------------------------------------------------------------------------------- 1 | package chimp 2 | 3 | //> using dep com.softwaremill.chimp::core:0.1.2 4 | //> using dep com.softwaremill.sttp.client4::core:4.0.8 5 | //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.33 6 | //> using dep ch.qos.logback::logback-classic:1.5.18 7 | 8 | import chimp.* 9 | import io.circe.Codec 10 | import io.circe.parser.decode 11 | import ox.either 12 | import ox.either.ok 13 | import sttp.client4.* 14 | import sttp.tapir.* 15 | import sttp.tapir.server.netty.sync.NettySyncServer 16 | 17 | case class WeatherInput(city: String) derives Codec, Schema 18 | 19 | // Represents a single result from Nominatim geocoding API 20 | case class NominatimResult(lat: String, lon: String, display_name: String) derives Codec 21 | 22 | // Represents the response from Open-Meteo API (current weather) 23 | case class OpenMeteoCurrentWeather(temperature: Double, weathercode: Int) derives Codec 24 | case class OpenMeteoResponse(current_weather: OpenMeteoCurrentWeather) derives Codec 25 | 26 | @main def weatherMcp(): Unit = 27 | val sttpBackend = DefaultSyncBackend() 28 | 29 | val weatherTool = tool("weather") 30 | .description("Checks the weather in the given city") 31 | .input[WeatherInput] 32 | .handle: input => 33 | either: 34 | val geocodeResult = geocodeCity(input.city, sttpBackend).ok() 35 | val weatherResult = fetchWeather(geocodeResult.lat, geocodeResult.lon, sttpBackend).ok() 36 | weatherDescription(geocodeResult.display_name, weatherResult.temperature, weatherResult.weathercode) 37 | 38 | val mcpServerEndpoint = mcpEndpoint(List(weatherTool), List("mcp")) 39 | NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() 40 | 41 | /** Maps Open-Meteo weather codes to human-readable descriptions. */ 42 | private val weatherCodeDescriptions = Map( 43 | 0 -> "Clear sky", 44 | 1 -> "Mainly clear", 45 | 2 -> "Partly cloudy", 46 | 3 -> "Overcast", 47 | 45 -> "Fog", 48 | 48 -> "Depositing rime fog", 49 | 51 -> "Drizzle: Light", 50 | 53 -> "Drizzle: Moderate", 51 | 55 -> "Drizzle: Dense", 52 | 56 -> "Freezing Drizzle: Light", 53 | 57 -> "Freezing Drizzle: Dense", 54 | 61 -> "Rain: Slight", 55 | 63 -> "Rain: Moderate", 56 | 65 -> "Rain: Heavy", 57 | 66 -> "Freezing Rain: Light", 58 | 67 -> "Freezing Rain: Heavy", 59 | 71 -> "Snow fall: Slight", 60 | 73 -> "Snow fall: Moderate", 61 | 75 -> "Snow fall: Heavy", 62 | 77 -> "Snow grains", 63 | 80 -> "Rain showers: Slight", 64 | 81 -> "Rain showers: Moderate", 65 | 82 -> "Rain showers: Violent", 66 | 85 -> "Snow showers: Slight", 67 | 86 -> "Snow showers: Heavy", 68 | 95 -> "Thunderstorm: Slight or moderate", 69 | 96 -> "Thunderstorm with slight hail", 70 | 99 -> "Thunderstorm with heavy hail" 71 | ) 72 | 73 | /** Geocodes a city name to (latitude, longitude) using the Nominatim API. */ 74 | private def geocodeCity(city: String, backend: SyncBackend): Either[String, NominatimResult] = 75 | val nominatimUrl = uri"https://nominatim.openstreetmap.org/search?format=json&limit=1&q=$city" 76 | val geoResp = basicRequest.get(nominatimUrl).header("User-Agent", "chimp-weather-tool").send(backend) 77 | geoResp.body match 78 | case Left(_) => Left(s"Failed to contact geocoding service: ${geoResp.code}") 79 | case Right(body) => 80 | decode[List[NominatimResult]](body) match 81 | case Left(err) => Left(s"Failed to decode geocoding response: $err") 82 | case Right(Nil) => Left(s"City not found: $city") 83 | case Right(firstResult :: _) => Right(firstResult) 84 | 85 | /** Fetches the current weather for the given coordinates using the Open-Meteo API. */ 86 | private def fetchWeather(lat: String, lon: String, backend: SyncBackend): Either[String, OpenMeteoCurrentWeather] = 87 | val meteoUrl = uri"https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t_weather=true" 88 | val meteoResp = basicRequest.get(meteoUrl).send(backend) 89 | meteoResp.body match 90 | case Left(_) => Left(s"Failed to contact weather service: ${meteoResp.code}") 91 | case Right(body) => 92 | decode[OpenMeteoResponse](body) match 93 | case Left(err) => Left(s"Failed to decode weather response: $err") 94 | case Right(meteo) => Right(meteo.current_weather) 95 | 96 | /** Maps an Open-Meteo weather code to a human-readable description. */ 97 | private def weatherDescription(where: String, temp: Double, code: Int): String = 98 | s"The weather in $where is ${weatherCodeDescriptions.getOrElse(code, s"Unknown (code $code)").toLowerCase()}, with a high of $temp celsius." 99 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.3 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | val sbtSoftwareMillVersion = "2.1.0" 2 | 3 | addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % sbtSoftwareMillVersion) 4 | addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % sbtSoftwareMillVersion) 5 | addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.7.2") 6 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") 7 | --------------------------------------------------------------------------------