Skip to content

Commit af32d2e

Browse files
committed
pass through information about the connection for things like clientIp and server hostname (depending on the protocol)
1 parent f3aa346 commit af32d2e

File tree

21 files changed

+187
-106
lines changed

21 files changed

+187
-106
lines changed

README.md

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,15 @@ import cats.implicits.*
3434
import ch.linkyard.mcp.jsonrpc2.transport.StdioJsonRpcConnection
3535
import ch.linkyard.mcp.protocol.Initialize.PartyInfo
3636
import ch.linkyard.mcp.server.*
37+
import ch.linkyard.mcp.server.McpServer.Client
38+
import ch.linkyard.mcp.server.McpServer.ConnectionInfo
3739
import ch.linkyard.mcp.server.ToolFunction.Effect
3840
import com.melvinlow.json.schema.generic.auto.given
3941
import io.circe.generic.auto.given
4042

4143
object SimpleEchoServer extends IOApp:
42-
// Define the input/output types for your tool
4344
case class EchoInput(text: String)
4445

45-
// Create the echo tool function
4646
private def echoTool: ToolFunction[IO] = ToolFunction.text(
4747
ToolFunction.Info(
4848
"echo",
@@ -54,7 +54,6 @@ object SimpleEchoServer extends IOApp:
5454
(input: EchoInput, _) => IO(input.text),
5555
)
5656

57-
// Define your server session
5857
private class Session extends McpServer.Session[IO] with McpServer.ToolProvider[IO]:
5958
override val serverInfo: PartyInfo = PartyInfo(
6059
"Simple Echo MCP",
@@ -63,20 +62,18 @@ object SimpleEchoServer extends IOApp:
6362
override def instructions: IO[Option[String]] = None.pure
6463
override val tools: IO[List[ToolFunction[IO]]] = List(echoTool).pure
6564

66-
// Define your server
6765
private class Server extends McpServer[IO]:
68-
override def connect(client: McpServer.Client[IO]): Resource[IO, McpServer.Session[IO]] =
69-
Resource.pure(Session())
66+
override def initialize(client: Client[IO], info: ConnectionInfo[IO]): Resource[IO, McpServer.Session[IO]] = ???
67+
Resource.pure(Session())
7068

7169
override def run(args: List[String]): IO[ExitCode] =
7270
// run with stdio transport
7371
Server().start(
74-
StdioJsonRpcConnection.resource[IO],
72+
StdioJsonRpcConnection.create[IO],
7573
e => IO(System.err.println(s"Error: $e")),
7674
).useForever.as(ExitCode.Success)
7775
end run
7876
end SimpleEchoServer
79-
8077
```
8178

8279
### Running Your Server
@@ -175,7 +172,7 @@ A demonstration of authentication and authorization in MCP servers using Bearer
175172

176173
- **Set up the Authentication**: Uses OAuthAuthorizationServer to guard the `/mcp` path and provide the `.well-known/oauth-protected-resource`. Pass it a token validator function to e.g. check the signature of the provided JWP.
177174
- **Proxy Authorization Server** (optional): Since not all OIDC IdP provide the necessary `.well-known/oauth-authorization-server` endpoint this route provides a proxy. The result will be the `.well-known/openid-configuration` of the IdP.
178-
- **Access the Token**: In the open session the authentication token is available as `client.authentication`. This is refreshed on every request, so it will be kept current (provided the client makes a request from time to time, eg a Ping).
175+
- **Access the Token**: In the open session the authentication token is available as `connectionInfo.authentication`, the connectionInfo is passed in when a session is started (McpServer.initialize). This is refreshed on every request, so it will be kept current (provided the client makes a request from time to time, eg a Ping).
179176

180177
This example is useful for understanding how to build secure MCP servers that require user authentication and implement proper authorization controls and have access to the token.
181178

build.sbt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ lazy val jsonrpc2 = (project in file("jsonrpc2"))
7878
libraryDependencies ++= Seq(
7979
"co.fs2" %% "fs2-core" % Dependencies.fs2,
8080
"io.circe" %% "circe-core" % Dependencies.circe,
81+
"com.comcast" %% "ip4s-core" % Dependencies.ip4s,
8182
),
8283
)
8384

example/demo/src/main/scala/ch/linkyard/mcp/example/demo/DemoServer.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ import ch.linkyard.mcp.protocol.Resources.ReadResource
1515
import ch.linkyard.mcp.server.CallContext
1616
import ch.linkyard.mcp.server.McpError
1717
import ch.linkyard.mcp.server.McpServer
18+
import ch.linkyard.mcp.server.McpServer.ConnectionInfo
1819
import ch.linkyard.mcp.server.McpServer.Pageable
1920
import ch.linkyard.mcp.server.PromptFunction
2021
import ch.linkyard.mcp.server.ResourceTemplate
2122
import ch.linkyard.mcp.server.ToolFunction
2223

2324
class DemoServer extends McpServer[IO]:
24-
override def initialize(client: McpServer.Client[IO]): Resource[IO, McpServer.Session[IO]] =
25+
override def initialize(client: McpServer.Client[IO], info: ConnectionInfo[IO]): Resource[IO, McpServer.Session[IO]] =
2526
Resource.pure(DemoSession(client))
2627

2728
private class DemoSession(client: McpServer.Client[IO]) extends McpServer.Session[IO] with McpServer.ToolProvider[IO]

example/demo/src/main/scala/ch/linkyard/mcp/example/demo/StdioDemoMcpServer.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ object StdioDemoMcpServer extends IOApp:
1111
// run with stdio transport
1212
IO(System.err.println("Welcome to Echo MCP")) >>
1313
DemoServer().start(
14-
StdioJsonRpcConnection.resource[IO],
14+
StdioJsonRpcConnection.create[IO],
1515
e => IO(System.err.println(s"Error: $e")),
1616
).useForever.as(ExitCode.Success)

example/simple-authenticated/src/main/scala/ch/linkyard/mcp/example/simpleAuthenticated/TheServer.scala

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ch.linkyard.mcp.example.simpleAuthenticated.TheServer.Session
77
import ch.linkyard.mcp.protocol.Initialize.PartyInfo
88
import ch.linkyard.mcp.server.McpServer
99
import ch.linkyard.mcp.server.McpServer.Client
10+
import ch.linkyard.mcp.server.McpServer.ConnectionInfo
1011
import ch.linkyard.mcp.server.ToolFunction
1112
import ch.linkyard.mcp.server.ToolFunction.Effect
1213
import com.melvinlow.json.schema.annotation.JsonSchemaField
@@ -15,16 +16,16 @@ import io.circe.generic.auto.given
1516
import io.circe.syntax.*
1617

1718
class TheServer extends McpServer[IO]:
18-
override def initialize(client: Client[IO]): Resource[IO, McpServer.Session[IO]] =
19-
Resource.pure(Session(client))
19+
override def initialize(client: Client[IO], info: ConnectionInfo[IO]): Resource[IO, McpServer.Session[IO]] =
20+
Resource.pure(Session(info))
2021

2122
object TheServer:
2223
case class HelloInput(
2324
@JsonSchemaField("description", "Your Name".asJson)
2425
name: String
2526
)
2627

27-
private def helloTool(client: Client[IO]): ToolFunction[IO] = ToolFunction.text(
28+
private def helloTool(info: ConnectionInfo[IO]): ToolFunction[IO] = ToolFunction.text(
2829
ToolFunction.Info(
2930
"hello",
3031
"Say Hello".some,
@@ -34,14 +35,15 @@ object TheServer:
3435
),
3536
(input: HelloInput, _) =>
3637
for
37-
auth <- client.authentication
38+
auth <- info.authentication
3839
yield s"Hello ${input.name}!\nYour authentication Token is $auth",
3940
)
4041

41-
private class Session(client: Client[IO]) extends McpServer.Session[IO] with McpServer.ToolProvider[IO]:
42+
private class Session(info: ConnectionInfo[IO]) extends McpServer.Session[IO]
43+
with McpServer.ToolProvider[IO]:
4244
override val serverInfo: PartyInfo = PartyInfo(
4345
"Simple Authenticated MCP",
4446
"1.0.0",
4547
)
4648
override def instructions: IO[Option[String]] = None.pure
47-
override val tools: IO[List[ToolFunction[IO]]] = List(helloTool(client)).pure
49+
override val tools: IO[List[ToolFunction[IO]]] = List(helloTool(info)).pure

example/simple-echo/src/main/scala/ch/linkyard/mcp/example/simpleEcho/SimpleEchoServer.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import cats.implicits.*
88
import ch.linkyard.mcp.jsonrpc2.transport.StdioJsonRpcConnection
99
import ch.linkyard.mcp.protocol.Initialize.PartyInfo
1010
import ch.linkyard.mcp.server.*
11+
import ch.linkyard.mcp.server.McpServer.Client
12+
import ch.linkyard.mcp.server.McpServer.ConnectionInfo
1113
import ch.linkyard.mcp.server.ToolFunction.Effect
1214
import com.melvinlow.json.schema.generic.auto.given
1315
import io.circe.generic.auto.given
@@ -35,13 +37,13 @@ object SimpleEchoServer extends IOApp:
3537
override val tools: IO[List[ToolFunction[IO]]] = List(echoTool).pure
3638

3739
private class Server extends McpServer[IO]:
38-
override def initialize(client: McpServer.Client[IO]): Resource[IO, McpServer.Session[IO]] =
39-
Resource.pure(Session())
40+
override def initialize(client: Client[IO], info: ConnectionInfo[IO]): Resource[IO, McpServer.Session[IO]] = ???
41+
Resource.pure(Session())
4042

4143
override def run(args: List[String]): IO[ExitCode] =
4244
// run with stdio transport
4345
Server().start(
44-
StdioJsonRpcConnection.resource[IO],
46+
StdioJsonRpcConnection.create[IO],
4547
e => IO(System.err.println(s"Error: $e")),
4648
).useForever.as(ExitCode.Success)
4749
end run
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
package ch.linkyard.mcp.jsonrpc2
22

3+
import com.comcast.ip4s.Host
4+
import com.comcast.ip4s.IpAddress
5+
import com.comcast.ip4s.Port
6+
import io.circe.Json
7+
38
trait JsonRpcConnection[F[_]]:
9+
def info: JsonRpcConnection.Info
410
def out: fs2.Pipe[F, JsonRpc.Message, Unit]
511
def in: fs2.Stream[F, JsonRpc.MessageEnvelope]
12+
13+
object JsonRpcConnection:
14+
enum Info:
15+
case Stdio(additional: Map[String, Json])
16+
case Http(server: Option[(Host, Port)], client: Option[IpAddress], additional: Map[String, Json])
17+
case Other(additional: Map[String, Json])
18+
def additional: Map[String, Json]

jsonrpc2/src/main/scala/ch/linkyard/mcp/jsonrpc2/JsonRpcServer.scala

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,12 @@ end JsonRpcServer
1212
object JsonRpcServer:
1313
def start[F[_]: Concurrent](
1414
server: JsonRpcServer[F],
15-
connection: Resource[F, JsonRpcConnection[F]],
15+
connection: JsonRpcConnection[F],
1616
): Resource[F, Unit] =
17-
connection.flatMap { conn =>
18-
val in = conn.in
19-
.through(server.handler)
20-
.through(conn.out)
21-
val out = server.out
22-
.through(conn.out)
23-
val merged = in.merge[F, Unit](out)
24-
merged.compile.resource.drain
25-
}
17+
val in = connection.in
18+
.through(server.handler)
19+
.through(connection.out)
20+
val out = server.out
21+
.through(connection.out)
22+
val merged = in.merge[F, Unit](out)
23+
merged.compile.resource.drain

jsonrpc2/src/test/scala/ch/linkyard/mcp/jsonrpc2/JsonRpcServerSpec.scala

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ package ch.linkyard.mcp.jsonrpc2
22

33
import cats.effect.IO
44
import cats.effect.Ref
5-
import cats.effect.Resource
65
import cats.effect.unsafe.implicits.global
76
import ch.linkyard.mcp.jsonrpc2.JsonRpc.*
7+
import ch.linkyard.mcp.jsonrpc2.JsonRpcConnection.Info
88
import fs2.Pipe
99
import fs2.Stream
1010
import io.circe.JsonObject
@@ -20,6 +20,7 @@ class JsonRpcServerSpec extends AnyFunSpec with Matchers with EitherValues {
2020
outPipe: Pipe[IO, Message, Unit],
2121
outMessages: Ref[IO, List[Message]],
2222
) extends JsonRpcConnection[IO] {
23+
def info: Info = Info.Other(Map.empty)
2324
def in: Stream[IO, MessageEnvelope] = inStream
2425
def out: Pipe[IO, Message, Unit] = outPipe
2526
}
@@ -60,8 +61,7 @@ class JsonRpcServerSpec extends AnyFunSpec with Matchers with EitherValues {
6061
outMessages <- Ref[IO].of(List.empty[Message])
6162
connection = createMockConnection(List(request), outMessages)
6263
server = createMockServer(Map(request -> response))
63-
connectionResource = Resource.pure[IO, JsonRpcConnection[IO]](connection)
64-
_ <- JsonRpcServer.start(server, connectionResource).use(IO.pure)
64+
_ <- JsonRpcServer.start(server, connection).use(IO.pure)
6565
finalMessages <- outMessages.get
6666
} yield finalMessages
6767
val messages = test.unsafeRunSync()
@@ -74,8 +74,7 @@ class JsonRpcServerSpec extends AnyFunSpec with Matchers with EitherValues {
7474
outMessages <- Ref[IO].of(List.empty[Message])
7575
connection = createMockConnection(Nil, outMessages)
7676
server = createMockServer(outMessages = List(serverOutMessage))
77-
connectionResource = Resource.pure[IO, JsonRpcConnection[IO]](connection)
78-
_ <- JsonRpcServer.start(server, connectionResource).use(IO.pure)
77+
_ <- JsonRpcServer.start(server, connection).use(IO.pure)
7978
finalMessages <- outMessages.get
8079
} yield finalMessages
8180
val messages = test.unsafeRunSync()
@@ -93,8 +92,7 @@ class JsonRpcServerSpec extends AnyFunSpec with Matchers with EitherValues {
9392
handlerResponses = Map(request -> response),
9493
outMessages = List(serverOutMessage),
9594
)
96-
connectionResource = Resource.pure[IO, JsonRpcConnection[IO]](connection)
97-
_ <- JsonRpcServer.start(server, connectionResource).use(IO.pure)
95+
_ <- JsonRpcServer.start(server, connection).use(IO.pure)
9896
finalMessages <- outMessages.get
9997
} yield finalMessages
10098
val messages = test.unsafeRunSync()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package ch.linkyard.mcp.server
2+
3+
import cats.MonadThrow
4+
import cats.effect.kernel.Async
5+
import cats.effect.kernel.Ref
6+
import cats.implicits.*
7+
import ch.linkyard.mcp.jsonrpc2.Authentication
8+
import ch.linkyard.mcp.jsonrpc2.JsonRpc.ErrorCode
9+
import ch.linkyard.mcp.jsonrpc2.JsonRpcConnection
10+
11+
private class ConnectionInfoRepr[F[_]: MonadThrow] private (
12+
authenticationRef: Ref[F, Authentication],
13+
override val connection: JsonRpcConnection.Info,
14+
) extends McpServer.ConnectionInfo[F]:
15+
def authentication: F[Authentication] = authenticationRef.get
16+
17+
private[server] def updateAuthentication(auth: Authentication): F[Unit] =
18+
authenticationRef.modify(old =>
19+
(old, auth) match
20+
case (Authentication.Anonymous, Authentication.Anonymous) => (auth, true)
21+
case (Authentication.BearerToken(_), Authentication.BearerToken(_)) => (auth, true)
22+
case _ => (old, false)
23+
).ifM(
24+
().pure[F],
25+
McpError.raise(ErrorCode.InvalidRequest, "Cannot change authentication mode after initialization").void,
26+
)
27+
28+
private object ConnectionInfoRepr:
29+
def apply[F[_]: Async](authentication: Authentication, connection: JsonRpcConnection.Info): F[ConnectionInfoRepr[F]] =
30+
Ref.of[F, Authentication](authentication).map(new ConnectionInfoRepr[F](_, connection))

0 commit comments

Comments
 (0)