Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/server/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- `handle` — synchronous logic from the argument values to `GetPromptResult`.
- `handleWithHeaders` — synchronous logic that also receives the request headers.
- `serverLogic` — effectful logic, with the request headers.
- `handleSecured` (synchronous) or `securedServerLogic` (effectful) — logic that also receives the principal made by the server's security logic; usable only on a secured server — see [transport security](transport.md).
- Register prompts with `.addPrompt` / `.addPrompts`.

```scala mdoc:compile-only
Expand Down
1 change: 1 addition & 0 deletions docs/server/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- `handle` — synchronous read logic.
- `handleWithHeaders` — synchronous read logic that also receives the request headers.
- `serverLogic` — effectful read logic, with the request headers.
- `handleSecured` (synchronous) or `securedServerLogic` (effectful) — logic that also receives the principal made by the server's security logic; usable only on a secured server — see [transport security](transport.md).
- Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`.

```scala mdoc:compile-only
Expand Down
14 changes: 9 additions & 5 deletions docs/server/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,18 @@ object SecuredMcpServer:
NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait()
```

The result of the security logic of `prependSecurity` does not reach the tool logic. Use `prependSecurity` if the tools do not need data from the caller. If a tool needs such data, read the request headers with `handleWithHeaders` (or `serverLogic` with headers), or give the tools a principal, as below.
The result of the security logic of `prependSecurity` does not reach the tool, prompt, or resource logic. Use `prependSecurity` if they do not need data from the caller. If they need such data, read the request headers with `handleWithHeaders` (or `serverLogic` with headers), or give them a principal, as below.

For all the security inputs - API keys, basic and bearer authorization, OAuth2 flows - see the [Tapir endpoint security documentation](https://tapir.softwaremill.com/en/latest/endpoint/security.html).

### Giving the security result to the tools
### Giving the security result to tools, prompts, and resources

To validate the caller one time and give the result to the tool logic, use `serverSecurityLogic` (or `serverSecurityLogicPure`, if the logic needs no effect). It takes the same security input and error output as `prependSecurity`, and makes a principal - a value of your own type, such as the identity of the caller.
To validate the caller one time and give the result to the tool, prompt, or resource logic, use `serverSecurityLogic` (or `serverSecurityLogicPure`, if the logic needs no effect). It takes the same security input and error output as `prependSecurity`, and makes a principal - a value of your own type, such as the identity of the caller.

The server gives the principal to the logic of each tool which you add to it. Define such a tool with `handleSecured`, or with `securedServerLogic` if the logic needs an effect. Tools which do not need the principal keep their usual logic. The other builders of `McpServer` stay available, so you can configure the server before or after you add the security logic:
The server gives the principal to the logic of each tool, prompt, or resource which you add to it. Define such handlers with `handleSecured`, or with `securedServerLogic` if the logic needs an effect. Handlers which do not need the principal keep their usual logic. The other builders of `McpServer` stay available, so you can configure the server before or after you add the security logic:

```scala mdoc:compile-only
import chimp.protocol.ResourceContents
import chimp.server.*
import sttp.model.StatusCode
import sttp.shared.Identity
Expand All @@ -129,6 +130,8 @@ object McpServerWithPrincipal:
def main(args: Array[String]): Unit =
val echo = tool("echo").input[String].handle(message => ToolResult.text(message))
val whoAmI = tool("whoAmI").input[String].handleSecured[User]((_, user) => ToolResult.text(user.email))
val profile = resource("user://profile")
.handleSecured[User](user => Right(List(ResourceContents.Text(uri = "user://profile", text = user.email))))

val securedEndpoint = McpServer[Identity]()
.serverSecurityLogicPure(
Expand All @@ -137,12 +140,13 @@ object McpServerWithPrincipal:
)(token => if token == "s3cret" then Right(User("employee@example.com")) else Left("Invalid token"))
.name("my-mcp-server")
.addTools(echo, whoAmI)
.addResource(profile)
.endpoint(List("mcp"))

NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait()
```

The security logic runs one time for each request, before the server handles the MCP message. If it gives a rejection, the server sends the error output and no tool logic runs. This is necessary if the client must get an HTTP status code, because a tool which rejects a call can only give a JSON-RPC error with status code 200.
The security logic runs one time for each request, before the server handles the MCP message. If it gives a rejection, the server sends the error output and no tool, prompt, or resource logic runs. This is necessary if the client must get an HTTP status code, because a tool which rejects a call can only give a JSON-RPC error with status code 200.

### Combining security with streaming

Expand Down
41 changes: 26 additions & 15 deletions server/src/main/scala/chimp/server/McpHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD
private val promptsByName = server.prompts.map(prompt => prompt.definition.name -> prompt).toMap
private val resourcesByUri = server.resources.map(resource => resource.definition.uri -> resource).toMap
private val hasResources = server.resources.nonEmpty || server.resourceTemplates.nonEmpty
private val hasPrompts = server.prompts.nonEmpty
private val toolDefinitions = server.tools.map(toolToDefinition)

private def toJsonSchema(toolSchema: ToolSchema): Json = toolSchema match
Expand Down Expand Up @@ -89,15 +90,15 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD
JSONRPCMessage.Response(id = id, result = ListResourceTemplatesResult(server.resourceTemplates.map(_.definition)).asJson)
).unit
case "resources/read" if hasResources =>
handleResourcesRead(params, id, headers).map(jsonResponse)
handleResourcesRead(params, id, headers, makeContext).map(jsonResponse)
case "resources/subscribe" if server.subscriptions.isDefined =>
handleSubscribe(params, id, subscribe = true).map(jsonResponse)
case "resources/unsubscribe" if server.subscriptions.isDefined =>
handleSubscribe(params, id, subscribe = false).map(jsonResponse)
case "prompts/list" if server.prompts.nonEmpty =>
case "prompts/list" if hasPrompts =>
jsonResponse(JSONRPCMessage.Response(id = id, result = ListPromptsResult(server.prompts.map(_.definition)).asJson)).unit
case "prompts/get" if server.prompts.nonEmpty =>
handlePromptsGet(params, id, headers).map(jsonResponse)
case "prompts/get" if hasPrompts =>
handlePromptsGet(params, id, headers, makeContext).map(jsonResponse)
case "completion/complete" if server.completion.isDefined =>
handleComplete(params, id).map(jsonResponse)
case "logging/setLevel" if server.loggingLevel.isDefined =>
Expand All @@ -123,7 +124,7 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD
val capabilities = ServerCapabilities(
logging = Option.when(server.loggingLevel.isDefined)(Json.obj()),
completions = Option.when(server.completion.isDefined)(Json.obj()),
prompts = Option.when(server.prompts.nonEmpty)(ServerPromptsCapability(listChanged = Some(false))),
prompts = Option.when(hasPrompts)(ServerPromptsCapability(listChanged = Some(false))),
resources =
Option.when(hasResources)(ServerResourcesCapability(subscribe = Some(server.subscriptions.isDefined), listChanged = Some(false))),
tools = Option.when(server.tools.nonEmpty)(ServerToolsCapability(listChanged = Some(false)))
Expand Down Expand Up @@ -178,17 +179,22 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD
).asJson
)

private def handleResourcesRead(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] =
private def handleResourcesRead(
params: Option[Json],
id: RequestId,
headers: Seq[Header],
makeContext: Option[ProgressToken] => C
)(using MonadError[F]): F[JSONRPCMessage] =
decodeParams[ReadResourceParams](params, id): params =>
val context = makeContext(None)
resourcesByUri.get(params.uri) match
case Some(resource) => resource.read(headers).map(resourceReadResponse(id, params.uri))
case Some(resource) => resource.read(context, headers).map(resourceReadResponse(id, params.uri))
case None =>
val templateMatch = server.resourceTemplates.iterator
server.resourceTemplates.iterator
.map(template => (template, template.matcher.matchUri(params.uri)))
.collectFirst { case (template, Some(vars)) => (template, vars) }
templateMatch match
case Some((template, vars)) => template.read(vars, params.uri, headers).map(resourceReadResponse(id, params.uri))
case None =>
.collectFirst { case (template, Some(vars)) => template.read(vars, params.uri, context, headers) } match
case Some(result) => result.map(resourceReadResponse(id, params.uri))
case None =>
protocolError(
id,
JSONRPCErrorCodes.ResourceNotFound.code,
Expand Down Expand Up @@ -221,13 +227,18 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD

private def emptyResult(id: RequestId): JSONRPCMessage = JSONRPCMessage.Response(id = id, result = Json.obj())

private def handlePromptsGet(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] =
private def handlePromptsGet(
params: Option[Json],
id: RequestId,
headers: Seq[Header],
makeContext: Option[ProgressToken] => C
)(using MonadError[F]): F[JSONRPCMessage] =
decodeParams[GetPromptParams](params, id): params =>
promptsByName.get(params.name) match
case Some(prompt) =>
prompt
.logic(params.arguments.getOrElse(Map.empty), headers)
.map(result => JSONRPCMessage.Response(id = id, result = result.asJson))
.logic(params.arguments.getOrElse(Map.empty), makeContext(None), headers)
.map(promptResult => JSONRPCMessage.Response(id = id, result = promptResult.asJson))
case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, s"Unknown prompt: ${params.name}").unit

private def handleComplete(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] =
Expand Down
Loading
Loading