Add MCP example
Inspired by https://github.com/dpella/mcp/blob/main/mcp-server/example.
This commit is contained in:
parent
da55b457ec
commit
737f557640
202
SKILL.md
Normal file
202
SKILL.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# Example MCP Server — Build & Test Skill
|
||||||
|
|
||||||
|
This skill explains how to build, run, and test the example MCP server that
|
||||||
|
demonstrates the `mcp` Haskell library.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- GHC 9.12.2 and cabal-install
|
||||||
|
- The project root is at the repository root (where `cabal.project` lives)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cabal build mcp-example
|
||||||
|
```
|
||||||
|
|
||||||
|
This compiles the example server along with its dependencies (`mcp-types` and
|
||||||
|
`mcp`).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cabal run mcp-example
|
||||||
|
```
|
||||||
|
|
||||||
|
The server starts on **http://localhost:8080/mcp** and prints a JWT bearer token
|
||||||
|
to stdout. Copy this token — you need it for all requests.
|
||||||
|
|
||||||
|
## Test with curl
|
||||||
|
|
||||||
|
Replace `$TOKEN` with the token printed by the server.
|
||||||
|
|
||||||
|
### 1. Initialize the session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Send initialized notification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"notifications/initialized","params":null}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. List tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Call the echo tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"message":"Hello from MCP!"}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Call the add tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"add","arguments":{"a":17,"b":25}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Call the current-time tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"current-time","arguments":{}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. List resources
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":6,"method":"resources/list","params":{}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Read a resource
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":7,"method":"resources/read","params":{"uri":"resource://example/readme"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. List resource templates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":8,"method":"resources/templates/list","params":{}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Read a templated resource
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"resource://example/users/42"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. List prompts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":10,"method":"prompts/list","params":{}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. Get a prompt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":11,"method":"prompts/get","params":{"name":"summarize","arguments":{"text":"The MCP protocol enables AI models to interact with external tools."}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13. Request completions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":12,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"summarize"},"argument":{"name":"text","value":"Hel"}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14. Set log level
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":13,"method":"logging/setLevel","params":{"level":"debug"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15. Ping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":14,"method":"ping","params":{}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Features
|
||||||
|
|
||||||
|
| Feature | Methods |
|
||||||
|
|----------------------|----------------------------------------------|
|
||||||
|
| Tools | `tools/list`, `tools/call` |
|
||||||
|
| Resources | `resources/list`, `resources/read` |
|
||||||
|
| Resource Templates | `resources/templates/list` |
|
||||||
|
| Prompts | `prompts/list`, `prompts/get` |
|
||||||
|
| Completions | `completion/complete` |
|
||||||
|
| Logging | `logging/setLevel` |
|
||||||
|
| Lifecycle | `initialize`, `notifications/initialized` |
|
||||||
|
| Health | `ping` |
|
||||||
|
|
||||||
|
## Run the agent test (Claude Agent SDK)
|
||||||
|
|
||||||
|
A Node.js test in `test/` uses the Claude Agent SDK to start the server and
|
||||||
|
exercise every tool endpoint through a Claude agent.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp-server/example/test
|
||||||
|
pnpm install
|
||||||
|
pnpm test # requires ANTHROPIC_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
The script starts the server, captures its JWT token, connects a Claude agent,
|
||||||
|
and verifies that all 3 tool checks pass (echo, add, current-time).
|
||||||
|
See `test/README.md` for details.
|
||||||
|
|
||||||
|
## Run the library tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cabal test all
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the full integration test suite (42 tests) for the `mcp` library.
|
||||||
606
app/Main.hs
606
app/Main.hs
@ -1,4 +1,608 @@
|
|||||||
|
{-# OPTIONS_GHC -Wno-orphans #-}
|
||||||
|
|
||||||
|
{- | An example MCP server demonstrating the full feature set of the @mcp@ library.
|
||||||
|
|
||||||
|
= Quick start
|
||||||
|
|
||||||
|
@
|
||||||
|
cabal run mcp-example -- HTTP with JWT auth
|
||||||
|
cabal run mcp-example -- --simple-http -- HTTP without auth (local only)
|
||||||
|
cabal run mcp-example -- --stdio -- stdio transport
|
||||||
|
@
|
||||||
|
|
||||||
|
In HTTP mode (default) the server starts on @http:\/\/localhost:8080\/mcp@ and
|
||||||
|
prints a JWT bearer token to stdout. In simple-http mode no authentication
|
||||||
|
is performed — use this only locally or behind an authenticating proxy.
|
||||||
|
|
||||||
|
= Features demonstrated
|
||||||
|
|
||||||
|
This single-file example covers every major capability of the library:
|
||||||
|
|
||||||
|
* __JWT authentication__ — a fresh key is generated on startup and a sample
|
||||||
|
token is printed so you can test immediately.
|
||||||
|
* __Tools__ (via 'ToolHandler' \/ 'withToolHandlers') — @echo@, @add@, and
|
||||||
|
@current-time@ show text results, structured output, and IO in handlers.
|
||||||
|
* __Resources__ — static text and JSON resources served by URI.
|
||||||
|
* __Resource templates__ — a @resource:\/\/example\/users\/{userId}@ URI template
|
||||||
|
that dynamically generates user profiles.
|
||||||
|
* __Prompts__ — a @summarize@ prompt with a required @text@ argument, returning
|
||||||
|
a multi-message conversation.
|
||||||
|
* __Completions__ — auto-complete suggestions for prompt argument values.
|
||||||
|
* __Logging__ — the server declares @LoggingCapability@ and supports
|
||||||
|
@logging\/setLevel@.
|
||||||
|
* __Lifecycle hooks__ — @handleInit@ and @handleFinalize@ show how to run
|
||||||
|
setup\/teardown logic around each session and request.
|
||||||
|
* __Server instructions__ — a free-text string sent to clients on initialization.
|
||||||
|
|
||||||
|
= Architecture overview
|
||||||
|
|
||||||
|
1. Define your user type (@ExampleUser@) and per-session state (@ExampleState@).
|
||||||
|
2. Wire them into the library via type family instances:
|
||||||
|
@type instance MCPHandlerState = ExampleState@ and
|
||||||
|
@type instance MCPHandlerUser = ExampleUser@.
|
||||||
|
3. Build a 'ProcessHandlers' record — start from 'defaultProcessHandlers' and
|
||||||
|
override the capabilities you need. Use 'withToolHandlers' for a convenient
|
||||||
|
tool-registration API.
|
||||||
|
4. Create an 'MCPServerState' via 'initMCPServerState', wrap it in an 'MVar',
|
||||||
|
and serve it with @serveWithContext (Proxy \@MCPAPI) ctx (mcpAPI stateVar)@
|
||||||
|
for JWT auth, or @simpleHttpApp stateVar@ for unauthenticated local use.
|
||||||
|
-}
|
||||||
module Main where
|
module Main where
|
||||||
|
|
||||||
|
import Control.Concurrent.MVar (newMVar)
|
||||||
|
import Control.Monad.IO.Class (liftIO)
|
||||||
|
import Data.Aeson (Value, object, toJSON, (.=))
|
||||||
|
import Data.Aeson qualified as Aeson
|
||||||
|
import Data.ByteString.Lazy qualified as BSL
|
||||||
|
import Data.Map.Strict qualified as Map
|
||||||
|
import Data.Text (Text)
|
||||||
|
import Data.Text qualified as T
|
||||||
|
import Data.Text.Encoding qualified as TE
|
||||||
|
import Data.Time.Clock (getCurrentTime)
|
||||||
|
import GHC.Generics (Generic)
|
||||||
|
import MCP.Server
|
||||||
|
import Network.Wai.Handler.Warp qualified as Warp
|
||||||
|
import Servant (Context (..), Proxy (..), serveWithContext)
|
||||||
|
import Servant.Auth.Server qualified as AuthServer
|
||||||
|
import System.Environment (getArgs, lookupEnv)
|
||||||
|
import System.IO (hSetBuffering, stderr, stdin, stdout, BufferMode (..))
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- User and state types
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | The authenticated user carried inside JWT claims.
|
||||||
|
--
|
||||||
|
-- The library requires instances of 'AuthServer.ToJWT' and 'AuthServer.FromJWT'
|
||||||
|
-- so it can encode\/decode your user in bearer tokens. Deriving 'Generic' plus
|
||||||
|
-- 'Aeson.ToJSON' \/ 'Aeson.FromJSON' gives you those for free via the default
|
||||||
|
-- methods.
|
||||||
|
data ExampleUser = ExampleUser
|
||||||
|
{ userId :: Text
|
||||||
|
, userName :: Text
|
||||||
|
}
|
||||||
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
|
instance Aeson.ToJSON ExampleUser
|
||||||
|
instance Aeson.FromJSON ExampleUser
|
||||||
|
instance AuthServer.ToJWT ExampleUser
|
||||||
|
instance AuthServer.FromJWT ExampleUser
|
||||||
|
|
||||||
|
-- | Per-session handler state.
|
||||||
|
--
|
||||||
|
-- This is threaded through every 'MCPServerT' handler via 'StateT'. You can
|
||||||
|
-- put anything you need here — database connections, caches, etc. For this
|
||||||
|
-- example we just track which user is active.
|
||||||
|
newtype ExampleState = ExampleState
|
||||||
|
{ currentUser :: Maybe Text
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | Wire the type families so the library knows our concrete types.
|
||||||
|
--
|
||||||
|
-- Every application using @mcp@ must provide exactly one instance for each.
|
||||||
|
-- @MCPHandlerState@ is the state available in handlers, and @MCPHandlerUser@
|
||||||
|
-- is the user type decoded from JWT tokens.
|
||||||
|
type instance MCPHandlerState = ExampleState
|
||||||
|
type instance MCPHandlerUser = ExampleUser
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Tools
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | All tools provided by the example server.
|
||||||
|
--
|
||||||
|
-- We use the 'toolHandler' smart constructor for convenience — it fills in
|
||||||
|
-- optional fields (title, output schema, annotations, metadata) with 'Nothing'.
|
||||||
|
-- Then 'withToolHandlers' wires them into a 'ProcessHandlers' record,
|
||||||
|
-- automatically generating the @tools\/list@ and @tools\/call@ dispatch logic.
|
||||||
|
exampleTools :: [ToolHandler]
|
||||||
|
exampleTools = [echoTool, addTool, currentTimeTool]
|
||||||
|
|
||||||
|
-- | A simple tool that echoes its input back.
|
||||||
|
--
|
||||||
|
-- Demonstrates 'toolTextResult' — the simplest way to return text from a tool.
|
||||||
|
echoTool :: ToolHandler
|
||||||
|
echoTool =
|
||||||
|
toolHandler
|
||||||
|
"echo"
|
||||||
|
(Just "Echoes the input message back to the caller")
|
||||||
|
(InputSchema "object" (Just $ Map.fromList [("message", object ["type" .= ("string" :: Text)])]) (Just ["message"]))
|
||||||
|
$ \args -> do
|
||||||
|
let msg = args >>= Map.lookup "message" >>= asText
|
||||||
|
case msg of
|
||||||
|
Just txt -> return $ ProcessSuccess $ toolTextResult [txt]
|
||||||
|
Nothing -> return $ ProcessSuccess $ toolTextError "Missing or invalid 'message' argument"
|
||||||
|
|
||||||
|
-- | A tool that adds two numbers.
|
||||||
|
--
|
||||||
|
-- Demonstrates returning structured output via 'CallToolResult' with a
|
||||||
|
-- 'structuredContent' map alongside the human-readable text content.
|
||||||
|
addTool :: ToolHandler
|
||||||
|
addTool =
|
||||||
|
toolHandler
|
||||||
|
"add"
|
||||||
|
(Just "Adds two numbers and returns the sum")
|
||||||
|
( InputSchema
|
||||||
|
"object"
|
||||||
|
( Just $
|
||||||
|
Map.fromList
|
||||||
|
[ ("a", object ["type" .= ("number" :: Text)])
|
||||||
|
, ("b", object ["type" .= ("number" :: Text)])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
(Just ["a", "b"])
|
||||||
|
)
|
||||||
|
$ \args -> do
|
||||||
|
let ma = args >>= Map.lookup "a" >>= asNumber
|
||||||
|
let mb = args >>= Map.lookup "b" >>= asNumber
|
||||||
|
case (ma, mb) of
|
||||||
|
(Just a, Just b) -> do
|
||||||
|
let s = a + b
|
||||||
|
return $
|
||||||
|
ProcessSuccess $
|
||||||
|
CallToolResult
|
||||||
|
{ content = [TextBlock $ TextContent "text" ("The sum is: " <> T.pack (show s)) Nothing Nothing]
|
||||||
|
, structuredContent = Just $ Map.fromList [("result", toJSON s)]
|
||||||
|
, isError = Just False
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
_ -> return $ ProcessSuccess $ toolTextError "Arguments 'a' and 'b' must be numbers"
|
||||||
|
|
||||||
|
-- | A tool that returns the current UTC time.
|
||||||
|
--
|
||||||
|
-- Demonstrates performing IO inside a tool handler via 'liftIO'.
|
||||||
|
currentTimeTool :: ToolHandler
|
||||||
|
currentTimeTool =
|
||||||
|
toolHandler
|
||||||
|
"current-time"
|
||||||
|
(Just "Returns the current UTC time")
|
||||||
|
(InputSchema "object" Nothing Nothing)
|
||||||
|
$ \_ -> do
|
||||||
|
now <- liftIO getCurrentTime
|
||||||
|
return $ ProcessSuccess $ toolTextResult [T.pack (show now)]
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Resources
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Static resources advertised by the server.
|
||||||
|
--
|
||||||
|
-- Clients discover these via @resources\/list@ and fetch their content with
|
||||||
|
-- @resources\/read@. Each resource has a stable URI, a human-readable name,
|
||||||
|
-- and an optional MIME type.
|
||||||
|
exampleResources :: [Resource]
|
||||||
|
exampleResources =
|
||||||
|
[ Resource
|
||||||
|
{ uri = "resource://example/readme"
|
||||||
|
, name = "readme"
|
||||||
|
, title = Just "Example README"
|
||||||
|
, description = Just "A sample text document"
|
||||||
|
, mimeType = Just "text/plain"
|
||||||
|
, size = Nothing
|
||||||
|
, annotations = Nothing
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
, Resource
|
||||||
|
{ uri = "resource://example/config"
|
||||||
|
, name = "config"
|
||||||
|
, title = Just "Example Configuration"
|
||||||
|
, description = Just "A sample JSON configuration"
|
||||||
|
, mimeType = Just "application/json"
|
||||||
|
, size = Nothing
|
||||||
|
, annotations = Nothing
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | Handle @resources\/read@ requests.
|
||||||
|
--
|
||||||
|
-- Routes by URI to return the appropriate content. Unknown URIs get a 404
|
||||||
|
-- error via 'ProcessRPCError'. URIs matching the @resource:\/\/example\/users\/{id}@
|
||||||
|
-- template are handled dynamically.
|
||||||
|
readResource :: ReadResourceParams -> MCPServerT (ProcessResult ReadResourceResult)
|
||||||
|
readResource (ReadResourceParams req_uri) =
|
||||||
|
case req_uri of
|
||||||
|
"resource://example/readme" ->
|
||||||
|
ok req_uri "text/plain" "This is the example MCP server README.\nIt demonstrates tools, resources, prompts, and more."
|
||||||
|
"resource://example/config" ->
|
||||||
|
ok req_uri "application/json" (T.pack $ show $ object ["version" .= ("1.0" :: Text), "debug" .= False])
|
||||||
|
_ ->
|
||||||
|
-- Handle resource template matches (see exampleResourceTemplates)
|
||||||
|
case T.stripPrefix "resource://example/users/" req_uri of
|
||||||
|
Just uid | not (T.null uid) ->
|
||||||
|
ok req_uri "application/json" (T.pack $ show $ object ["userId" .= uid, "name" .= ("User " <> uid)])
|
||||||
|
_ -> return $ ProcessRPCError 404 "Resource not found"
|
||||||
|
where
|
||||||
|
ok u mime txt =
|
||||||
|
return $
|
||||||
|
ProcessSuccess $
|
||||||
|
ReadResourceResult
|
||||||
|
{ contents = [TextResource $ TextResourceContents u txt (Just mime) Nothing]
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Resource Templates
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Resource templates advertised by the server.
|
||||||
|
--
|
||||||
|
-- Templates use RFC 6570 URI syntax (e.g. @{userId}@). Clients can discover
|
||||||
|
-- them via @resources\/templates\/list@ and then construct concrete URIs to pass
|
||||||
|
-- to @resources\/read@.
|
||||||
|
exampleResourceTemplates :: [ResourceTemplate]
|
||||||
|
exampleResourceTemplates =
|
||||||
|
[ ResourceTemplate
|
||||||
|
{ name = "user-profile"
|
||||||
|
, title = Just "User Profile"
|
||||||
|
, uriTemplate = "resource://example/users/{userId}"
|
||||||
|
, description = Just "Returns a JSON profile for a given user ID"
|
||||||
|
, mimeType = Just "application/json"
|
||||||
|
, annotations = Nothing
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Prompts
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Prompt templates advertised by the server.
|
||||||
|
--
|
||||||
|
-- Prompts are reusable conversation starters. Each prompt declares its
|
||||||
|
-- arguments (name, description, required flag) so clients can present a form
|
||||||
|
-- and fill in the values before calling @prompts\/get@.
|
||||||
|
examplePrompts :: [Prompt]
|
||||||
|
examplePrompts =
|
||||||
|
[ Prompt
|
||||||
|
{ name = "summarize"
|
||||||
|
, title = Just "Summarize Text"
|
||||||
|
, description = Just "Asks the LLM to produce a concise summary of the provided text"
|
||||||
|
, arguments =
|
||||||
|
Just
|
||||||
|
[ PromptArgument
|
||||||
|
{ name = "text"
|
||||||
|
, title = Just "Text to summarize"
|
||||||
|
, description = Just "The text content to summarize"
|
||||||
|
, required = Just True
|
||||||
|
}
|
||||||
|
]
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | Handle @prompts\/get@ requests.
|
||||||
|
--
|
||||||
|
-- Returns a list of 'PromptMessage' values that form a conversation.
|
||||||
|
-- The 'User' message includes the argument value; the 'Assistant' message
|
||||||
|
-- primes the model with a helpful starting response.
|
||||||
|
getPrompt :: GetPromptParams -> MCPServerT (ProcessResult GetPromptResult)
|
||||||
|
getPrompt = \case
|
||||||
|
GetPromptParams{name = "summarize", arguments = Just args} -> do
|
||||||
|
let txt = Map.findWithDefault "" "text" args
|
||||||
|
let msgs =
|
||||||
|
[ PromptMessage
|
||||||
|
{ role = User
|
||||||
|
, content = TextBlock $ TextContent "text" ("Please summarize the following text:\n\n" <> txt) Nothing Nothing
|
||||||
|
}
|
||||||
|
, PromptMessage
|
||||||
|
{ role = Assistant
|
||||||
|
, content = TextBlock $ TextContent "text" "I'll provide a concise summary of the text." Nothing Nothing
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return $
|
||||||
|
ProcessSuccess $
|
||||||
|
GetPromptResult
|
||||||
|
{ description = Just "A prompt to summarize text"
|
||||||
|
, messages = msgs
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
_ -> return $ ProcessRPCError 404 "Prompt not found"
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Completions
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Handle @completion\/complete@ requests.
|
||||||
|
--
|
||||||
|
-- Returns auto-complete suggestions for prompt argument values. The client
|
||||||
|
-- sends the argument name and a partial value; the server responds with
|
||||||
|
-- matching completions. Here we return a fixed set of suggestions for the
|
||||||
|
-- @text@ argument of the @summarize@ prompt.
|
||||||
|
handleComplete :: CompleteParams -> MCPServerT (ProcessResult CompleteResult)
|
||||||
|
handleComplete (CompleteParams _ref (CompletionArgument arg_name _) _ctx) =
|
||||||
|
case arg_name of
|
||||||
|
"text" ->
|
||||||
|
return $
|
||||||
|
ProcessSuccess $
|
||||||
|
CompleteResult
|
||||||
|
{ completion =
|
||||||
|
CompletionResult
|
||||||
|
{ values = ["Hello world", "Lorem ipsum dolor sit amet", "The quick brown fox"]
|
||||||
|
, total = Just 3
|
||||||
|
, hasMore = Just False
|
||||||
|
}
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
_ ->
|
||||||
|
return $
|
||||||
|
ProcessSuccess $
|
||||||
|
CompleteResult
|
||||||
|
{ completion = CompletionResult{values = [], total = Just 0, hasMore = Just False}
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Process handlers
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Assemble all handlers into a single 'ProcessHandlers' record.
|
||||||
|
--
|
||||||
|
-- Start from 'defaultProcessHandlers' (all fields 'Nothing'), override the
|
||||||
|
-- capabilities you want, then apply 'withToolHandlers' to wire up tool
|
||||||
|
-- listing and dispatch automatically.
|
||||||
|
--
|
||||||
|
-- Note that 'withToolHandlers' overwrites @listToolsHandler@ and
|
||||||
|
-- @callToolHandler@, so it should be the outermost wrapper.
|
||||||
|
exampleHandlers :: ProcessHandlers
|
||||||
|
exampleHandlers =
|
||||||
|
withToolHandlers exampleTools $
|
||||||
|
defaultProcessHandlers
|
||||||
|
{ listResourcesHandler = Just $ \_ ->
|
||||||
|
return $
|
||||||
|
ProcessSuccess $
|
||||||
|
ListResourcesResult
|
||||||
|
{ resources = exampleResources
|
||||||
|
, nextCursor = Nothing
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
, readResourceHandler = Just readResource
|
||||||
|
, listPromptsHandler = Just $ \_ ->
|
||||||
|
return $
|
||||||
|
ProcessSuccess $
|
||||||
|
ListPromptsResult
|
||||||
|
{ prompts = examplePrompts
|
||||||
|
, nextCursor = Nothing
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
, getPromptHandler = Just getPrompt
|
||||||
|
, listResourceTemplatesHandler = Just $ \_ ->
|
||||||
|
return $
|
||||||
|
ProcessSuccess $
|
||||||
|
ListResourceTemplatesResult
|
||||||
|
{ resourceTemplates = exampleResourceTemplates
|
||||||
|
, nextCursor = Nothing
|
||||||
|
, _meta = Nothing
|
||||||
|
}
|
||||||
|
, completeHandler = Just handleComplete
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Server capabilities
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Declare which MCP capabilities this server supports.
|
||||||
|
--
|
||||||
|
-- The client receives these during the @initialize@ handshake so it knows
|
||||||
|
-- which methods are available. Setting a capability to 'Nothing' means the
|
||||||
|
-- server does not support it; setting @listChanged = Just True@ advertises
|
||||||
|
-- that the server may send @notifications\/tools\/list_changed@ at runtime.
|
||||||
|
exampleCapabilities :: ServerCapabilities
|
||||||
|
exampleCapabilities =
|
||||||
|
ServerCapabilities
|
||||||
|
{ logging = Just LoggingCapability
|
||||||
|
, prompts = Just PromptsCapability{listChanged = Nothing}
|
||||||
|
, resources = Just ResourcesCapability{listChanged = Nothing, subscribe = Nothing}
|
||||||
|
, tools = Just ToolsCapability{listChanged = Just True}
|
||||||
|
, completions = Just CompletionsCapability
|
||||||
|
, experimental = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Lifecycle hooks
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Called once when the client sends @initialize@.
|
||||||
|
--
|
||||||
|
-- Use this to set up per-session state — open database connections, load
|
||||||
|
-- configuration, etc. The authenticated user is provided so you can
|
||||||
|
-- personalise the session.
|
||||||
|
handleInit :: ExampleUser -> ExampleState -> IO ExampleState
|
||||||
|
handleInit user st = do
|
||||||
|
putStrLn $ "Session initialized for user: " <> T.unpack (userId user)
|
||||||
|
return st{currentUser = Just (userId user)}
|
||||||
|
|
||||||
|
-- | Called after every request.
|
||||||
|
--
|
||||||
|
-- Use this for cleanup — close cursors, flush buffers, etc. In this example
|
||||||
|
-- we simply clear the current user.
|
||||||
|
handleFinalize :: ExampleState -> IO ExampleState
|
||||||
|
handleFinalize st = return st{currentUser = Nothing}
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Main
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Entry point.
|
||||||
|
--
|
||||||
|
-- Supports three modes:
|
||||||
|
--
|
||||||
|
-- * __HTTP mode__ (default): starts a Warp server with JWT authentication.
|
||||||
|
-- * __Simple HTTP mode__ (@--simple-http@): starts a Warp server without
|
||||||
|
-- authentication. Only use locally or behind an authenticating proxy.
|
||||||
|
-- * __Stdio mode__ (@--stdio@): reads JSON-RPC from stdin, writes to stdout.
|
||||||
|
-- No authentication is needed. Debug output goes to stderr.
|
||||||
|
--
|
||||||
|
-- @
|
||||||
|
-- cabal run mcp-example # HTTP with JWT on port 8080
|
||||||
|
-- cabal run mcp-example -- --simple-http # HTTP without auth on port 8080
|
||||||
|
-- cabal run mcp-example -- --stdio # stdio transport
|
||||||
|
-- @
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = putStrLn "Hello, Haskell!"
|
main = do
|
||||||
|
args <- getArgs
|
||||||
|
if "--stdio" `elem` args
|
||||||
|
then mainStdio
|
||||||
|
else
|
||||||
|
if "--simple-http" `elem` args
|
||||||
|
then mainSimpleHTTP
|
||||||
|
else mainHTTP
|
||||||
|
|
||||||
|
-- | Run in stdio transport mode.
|
||||||
|
--
|
||||||
|
-- Reads JSON-RPC messages from stdin and writes responses to stdout.
|
||||||
|
-- There is no JWT authentication — the caller is trusted.
|
||||||
|
-- Debug/log output goes to stderr so it doesn't interfere with the protocol.
|
||||||
|
mainStdio :: IO ()
|
||||||
|
mainStdio = do
|
||||||
|
hSetBuffering stderr LineBuffering
|
||||||
|
|
||||||
|
let impl = Implementation "mcp-example" "0.1.0" (Just "Example MCP Server")
|
||||||
|
let server_instructions = Just "This is an example MCP server. It provides echo, add, and current-time tools, sample resources, a summarize prompt, and completions."
|
||||||
|
let initial_state =
|
||||||
|
(initMCPServerState
|
||||||
|
(ExampleState Nothing) -- initial handler state
|
||||||
|
Nothing -- no handler init in stdio mode (no JWT user)
|
||||||
|
(Just handleFinalize) -- lifecycle: after each request
|
||||||
|
exampleCapabilities -- what we support
|
||||||
|
impl -- server name + version
|
||||||
|
server_instructions -- instructions for clients
|
||||||
|
exampleHandlers -- all our handlers
|
||||||
|
){mcp_log_level = Just Debug}
|
||||||
|
|
||||||
|
serveStdio stdin stdout initial_state
|
||||||
|
|
||||||
|
-- | Run in simple HTTP mode without authentication.
|
||||||
|
--
|
||||||
|
-- No JWT or servant-auth. This transport should only be used locally or
|
||||||
|
-- behind a reverse proxy that handles authentication.
|
||||||
|
--
|
||||||
|
-- @
|
||||||
|
-- cabal run mcp-example -- --simple-http
|
||||||
|
-- @
|
||||||
|
mainSimpleHTTP :: IO ()
|
||||||
|
mainSimpleHTTP = do
|
||||||
|
hSetBuffering stdout LineBuffering
|
||||||
|
|
||||||
|
port <- maybe 8080 read <$> lookupEnv "PORT"
|
||||||
|
|
||||||
|
let impl = Implementation "mcp-example" "0.1.0" (Just "Example MCP Server (SimpleHTTP)")
|
||||||
|
let server_instructions = Just "This is an example MCP server using the SimpleHTTP transport. It provides echo, add, and current-time tools, sample resources, a summarize prompt, and completions."
|
||||||
|
|
||||||
|
let initial_state =
|
||||||
|
(initMCPServerState
|
||||||
|
(ExampleState Nothing) -- initial handler state
|
||||||
|
Nothing -- no handler init (no user type in SimpleHTTP)
|
||||||
|
(Just handleFinalize) -- lifecycle: after each request
|
||||||
|
exampleCapabilities -- what we support
|
||||||
|
impl -- server name + version
|
||||||
|
server_instructions -- instructions for clients
|
||||||
|
exampleHandlers -- all our handlers
|
||||||
|
){mcp_log_level = Just Debug}
|
||||||
|
state_var <- newMVar initial_state
|
||||||
|
|
||||||
|
putStrLn "=== Example MCP Server (SimpleHTTP) ==="
|
||||||
|
putStrLn $ "Listening on http://localhost:" <> show port <> "/mcp"
|
||||||
|
putStrLn "WARNING: No authentication — only use locally or behind an authenticating proxy."
|
||||||
|
putStrLn ""
|
||||||
|
putStrLn "Example curl:"
|
||||||
|
putStrLn $ " curl -X POST http://localhost:" <> show port <> "/mcp \\"
|
||||||
|
putStrLn " -H 'Content-Type: application/json' \\"
|
||||||
|
putStrLn " -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"curl\",\"version\":\"1.0\"}}}'"
|
||||||
|
|
||||||
|
let app = simpleHttpApp state_var
|
||||||
|
Warp.run port app
|
||||||
|
|
||||||
|
-- | Run in HTTP mode with JWT authentication.
|
||||||
|
mainHTTP :: IO ()
|
||||||
|
mainHTTP = do
|
||||||
|
-- Use line buffering so output is visible immediately when piped.
|
||||||
|
hSetBuffering stdout LineBuffering
|
||||||
|
|
||||||
|
-- Read port from PORT env var, default to 8080.
|
||||||
|
port <- maybe 8080 read <$> lookupEnv "PORT"
|
||||||
|
|
||||||
|
-- Server metadata shown to clients during initialization.
|
||||||
|
let impl = Implementation "mcp-example" "0.1.0" (Just "Example MCP Server")
|
||||||
|
|
||||||
|
-- Free-text instructions sent to clients on initialization.
|
||||||
|
let server_instructions = Just "This is an example MCP server. It provides echo, add, and current-time tools, sample resources, a summarize prompt, and completions."
|
||||||
|
|
||||||
|
-- Create the initial server state. initMCPServerState sets sensible
|
||||||
|
-- defaults: not yet initialized, log level Warning, empty pending responses.
|
||||||
|
-- We override the log level to Debug so all requests/responses are logged.
|
||||||
|
let initial_state =
|
||||||
|
(initMCPServerState
|
||||||
|
(ExampleState Nothing) -- initial handler state
|
||||||
|
(Just handleInit) -- lifecycle: on initialize
|
||||||
|
(Just handleFinalize) -- lifecycle: after each request
|
||||||
|
exampleCapabilities -- what we support
|
||||||
|
impl -- server name + version
|
||||||
|
server_instructions -- instructions for clients
|
||||||
|
exampleHandlers -- all our handlers
|
||||||
|
){mcp_log_level = Just Debug}
|
||||||
|
state_var <- newMVar initial_state
|
||||||
|
|
||||||
|
-- Generate a fresh JWK key for signing tokens.
|
||||||
|
key <- AuthServer.generateKey
|
||||||
|
let jwt_cfg = AuthServer.defaultJWTSettings key
|
||||||
|
let cookie_cfg = AuthServer.defaultCookieSettings
|
||||||
|
-- The Servant context carries the auth configuration used by servant-auth.
|
||||||
|
let ctx = cookie_cfg :. jwt_cfg :. EmptyContext
|
||||||
|
|
||||||
|
-- Mint a JWT for a test user so you can start making requests immediately.
|
||||||
|
let test_user = ExampleUser "example-user" "Example User"
|
||||||
|
token_result <- AuthServer.makeJWT test_user jwt_cfg Nothing
|
||||||
|
case token_result of
|
||||||
|
Left err -> putStrLn $ "Failed to generate JWT: " <> show err
|
||||||
|
Right token -> do
|
||||||
|
let token_str = TE.decodeUtf8 $ BSL.toStrict token
|
||||||
|
putStrLn "=== Example MCP Server ==="
|
||||||
|
putStrLn $ "Listening on http://localhost:" <> show port <> "/mcp"
|
||||||
|
putStrLn ""
|
||||||
|
putStrLn "Bearer token for testing:"
|
||||||
|
putStrLn $ T.unpack token_str
|
||||||
|
putStrLn ""
|
||||||
|
putStrLn "Example curl:"
|
||||||
|
putStrLn $ " curl -X POST http://localhost:" <> show port <> "/mcp \\"
|
||||||
|
putStrLn " -H 'Content-Type: application/json' \\"
|
||||||
|
putStrLn $ " -H 'Authorization: Bearer " <> T.unpack token_str <> "' \\"
|
||||||
|
putStrLn " -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"curl\",\"version\":\"1.0\"}}}'"
|
||||||
|
|
||||||
|
-- Build the Servant application and start Warp.
|
||||||
|
let app = serveWithContext (Proxy @MCPAPI) ctx (mcpAPI state_var)
|
||||||
|
Warp.run port app
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- JSON value helpers
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- | Extract a 'Text' from a JSON 'Value', returning 'Nothing' for non-strings.
|
||||||
|
asText :: Value -> Maybe Text
|
||||||
|
asText (Aeson.String t) = Just t
|
||||||
|
asText _ = Nothing
|
||||||
|
|
||||||
|
-- | Extract a 'Double' from a JSON 'Value', returning 'Nothing' for non-numbers.
|
||||||
|
asNumber :: Value -> Maybe Double
|
||||||
|
asNumber (Aeson.Number n) = Just (realToFrac n)
|
||||||
|
asNumber _ = Nothing
|
||||||
|
|||||||
11
mcp-config.json
Normal file
11
mcp-config.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mcp-example": {
|
||||||
|
"url": "http://localhost:8080/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer <PASTE_TOKEN_FROM_SERVER_OUTPUT>"
|
||||||
|
},
|
||||||
|
"description": "Example MCP server demonstrating tools, resources, prompts, and completions. Start the server first with: cabal run mcp-example"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,17 @@
|
|||||||
cabal-version: 3.0
|
cabal-version: 3.0
|
||||||
name: hello-hs
|
name: mcp-example
|
||||||
version: 0.1.0.0
|
version: 0.1.0.0
|
||||||
license-file: LICENSE
|
license-file: LICENSE
|
||||||
author: George Thomas
|
author: George Thomas
|
||||||
maintainer: georgefsthomas@gmail.com
|
maintainer: georgefsthomas@gmail.com
|
||||||
|
|
||||||
executable hello-hs
|
executable mcp-example
|
||||||
main-is: Main.hs
|
main-is: Main.hs
|
||||||
hs-source-dirs: app
|
hs-source-dirs: app
|
||||||
default-language: GHC2021
|
default-language: GHC2021
|
||||||
ghc-options:
|
ghc-options:
|
||||||
-Wall
|
-Wall
|
||||||
|
-threaded
|
||||||
build-depends:
|
build-depends:
|
||||||
base,
|
base,
|
||||||
aeson,
|
aeson,
|
||||||
@ -27,5 +28,7 @@ executable hello-hs
|
|||||||
servant,
|
servant,
|
||||||
text,
|
text,
|
||||||
time,
|
time,
|
||||||
|
time,
|
||||||
transformers,
|
transformers,
|
||||||
wai,
|
wai,
|
||||||
|
warp,
|
||||||
1
test/.gitignore
vendored
Normal file
1
test/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
12
test/package.json
Normal file
12
test/package.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp-example-test",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node test_mcp_server.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.2.42"
|
||||||
|
}
|
||||||
|
}
|
||||||
193
test/pnpm-lock.yaml
generated
Normal file
193
test/pnpm-lock.yaml
generated
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
'@anthropic-ai/claude-agent-sdk':
|
||||||
|
specifier: ^0.2.42
|
||||||
|
version: 0.2.42(zod@4.3.6)
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@anthropic-ai/claude-agent-sdk@0.2.42':
|
||||||
|
resolution: {integrity: sha512-/CugP7AjP57Dqtl2sbsDtxdbpQoPKIhjyF5WrTViGu4NHQdM+UikrRs4MhZ2jeotiC5R7iK9ZUN9SiBgcZ8oLw==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^4.0.0
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.33.5':
|
||||||
|
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.33.5':
|
||||||
|
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.0.4':
|
||||||
|
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.0.4':
|
||||||
|
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.0.4':
|
||||||
|
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.0.5':
|
||||||
|
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.0.4':
|
||||||
|
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
||||||
|
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
||||||
|
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.33.5':
|
||||||
|
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.33.5':
|
||||||
|
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.33.5':
|
||||||
|
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.33.5':
|
||||||
|
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.33.5':
|
||||||
|
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.33.5':
|
||||||
|
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
zod@4.3.6:
|
||||||
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@anthropic-ai/claude-agent-sdk@0.2.42(zod@4.3.6)':
|
||||||
|
dependencies:
|
||||||
|
zod: 4.3.6
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.33.5
|
||||||
|
'@img/sharp-darwin-x64': 0.33.5
|
||||||
|
'@img/sharp-linux-arm': 0.33.5
|
||||||
|
'@img/sharp-linux-arm64': 0.33.5
|
||||||
|
'@img/sharp-linux-x64': 0.33.5
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.33.5
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.33.5
|
||||||
|
'@img/sharp-win32-x64': 0.33.5
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.33.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.0.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.33.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.0.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.0.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.33.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.0.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.33.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.0.5
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.33.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.0.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.33.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.33.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.33.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
zod@4.3.6: {}
|
||||||
525
test/test_mcp_server.mjs
Normal file
525
test/test_mcp_server.mjs
Normal file
@ -0,0 +1,525 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test the example MCP server using the Claude Agent SDK.
|
||||||
|
*
|
||||||
|
* Builds the example server once, then exercises every tool endpoint
|
||||||
|
* over HTTP (JWT), simple HTTP (unauthenticated), and stdio transports.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* # From the repository root:
|
||||||
|
* cabal build mcp-example
|
||||||
|
* cd mcp-server/example/test
|
||||||
|
* npm install
|
||||||
|
* npm test
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - ANTHROPIC_API_KEY environment variable set
|
||||||
|
* - GHC 9.12 + cabal (to build the server)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import { execSync, spawn } from "node:child_process";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function findRepoRoot() {
|
||||||
|
let d = __dirname;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
if (existsSync(join(d, "cabal.project"))) return d;
|
||||||
|
d = dirname(d);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServer(repoRoot) {
|
||||||
|
console.log(" Building mcp-example (this may take a while)...");
|
||||||
|
execSync("cabal build mcp-example", { cwd: repoRoot, stdio: "inherit" });
|
||||||
|
console.log(" Build succeeded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerBin(repoRoot) {
|
||||||
|
return execSync("cabal list-bin mcp-example", {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = createServer();
|
||||||
|
srv.listen(0, () => {
|
||||||
|
const { port } = srv.address();
|
||||||
|
srv.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
srv.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer(repoRoot, port) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log(` repo root: ${repoRoot}`);
|
||||||
|
console.log(` port: ${port}`);
|
||||||
|
const serverBin = getServerBin(repoRoot);
|
||||||
|
console.log(` binary: ${serverBin}`);
|
||||||
|
|
||||||
|
const proc = spawn(serverBin, [], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: { ...process.env, PORT: String(port) },
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on("data", (chunk) => {
|
||||||
|
for (const line of chunk.toString().split("\n").filter(Boolean)) {
|
||||||
|
console.log(` [server:stderr] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
console.log(" Waiting for server to print JWT token...");
|
||||||
|
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout.on("data", (chunk) => {
|
||||||
|
buf += chunk.toString();
|
||||||
|
const lines = buf.split("\n");
|
||||||
|
buf = lines.pop(); // keep incomplete trailing line
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
console.log(` [server:stdout] ${line}`);
|
||||||
|
if (!token && line.startsWith("eyJ")) {
|
||||||
|
token = line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (token) {
|
||||||
|
clearInterval(check);
|
||||||
|
// Give the server a moment to finish binding.
|
||||||
|
setTimeout(() => verifyAndResolve(), 1000);
|
||||||
|
} else if (Date.now() > deadline) {
|
||||||
|
clearInterval(check);
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error("Timed out waiting for JWT token"));
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
function verifyAndResolve() {
|
||||||
|
console.log(" Verifying server is reachable...");
|
||||||
|
const body = JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "ping",
|
||||||
|
});
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: "localhost",
|
||||||
|
port,
|
||||||
|
path: "/mcp",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (c) => (data += c));
|
||||||
|
res.on("end", () => {
|
||||||
|
console.log(` Server responded: ${data.slice(0, 200)}`);
|
||||||
|
resolve({ proc, token });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", (e) => {
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error(`Server not reachable on port ${port}: ${e.message}`));
|
||||||
|
});
|
||||||
|
req.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
clearInterval(check);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
proc.on("exit", (code) => {
|
||||||
|
if (!token) {
|
||||||
|
clearInterval(check);
|
||||||
|
reject(new Error(`Server exited with code ${code} before printing token`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSimpleHTTPServer(repoRoot, port) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log(` repo root: ${repoRoot}`);
|
||||||
|
console.log(` port: ${port}`);
|
||||||
|
const serverBin = getServerBin(repoRoot);
|
||||||
|
console.log(` binary: ${serverBin}`);
|
||||||
|
|
||||||
|
const proc = spawn(serverBin, ["--simple-http"], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: { ...process.env, PORT: String(port) },
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on("data", (chunk) => {
|
||||||
|
for (const line of chunk.toString().split("\n").filter(Boolean)) {
|
||||||
|
console.log(` [server:stderr] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let ready = false;
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
console.log(" Waiting for simple HTTP server to start...");
|
||||||
|
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout.on("data", (chunk) => {
|
||||||
|
buf += chunk.toString();
|
||||||
|
const lines = buf.split("\n");
|
||||||
|
buf = lines.pop(); // keep incomplete trailing line
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim();
|
||||||
|
console.log(` [server:stdout] ${line}`);
|
||||||
|
if (!ready && line.includes("Listening on")) {
|
||||||
|
ready = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (ready) {
|
||||||
|
clearInterval(check);
|
||||||
|
// Give the server a moment to finish binding.
|
||||||
|
setTimeout(() => verifyAndResolve(), 1000);
|
||||||
|
} else if (Date.now() > deadline) {
|
||||||
|
clearInterval(check);
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error("Timed out waiting for simple HTTP server"));
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
function verifyAndResolve() {
|
||||||
|
console.log(" Verifying server is reachable...");
|
||||||
|
const body = JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "ping",
|
||||||
|
});
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: "localhost",
|
||||||
|
port,
|
||||||
|
path: "/mcp",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (c) => (data += c));
|
||||||
|
res.on("end", () => {
|
||||||
|
console.log(` Server responded: ${data.slice(0, 200)}`);
|
||||||
|
resolve({ proc });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", (e) => {
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error(`Server not reachable on port ${port}: ${e.message}`));
|
||||||
|
});
|
||||||
|
req.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
clearInterval(check);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
proc.on("exit", (code) => {
|
||||||
|
if (!ready) {
|
||||||
|
clearInterval(check);
|
||||||
|
reject(new Error(`Server exited with code ${code} before becoming ready`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer(proc) {
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
setTimeout(() => proc.kill("SIGKILL"), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent runner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TEST_PROMPT = `\
|
||||||
|
You MUST call these MCP tools directly as tool calls (do NOT use Bash or any other tool):
|
||||||
|
|
||||||
|
1. Call mcp__mcp-example__echo with {"message": "Hello MCP!"} — report the returned text.
|
||||||
|
2. Call mcp__mcp-example__add with {"a": 17, "b": 25} — report the numeric result.
|
||||||
|
3. Call mcp__mcp-example__current-time with {} — report the returned time string.
|
||||||
|
|
||||||
|
After all three tool calls, print "ALL TESTS DONE".
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AGENT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
async function runAgent(mcpServers) {
|
||||||
|
const fullText = [];
|
||||||
|
|
||||||
|
console.log(" Starting agent (connecting to MCP server + API)...");
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log(` ERROR: Agent timed out after ${AGENT_TIMEOUT_MS / 1000}s`);
|
||||||
|
controller.abort();
|
||||||
|
}, AGENT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of query({
|
||||||
|
prompt: TEST_PROMPT,
|
||||||
|
options: {
|
||||||
|
mcpServers,
|
||||||
|
allowedTools: ["mcp__mcp-example__*"],
|
||||||
|
disallowedTools: [
|
||||||
|
"Bash",
|
||||||
|
"Read",
|
||||||
|
"Edit",
|
||||||
|
"Write",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
"NotebookEdit",
|
||||||
|
"TodoWrite",
|
||||||
|
"Task",
|
||||||
|
],
|
||||||
|
permissionMode: "bypassPermissions",
|
||||||
|
allowDangerouslySkipPermissions: true,
|
||||||
|
maxTurns: 10,
|
||||||
|
settingSources: [],
|
||||||
|
abortController: controller,
|
||||||
|
},
|
||||||
|
})) {
|
||||||
|
if (message.type === "system" && message.subtype === "init") {
|
||||||
|
console.log(
|
||||||
|
` [system:init] model=${message.model} tools=${JSON.stringify(message.tools)} mcp=${JSON.stringify(message.mcp_servers)}`,
|
||||||
|
);
|
||||||
|
} else if (message.type === "assistant") {
|
||||||
|
for (const block of message.message.content) {
|
||||||
|
if ("text" in block) {
|
||||||
|
console.log(` [assistant] ${block.text.slice(0, 200)}`);
|
||||||
|
fullText.push(block.text);
|
||||||
|
} else if ("name" in block) {
|
||||||
|
console.log(
|
||||||
|
` [tool_use] ${block.name}(${JSON.stringify(block.input).slice(0, 200)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.type === "user") {
|
||||||
|
const content = message.message?.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === "tool_result") {
|
||||||
|
const text =
|
||||||
|
typeof block.content === "string"
|
||||||
|
? block.content
|
||||||
|
: JSON.stringify(block.content);
|
||||||
|
console.log(` [tool_result] ${text?.slice(0, 200)}`);
|
||||||
|
if (text) fullText.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.type === "result") {
|
||||||
|
console.log(
|
||||||
|
` [result] subtype=${message.subtype} turns=${message.num_turns} cost=$${message.total_cost_usd}`,
|
||||||
|
);
|
||||||
|
if (message.subtype !== "success") {
|
||||||
|
console.log(` [result] errors: ${message.errors}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === "AbortError") {
|
||||||
|
console.log(" Agent aborted (timeout).");
|
||||||
|
} else {
|
||||||
|
console.log(` ERROR in query(): ${err.message}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullText.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result checking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHECKS = [
|
||||||
|
["echo tool", (t) => t.includes("Hello MCP!")],
|
||||||
|
["add tool", (t) => t.includes("42")],
|
||||||
|
["current-time tool", (t) => /\d{4}-\d{2}-\d{2}/.test(t)],
|
||||||
|
];
|
||||||
|
|
||||||
|
function evaluate(output) {
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const [name, check] of CHECKS) {
|
||||||
|
if (check(output)) {
|
||||||
|
console.log(` PASS: ${name}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(` FAIL: ${name}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { passed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transport tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function testHTTP(repoRoot) {
|
||||||
|
const port = await getFreePort();
|
||||||
|
console.log(`Starting MCP example server on port ${port}...`);
|
||||||
|
const { proc, token } = await startServer(repoRoot, port);
|
||||||
|
console.log(`Server started (token: ${token.slice(0, 20)}...)`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("\nRunning HTTP agent tests...");
|
||||||
|
const output = await runAgent({
|
||||||
|
"mcp-example": {
|
||||||
|
type: "http",
|
||||||
|
url: `http://localhost:${port}/mcp`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n--- HTTP agent output ---");
|
||||||
|
console.log(output);
|
||||||
|
console.log("--- End HTTP agent output ---\n");
|
||||||
|
|
||||||
|
console.log("HTTP results:");
|
||||||
|
return evaluate(output);
|
||||||
|
} finally {
|
||||||
|
console.log("\nStopping HTTP server...");
|
||||||
|
stopServer(proc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSimpleHTTP(repoRoot) {
|
||||||
|
const port = await getFreePort();
|
||||||
|
console.log(`Starting simple HTTP MCP server on port ${port}...`);
|
||||||
|
const { proc } = await startSimpleHTTPServer(repoRoot, port);
|
||||||
|
console.log("Server started (no auth).");
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("\nRunning simple HTTP agent tests...");
|
||||||
|
const output = await runAgent({
|
||||||
|
"mcp-example": {
|
||||||
|
type: "http",
|
||||||
|
url: `http://localhost:${port}/mcp`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n--- Simple HTTP agent output ---");
|
||||||
|
console.log(output);
|
||||||
|
console.log("--- End simple HTTP agent output ---\n");
|
||||||
|
|
||||||
|
console.log("Simple HTTP results:");
|
||||||
|
return evaluate(output);
|
||||||
|
} finally {
|
||||||
|
console.log("\nStopping simple HTTP server...");
|
||||||
|
stopServer(proc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testStdio(serverBin) {
|
||||||
|
console.log("\nRunning stdio agent tests...");
|
||||||
|
console.log(` binary: ${serverBin}`);
|
||||||
|
|
||||||
|
const output = await runAgent({
|
||||||
|
"mcp-example": {
|
||||||
|
type: "stdio",
|
||||||
|
command: serverBin,
|
||||||
|
args: ["--stdio"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n--- Stdio agent output ---");
|
||||||
|
console.log(output);
|
||||||
|
console.log("--- End stdio agent output ---\n");
|
||||||
|
|
||||||
|
console.log("Stdio results:");
|
||||||
|
return evaluate(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!process.env.ANTHROPIC_API_KEY) {
|
||||||
|
console.error("Error: ANTHROPIC_API_KEY environment variable not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = findRepoRoot();
|
||||||
|
if (!repoRoot) {
|
||||||
|
console.error("Error: could not find repository root (cabal.project)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build once, reuse for all transports.
|
||||||
|
buildServer(repoRoot);
|
||||||
|
const serverBin = getServerBin(repoRoot);
|
||||||
|
|
||||||
|
let totalPassed = 0;
|
||||||
|
let totalFailed = 0;
|
||||||
|
|
||||||
|
// --- HTTP ---
|
||||||
|
{
|
||||||
|
const { passed, failed } = await testHTTP(repoRoot);
|
||||||
|
totalPassed += passed;
|
||||||
|
totalFailed += failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Simple HTTP ---
|
||||||
|
{
|
||||||
|
const { passed, failed } = await testSimpleHTTP(repoRoot);
|
||||||
|
totalPassed += passed;
|
||||||
|
totalFailed += failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stdio ---
|
||||||
|
{
|
||||||
|
const { passed, failed } = await testStdio(serverBin);
|
||||||
|
totalPassed += passed;
|
||||||
|
totalFailed += failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nOverall: ${totalPassed}/${totalPassed + totalFailed} checks passed`);
|
||||||
|
if (totalFailed > 0) process.exit(1);
|
||||||
|
console.log("Done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user