├── .credo.exs ├── .dialyzerignore.exs ├── .env.dev ├── .envrc ├── .formatter.exs ├── .github ├── .release-please-manifest.json ├── pull_request_template.md ├── release-please-config.json └── workflows │ ├── ci.yml │ ├── release-please.yml │ └── release.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── ROADMAP.md ├── config └── config.exs ├── flake.lock ├── flake.nix ├── justfile ├── lib ├── hermes.ex ├── hermes │ ├── application.ex │ ├── cli.ex │ ├── client.ex │ ├── client │ │ ├── operation.ex │ │ ├── request.ex │ │ └── state.ex │ ├── http.ex │ ├── logging.ex │ ├── mcp │ │ ├── error.ex │ │ ├── id.ex │ │ ├── message.ex │ │ └── response.ex │ ├── protocol.ex │ ├── server.ex │ ├── server │ │ ├── base.ex │ │ ├── behaviour.ex │ │ ├── component.ex │ │ ├── component │ │ │ ├── prompt.ex │ │ │ ├── resource.ex │ │ │ ├── schema.ex │ │ │ └── tool.ex │ │ ├── configuration_error.ex │ │ ├── frame.ex │ │ ├── handlers.ex │ │ ├── handlers │ │ │ ├── prompts.ex │ │ │ ├── resources.ex │ │ │ └── tools.ex │ │ ├── registry.ex │ │ ├── registry │ │ │ └── adapter.ex │ │ ├── response.ex │ │ ├── session.ex │ │ ├── session │ │ │ └── supervisor.ex │ │ ├── supervisor.ex │ │ └── transport │ │ │ ├── sse.ex │ │ │ ├── sse │ │ │ └── plug.ex │ │ │ ├── stdio.ex │ │ │ ├── streamable_http.ex │ │ │ └── streamable_http │ │ │ └── plug.ex │ ├── sse.ex │ ├── sse │ │ ├── event.ex │ │ ├── parser.ex │ │ └── streaming.ex │ ├── telemetry.ex │ └── transport │ │ ├── behaviour.ex │ │ ├── sse.ex │ │ ├── stdio.ex │ │ ├── streamable_http.ex │ │ └── websocket.ex └── mix │ ├── interactive │ ├── cli.ex │ ├── commands.ex │ ├── shell.ex │ ├── state.ex │ ├── supervised_shell.ex │ └── ui.ex │ └── tasks │ ├── sse.interactive.ex │ ├── stdio.interactive.ex │ ├── streamable_http.interactive.ex │ └── websocket.interactive.ex ├── mix.exs ├── mix.lock ├── pages ├── cli_usage.md ├── client_usage.md ├── error_handling.md ├── home.md ├── installation.md ├── logging.md ├── message_handling.md ├── progress_tracking.md ├── protocol_upgrade_2025_03_26.md ├── rfc.md ├── server_components.md ├── server_implementation_rfc.md ├── server_quickstart.md ├── server_transport.md └── transport.md ├── priv └── dev │ ├── ascii │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── assets │ │ ├── css │ │ │ └── app.css │ │ ├── js │ │ │ └── app.js │ │ ├── tailwind.config.js │ │ └── vendor │ │ │ └── topbar.js │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ ├── runtime.exs │ │ └── test.exs │ ├── lib │ │ ├── ascii.ex │ │ ├── ascii │ │ │ ├── application.ex │ │ │ ├── art_generator.ex │ │ │ ├── art_history.ex │ │ │ ├── mcp_server.ex │ │ │ └── repo.ex │ │ ├── ascii_web.ex │ │ └── ascii_web │ │ │ ├── components │ │ │ ├── core_components.ex │ │ │ ├── layouts.ex │ │ │ └── layouts │ │ │ │ ├── app.html.heex │ │ │ │ └── root.html.heex │ │ │ ├── controllers │ │ │ ├── error_html.ex │ │ │ └── error_json.ex │ │ │ ├── endpoint.ex │ │ │ ├── live │ │ │ └── ascii_live.ex │ │ │ ├── router.ex │ │ │ └── telemetry.ex │ ├── mix.exs │ ├── mix.lock │ ├── priv │ │ ├── repo │ │ │ ├── migrations │ │ │ │ ├── .formatter.exs │ │ │ │ └── 20250606143630_create_art_history.exs │ │ │ └── seeds.exs │ │ └── static │ │ │ ├── favicon.ico │ │ │ ├── images │ │ │ └── logo.svg │ │ │ └── robots.txt │ └── test │ │ ├── ascii_web │ │ └── controllers │ │ │ ├── error_html_test.exs │ │ │ ├── error_json_test.exs │ │ │ └── page_controller_test.exs │ │ ├── support │ │ ├── conn_case.ex │ │ └── data_case.ex │ │ └── test_helper.exs │ ├── calculator │ ├── go.mod │ ├── go.sum │ └── main.go │ ├── echo-elixir │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── config │ │ ├── config.exs │ │ ├── dev.exs │ │ ├── prod.exs │ │ ├── runtime.exs │ │ └── test.exs │ ├── lib │ │ ├── echo.ex │ │ ├── echo │ │ │ └── application.ex │ │ ├── echo_mcp │ │ │ ├── server.ex │ │ │ └── tools │ │ │ │ └── echo.ex │ │ ├── echo_web.ex │ │ └── echo_web │ │ │ ├── controllers │ │ │ └── error_json.ex │ │ │ ├── endpoint.ex │ │ │ └── router.ex │ ├── mix.exs │ ├── mix.lock │ ├── priv │ │ └── static │ │ │ └── robots.txt │ ├── rel │ │ └── overlays │ │ │ └── bin │ │ │ ├── server │ │ │ └── server.bat │ └── test │ │ ├── echo_web │ │ └── controllers │ │ │ └── error_json_test.exs │ │ ├── support │ │ └── conn_case.ex │ │ └── test_helper.exs │ ├── echo │ ├── .python-version │ ├── index.py │ ├── pyproject.toml │ └── uv.lock │ └── upcase │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── config │ └── config.exs │ ├── lib │ └── upcase │ │ ├── application.ex │ │ ├── prompts │ │ └── text_transform.ex │ │ ├── resources │ │ └── examples.ex │ │ ├── router.ex │ │ ├── server.ex │ │ └── tools │ │ ├── analyze_text.ex │ │ └── upcase.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ └── test_helper.exs └── test ├── hermes ├── client │ └── state_test.exs ├── client_test.exs ├── mcp │ ├── error_test.exs │ ├── id_test.exs │ ├── message_test.exs │ └── response_test.exs ├── server │ ├── base_test.exs │ ├── component │ │ └── schema_test.exs │ ├── component_field_macro_test.exs │ ├── component_prompt_test.exs │ ├── response_test.exs │ └── transport │ │ ├── sse │ │ └── plug_test.exs │ │ ├── sse_test.exs │ │ ├── stdio_test.exs │ │ └── streamable_http_test.exs ├── sse │ └── parser_test.exs └── transport │ ├── sse_test.exs │ ├── stdio_test.exs │ ├── streamable_http_test.exs │ └── websocket_test.exs ├── hermes_test.exs ├── support ├── mcp │ ├── assertions.ex │ ├── builders.ex │ ├── case.ex │ └── setup.ex ├── mock_transport.ex ├── stub_client.ex ├── stub_server.ex ├── stub_transport.ex └── test_tools.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | strict: true, 6 | checks: %{ 7 | disabled: [ 8 | {Credo.Check.Design.TagTODO, []}, 9 | ] 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.dialyzerignore.exs: -------------------------------------------------------------------------------- 1 | [{"lib/hermes/http.ex", :call}] 2 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | if [ -f .env.local ]; then 2 | source .env.local; 3 | fi 4 | 5 | export HERMES_MCP_COMPILE_CLI=true 6 | export MIX_ENV=prod 7 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export GPG_TTY="$(tty)" 2 | 3 | # this allows mix to work on the local directory 4 | export MIX_HOME=$PWD/.nix-mix 5 | export HEX_HOME=$PWD/.nix-mix 6 | export PATH=$MIX_HOME/bin:$HEX_HOME/bin:$PATH 7 | export ERL_AFLAGS="-kernel shell_history enabled" 8 | 9 | # this allows go to work on the local directory 10 | # Set a project-local GOPATH (legacy support, if needed) 11 | export GOPATH="$PWD/.gopath" 12 | mkdir -p "$GOPATH/bin" 13 | 14 | export GOBIN="$GOPATH/bin" 15 | export PATH="$GOBIN:$PATH" 16 | 17 | export GO111MODULE=on 18 | 19 | export LANG=en_US.UTF-8 20 | 21 | use flake 22 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals = [ 3 | assert_response: 3, 4 | assert_error: 3, 5 | assert_notification: 2, 6 | component: 1, 7 | component: 2, 8 | schema: 1, 9 | field: 2, 10 | field: 3 11 | ] 12 | 13 | test = [ 14 | assert_client_initialized: 1, 15 | assert_server_initialized: 1 16 | ] 17 | 18 | [ 19 | plugins: [Styler], 20 | import_deps: [:peri], 21 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "priv/dev/upcase/{lib,config,test}/*.{ex,exs}"], 22 | locals_without_parens: locals ++ test, 23 | export: locals 24 | ] 25 | -------------------------------------------------------------------------------- /.github/.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.8.1" 3 | } -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Problem 2 | 3 | 4 | 5 | ## Solution 6 | 7 | 8 | 9 | ## Rationale 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap-sha": "fa1453f", 3 | "pull-request-header": ":rocket: Want to release this?", 4 | "pull-request-title-pattern": "chore: release ${version}", 5 | "changelog-sections": [ 6 | { 7 | "type": "feat", 8 | "section": "Features" 9 | }, 10 | { 11 | "type": "feature", 12 | "section": "Features" 13 | }, 14 | { 15 | "type": "fix", 16 | "section": "Bug Fixes" 17 | }, 18 | { 19 | "type": "perf", 20 | "section": "Performance Improvements" 21 | }, 22 | { 23 | "type": "revert", 24 | "section": "Reverts" 25 | }, 26 | { 27 | "type": "docs", 28 | "section": "Documentation", 29 | "hidden": false 30 | }, 31 | { 32 | "type": "style", 33 | "section": "Styles", 34 | "hidden": false 35 | }, 36 | { 37 | "type": "chore", 38 | "section": "Miscellaneous Chores", 39 | "hidden": false 40 | }, 41 | { 42 | "type": "refactor", 43 | "section": "Code Refactoring", 44 | "hidden": false 45 | }, 46 | { 47 | "type": "test", 48 | "section": "Tests", 49 | "hidden": false 50 | }, 51 | { 52 | "type": "build", 53 | "section": "Build System", 54 | "hidden": false 55 | }, 56 | { 57 | "type": "ci", 58 | "section": "Continuous Integration", 59 | "hidden": false 60 | } 61 | ], 62 | "extra-files": [ 63 | { 64 | "type": "generic", 65 | "path": "flake.nix", 66 | "glob": false 67 | }, 68 | { 69 | "type": "generic", 70 | "path": "README.md", 71 | "glob": false 72 | }, 73 | { 74 | "type": "generic", 75 | "path": "pages/installation.md", 76 | "glob": false 77 | } 78 | ], 79 | "packages": { 80 | ".": { 81 | "release-type": "elixir" 82 | } 83 | }, 84 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 85 | } -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | name: Release Please 12 | 13 | jobs: 14 | release-please: 15 | runs-on: ubuntu-latest 16 | outputs: 17 | release_created: ${{ steps.release.outputs.release_created }} 18 | tag_name: ${{ steps.release.outputs.tag_name }} 19 | steps: 20 | - name: Create GitHub App Token 21 | id: create_token 22 | uses: tibdex/github-app-token@v2 23 | with: 24 | app_id: ${{ secrets.APP_ID }} 25 | private_key: ${{ secrets.APP_PRIVATE_KEY }} 26 | 27 | - uses: googleapis/release-please-action@v4 28 | id: release 29 | with: 30 | token: ${{ steps.create_token.outputs.token }} 31 | config-file: .github/release-please-config.json 32 | manifest-file: .github/.release-please-manifest.json 33 | 34 | build-and-release: 35 | needs: release-please 36 | if: ${{ needs.release-please.outputs.release_created }} 37 | runs-on: ubuntu-latest 38 | permissions: 39 | contents: write 40 | env: 41 | MIX_ENV: prod 42 | HERMES_MCP_COMPILE_CLI: "true" 43 | 44 | steps: 45 | - name: Checkout code 46 | uses: actions/checkout@v4 47 | with: 48 | ref: ${{ needs.release-please.outputs.tag_name }} 49 | 50 | - name: Set up Elixir 51 | uses: erlef/setup-beam@v1 52 | with: 53 | elixir-version: '1.19.0-rc.0' 54 | otp-version: '27.3' 55 | 56 | - name: Install dependencies 57 | run: | 58 | mix local.hex --force 59 | mix local.rebar --force 60 | mix deps.get 61 | 62 | - name: Install Zig 63 | uses: goto-bus-stop/setup-zig@v2 64 | with: 65 | version: 0.14.0 66 | 67 | - name: Install XZ 68 | run: sudo apt-get update && sudo apt-get install -y xz-utils 69 | 70 | - name: Install 7z 71 | run: sudo apt-get install -y p7zip-full 72 | 73 | - name: Build release 74 | run: | 75 | MIX_ENV=prod mix release 76 | 77 | - name: Upload binaries to GitHub Release 78 | uses: softprops/action-gh-release@v2 79 | with: 80 | tag_name: ${{ needs.release-please.outputs.tag_name }} 81 | files: ./burrito_out/* 82 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Manual Release Binaries 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: 'Tag to build release for (e.g. v0.3.3)' 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | build: 13 | name: Build Binaries 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | env: 18 | MIX_ENV: prod 19 | HERMES_MCP_COMPILE_CLI: "true" 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | with: 25 | ref: ${{ github.event.inputs.tag || github.ref }} 26 | 27 | - name: Set up Elixir 28 | uses: erlef/setup-beam@v1 29 | with: 30 | elixir-version: '1.19.0-rc.0' 31 | otp-version: '27.3' 32 | 33 | - name: Install dependencies 34 | run: | 35 | mix local.hex --force 36 | mix local.rebar --force 37 | mix deps.get 38 | 39 | - name: Install Zig 40 | uses: goto-bus-stop/setup-zig@v2 41 | with: 42 | version: 0.14.0 43 | 44 | - name: Install XZ 45 | run: sudo apt-get update && sudo apt-get install -y xz-utils 46 | 47 | - name: Install 7z 48 | run: sudo apt-get install -y p7zip-full 49 | 50 | - name: Build release 51 | run: | 52 | MIX_ENV=prod mix release 53 | 54 | - name: Create new GitHub Release 55 | uses: softprops/action-gh-release@v2 56 | with: 57 | tag_name: ${{ github.event.inputs.tag || github.ref_name }} 58 | make_latest: ${{ github.event.inputs.tag == '' || github.event.inputs.tag == github.ref_name }} 59 | generate_release_notes: true 60 | files: ./burrito_out/* 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | hermes_mcp-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Dialyzer 26 | /priv/plts/ 27 | 28 | # Python specific 29 | /priv/dev/**/__pycache__/ 30 | /priv/dev/**/.venv/ 31 | 32 | # golang specific 33 | /.gopath/ 34 | /priv/dev/calculator/calculator 35 | 36 | /.context/ 37 | 38 | # Burrito specific 39 | /burrito_out/ 40 | 41 | # Local envs 42 | .env.* 43 | 44 | # Nix files 45 | result 46 | 47 | # Claude 48 | /.claude/ 49 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.3 2 | elixir 1.18.3-otp-27 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Hermes MCP 2 | 3 | Thank you for your interest in contributing to Hermes MCP! This document provides guidelines and instructions for contributing to the project. 4 | 5 | Firstly, have sure to follow the official MCP (Model Context Protocol) [specification](https://spec.modelcontextprotocol.io/specification/2024-11-05/)! 6 | 7 | ## Development Setup 8 | 9 | ### Prerequisites 10 | 11 | - Elixir 1.18+ 12 | - Erlang/OTP 26+ 13 | - Python 3.11+ with uv (for echo server) 14 | - Go 1.21+ (for calculator server) 15 | - Just command runner 16 | 17 | ### Getting Started 18 | 19 | 1. Clone the repository 20 | ```bash 21 | git clone https://github.com/cloudwalk/hermes-mcp.git 22 | cd hermes-mcp 23 | ``` 24 | 25 | 2. Install dependencies 26 | ```bash 27 | mix setup 28 | ``` 29 | 30 | 3. Run tests to ensure everything is working 31 | ```bash 32 | mix test 33 | ``` 34 | 35 | ## Development Workflow 36 | 37 | ### Running MCP Servers 38 | 39 | For development and testing, you can use the provided MCP server implementations: 40 | 41 | ```bash 42 | # Start the Echo server (Python) 43 | # For now only support stdio transport 44 | just echo-server 45 | 46 | # Start the Calculator server (Go) 47 | # Supports both stdio and http/sse transport 48 | just calculator-server sse 49 | ``` 50 | 51 | ### Code Quality 52 | 53 | Before submitting a pull request, ensure your code passes all quality checks: 54 | 55 | ```bash 56 | # Run all code quality checks 57 | mix lint 58 | 59 | # Individual checks 60 | mix format # Code formatting 61 | mix credo # Linting 62 | mix dialyzer # Type checking 63 | ``` 64 | 65 | ### Testing 66 | 67 | Write tests for all new features and bug fixes: 68 | 69 | ```bash 70 | # Run all tests 71 | mix test 72 | ``` 73 | 74 | ## Submitting Contributions 75 | 76 | 1. Create a new branch for your feature or bugfix 77 | ```bash 78 | git checkout -b feature/your-feature-name 79 | ``` 80 | 81 | 2. Make your changes and commit them with clear, descriptive messages 82 | 83 | 3. Push your branch to GitHub 84 | ```bash 85 | git push origin feature/your-feature-name 86 | ``` 87 | 88 | 4. Open a pull request against the main branch 89 | 90 | ## Pull Request Guidelines 91 | 92 | - Follow the existing code style and conventions 93 | - Include tests for new functionality 94 | - Update documentation as needed 95 | - Keep pull requests focused on a single topic 96 | - Reference any related issues in your PR description 97 | 98 | ## Documentation 99 | 100 | Update documentation for any user-facing changes: 101 | 102 | - Update relevant sections in `pages/` directory 103 | - Add examples for new features 104 | - Document any breaking changes 105 | 106 | ## Release Process 107 | 108 | Releases are managed by the maintainers. Version numbers follow [Semantic Versioning](https://semver.org/). 109 | 110 | ## License 111 | 112 | By contributing to Hermes MCP, you agree that your contributions will be licensed under the project's [MIT License](./LICENSE). 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 zoedsoupe 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Hermes MCP Roadmap 2 | 3 | This document outlines the development roadmap for Hermes MCP, an Elixir implementation of the Model Context Protocol (MCP). The roadmap is organized by key milestones and development areas. 4 | 5 | ## Current Status 6 | 7 | Hermes MCP currently provides a complete client implementation for the MCP 2024-11-05 specification with: 8 | 9 | - Full protocol lifecycle management (initialization, operation, shutdown) 10 | - Multiple transport options (STDIO, HTTP/SSE, and WebSocket) 11 | - Connection supervision and automatic recovery 12 | - Comprehensive capability negotiation 13 | - Progress tracking for long-running operations 14 | - Cancellation support 15 | - Structured logging 16 | - Interactive test shell for development 17 | 18 | ## Upcoming Milestones 19 | 20 | ### 1. MCP 2025-03-26 Specification Support 21 | 22 | The MCP specification was updated on 2025-03-26 with several breaking changes and new features. Our implementation plan includes: 23 | 24 | #### Phase 1: Core Infrastructure (Q2 2025) 25 | 26 | - Support for protocol version negotiation 27 | - Multi-version compatibility layer 28 | - Backward compatibility with 2024-11-05 servers 29 | 30 | #### Phase 2: Transport Layer Updates (Q2-Q3 2025) 31 | 32 | - Implement Streamable HTTP transport 33 | - Maintain compatibility with HTTP+SSE for older servers 34 | - Support session management via `Mcp-Session-Id` header 35 | - Implement stream resumability with `Last-Event-ID` 36 | 37 | #### Phase 3: New Protocol Features (Q3 2025) 38 | 39 | - Support for OAuth 2.1 authorization framework 40 | - Server metadata discovery 41 | - Dynamic client registration 42 | - Token management and refresh 43 | - JSON-RPC batching for improved performance 44 | - Enhanced tool annotations 45 | - Explicit completions capability 46 | - Audio content type support 47 | - Message field for progress notifications 48 | 49 | See [protocol_upgrade_2025_03_26.md](./pages/protocol_upgrade_2025_03_26.md) for detailed information about these changes. 50 | 51 | ### 2. Server Implementation (Q3-Q4 2025) 52 | 53 | After stabilizing the client implementation for both protocol versions, we plan to develop a complete server-side implementation: 54 | 55 | #### Phase 1: Core Server Infrastructure 56 | 57 | - Server-side protocol implementation 58 | - Capability management 59 | - Request handling framework 60 | - Resource, prompt, and tool abstractions 61 | 62 | #### Phase 2: Transport Implementations 63 | 64 | - STDIO transport for local process communication 65 | - HTTP/SSE transport for 2024-11-05 compatibility 66 | - Streamable HTTP transport for 2025-03-26 support 67 | 68 | #### Phase 3: Feature Implementations 69 | 70 | - Resources management and subscription 71 | - Tools registration and invocation 72 | - Prompts template system 73 | - Logging and telemetry 74 | - Authorization framework 75 | 76 | ### 3. Sample Implementations and Integration Examples (Q4 2025+) 77 | 78 | Once both client and server implementations are stable, we plan to provide: 79 | 80 | - Reference server implementations 81 | - Sample integration with Elixir ecosystem libraries 82 | - Example applications demonstrating MCP use cases 83 | - Integration with popular AI frameworks and platforms 84 | 85 | ## Feature Backlog 86 | 87 | Beyond the core roadmap, we maintain a backlog of features for future consideration: 88 | 89 | - **Observability**: Advanced telemetry and monitoring integration 90 | - **Rate Limiting and Quota Management**: For server implementations 91 | - **Testing Tools**: Extended tools for protocol testing and validation 92 | 93 | ## Contributing 94 | 95 | We welcome contributions to any part of this roadmap. See [CONTRIBUTING.md](./CONTRIBUTING.md) for details on how to get involved. 96 | 97 | Issues are tracked in GitHub and tagged with milestone information. For current development priorities, see our [open issues](https://github.com/cloudwalk/hermes-mcp/issues). 98 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | boolean = fn env -> 4 | System.get_env(env) in ["1", "true"] 5 | end 6 | 7 | config :hermes_mcp, compile_cli?: boolean.("HERMES_MCP_COMPILE_CLI") 8 | 9 | config :logger, :default_formatter, 10 | format: "[$level] $message $metadata\n", 11 | metadata: [:mcp_server, :mcp_client, :mcp_client_name, :mcp_transport] 12 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "elixir-overlay": { 4 | "inputs": { 5 | "flake-utils": "flake-utils", 6 | "nixpkgs": "nixpkgs" 7 | }, 8 | "locked": { 9 | "lastModified": 1749482522, 10 | "narHash": "sha256-nLBqNIgCToSgVfodrY82UxSz1zvc6oiYILuJzDuLpGM=", 11 | "owner": "zoedsoupe", 12 | "repo": "elixir-overlay", 13 | "rev": "929539a8d91f7ecd24777f04567c16f29373a50f", 14 | "type": "github" 15 | }, 16 | "original": { 17 | "owner": "zoedsoupe", 18 | "repo": "elixir-overlay", 19 | "type": "github" 20 | } 21 | }, 22 | "flake-utils": { 23 | "inputs": { 24 | "systems": "systems" 25 | }, 26 | "locked": { 27 | "lastModified": 1731533236, 28 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 29 | "owner": "numtide", 30 | "repo": "flake-utils", 31 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "numtide", 36 | "repo": "flake-utils", 37 | "type": "github" 38 | } 39 | }, 40 | "nixpkgs": { 41 | "locked": { 42 | "lastModified": 1747744144, 43 | "narHash": "sha256-W7lqHp0qZiENCDwUZ5EX/lNhxjMdNapFnbErcbnP11Q=", 44 | "owner": "NixOS", 45 | "repo": "nixpkgs", 46 | "rev": "2795c506fe8fb7b03c36ccb51f75b6df0ab2553f", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "NixOS", 51 | "ref": "nixos-unstable", 52 | "repo": "nixpkgs", 53 | "type": "github" 54 | } 55 | }, 56 | "nixpkgs_2": { 57 | "locked": { 58 | "lastModified": 1749237914, 59 | "narHash": "sha256-N5waoqWt8aMr/MykZjSErOokYH6rOsMMXu3UOVH5kiw=", 60 | "owner": "NixOS", 61 | "repo": "nixpkgs", 62 | "rev": "70c74b02eac46f4e4aa071e45a6189ce0f6d9265", 63 | "type": "github" 64 | }, 65 | "original": { 66 | "owner": "NixOS", 67 | "ref": "nixos-25.05", 68 | "repo": "nixpkgs", 69 | "type": "github" 70 | } 71 | }, 72 | "root": { 73 | "inputs": { 74 | "elixir-overlay": "elixir-overlay", 75 | "nixpkgs": "nixpkgs_2" 76 | } 77 | }, 78 | "systems": { 79 | "locked": { 80 | "lastModified": 1681028828, 81 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 82 | "owner": "nix-systems", 83 | "repo": "default", 84 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-systems", 89 | "repo": "default", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Model Context Protocol SDK for Elixir"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; 6 | elixir-overlay.url = "github:zoedsoupe/elixir-overlay"; 7 | }; 8 | 9 | outputs = { 10 | self, 11 | nixpkgs, 12 | elixir-overlay, 13 | }: let 14 | inherit (nixpkgs.lib) genAttrs; 15 | inherit (nixpkgs.lib.systems) flakeExposed; 16 | 17 | forAllSystems = f: 18 | genAttrs flakeExposed ( 19 | system: let 20 | overlays = [elixir-overlay.overlays.default]; 21 | pkgs = import nixpkgs {inherit system overlays;}; 22 | in 23 | f pkgs 24 | ); 25 | 26 | zig = pkgs: 27 | pkgs.zig.overrideAttrs (old: rec { 28 | version = "0.14.0"; 29 | src = pkgs.fetchFromGitHub { 30 | inherit (old.src) owner repo; 31 | rev = version; 32 | hash = "sha256-VyteIp5ZRt6qNcZR68KmM7CvN2GYf8vj5hP+gHLkuVk="; 33 | }; 34 | }); 35 | in { 36 | devShells = forAllSystems (pkgs: { 37 | default = pkgs.mkShell { 38 | name = "hermes-mcp-dev"; 39 | buildInputs = with pkgs; [ 40 | elixir-bin."1.19.0-rc.0" 41 | erlang 42 | uv 43 | just 44 | go 45 | (zig pkgs) 46 | _7zz 47 | xz 48 | ]; 49 | }; 50 | }); 51 | 52 | packages = forAllSystems (pkgs: { 53 | default = pkgs.stdenv.mkDerivation { 54 | pname = "hermes-mcp"; 55 | version = "0.8.1"; # x-release-please-version 56 | src = ./.; 57 | 58 | buildInputs = with pkgs; [ 59 | elixir-bin.latest 60 | erlang 61 | (zig pkgs) 62 | _7zz 63 | xz 64 | git 65 | ]; 66 | 67 | buildPhase = '' 68 | export MIX_ENV=prod 69 | export HERMES_MCP_COMPILE_CLI=true 70 | export HOME=$TMPDIR 71 | 72 | mix do deps.get, compile 73 | mix release hermes_mcp --overwrite 74 | ''; 75 | 76 | installPhase = '' 77 | mkdir -p $out/bin 78 | 79 | echo "=== Build output structure ===" 80 | find _build -type f -name "*hermes*" 2>/dev/null || true 81 | 82 | if [ -d "_build/prod/rel/hermes_mcp/burrito_out" ]; then 83 | echo "Found burrito_out directory" 84 | cp -r _build/prod/rel/hermes_mcp/burrito_out/* $out/bin/ || true 85 | elif [ -d "_build/prod/rel/hermes_mcp/bin" ]; then 86 | echo "Found standard release bin directory" 87 | cp -r _build/prod/rel/hermes_mcp/bin/* $out/bin/ || true 88 | else 89 | echo "No bin directory found, checking for other release outputs" 90 | find _build/prod/rel -name "*" -type f -executable | head -5 91 | fi 92 | 93 | if [ -n "$(ls -A $out/bin 2>/dev/null)" ]; then 94 | chmod +x $out/bin/* || true 95 | echo "=== Installed binaries ===" 96 | ls -la $out/bin/ 97 | else 98 | echo "ERROR: No binaries were installed!" 99 | exit 1 100 | fi 101 | ''; 102 | 103 | meta = with pkgs.lib; { 104 | description = "Model Context Protocol (MCP) implementation in Elixir"; 105 | homepage = "https://github.com/cloudwalk/hermes-mcp"; 106 | license = licenses.mit; 107 | maintainers = with maintainers; [zoedsoupe]; 108 | platforms = platforms.unix ++ platforms.darwin; 109 | }; 110 | }; 111 | }); 112 | 113 | apps = forAllSystems (pkgs: { 114 | default = { 115 | type = "app"; 116 | program = "${self.packages.${pkgs.system}.default}/bin/hermes_mcp"; 117 | }; 118 | }); 119 | }; 120 | } 121 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | setup-venv: 2 | @echo "Run: source priv/dev/echo/.venv/bin/activate" 3 | 4 | echo-server transport="stdio": 5 | source priv/dev/echo/.venv/bin/activate && \ 6 | mcp run -t {{transport}} priv/dev/echo/index.py 7 | 8 | calculator-server transport="stdio": 9 | cd priv/dev/calculator && go build && ./calculator -t {{transport}} || cd - 10 | 11 | [working-directory: 'priv/dev/upcase'] 12 | upcase-server: 13 | HERMES_MCP_SERVER=true iex -S mix 14 | 15 | [working-directory: 'priv/dev/ascii'] 16 | ascii-server: 17 | iex -S mix phx.server 18 | 19 | [working-directory: 'priv/dev/echo-elixir'] 20 | echo-ex-server: 21 | iex -S mix phx.server 22 | -------------------------------------------------------------------------------- /lib/hermes.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes do 2 | @moduledoc false 3 | 4 | import Peri 5 | 6 | alias Hermes.Server.Transport.SSE, as: ServerSSE 7 | alias Hermes.Server.Transport.STDIO, as: ServerSTDIO 8 | alias Hermes.Server.Transport.StreamableHTTP, as: ServerStreamableHTTP 9 | alias Hermes.Transport.SSE, as: ClientSSE 10 | alias Hermes.Transport.STDIO, as: ClientSTDIO 11 | alias Hermes.Transport.StreamableHTTP, as: ClientStreamableHTTP 12 | 13 | @client_transports if Mix.env() == :test, 14 | do: [ClientSTDIO, ClientSSE, ClientStreamableHTTP, StubTransport, Hermes.MockTransport], 15 | else: [ClientSTDIO, ClientSSE, ClientStreamableHTTP] 16 | 17 | @server_transports if Mix.env() == :test, 18 | do: [ServerSTDIO, ServerStreamableHTTP, ServerSSE, StubTransport], 19 | else: [ServerSTDIO, ServerStreamableHTTP, ServerSSE] 20 | 21 | defschema :client_transport, 22 | layer: {:required, {:enum, @client_transports}}, 23 | name: {:required, get_schema(:process_name)} 24 | 25 | defschema :server_transport, 26 | layer: {:required, {:enum, @server_transports}}, 27 | name: {:required, get_schema(:process_name)} 28 | 29 | defschema :process_name, {:either, {:pid, {:custom, &genserver_name/1}}} 30 | 31 | @doc "Checks if hermes should be compiled/used as standalone CLI or OTP library" 32 | def should_compile_cli? do 33 | Code.ensure_loaded?(Burrito) and Application.get_env(:hermes_mcp, :compile_cli?, false) 34 | end 35 | 36 | @doc """ 37 | Validates a possible GenServer name using `peri` `:custom` type definition. 38 | """ 39 | def genserver_name({:via, registry, _}) when is_atom(registry), do: :ok 40 | def genserver_name({:global, _}), do: :ok 41 | def genserver_name(name) when is_atom(name), do: :ok 42 | 43 | def genserver_name(val) do 44 | {:error, "#{inspect(val, pretty: true)} is not a valid name for a GenServer"} 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/hermes/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | :logger.add_handlers(:hermes_mcp) 11 | 12 | children = 13 | [ 14 | {Finch, name: Hermes.Finch, pools: %{default: [size: 15]}} 15 | ] 16 | 17 | # See https://hexdocs.pm/elixir/Supervisor.html 18 | # for other strategies and supported options 19 | opts = [strategy: :one_for_one, name: Hermes.Supervisor] 20 | {:ok, pid} = Supervisor.start_link(children, opts) 21 | 22 | if Hermes.should_compile_cli?() do 23 | with {:module, cli} <- Code.ensure_loaded(Hermes.CLI), do: cli.main() 24 | {:ok, pid} 25 | else 26 | {:ok, pid} 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/hermes/cli.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Burrito) do 2 | defmodule Hermes.CLI do 3 | @moduledoc """ 4 | CLI entry point for the Hermes MCP standalone binary. 5 | 6 | This module serves as the main entry point when the application is built 7 | as a standalone binary with Burrito. It delegates to the Mix.Interactive.CLI 8 | module for the actual CLI implementation. 9 | """ 10 | 11 | alias Burrito.Util 12 | alias Mix.Interactive 13 | 14 | @doc """ 15 | Main entry point for the standalone CLI application. 16 | """ 17 | def main do 18 | args = Util.Args.argv() 19 | 20 | if Enum.join(args) =~ "help" do 21 | Interactive.CLI.show_help() 22 | System.halt(0) 23 | end 24 | 25 | Interactive.CLI.main(args) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/hermes/client/operation.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Client.Operation do 2 | @moduledoc """ 3 | Represents an operation to be performed by the MCP client. 4 | 5 | This struct encapsulates all information about a client API call: 6 | - `method` - The MCP method to call 7 | - `params` - The parameters to send to the server 8 | - `progress_opts` - Progress tracking options (optional) 9 | - `timeout` - The timeout for this specific operation (default: 30 seconds) 10 | """ 11 | 12 | @default_timeout to_timeout(second: 30) 13 | 14 | @type progress_options :: [ 15 | token: String.t() | integer(), 16 | callback: (String.t() | integer(), number(), number() | nil -> any()) 17 | ] 18 | 19 | @type t :: %__MODULE__{ 20 | method: String.t(), 21 | params: map(), 22 | progress_opts: progress_options() | nil, 23 | timeout: pos_integer() 24 | } 25 | 26 | defstruct [ 27 | :method, 28 | :params, 29 | :progress_opts, 30 | timeout: @default_timeout 31 | ] 32 | 33 | @doc """ 34 | Creates a new operation struct. 35 | 36 | ## Parameters 37 | 38 | * `attrs` - Map containing the operation attributes 39 | * `:method` - The MCP method name (required) 40 | * `:params` - The parameters to send to the server (required) 41 | * `:progress_opts` - Progress tracking options (optional) 42 | * `:timeout` - The timeout for this operation in milliseconds (optional, defaults to 30s) 43 | """ 44 | @spec new(%{ 45 | required(:method) => String.t(), 46 | optional(:params) => map(), 47 | optional(:progress_opts) => progress_options() | nil, 48 | optional(:timeout) => pos_integer() 49 | }) :: t() 50 | def new(%{method: method} = attrs) do 51 | %__MODULE__{ 52 | method: method, 53 | params: Map.get(attrs, :params) || %{}, 54 | progress_opts: Map.get(attrs, :progress_opts), 55 | timeout: Map.get(attrs, :timeout) || @default_timeout 56 | } 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/hermes/client/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Client.Request do 2 | @moduledoc """ 3 | Represents a pending request in the MCP client. 4 | 5 | This struct encapsulates all information about an in-progress request: 6 | - `id` - The unique request ID 7 | - `method` - The MCP method being called 8 | - `from` - The GenServer caller reference 9 | - `timer_ref` - Reference to the request-specific timeout timer 10 | - `start_time` - When the request started (monotonic time in milliseconds) 11 | """ 12 | 13 | @type t :: %__MODULE__{ 14 | id: String.t(), 15 | method: String.t(), 16 | from: GenServer.from(), 17 | timer_ref: reference(), 18 | start_time: integer() 19 | } 20 | 21 | defstruct [:id, :method, :from, :timer_ref, :start_time] 22 | 23 | @doc """ 24 | Creates a new request struct. 25 | 26 | ## Parameters 27 | 28 | * `attrs` - Map containing the request attributes 29 | * `:id` - The unique request ID 30 | * `:method` - The MCP method name 31 | * `:from` - The GenServer caller reference 32 | * `:timer_ref` - Reference to the request-specific timeout timer 33 | """ 34 | @spec new(%{id: String.t(), method: String.t(), from: GenServer.from(), timer_ref: reference()}) :: t() 35 | def new(%{id: id, method: method, from: from, timer_ref: timer_ref}) do 36 | %__MODULE__{ 37 | id: id, 38 | method: method, 39 | from: from, 40 | timer_ref: timer_ref, 41 | start_time: System.monotonic_time(:millisecond) 42 | } 43 | end 44 | 45 | @doc """ 46 | Calculates the elapsed time for a request in milliseconds. 47 | """ 48 | @spec elapsed_time(t()) :: integer() 49 | def elapsed_time(%__MODULE__{start_time: start_time}) do 50 | System.monotonic_time(:millisecond) - start_time 51 | end 52 | end 53 | 54 | defimpl Inspect, for: Hermes.Client.Request do 55 | def inspect(%{id: id, method: method, start_time: start_time}, _opts) do 56 | elapsed = System.monotonic_time(:millisecond) - start_time 57 | "#MCP.Client.Request<#{id} #{method} (elapsed: #{elapsed}ms)>" 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/hermes/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.HTTP do 2 | @moduledoc """ 3 | HTTP utilities. 4 | """ 5 | 6 | require Logger 7 | 8 | @default_headers %{ 9 | "content-type" => "application/json" 10 | } 11 | 12 | def build(method, url, headers \\ %{}, body \\ nil, opts \\ []) do 13 | with {:ok, uri} <- parse_uri(url) do 14 | headers = Map.merge(@default_headers, headers) 15 | Finch.build(method, uri, Map.to_list(headers), body, opts) 16 | end 17 | end 18 | 19 | @doc """ 20 | Performs a POST request to the given URL. 21 | """ 22 | def post(url, headers \\ %{}, body \\ nil) do 23 | headers = Map.merge(@default_headers, headers) 24 | build(:post, url, headers, body) 25 | end 26 | 27 | defp parse_uri(url) do 28 | with {:error, _} <- URI.new(url), do: {:error, :invalid_url} 29 | end 30 | 31 | @max_redirects 3 32 | 33 | # @spec follow_redirect(Finch.Request.t(), non_neg_integer) :: 34 | # {:ok, Finch.Response.t()} | {:error, term} 35 | def follow_redirect(%Finch.Request{} = request, attempts \\ @max_redirects) do 36 | with {:ok, resp} <- Finch.request(request, Hermes.Finch), 37 | do: do_follow_redirect(request, resp, attempts) 38 | end 39 | 40 | defp do_follow_redirect(_req, _resp, 0), do: {:error, :max_redirects} 41 | 42 | defp do_follow_redirect(req, %Finch.Response{status: 307, headers: headers}, attempts) when is_integer(attempts) do 43 | location = List.keyfind(headers, "location", 0) 44 | 45 | Hermes.Logging.transport_event("redirect", %{ 46 | location: location, 47 | attempts_left: attempts, 48 | method: req.method 49 | }) 50 | 51 | {:ok, uri} = URI.new(location) 52 | req = %{req | host: uri.host, port: uri.port, path: uri.path, scheme: uri.scheme} 53 | follow_redirect(req, max(0, attempts - 1)) 54 | end 55 | 56 | defp do_follow_redirect(_req, %Finch.Response{} = resp, _attempts), do: {:ok, resp} 57 | end 58 | -------------------------------------------------------------------------------- /lib/hermes/server/component/tool.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.Component.Tool do 2 | @moduledoc """ 3 | Defines the behaviour for MCP tools. 4 | 5 | Tools are functions that can be invoked by the client with specific parameters. 6 | Each tool must define its name, description, and parameter schema, as well as 7 | implement the execution logic. 8 | 9 | ## Example 10 | 11 | defmodule MyServer.Tools.Calculator do 12 | @behaviour Hermes.Server.Behaviour.Tool 13 | 14 | alias Hermes.Server.Frame 15 | 16 | @impl true 17 | def name, do: "calculator" 18 | 19 | @impl true 20 | def description, do: "Performs basic arithmetic operations" 21 | 22 | @impl true 23 | def input_schema do 24 | %{ 25 | "type" => "object", 26 | "properties" => %{ 27 | "operation" => %{ 28 | "type" => "string", 29 | "enum" => ["add", "subtract", "multiply", "divide"] 30 | }, 31 | "a" => %{"type" => "number"}, 32 | "b" => %{"type" => "number"} 33 | }, 34 | "required" => ["operation", "a", "b"] 35 | } 36 | end 37 | 38 | @impl true 39 | def execute(%{"operation" => "add", "a" => a, "b" => b}, frame) do 40 | result = a + b 41 | 42 | # Can access frame assigns 43 | user_id = frame.assigns[:user_id] 44 | 45 | # Can return updated frame if needed 46 | new_frame = Frame.assign(frame, :last_calculation, result) 47 | 48 | {:ok, result, new_frame} 49 | end 50 | 51 | @impl true 52 | def execute(%{"operation" => "divide", "a" => a, "b" => 0}, _frame) do 53 | {:error, "Cannot divide by zero"} 54 | end 55 | end 56 | """ 57 | 58 | alias Hermes.MCP.Error 59 | alias Hermes.Server.Frame 60 | alias Hermes.Server.Response 61 | 62 | @type params :: map() 63 | @type result :: term() 64 | @type schema :: map() 65 | 66 | @doc """ 67 | Returns the JSON Schema for the tool's input parameters. 68 | 69 | This schema is used to validate client requests and generate documentation. 70 | The schema should follow the JSON Schema specification. 71 | """ 72 | @callback input_schema() :: schema() 73 | 74 | @doc """ 75 | Executes the tool with the given parameters. 76 | 77 | ## Parameters 78 | 79 | - `params` - The validated input parameters from the client 80 | - `frame` - The server frame containing: 81 | - `assigns` - Custom data like session_id, client_info, user permissions 82 | - `initialized` - Whether the server has been initialized 83 | 84 | ## Return Values 85 | 86 | - `{:ok, result}` - Tool executed successfully, frame unchanged 87 | - `{:ok, result, new_frame}` - Tool executed successfully with frame updates 88 | - `{:error, reason}` - Tool failed with the given reason 89 | 90 | ## Frame Usage 91 | 92 | The frame provides access to server state and context: 93 | 94 | def execute(params, frame) do 95 | # Access assigns 96 | user_id = frame.assigns[:user_id] 97 | permissions = frame.assigns[:permissions] 98 | 99 | # Update frame if needed 100 | new_frame = Frame.assign(frame, :last_tool_call, DateTime.utc_now()) 101 | 102 | {:ok, "Result", new_frame} 103 | end 104 | """ 105 | @callback execute(params :: params(), frame :: Frame.t()) :: 106 | {:reply, response :: Response.t(), new_state :: Frame.t()} 107 | | {:noreply, new_state :: Frame.t()} 108 | | {:error, error :: Error.t(), new_state :: Frame.t()} 109 | 110 | @doc """ 111 | Converts a tool module into the MCP protocol format. 112 | """ 113 | @spec to_protocol(module()) :: map() 114 | def to_protocol(tool_module) do 115 | %{ 116 | "name" => tool_module.name(), 117 | "description" => tool_module.description(), 118 | "inputSchema" => tool_module.input_schema() 119 | } 120 | end 121 | 122 | @doc """ 123 | Validates that a module implements the Tool behaviour. 124 | """ 125 | @spec implements?(module()) :: boolean() 126 | def implements?(module) do 127 | behaviours = 128 | :attributes 129 | |> module.__info__() 130 | |> Keyword.get(:behaviour, []) 131 | |> List.flatten() 132 | 133 | __MODULE__ in behaviours 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/hermes/server/configuration_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.ConfigurationError do 2 | @moduledoc """ 3 | Raised when required MCP server configuration is missing or invalid. 4 | 5 | The MCP specification requires servers to provide: 6 | - `name`: A human-readable name for the server 7 | - `version`: The server's version string 8 | 9 | ## Examples 10 | 11 | # This will raise an error - missing required options 12 | defmodule BadServer do 13 | use Hermes.Server # Raises Hermes.Server.ConfigurationError 14 | end 15 | 16 | # This is correct 17 | defmodule GoodServer do 18 | use Hermes.Server, 19 | name: "My Server", 20 | version: "1.0.0" 21 | end 22 | """ 23 | 24 | defexception [:message, :module, :missing_key] 25 | 26 | @impl true 27 | def exception(opts) do 28 | module = Keyword.fetch!(opts, :module) 29 | missing_key = Keyword.fetch!(opts, :missing_key) 30 | 31 | message = build_message(module, missing_key) 32 | 33 | %__MODULE__{ 34 | message: message, 35 | module: module, 36 | missing_key: missing_key 37 | } 38 | end 39 | 40 | defp build_message(module, :name) do 41 | """ 42 | MCP server configuration error in #{inspect(module)} 43 | 44 | Missing required option: :name 45 | 46 | The MCP specification requires all servers to provide a name. 47 | Please add the :name option to your use statement: 48 | 49 | defmodule #{inspect(module)} do 50 | use Hermes.Server, 51 | name: "Your Server Name", # <-- Add this 52 | version: "1.0.0" 53 | end 54 | 55 | The name should be a human-readable string that identifies your server. 56 | """ 57 | end 58 | 59 | defp build_message(module, :version) do 60 | """ 61 | MCP server configuration error in #{inspect(module)} 62 | 63 | Missing required option: :version 64 | 65 | The MCP specification requires all servers to provide a version. 66 | Please add the :version option to your use statement: 67 | 68 | defmodule #{inspect(module)} do 69 | use Hermes.Server, 70 | name: "Your Server", 71 | version: "1.0.0" # <-- Add this 72 | end 73 | 74 | The version should follow semantic versioning (e.g., "1.0.0", "2.1.3"). 75 | """ 76 | end 77 | 78 | defp build_message(module, :both) do 79 | """ 80 | MCP server configuration error in #{inspect(module)} 81 | 82 | Missing required options: :name and :version 83 | 84 | The MCP specification requires all servers to provide both name and version. 85 | Please add these options to your use statement: 86 | 87 | defmodule #{inspect(module)} do 88 | use Hermes.Server, 89 | name: "Your Server Name", # <-- Add this 90 | version: "1.0.0" # <-- Add this 91 | end 92 | 93 | Example: 94 | defmodule Calculator do 95 | use Hermes.Server, 96 | name: "Calculator Server", 97 | version: "1.0.0" 98 | end 99 | """ 100 | end 101 | 102 | defp build_message(module, key) do 103 | """ 104 | MCP server configuration error in #{inspect(module)} 105 | 106 | Invalid or missing required option: #{inspect(key)} 107 | """ 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/hermes/server/handlers.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.Handlers do 2 | @moduledoc false 3 | 4 | alias Hermes.MCP.Error 5 | alias Hermes.Server.Frame 6 | alias Hermes.Server.Handlers.Prompts 7 | alias Hermes.Server.Handlers.Resources 8 | alias Hermes.Server.Handlers.Tools 9 | alias Hermes.Server.Response 10 | 11 | @spec handle(map, module, Frame.t()) :: 12 | {:reply, response :: Response.t(), new_state :: Frame.t()} 13 | | {:noreply, new_state :: Frame.t()} 14 | | {:error, error :: Error.t(), new_state :: Frame.t()} 15 | def handle(%{"method" => "tools/" <> action} = request, module, frame) do 16 | case action do 17 | "list" -> Tools.handle_list(frame, module) 18 | "call" -> Tools.handle_call(request, frame, module) 19 | end 20 | end 21 | 22 | def handle(%{"method" => "prompts/" <> action} = request, module, frame) do 23 | case action do 24 | "list" -> Prompts.handle_list(frame, module) 25 | "get" -> Prompts.handle_get(request, frame, module) 26 | end 27 | end 28 | 29 | def handle(%{"method" => "resources/" <> action} = request, module, frame) do 30 | case action do 31 | "list" -> Resources.handle_list(frame, module) 32 | "read" -> Resources.handle_read(request, frame, module) 33 | end 34 | end 35 | 36 | def handle(%{"method" => method}, _module, frame) do 37 | {:error, Error.protocol(:method_not_found, %{method: method}), frame} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/hermes/server/handlers/prompts.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.Handlers.Prompts do 2 | @moduledoc """ 3 | Handles MCP protocol prompt-related methods. 4 | 5 | This module processes: 6 | - `prompts/list` - Lists available prompts with optional pagination 7 | - `prompts/get` - Retrieves and generates messages for a specific prompt 8 | 9 | ## Pagination Support 10 | 11 | The `prompts/list` method supports pagination through cursor parameters: 12 | 13 | # Request 14 | %{"method" => "prompts/list", "params" => %{"cursor" => "optional-cursor"}} 15 | 16 | # Response with more results 17 | %{ 18 | "prompts" => [...], 19 | "nextCursor" => "next-page-cursor" 20 | } 21 | 22 | # Response for last page 23 | %{"prompts" => [...]} 24 | """ 25 | 26 | alias Hermes.MCP.Error 27 | alias Hermes.Server.Component 28 | alias Hermes.Server.Component.Schema 29 | alias Hermes.Server.Frame 30 | alias Hermes.Server.Response 31 | 32 | @doc """ 33 | Handles the prompts/list request with optional pagination. 34 | 35 | ## Parameters 36 | 37 | - `request` - The MCP request containing optional cursor in params 38 | - `frame` - The server frame 39 | - `server_module` - The server module implementing prompt components 40 | 41 | ## Returns 42 | 43 | - `{:reply, result, frame}` - List of prompts with optional nextCursor 44 | - `{:error, error, frame}` - If pagination cursor is invalid 45 | """ 46 | @spec handle_list(Frame.t(), module()) :: 47 | {:reply, map(), Frame.t()} | {:error, Error.t(), Frame.t()} 48 | def handle_list(frame, server_module) do 49 | prompts = server_module.__components__(:prompt) 50 | response = %{"prompts" => Enum.map(prompts, &parse_prompt_definition/1)} 51 | 52 | {:reply, response, frame} 53 | end 54 | 55 | @doc """ 56 | Handles the prompts/get request to retrieve messages for a specific prompt. 57 | 58 | ## Parameters 59 | 60 | - `request` - The MCP request containing prompt name and arguments 61 | - `frame` - The server frame 62 | - `server_module` - The server module implementing prompt components 63 | 64 | ## Returns 65 | 66 | - `{:reply, result, frame}` - Generated messages from the prompt 67 | - `{:error, error, frame}` - If prompt not found or generation fails 68 | """ 69 | @spec handle_get(map(), Frame.t(), module()) :: 70 | {:reply, map(), Frame.t()} | {:error, Error.t(), Frame.t()} 71 | def handle_get(%{"params" => %{"name" => prompt_name, "arguments" => params}}, frame, server_module) do 72 | registered_prompts = server_module.__components__(:prompt) 73 | 74 | if prompt = find_prompt_module(registered_prompts, prompt_name) do 75 | with {:ok, params} <- validate_params(params, prompt, frame), do: forward_to(prompt, params, frame) 76 | else 77 | payload = %{message: "Prompt not found: #{prompt_name}"} 78 | {:error, Error.protocol(:invalid_params, payload), frame} 79 | end 80 | end 81 | 82 | # Private functions 83 | 84 | defp find_prompt_module(prompts, name) do 85 | Enum.find_value(prompts, fn 86 | {^name, module} -> module 87 | _ -> nil 88 | end) 89 | end 90 | 91 | defp parse_prompt_definition({name, module}) do 92 | %{ 93 | "name" => name, 94 | "description" => Component.get_description(module), 95 | "arguments" => module.arguments() 96 | } 97 | end 98 | 99 | defp validate_params(params, module, frame) do 100 | with {:error, errors} <- module.mcp_schema(params) do 101 | message = Schema.format_errors(errors) 102 | {:error, Error.protocol(:invalid_params, %{message: message}), frame} 103 | end 104 | end 105 | 106 | defp forward_to(module, params, frame) do 107 | case module.get_messages(params, frame) do 108 | {:reply, %Response{} = response, frame} -> 109 | {:reply, Response.to_protocol(response), frame} 110 | 111 | {:noreply, frame} -> 112 | {:reply, %{"content" => [], "isError" => false}, frame} 113 | 114 | {:error, %Error{} = error, frame} -> 115 | {:error, error, frame} 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/hermes/server/handlers/tools.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.Handlers.Tools do 2 | @moduledoc """ 3 | Handles MCP protocol tool-related methods. 4 | 5 | This module processes: 6 | - `tools/list` - Lists available tools with optional pagination 7 | - `tools/call` - Executes a specific tool with given arguments 8 | 9 | ## Pagination Support 10 | 11 | The `tools/list` method supports pagination through cursor parameters: 12 | 13 | # Request 14 | %{"method" => "tools/list", "params" => %{"cursor" => "optional-cursor"}} 15 | 16 | # Response with more results 17 | %{ 18 | "tools" => [...], 19 | "nextCursor" => "next-page-cursor" 20 | } 21 | 22 | # Response for last page 23 | %{"tools" => [...]} 24 | """ 25 | 26 | alias Hermes.MCP.Error 27 | alias Hermes.Server.Component 28 | alias Hermes.Server.Component.Schema 29 | alias Hermes.Server.Frame 30 | alias Hermes.Server.Response 31 | 32 | @doc """ 33 | Handles the tools/list request with optional pagination. 34 | 35 | ## Parameters 36 | 37 | - `request` - The MCP request containing optional cursor in params 38 | - `frame` - The server frame 39 | - `server_module` - The server module implementing tool components 40 | 41 | ## Returns 42 | 43 | - `{:reply, result, frame}` - List of tools with optional nextCursor 44 | - `{:error, error, frame}` - If pagination cursor is invalid 45 | """ 46 | @spec handle_list(Frame.t(), module()) :: 47 | {:reply, map(), Frame.t()} | {:error, Error.t(), Frame.t()} 48 | def handle_list(frame, server_module) do 49 | tools = server_module.__components__(:tool) 50 | response = %{"tools" => Enum.map(tools, &parse_tool_definition/1)} 51 | 52 | {:reply, response, frame} 53 | end 54 | 55 | @doc """ 56 | Handles the tools/call request to execute a specific tool. 57 | 58 | ## Parameters 59 | 60 | - `request` - The MCP request containing tool name and arguments 61 | - `frame` - The server frame 62 | - `server_module` - The server module implementing tool components 63 | 64 | ## Returns 65 | 66 | - `{:reply, result, frame}` - Tool execution result 67 | - `{:error, error, frame}` - If tool not found or execution fails 68 | """ 69 | @spec handle_call(map(), Frame.t(), module()) :: 70 | {:reply, map(), Frame.t()} | {:error, Error.t(), Frame.t()} 71 | def handle_call(%{"params" => %{"name" => tool_name, "arguments" => params}}, frame, server_module) do 72 | registered_tools = server_module.__components__(:tool) 73 | 74 | if tool = find_tool_module(registered_tools, tool_name) do 75 | with {:ok, params} <- validate_params(params, tool, frame), do: forward_to(tool, params, frame) 76 | else 77 | payload = %{message: "Tool not found: #{tool_name}"} 78 | {:error, Error.protocol(:invalid_params, payload), frame} 79 | end 80 | end 81 | 82 | # Private functions 83 | 84 | defp find_tool_module(tools, name) do 85 | Enum.find_value(tools, fn 86 | {^name, module} -> module 87 | _ -> nil 88 | end) 89 | end 90 | 91 | defp parse_tool_definition({name, module}) do 92 | %{ 93 | "name" => name, 94 | "description" => Component.get_description(module), 95 | "inputSchema" => module.input_schema() 96 | } 97 | end 98 | 99 | defp validate_params(params, module, frame) do 100 | with {:error, errors} <- module.mcp_schema(params) do 101 | message = Schema.format_errors(errors) 102 | {:error, Error.protocol(:invalid_params, %{message: message}), frame} 103 | end 104 | end 105 | 106 | defp forward_to(module, params, frame) do 107 | case module.execute(params, frame) do 108 | {:reply, %Response{} = response, frame} -> 109 | {:reply, Response.to_protocol(response), frame} 110 | 111 | {:noreply, frame} -> 112 | {:reply, %{"content" => [], "isError" => false}, frame} 113 | 114 | {:error, %Error{} = error, frame} -> 115 | {:error, error, frame} 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/hermes/server/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.Registry do 2 | @moduledoc """ 3 | Registry for MCP server and transport processes. 4 | 5 | This module provides a safe way to manage process names without creating 6 | atoms dynamically at runtime. It uses a via tuple pattern with Registry. 7 | 8 | ## Usage 9 | 10 | # Register a server process 11 | {:ok, _pid} = GenServer.start_link(MyServer, arg, name: Registry.server(MyModule)) 12 | 13 | # Register a transport process 14 | {:ok, _pid} = GenServer.start_link(Transport, arg, name: Registry.transport(MyModule, :stdio)) 15 | 16 | # Look up a process 17 | GenServer.call(Registry.server(MyModule), :ping) 18 | """ 19 | 20 | def child_spec(_) do 21 | Registry.child_spec(keys: :unique, name: __MODULE__) 22 | end 23 | 24 | @doc """ 25 | Returns a via tuple for naming a server process. 26 | """ 27 | @spec server(server_module :: module()) :: GenServer.name() 28 | def server(module) do 29 | {:via, Registry, {__MODULE__, {:server, module}}} 30 | end 31 | 32 | @doc """ 33 | Returns a via tuple for naming a server session process. 34 | """ 35 | @spec server_session(server_module :: module(), session_id :: String.t()) :: GenServer.name() 36 | def server_session(server, session_id) do 37 | {:via, Registry, {__MODULE__, {:session, server, session_id}}} 38 | end 39 | 40 | @doc """ 41 | Returns a via tuple for naming a transport process. 42 | """ 43 | @spec transport(server_module :: module(), transport_type :: atom()) :: GenServer.name() 44 | def transport(module, type) when is_atom(module) do 45 | {:via, Registry, {__MODULE__, {:transport, module, type}}} 46 | end 47 | 48 | @doc """ 49 | Returns a via tuple for naming a supervisor process. 50 | """ 51 | @spec supervisor(kind :: atom(), server_module :: module()) :: GenServer.name() 52 | def supervisor(kind \\ :supervisor, module) do 53 | {:via, Registry, {__MODULE__, {kind, module}}} 54 | end 55 | 56 | @doc """ 57 | Gets the PID of a session-specific server. 58 | """ 59 | @spec whereis_server_session(server_module :: module(), session_id :: String.t()) :: pid | nil 60 | def whereis_server_session(module, session_id) do 61 | case Registry.lookup(__MODULE__, {:session, module, session_id}) do 62 | [{pid, _}] -> pid 63 | [] -> nil 64 | end 65 | end 66 | 67 | @doc """ 68 | Gets the PID of a supervisor process. 69 | """ 70 | @spec whereis_supervisor(atom(), module()) :: pid() | nil 71 | def whereis_supervisor(server, kind \\ :supervisor) when is_atom(server) do 72 | case Registry.lookup(__MODULE__, {kind, server}) do 73 | [{pid, _}] -> pid 74 | [] -> nil 75 | end 76 | end 77 | 78 | @doc """ 79 | Gets the PID of a registered server. 80 | """ 81 | @spec whereis_server(module()) :: pid | nil 82 | def whereis_server(module) when is_atom(module) do 83 | case Registry.lookup(__MODULE__, {:server, module}) do 84 | [{pid, _}] -> pid 85 | [] -> nil 86 | end 87 | end 88 | 89 | @doc """ 90 | Gets the PID of a registered transport. 91 | """ 92 | @spec whereis_transport(module(), atom()) :: pid | nil 93 | def whereis_transport(module, type) when is_atom(module) and is_atom(type) do 94 | case Registry.lookup(__MODULE__, {:transport, module, type}) do 95 | [{pid, _}] -> pid 96 | [] -> nil 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/hermes/sse/event.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.SSE.Event do 2 | @moduledoc """ 3 | Represents a Server-Sent Event. 4 | 5 | Fields: 6 | - `:id` - identifier of the event (if any) 7 | - `:event` - event type (defaults to "message") 8 | - `:data` - the event data (concatenates multiple data lines with a newline) 9 | - `:retry` - reconnection time (parsed as integer, if provided) 10 | """ 11 | 12 | @type t :: %__MODULE__{ 13 | id: String.t() | nil, 14 | event: String.t(), 15 | data: String.t(), 16 | retry: integer() | nil 17 | } 18 | 19 | defstruct id: nil, event: "message", data: "", retry: nil 20 | 21 | @doc """ 22 | Encodes SSE events into the wire format. 23 | 24 | ## Examples 25 | 26 | iex> event = %Hermes.SSE.Event{data: "hello"} 27 | iex> inspect(event) 28 | "event: message\\ndata: hello\\n\\n" 29 | 30 | iex> event = %Hermes.SSE.Event{id: "123", event: "ping", data: "pong"} 31 | iex> inspect(event) 32 | "id: 123\\nevent: ping\\ndata: pong\\n\\n" 33 | """ 34 | def encode(%__MODULE__{} = event) do 35 | event 36 | |> build_fields() 37 | |> Enum.join() 38 | end 39 | 40 | defp build_fields(event) do 41 | [] 42 | |> maybe_add_field("id", event.id) 43 | |> maybe_add_field("event", event.event) 44 | |> maybe_add_field("retry", event.retry) 45 | |> add_data_field(event.data) 46 | |> add_terminator() 47 | end 48 | 49 | defp maybe_add_field(fields, _name, nil), do: fields 50 | defp maybe_add_field(fields, _name, ""), do: fields 51 | 52 | defp maybe_add_field(fields, name, value) do 53 | fields ++ ["#{name}: #{value}\n"] 54 | end 55 | 56 | defp add_data_field(fields, ""), do: fields 57 | 58 | defp add_data_field(fields, data) when is_binary(data) do 59 | data 60 | |> String.split("\n") 61 | |> Enum.reduce(fields, fn line, acc -> 62 | acc ++ ["data: #{line}\n"] 63 | end) 64 | end 65 | 66 | defp add_terminator(fields), do: fields ++ ["\n"] 67 | end 68 | -------------------------------------------------------------------------------- /lib/hermes/sse/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.SSE.Parser do 2 | @moduledoc """ 3 | Parses a raw SSE stream into a list of `%SSE.Event{}` structs. 4 | """ 5 | 6 | alias Hermes.SSE.Event 7 | 8 | @doc """ 9 | Parses a string containing one or more SSE events. 10 | 11 | Each event is separated by an empty line (two consecutive newlines). 12 | Returns a list of `%SSE.Event{}` structs. 13 | """ 14 | def run(sse_data) when is_binary(sse_data) do 15 | sse_data 16 | |> String.split(~r/\r?\n\r?\n/, trim: true) 17 | |> Enum.map(&parse_event/1) 18 | |> Enum.reject(&(&1.data == "")) 19 | end 20 | 21 | defp parse_event(event_block) do 22 | event_block 23 | |> String.split(~r/\r?\n/) 24 | |> Enum.reduce(%Event{}, &parse_event_line/2) 25 | end 26 | 27 | defp parse_event_line("", event), do: event 28 | # ignore SSE comments 29 | defp parse_event_line(<<":", _rest::binary>>, event), do: event 30 | 31 | defp parse_event_line(line, event) do 32 | case String.split(line, ":", parts: 2) do 33 | ["id", value] -> %{event | id: String.trim_leading(value)} 34 | ["event", value] -> %{event | event: String.trim_leading(value)} 35 | ["data", value] -> handle_data(event, String.trim_leading(value)) 36 | ["retry", value] -> handle_retry(event, String.trim_leading(value)) 37 | [_, _] -> event 38 | [_] -> event 39 | end 40 | end 41 | 42 | defp handle_data(%Event{data: ""} = event, data) do 43 | %{event | data: data} 44 | end 45 | 46 | defp handle_data(%Event{data: current_data} = event, data) do 47 | %{event | data: current_data <> "\n" <> data} 48 | end 49 | 50 | defp handle_retry(%Event{retry: _} = event, value) do 51 | case Integer.parse(value) do 52 | {retry, _} -> %{event | retry: retry} 53 | :error -> event 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/hermes/sse/streaming.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.SSE.Streaming do 2 | @moduledoc """ 3 | Handles Server-Sent Events streaming for MCP connections. 4 | 5 | This module provides the core SSE streaming functionality, 6 | managing the event loop and message formatting. 7 | """ 8 | 9 | alias Hermes.Logging 10 | alias Hermes.SSE.Event 11 | 12 | @type conn :: Plug.Conn.t() 13 | @type transport :: GenServer.server() 14 | @type session_id :: String.t() 15 | 16 | @doc """ 17 | Starts the SSE streaming loop for a connection. 18 | 19 | This function takes control of the connection and enters a receive loop, 20 | streaming messages to the client as they arrive. 21 | 22 | ## Parameters 23 | - `conn` - The Plug.Conn that has been prepared for chunked response 24 | - `transport` - The transport process 25 | - `session_id` - The session identifier 26 | - `opts` - Options including: 27 | - `:initial_event_id` - Starting event ID (default: 0) 28 | - `:on_close` - Function to call when connection closes 29 | 30 | ## Messages handled 31 | - `{:sse_message, binary}` - Message to send to client 32 | - `:close_sse` - Close the connection gracefully 33 | """ 34 | @spec start(conn, transport, session_id, keyword()) :: conn 35 | def start(conn, transport, session_id, opts \\ []) do 36 | initial_event_id = Keyword.get(opts, :initial_event_id, 0) 37 | on_close = Keyword.get(opts, :on_close, fn -> :ok end) 38 | 39 | try do 40 | loop(conn, transport, session_id, initial_event_id) 41 | after 42 | on_close.() 43 | end 44 | end 45 | 46 | @doc """ 47 | Prepares a connection for SSE streaming. 48 | 49 | Sets appropriate headers and starts chunked response. 50 | """ 51 | @spec prepare_connection(conn) :: conn 52 | def prepare_connection(conn) do 53 | conn 54 | |> Plug.Conn.put_resp_content_type("text/event-stream") 55 | |> Plug.Conn.put_resp_header("cache-control", "no-cache") 56 | |> Plug.Conn.put_resp_header("x-accel-buffering", "no") 57 | |> Plug.Conn.send_chunked(200) 58 | end 59 | 60 | @doc """ 61 | Sends a single SSE event. 62 | 63 | This is useful for sending events outside of the main loop. 64 | """ 65 | @spec send_event(conn, binary(), non_neg_integer()) :: {:ok, conn} | {:error, term()} 66 | def send_event(conn, data, event_id) when is_binary(data) do 67 | event = %Event{ 68 | id: to_string(event_id), 69 | event: "message", 70 | data: data 71 | } 72 | 73 | case Plug.Conn.chunk(conn, Event.encode(event)) do 74 | {:ok, conn} -> {:ok, conn} 75 | {:error, reason} -> {:error, reason} 76 | end 77 | end 78 | 79 | # Private functions 80 | 81 | defp loop(conn, transport, session_id, event_counter) do 82 | receive do 83 | {:sse_message, message} when is_binary(message) -> 84 | case send_event(conn, message, event_counter) do 85 | {:ok, conn} -> 86 | loop(conn, transport, session_id, event_counter + 1) 87 | 88 | {:error, reason} -> 89 | Logging.transport_event( 90 | "sse_send_failed", 91 | %{ 92 | session_id: session_id, 93 | reason: reason 94 | }, 95 | level: :error 96 | ) 97 | 98 | conn 99 | end 100 | 101 | :close_sse -> 102 | Logging.transport_event("sse_closing", %{session_id: session_id}) 103 | Plug.Conn.halt(conn) 104 | 105 | {:plug_conn, :sent} -> 106 | # Ignore Plug internal messages 107 | loop(conn, transport, session_id, event_counter) 108 | 109 | msg -> 110 | Logging.transport_event( 111 | "sse_unknown_message", 112 | %{ 113 | session_id: session_id, 114 | message: inspect(msg) 115 | }, 116 | level: :warning 117 | ) 118 | 119 | loop(conn, transport, session_id, event_counter) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/hermes/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Telemetry do 2 | @moduledoc """ 3 | Telemetry integration for Hermes MCP. 4 | 5 | This module defines telemetry events emitted by Hermes MCP and provides 6 | helper functions for emitting events consistently across the codebase. 7 | 8 | ## Event Naming Convention 9 | 10 | All telemetry events emitted by Hermes MCP follow the namespace pattern: 11 | `[:hermes_mcp, component, action]` 12 | 13 | Where: 14 | - `:hermes_mcp` is the root namespace 15 | - `component` is the specific component emitting the event (e.g., `:client`, `:transport`) 16 | - `action` is the specific action or lifecycle event (e.g., `:init`, `:request`, `:response`) 17 | 18 | ## Span Events 19 | 20 | Many operations in Hermes MCP emit span events using `:telemetry.span/3`, which 21 | generates three potential events: 22 | - `[..., :start]` - When the operation begins 23 | - `[..., :stop]` - When the operation completes successfully 24 | - `[..., :exception]` - When the operation fails with an exception 25 | 26 | ## Example 27 | 28 | ```elixir 29 | :telemetry.attach( 30 | "log-client-requests", 31 | [:hermes_mcp, :client, :request, :stop], 32 | fn _event, %{duration: duration}, %{method: method}, _config -> 33 | Logger.info("Request to \#{method} completed in \#{div(duration, 1_000_000)} ms") 34 | end, 35 | nil 36 | ) 37 | ``` 38 | """ 39 | 40 | @doc """ 41 | Execute a telemetry event with the Hermes MCP namespace. 42 | 43 | ## Parameters 44 | - `event_name` - List of atoms for the event name, excluding the :hermes_mcp prefix 45 | - `measurements` - Map of measurements for the event 46 | - `metadata` - Map of metadata for the event 47 | """ 48 | @spec execute(list(atom()), map(), map()) :: :ok 49 | def execute(event_name, measurements, metadata) do 50 | :telemetry.execute([:hermes_mcp | event_name], measurements, metadata) 51 | end 52 | 53 | # Define event name constants to ensure consistency 54 | 55 | # Client events 56 | def event_client_init, do: [:client, :init] 57 | def event_client_request, do: [:client, :request] 58 | def event_client_response, do: [:client, :response] 59 | def event_client_terminate, do: [:client, :terminate] 60 | def event_client_error, do: [:client, :error] 61 | 62 | # Server events 63 | def event_server_init, do: [:server, :init] 64 | def event_server_request, do: [:server, :request] 65 | def event_server_response, do: [:server, :response] 66 | def event_server_notification, do: [:server, :notification] 67 | def event_server_error, do: [:server, :error] 68 | def event_server_terminate, do: [:server, :terminate] 69 | def event_server_tool_call, do: [:server, :tool_call] 70 | def event_server_resource_read, do: [:server, :resource_read] 71 | def event_server_prompt_get, do: [:server, :prompt_get] 72 | 73 | # Transport events 74 | def event_transport_init, do: [:transport, :init] 75 | def event_transport_connect, do: [:transport, :connect] 76 | def event_transport_send, do: [:transport, :send] 77 | def event_transport_receive, do: [:transport, :receive] 78 | def event_transport_disconnect, do: [:transport, :disconnect] 79 | def event_transport_error, do: [:transport, :error] 80 | def event_transport_terminate, do: [:transport, :terminate] 81 | 82 | # Message events 83 | def event_message_encode, do: [:message, :encode] 84 | def event_message_decode, do: [:message, :decode] 85 | 86 | # Progress events 87 | def event_progress_update, do: [:progress, :update] 88 | 89 | # Roots events 90 | def event_client_roots, do: [:client, :roots] 91 | 92 | # Session events (for StreamableHTTP transport) 93 | def event_server_session_created, do: [:server, :session, :created] 94 | def event_server_session_terminated, do: [:server, :session, :terminated] 95 | def event_server_session_cleanup, do: [:server, :session, :cleanup] 96 | end 97 | -------------------------------------------------------------------------------- /lib/hermes/transport/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Transport.Behaviour do 2 | @moduledoc """ 3 | Defines the behavior that all transport implementations must follow. 4 | """ 5 | 6 | alias Hermes.MCP.Error 7 | 8 | @type t :: GenServer.server() 9 | @typedoc "The JSON-RPC message encoded" 10 | @type message :: String.t() 11 | @type reason :: term() | Error.t() 12 | 13 | @callback start_link(keyword()) :: GenServer.on_start() 14 | @callback send_message(t(), message()) :: :ok | {:error, reason()} 15 | @callback shutdown(t()) :: :ok | {:error, reason()} 16 | 17 | @doc """ 18 | Returns the list of MCP protocol versions supported by this transport. 19 | 20 | ## Examples 21 | 22 | iex> MyTransport.supported_protocol_versions() 23 | ["2024-11-05", "2025-03-26"] 24 | """ 25 | @callback supported_protocol_versions() :: [String.t()] 26 | end 27 | -------------------------------------------------------------------------------- /lib/mix/interactive/shell.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Interactive.Shell do 2 | @moduledoc """ 3 | Base functionality for interactive MCP shells. 4 | 5 | This module acts as the core command loop for interactive MCP shells, 6 | providing a consistent interface and user experience across different 7 | transport implementations (SSE, STDIO). 8 | 9 | It handles basic functionality like reading user input and delegating 10 | command processing to the appropriate handlers. 11 | """ 12 | 13 | alias Mix.Interactive.Commands 14 | alias Mix.Interactive.UI 15 | 16 | @doc """ 17 | Main command loop for interactive shells. 18 | """ 19 | def loop(client) do 20 | IO.write("#{UI.colors().prompt}mcp> #{UI.colors().reset}") 21 | 22 | "" 23 | |> IO.gets() 24 | |> String.trim() 25 | |> Commands.process_command(client, fn -> loop(client) end) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/mix/tasks/sse.interactive.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Hermes.Sse.Interactive do 2 | @shortdoc "Test the SSE transport implementation interactively." 3 | 4 | @moduledoc """ 5 | Mix task to test the SSE transport implementation, interactively sending commands. 6 | 7 | ## Options 8 | 9 | * `--base-url` - Base URL for the SSE server (default: http://localhost:8000) 10 | * `--base-path` - Base path to append to the base URL 11 | * `--sse-path` - Specific SSE endpoint path 12 | """ 13 | 14 | use Mix.Task 15 | 16 | alias Hermes.Transport.SSE 17 | alias Mix.Interactive.SupervisedShell 18 | alias Mix.Interactive.UI 19 | 20 | @switches [ 21 | base_url: :string, 22 | base_path: :string, 23 | sse_path: :string, 24 | verbose: :count 25 | ] 26 | 27 | def run(args) do 28 | # Start required applications without requiring a project 29 | Application.ensure_all_started([:hermes_mcp, :peri]) 30 | 31 | # Parse arguments and set log level 32 | {parsed, _} = 33 | OptionParser.parse!(args, 34 | strict: @switches, 35 | aliases: [v: :verbose] 36 | ) 37 | 38 | verbose_count = parsed[:verbose] || 0 39 | log_level = get_log_level(verbose_count) 40 | configure_logger(log_level) 41 | 42 | base_url = parsed[:base_url] || "http://localhost:8000" 43 | base_path = parsed[:base_path] || "/" 44 | sse_path = parsed[:sse_path] || "/sse" 45 | 46 | if base_url == "" do 47 | IO.puts("#{UI.colors().error}Error: --base-url cannot be empty#{UI.colors().reset}") 48 | IO.puts("Please provide a valid URL, e.g., --base-url=http://localhost:8000") 49 | System.halt(1) 50 | end 51 | 52 | server_url = base_url |> URI.merge(base_path) |> URI.to_string() 53 | 54 | header = UI.header("HERMES MCP SSE INTERACTIVE") 55 | IO.puts(header) 56 | IO.puts("#{UI.colors().info}Connecting to SSE server at: #{server_url}#{UI.colors().reset}\n") 57 | 58 | SupervisedShell.start( 59 | transport_module: SSE, 60 | transport_opts: [ 61 | name: SSE, 62 | client: :sse_test, 63 | server: [ 64 | base_url: base_url, 65 | base_path: base_path, 66 | sse_path: sse_path 67 | ] 68 | ], 69 | client_opts: [ 70 | name: :sse_test, 71 | transport: [layer: SSE, name: SSE], 72 | protocol_version: "2024-11-05", 73 | client_info: %{ 74 | "name" => "Mix.Tasks.SSE", 75 | "version" => "1.0.0" 76 | }, 77 | capabilities: %{ 78 | "tools" => %{}, 79 | "sampling" => %{} 80 | } 81 | ] 82 | ) 83 | end 84 | 85 | # Helper functions 86 | defp get_log_level(count) do 87 | case count do 88 | 0 -> :error 89 | 1 -> :warning 90 | 2 -> :info 91 | _ -> :debug 92 | end 93 | end 94 | 95 | defp configure_logger(log_level) do 96 | metadata = Logger.metadata() 97 | Logger.configure(level: log_level) 98 | Logger.metadata(metadata) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/mix/tasks/stdio.interactive.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Hermes.Stdio.Interactive do 2 | @shortdoc "Test the STDIO transport implementation interactively." 3 | 4 | @moduledoc """ 5 | Mix task to test the STDIO transport implementation, interactively sending commands. 6 | 7 | ## Options 8 | 9 | * `--command` - Command to execute for the STDIO transport (default: "mcp") 10 | * `--args` - Comma-separated arguments for the command (default: "run,priv/dev/echo/index.py") 11 | """ 12 | 13 | use Mix.Task 14 | 15 | alias Hermes.Transport.STDIO 16 | alias Mix.Interactive.SupervisedShell 17 | alias Mix.Interactive.UI 18 | 19 | @switches [ 20 | command: :string, 21 | args: :string, 22 | verbose: :count 23 | ] 24 | 25 | def run(args) do 26 | # Start required applications without requiring a project 27 | Application.ensure_all_started(:hermes_mcp) 28 | 29 | # Parse arguments and set log level 30 | {parsed, _} = 31 | OptionParser.parse!(args, 32 | strict: @switches, 33 | aliases: [c: :command, v: :verbose] 34 | ) 35 | 36 | verbose_count = parsed[:verbose] || 0 37 | log_level = get_log_level(verbose_count) 38 | configure_logger(log_level) 39 | 40 | cmd = parsed[:command] || "mcp" 41 | args = String.split(parsed[:args] || "run,priv/dev/echo/index.py", ",", trim: true) 42 | 43 | header = UI.header("HERMES MCP STDIO INTERACTIVE") 44 | IO.puts(header) 45 | IO.puts("#{UI.colors().info}Starting STDIO interaction MCP server#{UI.colors().reset}\n") 46 | 47 | if cmd == "mcp" and not (!!System.find_executable("mcp")) do 48 | IO.puts( 49 | "#{UI.colors().error}Error: mcp executable not found in PATH, maybe you need to activate venv#{UI.colors().reset}" 50 | ) 51 | 52 | System.halt(1) 53 | end 54 | 55 | SupervisedShell.start( 56 | transport_module: STDIO, 57 | transport_opts: [ 58 | name: STDIO, 59 | command: cmd, 60 | args: args, 61 | client: :stdio_test 62 | ], 63 | client_opts: [ 64 | name: :stdio_test, 65 | transport: [layer: STDIO, name: STDIO], 66 | client_info: %{ 67 | "name" => "Mix.Tasks.STDIO", 68 | "version" => "1.0.0" 69 | }, 70 | capabilities: %{ 71 | "roots" => %{ 72 | "listChanged" => true 73 | }, 74 | "sampling" => %{} 75 | } 76 | ] 77 | ) 78 | end 79 | 80 | # Helper functions 81 | defp get_log_level(count) do 82 | case count do 83 | 0 -> :error 84 | 1 -> :warning 85 | 2 -> :info 86 | _ -> :debug 87 | end 88 | end 89 | 90 | defp configure_logger(log_level) do 91 | metadata = Logger.metadata() 92 | Logger.configure(level: log_level) 93 | Logger.metadata(metadata) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/mix/tasks/streamable_http.interactive.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Hermes.StreamableHttp.Interactive do 2 | @shortdoc "Test the Streamable HTTP transport implementation interactively." 3 | 4 | @moduledoc """ 5 | Mix task to test the Streamable HTTP transport implementation, interactively sending commands. 6 | 7 | ## Options 8 | 9 | * `--base-url` - Base URL for the MCP server (default: http://localhost:8000) 10 | * `--mcp-path` - MCP endpoint path (default: /mcp) 11 | """ 12 | 13 | use Mix.Task 14 | 15 | alias Hermes.Transport.StreamableHTTP 16 | alias Mix.Interactive.SupervisedShell 17 | alias Mix.Interactive.UI 18 | 19 | @switches [ 20 | base_url: :string, 21 | mcp_path: :string, 22 | verbose: :count 23 | ] 24 | 25 | def run(args) do 26 | # Start required applications without requiring a project 27 | Application.ensure_all_started([:hermes_mcp, :peri]) 28 | 29 | {parsed, _} = 30 | OptionParser.parse!(args, 31 | strict: @switches, 32 | aliases: [v: :verbose] 33 | ) 34 | 35 | verbose_count = parsed[:verbose] || 0 36 | log_level = get_log_level(verbose_count) 37 | configure_logger(log_level) 38 | 39 | base_url = parsed[:base_url] || "http://localhost:8000" 40 | mcp_path = parsed[:mcp_path] || "/mcp" 41 | server_url = Path.join(base_url, mcp_path) 42 | 43 | header = UI.header("HERMES MCP STREAMABLE HTTP INTERACTIVE") 44 | IO.puts(header) 45 | IO.puts("#{UI.colors().info}Connecting to Streamable HTTP server at: #{server_url}#{UI.colors().reset}\n") 46 | 47 | SupervisedShell.start( 48 | transport_module: StreamableHTTP, 49 | transport_opts: [ 50 | name: StreamableHTTP, 51 | client: :streamable_http_test, 52 | base_url: base_url, 53 | mcp_path: mcp_path 54 | ], 55 | client_opts: [ 56 | name: :streamable_http_test, 57 | transport: [layer: StreamableHTTP, name: StreamableHTTP], 58 | protocol_version: "2025-03-26", 59 | client_info: %{ 60 | "name" => "Mix.Tasks.StreamableHTTP", 61 | "version" => "1.0.0" 62 | }, 63 | capabilities: %{ 64 | "roots" => %{ 65 | "listChanged" => true 66 | }, 67 | "tools" => %{}, 68 | "sampling" => %{} 69 | } 70 | ] 71 | ) 72 | end 73 | 74 | # Helper functions 75 | defp get_log_level(count) do 76 | case count do 77 | 0 -> :error 78 | 1 -> :warning 79 | 2 -> :info 80 | _ -> :debug 81 | end 82 | end 83 | 84 | defp configure_logger(log_level) do 85 | metadata = Logger.metadata() 86 | Logger.configure(level: log_level) 87 | Logger.metadata(metadata) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/mix/tasks/websocket.interactive.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:gun) do 2 | defmodule Mix.Tasks.Hermes.Websocket.Interactive do 3 | @shortdoc "Test the WebSocket transport implementation interactively." 4 | 5 | @moduledoc """ 6 | Mix task to test the WebSocket transport implementation, interactively sending commands. 7 | 8 | ## Options 9 | 10 | * `--base-url` - Base URL for the WebSocket server (default: http://localhost:8000) 11 | * `--base-path` - Base path to append to the base URL 12 | * `--ws-path` - Specific WebSocket endpoint path (default: /ws) 13 | """ 14 | 15 | use Mix.Task 16 | 17 | alias Hermes.Transport.WebSocket 18 | alias Mix.Interactive.SupervisedShell 19 | alias Mix.Interactive.UI 20 | 21 | @switches [ 22 | base_url: :string, 23 | base_path: :string, 24 | ws_path: :string, 25 | verbose: :count 26 | ] 27 | 28 | def run(args) do 29 | # Start required applications without requiring a project 30 | Application.ensure_all_started([:hermes_mcp, :peri, :gun]) 31 | 32 | # Parse arguments and set log level 33 | {parsed, _} = 34 | OptionParser.parse!(args, 35 | strict: @switches, 36 | aliases: [v: :verbose] 37 | ) 38 | 39 | verbose_count = parsed[:verbose] || 0 40 | log_level = get_log_level(verbose_count) 41 | configure_logger(log_level) 42 | 43 | server_options = Keyword.put_new(parsed, :base_url, "http://localhost:8000") 44 | server_url = Path.join(server_options[:base_url], server_options[:base_path] || "") 45 | 46 | header = UI.header("HERMES MCP WEBSOCKET INTERACTIVE") 47 | IO.puts(header) 48 | IO.puts("#{UI.colors().info}Connecting to WebSocket server at: #{server_url}#{UI.colors().reset}\n") 49 | 50 | SupervisedShell.start( 51 | transport_module: WebSocket, 52 | transport_opts: [ 53 | name: WebSocket, 54 | client: :websocket_test, 55 | server: server_options 56 | ], 57 | client_opts: [ 58 | name: :websocket_test, 59 | transport: [layer: WebSocket, name: WebSocket], 60 | client_info: %{ 61 | "name" => "Mix.Tasks.WebSocket", 62 | "version" => "1.0.0" 63 | }, 64 | capabilities: %{ 65 | "tools" => %{}, 66 | "sampling" => %{} 67 | } 68 | ] 69 | ) 70 | end 71 | 72 | # Helper functions 73 | defp get_log_level(count) do 74 | case count do 75 | 0 -> :error 76 | 1 -> :warning 77 | 2 -> :info 78 | _ -> :debug 79 | end 80 | end 81 | 82 | defp configure_logger(log_level) do 83 | metadata = Logger.metadata() 84 | Logger.configure(level: log_level) 85 | Logger.metadata(metadata) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /pages/cli_usage.md: -------------------------------------------------------------------------------- 1 | # Interactive CLI 2 | 3 | Test and debug MCP servers with Hermes interactive CLI. 4 | 5 | ## Available CLIs 6 | 7 | - **STDIO Interactive** - For local subprocess servers 8 | - **StreamableHTTP Interactive** - For HTTP/SSE servers 9 | - **WebSocket Interactive** - For WebSocket servers 10 | - **SSE Interactive** - For legacy SSE servers 11 | 12 | ## Quick Start 13 | 14 | ### STDIO Server 15 | 16 | ```shell 17 | mix hermes.stdio.interactive --command=python --args=-m,mcp.server,my_server.py 18 | ``` 19 | 20 | ### HTTP Server 21 | 22 | ```shell 23 | mix hermes.streamable_http.interactive --base-url=http://localhost:8080 24 | ``` 25 | 26 | ### WebSocket Server 27 | 28 | ```shell 29 | mix hermes.websocket.interactive --base-url=ws://localhost:8081 30 | ``` 31 | 32 | ## Common Options 33 | 34 | | Option | Description | 35 | |--------|-------------| 36 | | `-h, --help` | Show help | 37 | | `-v` | Increase verbosity (can stack: `-vvv`) | 38 | 39 | Verbosity levels: 40 | - No flag: Errors only 41 | - `-v`: + Warnings 42 | - `-vv`: + Info 43 | - `-vvv`: + Debug 44 | 45 | ## Interactive Commands 46 | 47 | | Command | Description | 48 | |---------|-------------| 49 | | `help` | Show available commands | 50 | | `ping` | Check server connection | 51 | | `list_tools` | List available tools | 52 | | `call_tool` | Call a tool | 53 | | `list_prompts` | List prompts | 54 | | `get_prompt` | Get prompt messages | 55 | | `list_resources` | List resources | 56 | | `read_resource` | Read resource content | 57 | | `show_state` | Debug state info | 58 | | `clear` | Clear screen | 59 | | `exit` | Exit CLI | 60 | 61 | ## Examples 62 | 63 | ### Test Local Server 64 | 65 | ```shell 66 | # Start interactive session 67 | mix hermes.stdio.interactive --command=./my-server 68 | 69 | # In the CLI 70 | mcp> ping 71 | pong 72 | 73 | mcp> list_tools 74 | Available tools: 75 | - calculator: Perform calculations 76 | - file_reader: Read files 77 | 78 | mcp> call_tool 79 | Tool name: calculator 80 | Tool arguments (JSON): {"operation": "add", "a": 5, "b": 3} 81 | Result: 8 82 | ``` 83 | 84 | ### Debug Connection 85 | 86 | ```shell 87 | # Verbose mode 88 | mix hermes.streamable_http.interactive -vvv --base-url=http://api.example.com 89 | 90 | mcp> show_state 91 | Client State: 92 | Protocol: 2025-03-26 93 | Initialized: true 94 | Capabilities: %{"tools" => %{}} 95 | 96 | Transport State: 97 | Type: StreamableHTTP 98 | URL: http://api.example.com 99 | Connected: true 100 | ``` 101 | 102 | ### Test Tool Execution 103 | 104 | ``` 105 | mcp> list_tools 106 | mcp> call_tool 107 | Tool name: search 108 | Tool arguments (JSON): {"query": "elixir"} 109 | ``` 110 | 111 | ## Transport-Specific Options 112 | 113 | ### STDIO 114 | 115 | | Option | Description | Default | 116 | |--------|-------------|---------| 117 | | `--command` | Command to run | `mcp` | 118 | | `--args` | Comma-separated args | none | 119 | 120 | ### StreamableHTTP 121 | 122 | | Option | Description | Default | 123 | |--------|-------------|---------| 124 | | `--base-url` | Server URL | `http://localhost:8080` | 125 | | `--base-path` | Base path | `/` | 126 | 127 | ### WebSocket 128 | 129 | | Option | Description | Default | 130 | |--------|-------------|---------| 131 | | `--base-url` | WebSocket URL | `ws://localhost:8081` | 132 | | `--ws-path` | WebSocket path | `/ws` | 133 | 134 | ## Tips 135 | 136 | 1. Use `-vvv` for debugging connection issues 137 | 2. `show_state` reveals internal state 138 | 3. JSON arguments must be valid JSON 139 | 4. Exit with `exit` or Ctrl+C -------------------------------------------------------------------------------- /pages/error_handling.md: -------------------------------------------------------------------------------- 1 | # Error Handling 2 | 3 | This guide explains how errors work in Hermes MCP. 4 | 5 | ## Error Types 6 | 7 | Hermes distinguishes between: 8 | 9 | 1. **Protocol errors** - Standard JSON-RPC errors 10 | 2. **Domain errors** - Application-level errors (tool returns `isError: true`) 11 | 3. **Transport errors** - Network/connection failures 12 | 13 | ## Creating Errors 14 | 15 | Hermes provides a fluent API for creating errors: 16 | 17 | ```elixir 18 | # Protocol errors 19 | Hermes.MCP.Error.protocol(:parse_error) 20 | Hermes.MCP.Error.protocol(:method_not_found, %{method: "unknown"}) 21 | 22 | # Transport errors 23 | Hermes.MCP.Error.transport(:connection_refused) 24 | Hermes.MCP.Error.transport(:timeout, %{elapsed_ms: 30000}) 25 | 26 | # Resource errors 27 | Hermes.MCP.Error.resource(:not_found, %{uri: "file:///missing.txt"}) 28 | 29 | # Execution errors 30 | Hermes.MCP.Error.execution("Database connection failed", %{retries: 3}) 31 | ``` 32 | 33 | ## Error Structure 34 | 35 | All errors have: 36 | - `code` - JSON-RPC error code 37 | - `reason` - Semantic atom (e.g., `:method_not_found`) 38 | - `message` - Human-readable message 39 | - `data` - Additional context map 40 | 41 | ## Handling Client Responses 42 | 43 | ```elixir 44 | case Hermes.Client.call_tool(client, "search", %{query: "test"}) do 45 | # Success 46 | {:ok, %Hermes.MCP.Response{is_error: false, result: result}} -> 47 | IO.puts("Success: #{inspect(result)}") 48 | 49 | # Domain error (tool returned isError: true) 50 | {:ok, %Hermes.MCP.Response{is_error: true, result: result}} -> 51 | IO.puts("Tool error: #{result["message"]}") 52 | 53 | # Protocol/transport error 54 | {:error, %Hermes.MCP.Error{reason: reason}} -> 55 | IO.puts("Error: #{reason}") 56 | end 57 | ``` 58 | 59 | ## Common Error Patterns 60 | 61 | ### Timeout Handling 62 | 63 | ```elixir 64 | # Set custom timeout (default is 30 seconds) 65 | case Hermes.Client.call_tool(client, "slow_tool", %{}, timeout: 60_000) do 66 | {:error, %Hermes.MCP.Error{reason: :timeout}} -> 67 | IO.puts("Request timed out") 68 | 69 | other -> 70 | handle_response(other) 71 | end 72 | ``` 73 | 74 | ### Transport Errors 75 | 76 | ```elixir 77 | {:error, %Hermes.MCP.Error{reason: reason}} when reason in [ 78 | :connection_refused, 79 | :connection_closed, 80 | :timeout 81 | ] -> 82 | # Handle network issues 83 | ``` 84 | 85 | ### Method Not Found 86 | 87 | ```elixir 88 | {:error, %Hermes.MCP.Error{reason: :method_not_found}} -> 89 | # Server doesn't support this method 90 | ``` 91 | 92 | ## Error Codes 93 | 94 | Standard JSON-RPC codes: 95 | - `-32700` - Parse error 96 | - `-32600` - Invalid request 97 | - `-32601` - Method not found 98 | - `-32602` - Invalid params 99 | - `-32603` - Internal error 100 | 101 | MCP-specific: 102 | - `-32002` - Resource not found 103 | - `-32000` - Generic server error 104 | 105 | ## Debugging 106 | 107 | Errors have clean inspect output: 108 | 109 | ``` 110 | #MCP.Error 111 | #MCP.Error 112 | ``` 113 | -------------------------------------------------------------------------------- /pages/home.md: -------------------------------------------------------------------------------- 1 | # Hermes MCP 2 | 3 | A high-performance Model Context Protocol (MCP) implementation in Elixir with first-class Phoenix support. 4 | 5 | ## Overview 6 | 7 | Hermes MCP provides a unified solution for building both MCP clients and servers in Elixir, leveraging the language's exceptional concurrency model and fault tolerance capabilities. The library currently focuses on a robust client implementation, with server functionality planned for future releases. 8 | 9 | ## Key Features 10 | 11 | - **High-Performance Client**: Built for concurrency and fault tolerance 12 | - **Multiple Transport Options**: Support for SSE and STDIO transports 13 | - **Interactive CLI Tools**: Command-line interfaces for testing and debugging ([CLI Usage Guide](cli_usage.html)) 14 | - **Protocol-Compliant**: Full implementation of the Model Context Protocol specification 15 | 16 | ## Protocol Compliance 17 | 18 | Hermes MCP implements the [Model Context Protocol specification](https://spec.modelcontextprotocol.io/specification/2024-11-05/), ensuring interoperability with other MCP-compliant tools and services. The library handles all aspects of the MCP lifecycle, from initialization and capability negotiation to request routing and response handling. 19 | 20 | ## Why Hermes? 21 | 22 | The library is named after Hermes, the Greek god of boundaries, communication, and commerce. This namesake reflects the core purpose of the Model Context Protocol: to establish standardized communication between AI applications and external tools. Like Hermes who served as a messenger between gods and mortals, this library facilitates seamless interaction between Large Language Models and various data sources or tools. 23 | 24 | Furthermore, Hermes was known for his speed and reliability in delivering messages, which aligns with our implementation's focus on high performance and fault tolerance in the Elixir ecosystem. 25 | -------------------------------------------------------------------------------- /pages/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Add Hermes MCP to your Elixir project. 4 | 5 | ## Add Dependency 6 | 7 | In `mix.exs`: 8 | 9 | ```elixir 10 | def deps do 11 | [ 12 | {:hermes_mcp, "~> 0.8"} 13 | ] 14 | end 15 | ``` 16 | 17 | Then: 18 | 19 | ```shell 20 | mix deps.get 21 | ``` 22 | 23 | ## Client Setup 24 | 25 | Add to your supervision tree: 26 | 27 | ```elixir 28 | defmodule MyApp.Application do 29 | use Application 30 | 31 | def start(_type, _args) do 32 | children = [ 33 | # Transport layer 34 | {Hermes.Transport.STDIO, [ 35 | name: MyApp.MCPTransport, 36 | client: MyApp.MCPClient, 37 | command: "python", 38 | args: ["-m", "mcp.server", "my_server.py"] 39 | ]}, 40 | 41 | # MCP client 42 | {Hermes.Client, [ 43 | name: MyApp.MCPClient, 44 | transport: [ 45 | layer: Hermes.Transport.STDIO, 46 | name: MyApp.MCPTransport 47 | ], 48 | client_info: %{ 49 | "name" => "MyApp", 50 | "version" => "1.0.0" 51 | } 52 | ]} 53 | ] 54 | 55 | opts = [strategy: :one_for_all, name: MyApp.Supervisor] 56 | Supervisor.start_link(children, opts) 57 | end 58 | end 59 | ``` 60 | 61 | ## Server Setup 62 | 63 | For MCP servers, see [Server Quick Start](server_quickstart.md). 64 | 65 | ## Client Options 66 | 67 | | Option | Type | Description | Default | 68 | |--------|------|-------------|---------| 69 | | `:name` | atom | Process name | Required | 70 | | `:transport` | keyword | Transport config | Required | 71 | | `:client_info` | map | Client metadata | Required | 72 | | `:capabilities` | map | Client capabilities | `%{}` | 73 | | `:protocol_version` | string | MCP version | `"2025-03-26"` | 74 | | `:request_timeout` | integer | Timeout (ms) | `30_000` | 75 | 76 | ## Next Steps 77 | 78 | - [Client Usage](client_usage.md) - Using the client 79 | - [Transport Layer](transport.md) - Transport details 80 | - [Server Development](server_quickstart.md) - Build servers 81 | -------------------------------------------------------------------------------- /pages/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | Hermes MCP supports server-to-client logging as specified in the [MCP protocol](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/logging/). This allows servers to send structured log messages to clients for debugging and operational visibility. 4 | 5 | ## Overview 6 | 7 | The logging mechanism follows the standard syslog severity levels and provides a way for servers to emit structured log messages. Clients can control the verbosity by setting minimum log levels, and register callbacks to handle log messages for custom processing. 8 | 9 | ## Setting Log Level 10 | 11 | Clients can specify the minimum log level they want to receive from servers: 12 | 13 | ```elixir 14 | # Configure the client to receive logs at "info" level or higher 15 | {:ok, _} = Hermes.Client.set_log_level(client, "info") 16 | ``` 17 | 18 | Available log levels, in order of increasing severity: 19 | - `"debug"` - Detailed information for debugging 20 | - `"info"` - General information messages 21 | - `"notice"` - Normal but significant events 22 | - `"warning"` - Warning conditions 23 | - `"error"` - Error conditions 24 | - `"critical"` - Critical conditions 25 | - `"alert"` - Action must be taken immediately 26 | - `"emergency"` - System is unusable 27 | 28 | Setting a level will result in receiving all messages at that level and above (more severe). 29 | 30 | ## Receiving Log Messages 31 | 32 | ### Registering Callbacks 33 | 34 | You can register a callback function to process log messages as they are received: 35 | 36 | ```elixir 37 | # Register a callback to handle incoming log messages 38 | Hermes.Client.register_log_callback(client, fn level, data, logger -> 39 | IO.puts("[#{level}] #{if logger, do: "[#{logger}] ", else: ""}#{inspect(data)}") 40 | end) 41 | ``` 42 | 43 | The callback function receives: 44 | - `level` - The log level (debug, info, notice, etc.) 45 | - `data` - The log data (any JSON-serializable value) 46 | - `logger` - Optional string identifying the logger source 47 | 48 | ### Unregistering Callbacks 49 | 50 | When you no longer need to process log messages, unregister the callback: 51 | 52 | ```elixir 53 | # Unregister a previously registered callback 54 | Hermes.Client.unregister_log_callback(client) 55 | # :ok 56 | ``` 57 | 58 | ## Integrating with Elixir's Logger 59 | 60 | By default, Hermes MCP automatically logs received messages to `Logger` app, mapping MCP log levels to their Elixir equivalents: 61 | 62 | - `"debug"` → `Logger.debug/1` 63 | - `"info"`, `"notice"` → `Logger.info/1` 64 | - `"warning"` → `Logger.warning/1` 65 | - `"error"`, `"critical"`, `"alert"`, `"emergency"` → `Logger.error/1` 66 | 67 | This provides seamless integration with your existing logging setup, respecting the logger level you 68 | define con your config. 69 | -------------------------------------------------------------------------------- /pages/message_handling.md: -------------------------------------------------------------------------------- 1 | # Message Handling 2 | 3 | How Hermes handles MCP protocol messages. 4 | 5 | ## Message Types 6 | 7 | MCP uses JSON-RPC 2.0 with three message types: 8 | 9 | 1. **Requests** - Expect a response (have ID) 10 | 2. **Responses** - Reply to requests (match ID) 11 | 3. **Notifications** - One-way messages (no ID) 12 | 13 | ## Message Flow 14 | 15 | ``` 16 | Client ──request──> Transport ──> Server 17 | <─response── Transport <── 18 | ``` 19 | 20 | ## Message Encoding 21 | 22 | Use `Hermes.MCP.Message` for all message operations: 23 | 24 | ```elixir 25 | # Encode request 26 | {:ok, encoded} = Message.encode_request(%{ 27 | method: "tools/call", 28 | params: %{name: "calculator", arguments: %{}} 29 | }, "req_123") 30 | 31 | # Encode response 32 | {:ok, encoded} = Message.encode_response(%{ 33 | result: %{answer: 42} 34 | }, "req_123") 35 | 36 | # Encode notification 37 | {:ok, encoded} = Message.encode_notification(%{ 38 | method: "cancelled", 39 | params: %{requestId: "req_123"} 40 | }) 41 | 42 | # Decode any message 43 | {:ok, [decoded]} = Message.decode(json_string) 44 | ``` 45 | 46 | ## Message Guards 47 | 48 | Check message types: 49 | 50 | ```elixir 51 | case decoded do 52 | msg when Message.is_request(msg) -> 53 | handle_request(msg) 54 | 55 | msg when Message.is_response(msg) -> 56 | handle_response(msg) 57 | 58 | msg when Message.is_notification(msg) -> 59 | handle_notification(msg) 60 | end 61 | ``` 62 | 63 | ## Request IDs 64 | 65 | Hermes generates unique request IDs: 66 | 67 | ```elixir 68 | id = Hermes.MCP.ID.generate_request_id() 69 | # => "req_hyp_abcd1234efgh5678" 70 | ``` 71 | 72 | ## Timeouts 73 | 74 | Requests timeout after 30 seconds by default: 75 | 76 | ```elixir 77 | # Custom timeout 78 | Hermes.Client.call_tool(client, "slow_tool", %{}, timeout: 60_000) 79 | ``` 80 | 81 | On timeout: 82 | 1. Request removed from pending 83 | 2. Cancellation sent to server 84 | 3. Error returned to caller 85 | 86 | ## Common Patterns 87 | 88 | ### Client Side 89 | 90 | ```elixir 91 | # The client handles correlation automatically 92 | {:ok, response} = Hermes.Client.call_tool(client, "tool", %{}) 93 | ``` 94 | 95 | ### Server Side 96 | 97 | ```elixir 98 | def handle_request(%{"method" => method, "id" => id}, frame) do 99 | result = process_method(method) 100 | {:reply, result, frame} 101 | end 102 | 103 | def handle_notification(%{"method" => "cancelled"}, frame) do 104 | # Cancel any running operation 105 | {:noreply, frame} 106 | end 107 | ``` 108 | -------------------------------------------------------------------------------- /pages/progress_tracking.md: -------------------------------------------------------------------------------- 1 | # Progress Tracking 2 | 3 | Hermes MCP supports progress notifications for long-running operations as specified in the [MCP protocol](https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/utilities/progress/). 4 | 5 | ## Overview 6 | 7 | The MCP specification includes a progress notification mechanism that allows communicating the progress of long-running operations. Progress updates are useful for: 8 | 9 | - Providing feedback for long-running server operations 10 | - Updating users about the status of operations that might take time to complete 11 | - Enabling client applications to display progress indicators for better UX 12 | 13 | ## Progress Tokens 14 | 15 | Progress is tracked using tokens that uniquely identify a specific operation. Hermes provides a helper function to generate unique tokens: 16 | 17 | ```elixir 18 | # Generate a unique progress token 19 | progress_token = Hermes.MCP.ID.generate_progress_token() 20 | ``` 21 | 22 | ## Making Requests with Progress Tracking 23 | 24 | Any client API request can include progress tracking options. Internally, these options are encapsulated in the `Operation` struct, which standardizes how client operations are handled: 25 | 26 | ```elixir 27 | # Make a request with progress tracking 28 | Hermes.Client.read_resource(client, "resource-uri", 29 | progress: [token: progress_token] 30 | ) 31 | ``` 32 | 33 | ### Internal Operation Structure 34 | 35 | Behind the scenes, client methods create an `Operation` struct that encapsulates the method, parameters, progress options, and timeout: 36 | 37 | ```elixir 38 | # This is what happens internally in client methods 39 | operation = Operation.new(%{ 40 | method: "resources/read", 41 | params: %{"uri" => uri}, 42 | progress_opts: [token: progress_token, callback: callback], 43 | timeout: custom_timeout 44 | }) 45 | ``` 46 | 47 | The `Operation` struct provides a standardized way to handle all client requests with consistent timeout and progress tracking behavior. 48 | 49 | ## Receiving Progress Updates 50 | 51 | You can combine the token and callback in a single request: 52 | 53 | ```elixir 54 | token = Hermes.MCP.ID.generate_progress_token() 55 | 56 | callback = fn ^token, progress, total -> 57 | IO.puts("Progress: #{progress}/#{total || "unknown"}") 58 | end 59 | 60 | Hermes.Client.list_tools(client, progress: [token: token, callback: callback]) 61 | ``` 62 | -------------------------------------------------------------------------------- /pages/server_quickstart.md: -------------------------------------------------------------------------------- 1 | # MCP Server Quick Start Guide 2 | 3 | This guide will help you create your first MCP server using Hermes in under 5 minutes. 4 | 5 | ## What is an MCP Server? 6 | 7 | An MCP (Model Context Protocol) server provides tools, prompts, and resources to AI assistants. Think of it as an API that AI models can interact with to perform actions or retrieve information. 8 | 9 | ## Basic Server Setup 10 | 11 | ### Step 1: Define Your Server Module 12 | 13 | Create a new Elixir module that uses `Hermes.Server`: 14 | 15 | ```elixir 16 | defmodule MyApp.MCPServer do 17 | use Hermes.Server, 18 | name: "My First MCP Server", 19 | version: "1.0.0", 20 | capabilities: [:tools] 21 | 22 | def start_link(opts) do 23 | Hermes.Server.start_link(__MODULE__, :ok, opts) 24 | end 25 | 26 | @impl Hermes.Server.Behaviour 27 | def init(:ok, frame) do 28 | {:ok, frame} 29 | end 30 | end 31 | ``` 32 | 33 | ### Step 2: Add to Your Application Supervisor 34 | 35 | In your `application.ex`: 36 | 37 | ```elixir 38 | defmodule MyApp.Application do 39 | use Application 40 | 41 | def start(_type, _args) do 42 | children = [ 43 | # Registry to handle processes names 44 | Hermes.Server.Registry, 45 | # Start with STDIO transport (for local tests) 46 | {MyApp.MCPServer, transport: :stdio} 47 | ] 48 | 49 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 50 | Supervisor.start_link(children, opts) 51 | end 52 | end 53 | ``` 54 | 55 | ### Step 3: Create Your First Tool 56 | 57 | Tools are functions that AI assistants can call: 58 | 59 | ```elixir 60 | defmodule MyApp.MCPServer.Tools.Greeter do 61 | @moduledoc "Greet someone by name" 62 | 63 | use Hermes.Server.Component, type: :tool 64 | 65 | schema do 66 | %{name: {:required, :string}} 67 | end 68 | 69 | @impl true 70 | def execute(%{name: name}, frame) do 71 | {:ok, "Hello, #{name}! Welcome to MCP!", frame} 72 | end 73 | end 74 | ``` 75 | 76 | ### Step 4: Register the Tool 77 | 78 | Update your server module to include the tool: 79 | 80 | ```elixir 81 | defmodule MyApp.MCPServer do 82 | use Hermes.Server, 83 | name: "My First MCP Server", 84 | version: "1.0.0", 85 | capabilities: [:tools] 86 | 87 | def start_link(opts) do 88 | Hermes.Server.start_link(__MODULE__, :ok, opts) 89 | end 90 | 91 | # Register your tool 92 | component MyApp.MCPServer.Tools.Greeter 93 | 94 | @impl Hermes.Server.Behaviour 95 | def init(_arg, frame) do 96 | {:ok, frame} 97 | end 98 | end 99 | ``` 100 | 101 | ## Testing Your Server 102 | 103 | ### Using the Interactive Shell 104 | 105 | Hermes provides interactive mix tasks for testing: 106 | 107 | ```bash 108 | # Test with STDIO transport 109 | mix help hermes.stdio.interactive 110 | 111 | # Test with HTTP transport 112 | mix help hermes.streamable_http.interactive 113 | ``` 114 | 115 | ## Next Steps 116 | 117 | - Explore the [Component System](server_components.md) for building tools, prompts, and resources 118 | - Configure different [Transport Options](server_transport.md) 119 | -------------------------------------------------------------------------------- /pages/server_transport.md: -------------------------------------------------------------------------------- 1 | # Server Transport Configuration 2 | 3 | Hermes MCP servers support multiple transport mechanisms to accept connections from clients. This page covers the available options and how to configure them. 4 | 5 | ## Available Transports 6 | 7 | | Transport | Module | Use Case | Multi-Client | 8 | |-----------|--------|----------|--------------| 9 | | **STDIO** | `Hermes.Server.Transport.STDIO` | CLI tools, local scripts | No | 10 | | **StreamableHTTP** | `Hermes.Server.Transport.StreamableHTTP` | Web apps, HTTP APIs | Yes | 11 | 12 | ## STDIO Transport 13 | 14 | The STDIO transport enables communication through standard input/output streams. It's suitable for local integrations and CLI tools. 15 | 16 | ### Configuration 17 | 18 | ```elixir 19 | # Start server with STDIO transport 20 | {MyServer, transport: :stdio} 21 | 22 | # With explicit configuration 23 | {MyServer, transport: {:stdio, name: :my_stdio_server}} 24 | ``` 25 | 26 | ### Example: CLI Tool Server 27 | 28 | ```elixir 29 | defmodule MyApp.Application do 30 | use Application 31 | 32 | def start(_type, _args) do 33 | children = [ 34 | {MyApp.MCPServer, transport: :stdio} 35 | ] 36 | 37 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 38 | Supervisor.start_link(children, opts) 39 | end 40 | end 41 | ``` 42 | 43 | ## StreamableHTTP Transport 44 | 45 | The StreamableHTTP transport enables communication over HTTP using Server-Sent Events (SSE) for responses. It supports multiple concurrent clients. 46 | 47 | ### Configuration 48 | 49 | ```elixir 50 | # Basic configuration 51 | {MyServer, transport: :streamable_http} 52 | 53 | # With port configuration 54 | {MyServer, transport: {:streamable_http, port: 8080}} 55 | 56 | # Full configuration 57 | {MyServer, transport: {:streamable_http, 58 | port: 8080, 59 | path: "/mcp", 60 | start: true # Force start even without HTTP server 61 | }} 62 | ``` 63 | 64 | ### Configuration Options 65 | 66 | | Option | Type | Description | Default | 67 | |--------|------|-------------|---------| 68 | | `:port` | integer | Port number for HTTP server | `8080` | 69 | | `:path` | string | URL path for MCP endpoint | `"/mcp"` | 70 | | `:start` | boolean/`:auto` | Start behavior | `:auto` | 71 | 72 | ### Conditional Startup 73 | 74 | StreamableHTTP transport has smart startup behavior: 75 | 76 | ```elixir 77 | # Auto-detect (default) - starts only if HTTP server is running 78 | {MyServer, transport: :streamable_http} 79 | 80 | # Force start - always start HTTP server 81 | {MyServer, transport: {:streamable_http, start: true}} 82 | 83 | # Prevent start - never start, useful for tests 84 | {MyServer, transport: {:streamable_http, start: false}} 85 | ``` 86 | 87 | ### Integration with Phoenix 88 | 89 | If you're using Phoenix, you can integrate the MCP server with your existing endpoint: 90 | 91 | ```elixir 92 | # In lib/my_app_web/endpoint.ex 93 | defmodule MyAppWeb.Endpoint do 94 | use Phoenix.Endpoint, otp_app: :my_app 95 | 96 | # Add the MCP plug 97 | plug Hermes.Server.Transport.StreamableHTTP.Plug, 98 | server: MyApp.MCPServer, 99 | path: "/mcp" 100 | 101 | # Other plugs... 102 | end 103 | ``` 104 | 105 | ## Transport Selection 106 | 107 | ### Use STDIO when: 108 | - Building CLI tools 109 | - Local development and testing 110 | - Single-client scenarios 111 | - Subprocess communication 112 | 113 | ### Use StreamableHTTP when: 114 | - Building web applications 115 | - Need multiple concurrent clients 116 | - Integrating with existing HTTP services 117 | - Production deployments 118 | 119 | ## Supervision 120 | 121 | Transports are supervised as part of the server supervision tree: 122 | 123 | ```elixir 124 | # The server supervisor handles both the server and transport 125 | children = [ 126 | {MyApp.MCPServer, transport: :stdio} 127 | ] 128 | 129 | Supervisor.start_link(children, strategy: :one_for_one) 130 | ``` 131 | 132 | ## Custom Transport Options 133 | 134 | For advanced configurations, you can specify the transport module directly: 135 | 136 | ```elixir 137 | {MyServer, transport: [ 138 | layer: Hermes.Server.Transport.STDIO, 139 | name: :custom_stdio 140 | # Additional options... 141 | ]} 142 | ``` 143 | 144 | ## References 145 | 146 | For more information about MCP transport layers, see the official [MCP specification](https://spec.modelcontextprotocol.io/specification/basic/transports/) 147 | -------------------------------------------------------------------------------- /pages/transport.md: -------------------------------------------------------------------------------- 1 | # Transport Layer 2 | 3 | Connect MCP clients to servers using different transport mechanisms. 4 | 5 | ## Available Transports 6 | 7 | | Transport | Module | Use Case | 8 | |-----------|--------|----------| 9 | | **STDIO** | `Hermes.Transport.STDIO` | Local subprocess servers | 10 | | **SSE** | `Hermes.Transport.SSE` | HTTP servers with Server-Sent Events | 11 | | **WebSocket** | `Hermes.Transport.WebSocket` | Real-time bidirectional communication | 12 | | **StreamableHTTP** | `Hermes.Transport.StreamableHTTP` | HTTP with streaming responses | 13 | 14 | ## Transport Interface 15 | 16 | All transports implement: 17 | 18 | ```elixir 19 | @callback start_link(keyword()) :: GenServer.on_start() 20 | @callback send_message(t(), message()) :: :ok | {:error, reason()} 21 | @callback shutdown(t()) :: :ok | {:error, reason()} 22 | ``` 23 | 24 | ## STDIO Transport 25 | 26 | For local subprocess servers. 27 | 28 | ### Configuration 29 | 30 | ```elixir 31 | {Hermes.Transport.STDIO, [ 32 | name: MyApp.MCPTransport, 33 | client: MyApp.MCPClient, 34 | command: "python", 35 | args: ["-m", "mcp.server", "my_server.py"], 36 | env: %{"PYTHONPATH" => "/path/to/modules"}, 37 | cwd: "/path/to/server" 38 | ]} 39 | ``` 40 | 41 | ### Options 42 | 43 | | Option | Type | Description | Default | 44 | |--------|------|-------------|---------| 45 | | `:name` | atom | Process name | Required | 46 | | `:client` | pid/name | Client process | Required | 47 | | `:command` | string | Command to run | Required | 48 | | `:args` | list | Command arguments | `[]` | 49 | | `:env` | map | Environment vars | System defaults | 50 | | `:cwd` | string | Working directory | Current dir | 51 | 52 | ## SSE Transport 53 | 54 | For HTTP servers with Server-Sent Events. 55 | 56 | ### Configuration 57 | 58 | ```elixir 59 | {Hermes.Transport.SSE, [ 60 | name: MyApp.HTTPTransport, 61 | client: MyApp.MCPClient, 62 | server: [ 63 | base_url: "https://api.example.com", 64 | base_path: "/mcp", 65 | sse_path: "/sse" 66 | ], 67 | headers: [{"Authorization", "Bearer token"}] 68 | ]} 69 | ``` 70 | 71 | ### Options 72 | 73 | | Option | Type | Description | Default | 74 | |--------|------|-------------|---------| 75 | | `:server.base_url` | string | Server URL | Required | 76 | | `:server.base_path` | string | Base path | `"/"` | 77 | | `:server.sse_path` | string | SSE endpoint | `"/sse"` | 78 | | `:headers` | list | HTTP headers | `[]` | 79 | 80 | ## WebSocket Transport 81 | 82 | For real-time bidirectional communication. 83 | 84 | ### Configuration 85 | 86 | ```elixir 87 | {Hermes.Transport.WebSocket, [ 88 | name: MyApp.WSTransport, 89 | client: MyApp.MCPClient, 90 | server: [ 91 | base_url: "wss://api.example.com", 92 | ws_path: "/ws" 93 | ] 94 | ]} 95 | ``` 96 | 97 | ### Options 98 | 99 | | Option | Type | Description | Default | 100 | |--------|------|-------------|---------| 101 | | `:server.base_url` | string | WebSocket URL | Required | 102 | | `:server.ws_path` | string | WS endpoint | `"/ws"` | 103 | | `:headers` | list | HTTP headers | `[]` | 104 | 105 | ## Custom Transport 106 | 107 | Implement the behaviour: 108 | 109 | ```elixir 110 | defmodule MyTransport do 111 | @behaviour Hermes.Transport.Behaviour 112 | use GenServer 113 | 114 | def start_link(opts) do 115 | GenServer.start_link(__MODULE__, opts, name: opts[:name]) 116 | end 117 | 118 | def send_message(pid, message) do 119 | GenServer.call(pid, {:send, message}) 120 | end 121 | 122 | def shutdown(pid) do 123 | GenServer.stop(pid) 124 | end 125 | 126 | # GenServer callbacks... 127 | end 128 | ``` 129 | 130 | ## Supervision 131 | 132 | Use `:one_for_all` strategy: 133 | 134 | ```elixir 135 | children = [ 136 | {Hermes.Transport.STDIO, transport_opts}, 137 | {Hermes.Client, client_opts} 138 | ] 139 | 140 | Supervisor.start_link(children, strategy: :one_for_all) 141 | ``` 142 | 143 | Transport and client depend on each other - if one fails, restart both. 144 | 145 | ## References 146 | 147 | See [MCP transport specification](https://spec.modelcontextprotocol.io/specification/basic/transports/) 148 | -------------------------------------------------------------------------------- /priv/dev/ascii/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /priv/dev/ascii/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | ascii-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | # Database files 39 | *.db 40 | *.db-* 41 | 42 | -------------------------------------------------------------------------------- /priv/dev/ascii/README.md: -------------------------------------------------------------------------------- 1 | # ASCII Art Studio with Hermes MCP 2 | 3 | A Phoenix LiveView application that generates ASCII art and exposes it via MCP (Model Context Protocol) using Hermes MCP. 4 | 5 | ## Features 6 | 7 | - Generate ASCII art from text using 4 different fonts (Standard, Slant, 3D, Banner) 8 | - Create simple text banners with borders 9 | - Save generation history to database 10 | - Delete unwanted art from history 11 | - Copy ASCII art to clipboard 12 | - Real-time generation stats 13 | - MCP server integration for programmatic access 14 | 15 | ## Setup 16 | 17 | ```bash 18 | mix deps.get 19 | mix ecto.setup 20 | mix phx.server 21 | ``` 22 | 23 | Visit http://localhost:4000 to use the web interface. 24 | 25 | ## MCP Integration 26 | 27 | The app includes an MCP server that exposes ASCII art generation as a tool: 28 | 29 | ```elixir 30 | # Start the MCP server 31 | {:ok, _pid} = Ascii.MCPServer.start_link(transport: [layer: Hermes.Transport.STDIO]) 32 | ``` 33 | 34 | Available MCP tools: 35 | - `generate_ascii_art` - Generate ASCII art with specified text and font 36 | - `list_fonts` - Get available font options 37 | 38 | ## Usage Example 39 | 40 | ```elixir 41 | # Via MCP client 42 | {:ok, client} = Hermes.Client.start_link(transport: [layer: Hermes.Transport.STDIO]) 43 | {:ok, result} = Hermes.Client.call_tool(client, "generate_ascii_art", %{ 44 | text: "HELLO", 45 | font: "standard" 46 | }) 47 | ``` 48 | 49 | Built with Phoenix LiveView and Hermes MCP. -------------------------------------------------------------------------------- /priv/dev/ascii/assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | -------------------------------------------------------------------------------- /priv/dev/ascii/assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from "phoenix" 22 | import {LiveSocket} from "phoenix_live_view" 23 | import topbar from "../vendor/topbar" 24 | 25 | let Hooks = {} 26 | 27 | Hooks.CopyToClipboard = { 28 | mounted() { 29 | this.el.addEventListener("click", () => { 30 | const text = this.el.dataset.text 31 | navigator.clipboard.writeText(text) 32 | }) 33 | } 34 | } 35 | 36 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 37 | let liveSocket = new LiveSocket("/live", Socket, { 38 | longPollFallbackMs: 2500, 39 | params: {_csrf_token: csrfToken}, 40 | hooks: Hooks 41 | }) 42 | 43 | // Show progress bar on live navigation and form submits 44 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 45 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 46 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 47 | 48 | // connect if there are any LiveViews on the page 49 | liveSocket.connect() 50 | 51 | // expose liveSocket on window for web console debug logs and latency simulation: 52 | // >> liveSocket.enableDebug() 53 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 54 | // >> liveSocket.disableLatencySim() 55 | window.liveSocket = liveSocket 56 | 57 | -------------------------------------------------------------------------------- /priv/dev/ascii/assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | const fs = require("fs") 6 | const path = require("path") 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "../lib/ascii_web.ex", 12 | "../lib/ascii_web/**/*.*ex" 13 | ], 14 | theme: { 15 | extend: { 16 | colors: { 17 | brand: "#FD4F00", 18 | } 19 | }, 20 | }, 21 | plugins: [ 22 | require("@tailwindcss/forms"), 23 | // Allows prefixing tailwind classes with LiveView classes to add rules 24 | // only when LiveView classes are applied, for example: 25 | // 26 | //
27 | // 28 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 29 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 30 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), 31 | 32 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 33 | // See your `CoreComponents.icon/1` for more information. 34 | // 35 | plugin(function({matchComponents, theme}) { 36 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") 37 | let values = {} 38 | let icons = [ 39 | ["", "/24/outline"], 40 | ["-solid", "/24/solid"], 41 | ["-mini", "/20/solid"], 42 | ["-micro", "/16/solid"] 43 | ] 44 | icons.forEach(([suffix, dir]) => { 45 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { 46 | let name = path.basename(file, ".svg") + suffix 47 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)} 48 | }) 49 | }) 50 | matchComponents({ 51 | "hero": ({name, fullPath}) => { 52 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") 53 | let size = theme("spacing.6") 54 | if (name.endsWith("-mini")) { 55 | size = theme("spacing.5") 56 | } else if (name.endsWith("-micro")) { 57 | size = theme("spacing.4") 58 | } 59 | return { 60 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 61 | "-webkit-mask": `var(--hero-${name})`, 62 | "mask": `var(--hero-${name})`, 63 | "mask-repeat": "no-repeat", 64 | "background-color": "currentColor", 65 | "vertical-align": "middle", 66 | "display": "inline-block", 67 | "width": size, 68 | "height": size 69 | } 70 | } 71 | }, {values}) 72 | }) 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /priv/dev/ascii/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :ascii, 11 | ecto_repos: [Ascii.Repo], 12 | generators: [timestamp_type: :utc_datetime] 13 | 14 | # Configures the endpoint 15 | config :ascii, AsciiWeb.Endpoint, 16 | url: [host: "localhost"], 17 | adapter: Bandit.PhoenixAdapter, 18 | render_errors: [ 19 | formats: [html: AsciiWeb.ErrorHTML, json: AsciiWeb.ErrorJSON], 20 | layout: false 21 | ], 22 | pubsub_server: Ascii.PubSub, 23 | live_view: [signing_salt: "6lpi4bIV"] 24 | 25 | # Configure esbuild (the version is required) 26 | config :esbuild, 27 | version: "0.17.11", 28 | ascii: [ 29 | args: 30 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 31 | cd: Path.expand("../assets", __DIR__), 32 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 33 | ] 34 | 35 | # Configure tailwind (the version is required) 36 | config :tailwind, 37 | version: "3.4.3", 38 | ascii: [ 39 | args: ~w( 40 | --config=tailwind.config.js 41 | --input=css/app.css 42 | --output=../priv/static/assets/app.css 43 | ), 44 | cd: Path.expand("../assets", __DIR__) 45 | ] 46 | 47 | # Configures Elixir's Logger 48 | config :logger, :console, 49 | format: "$time $metadata[$level] $message\n", 50 | metadata: [:request_id] 51 | 52 | # Use Jason for JSON parsing in Phoenix 53 | config :phoenix, :json_library, Jason 54 | 55 | # Import environment specific config. This must remain at the bottom 56 | # of this file so it overrides the configuration defined above. 57 | import_config "#{config_env()}.exs" 58 | -------------------------------------------------------------------------------- /priv/dev/ascii/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :ascii, Ascii.Repo, 5 | database: Path.expand("../ascii_dev.db", __DIR__), 6 | pool_size: 5, 7 | stacktrace: true, 8 | show_sensitive_data_on_connection_error: true 9 | 10 | # For development, we disable any cache and enable 11 | # debugging and code reloading. 12 | # 13 | # The watchers configuration can be used to run external 14 | # watchers to your application. For example, we can use it 15 | # to bundle .js and .css sources. 16 | config :ascii, AsciiWeb.Endpoint, 17 | # Binding to loopback ipv4 address prevents access from other machines. 18 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 19 | http: [ip: {127, 0, 0, 1}, port: 4000], 20 | check_origin: false, 21 | code_reloader: true, 22 | debug_errors: true, 23 | secret_key_base: "TUhfuDDGnnSN9Hspbx2fgMXvDUY7rSQyZYRMDKfaIGER9NF8g7UXU90idjNfNQ54", 24 | watchers: [ 25 | esbuild: {Esbuild, :install_and_run, [:ascii, ~w(--sourcemap=inline --watch)]}, 26 | tailwind: {Tailwind, :install_and_run, [:ascii, ~w(--watch)]} 27 | ] 28 | 29 | # ## SSL Support 30 | # 31 | # In order to use HTTPS in development, a self-signed 32 | # certificate can be generated by running the following 33 | # Mix task: 34 | # 35 | # mix phx.gen.cert 36 | # 37 | # Run `mix help phx.gen.cert` for more information. 38 | # 39 | # The `http:` config above can be replaced with: 40 | # 41 | # https: [ 42 | # port: 4001, 43 | # cipher_suite: :strong, 44 | # keyfile: "priv/cert/selfsigned_key.pem", 45 | # certfile: "priv/cert/selfsigned.pem" 46 | # ], 47 | # 48 | # If desired, both `http:` and `https:` keys can be 49 | # configured to run both http and https servers on 50 | # different ports. 51 | 52 | # Watch static and templates for browser reloading. 53 | config :ascii, AsciiWeb.Endpoint, 54 | live_reload: [ 55 | patterns: [ 56 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 57 | ~r"lib/ascii_web/(controllers|live|components)/.*(ex|heex)$" 58 | ] 59 | ] 60 | 61 | # Enable dev routes for dashboard and mailbox 62 | config :ascii, dev_routes: true 63 | 64 | # Do not include metadata nor timestamps in development logs 65 | config :logger, :console, format: "[$level] $message\n" 66 | 67 | # Set a higher stacktrace during development. Avoid configuring such 68 | # in production as building large stacktraces may be expensive. 69 | config :phoenix, :stacktrace_depth, 20 70 | 71 | # Initialize plugs at runtime for faster development compilation 72 | config :phoenix, :plug_init_mode, :runtime 73 | 74 | config :phoenix_live_view, 75 | # Include HEEx debug annotations as HTML comments in rendered markup 76 | debug_heex_annotations: true, 77 | # Enable helpful, but potentially expensive runtime checks 78 | enable_expensive_runtime_checks: true 79 | -------------------------------------------------------------------------------- /priv/dev/ascii/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :ascii, AsciiWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 9 | 10 | # Do not print debug messages in production 11 | config :logger, level: :info 12 | 13 | # Runtime production configuration, including reading 14 | # of environment variables, is done on config/runtime.exs. 15 | -------------------------------------------------------------------------------- /priv/dev/ascii/config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/ascii start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :ascii, AsciiWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_path = 25 | System.get_env("DATABASE_PATH") || 26 | raise """ 27 | environment variable DATABASE_PATH is missing. 28 | For example: /etc/ascii/ascii.db 29 | """ 30 | 31 | config :ascii, Ascii.Repo, 32 | database: database_path, 33 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") 34 | 35 | # The secret key base is used to sign/encrypt cookies and other secrets. 36 | # A default value is used in config/dev.exs and config/test.exs but you 37 | # want to use a different value for prod and you most likely don't want 38 | # to check this value into version control, so we use an environment 39 | # variable instead. 40 | secret_key_base = 41 | System.get_env("SECRET_KEY_BASE") || 42 | raise """ 43 | environment variable SECRET_KEY_BASE is missing. 44 | You can generate one by calling: mix phx.gen.secret 45 | """ 46 | 47 | host = System.get_env("PHX_HOST") || "example.com" 48 | port = String.to_integer(System.get_env("PORT") || "4000") 49 | 50 | config :ascii, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 51 | 52 | config :ascii, AsciiWeb.Endpoint, 53 | url: [host: host, port: 443, scheme: "https"], 54 | http: [ 55 | # Enable IPv6 and bind on all interfaces. 56 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 57 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 58 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 59 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 60 | port: port 61 | ], 62 | secret_key_base: secret_key_base 63 | 64 | # ## SSL Support 65 | # 66 | # To get SSL working, you will need to add the `https` key 67 | # to your endpoint configuration: 68 | # 69 | # config :ascii, AsciiWeb.Endpoint, 70 | # https: [ 71 | # ..., 72 | # port: 443, 73 | # cipher_suite: :strong, 74 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 75 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 76 | # ] 77 | # 78 | # The `cipher_suite` is set to `:strong` to support only the 79 | # latest and more secure SSL ciphers. This means old browsers 80 | # and clients may not be supported. You can set it to 81 | # `:compatible` for wider support. 82 | # 83 | # `:keyfile` and `:certfile` expect an absolute path to the key 84 | # and cert in disk or a relative path inside priv, for example 85 | # "priv/ssl/server.key". For all supported SSL configuration 86 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 87 | # 88 | # We also recommend setting `force_ssl` in your config/prod.exs, 89 | # ensuring no data is ever sent via http, always redirecting to https: 90 | # 91 | # config :ascii, AsciiWeb.Endpoint, 92 | # force_ssl: [hsts: true] 93 | # 94 | # Check `Plug.SSL` for all available options in `force_ssl`. 95 | end 96 | -------------------------------------------------------------------------------- /priv/dev/ascii/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :ascii, Ascii.Repo, 9 | database: Path.expand("../ascii_test.db", __DIR__), 10 | pool_size: 5, 11 | pool: Ecto.Adapters.SQL.Sandbox 12 | 13 | # We don't run a server during test. If one is required, 14 | # you can enable the server option below. 15 | config :ascii, AsciiWeb.Endpoint, 16 | http: [ip: {127, 0, 0, 1}, port: 4002], 17 | secret_key_base: "KdHU2KwF8c45MWgvlD9KJJQMUulv7e3rrogvffdHV3++Zrv4+2vhEUmL0y+uelBm", 18 | server: false 19 | 20 | # Print only warnings and errors during test 21 | config :logger, level: :warning 22 | 23 | # Initialize plugs at runtime for faster test compilation 24 | config :phoenix, :plug_init_mode, :runtime 25 | 26 | # Enable helpful, but potentially expensive runtime checks 27 | config :phoenix_live_view, 28 | enable_expensive_runtime_checks: true 29 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii.ex: -------------------------------------------------------------------------------- 1 | defmodule Ascii do 2 | @moduledoc """ 3 | Ascii keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Ascii.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | AsciiWeb.Telemetry, 12 | Ascii.Repo, 13 | {Ecto.Migrator, 14 | repos: Application.fetch_env!(:ascii, :ecto_repos), skip: skip_migrations?()}, 15 | {DNSCluster, query: Application.get_env(:ascii, :dns_cluster_query) || :ignore}, 16 | {Phoenix.PubSub, name: Ascii.PubSub}, 17 | # Start a worker by calling: Ascii.Worker.start_link(arg) 18 | # {Ascii.Worker, arg}, 19 | # Start to serve requests, typically the last entry 20 | AsciiWeb.Endpoint, 21 | # relevant line for MCP 22 | Hermes.Server.Registry, 23 | {Ascii.MCPServer, transport: {:streamable_http, []}} 24 | ] 25 | 26 | # See https://hexdocs.pm/elixir/Supervisor.html 27 | # for other strategies and supported options 28 | opts = [strategy: :one_for_one, name: Ascii.Supervisor] 29 | Supervisor.start_link(children, opts) 30 | end 31 | 32 | # Tell Phoenix to update the endpoint configuration 33 | # whenever the application is updated. 34 | @impl true 35 | def config_change(changed, _new, removed) do 36 | AsciiWeb.Endpoint.config_change(changed, removed) 37 | :ok 38 | end 39 | 40 | defp skip_migrations?() do 41 | # By default, sqlite migrations are run when using a release 42 | System.get_env("RELEASE_NAME") != nil 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii/art_history.ex: -------------------------------------------------------------------------------- 1 | defmodule Ascii.ArtHistory do 2 | @moduledoc """ 3 | Schema and context for storing ASCII art generation history. 4 | """ 5 | 6 | use Ecto.Schema 7 | import Ecto.Changeset 8 | import Ecto.Query 9 | alias Ascii.Repo 10 | 11 | schema "art_history" do 12 | field :text, :string 13 | field :font, :string 14 | field :result, :string 15 | 16 | timestamps(type: :utc_datetime) 17 | end 18 | 19 | @doc false 20 | def changeset(art_history, attrs) do 21 | art_history 22 | |> cast(attrs, [:text, :font, :result]) 23 | |> validate_required([:text, :font, :result]) 24 | |> validate_length(:text, max: 100) 25 | |> validate_inclusion(:font, ["standard", "slant", "3d", "banner"]) 26 | end 27 | 28 | @doc """ 29 | Creates a new art history entry. 30 | """ 31 | def create_art(attrs \\ %{}) do 32 | %__MODULE__{} 33 | |> changeset(attrs) 34 | |> Repo.insert() 35 | end 36 | 37 | @doc """ 38 | Lists recent art history entries. 39 | """ 40 | def list_recent(limit \\ 10) do 41 | __MODULE__ 42 | |> order_by([a], desc: a.inserted_at) 43 | |> limit(^limit) 44 | |> Repo.all() 45 | end 46 | 47 | @doc """ 48 | Gets art history by ID. 49 | """ 50 | def get_art!(id) do 51 | Repo.get!(__MODULE__, id) 52 | end 53 | 54 | @doc """ 55 | Searches art history by text. 56 | """ 57 | def search_by_text(search_term) do 58 | like_term = "%#{search_term}%" 59 | 60 | __MODULE__ 61 | |> where([a], ilike(a.text, ^like_term)) 62 | |> order_by([a], desc: a.inserted_at) 63 | |> Repo.all() 64 | end 65 | 66 | @doc """ 67 | Deletes art history by ID. 68 | """ 69 | def delete_art(id) do 70 | __MODULE__ 71 | |> Repo.get(id) 72 | |> case do 73 | nil -> {:error, :not_found} 74 | art -> Repo.delete(art) 75 | end 76 | end 77 | 78 | @doc """ 79 | Gets statistics about art generation. 80 | """ 81 | def get_stats do 82 | font_stats = 83 | __MODULE__ 84 | |> group_by([a], a.font) 85 | |> select([a], {a.font, count(a.id)}) 86 | |> Repo.all() 87 | |> Map.new() 88 | 89 | total_count = 90 | __MODULE__ 91 | |> Repo.aggregate(:count, :id) 92 | 93 | %{ 94 | total_generations: total_count, 95 | font_usage: font_stats 96 | } 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Ascii.Repo do 2 | use Ecto.Repo, 3 | otp_app: :ascii, 4 | adapter: Ecto.Adapters.SQLite3 5 | end 6 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web.ex: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use AsciiWeb, :controller 9 | use AsciiWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: AsciiWeb.Layouts] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def live_view do 52 | quote do 53 | use Phoenix.LiveView, 54 | layout: {AsciiWeb.Layouts, :app} 55 | 56 | unquote(html_helpers()) 57 | end 58 | end 59 | 60 | def live_component do 61 | quote do 62 | use Phoenix.LiveComponent 63 | 64 | unquote(html_helpers()) 65 | end 66 | end 67 | 68 | def html do 69 | quote do 70 | use Phoenix.Component 71 | 72 | # Import convenience functions from controllers 73 | import Phoenix.Controller, 74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 75 | 76 | # Include general helpers for rendering HTML 77 | unquote(html_helpers()) 78 | end 79 | end 80 | 81 | defp html_helpers do 82 | quote do 83 | # HTML escaping functionality 84 | import Phoenix.HTML 85 | # Core UI components 86 | import AsciiWeb.CoreComponents 87 | 88 | # Shortcut for generating JS commands 89 | alias Phoenix.LiveView.JS 90 | 91 | # Routes generation with the ~p sigil 92 | unquote(verified_routes()) 93 | end 94 | end 95 | 96 | def verified_routes do 97 | quote do 98 | use Phoenix.VerifiedRoutes, 99 | endpoint: AsciiWeb.Endpoint, 100 | router: AsciiWeb.Router, 101 | statics: AsciiWeb.static_paths() 102 | end 103 | end 104 | 105 | @doc """ 106 | When used, dispatch to the appropriate controller/live_view/etc. 107 | """ 108 | defmacro __using__(which) when is_atom(which) do 109 | apply(__MODULE__, which, []) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use AsciiWeb, :controller` and 9 | `use AsciiWeb, :live_view`. 10 | """ 11 | use AsciiWeb, :html 12 | 13 | embed_templates "layouts/*" 14 | end 15 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 | {@inner_content} 2 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title default="ASCII Art Studio"> 8 | {assigns[:page_title]} 9 | 10 | 11 | 13 | 14 | 15 | {@inner_content} 16 | 17 | 18 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use AsciiWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/ascii_web/controllers/error_html/404.html.heex 14 | # * lib/ascii_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :ascii 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_ascii_key", 10 | signing_salt: "HG+fCs0i", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | 18 | # Serve at "/" the static files from "priv/static" directory. 19 | # 20 | # You should set gzip to true if you are running phx.digest 21 | # when deploying your static files in production. 22 | plug Plug.Static, 23 | at: "/", 24 | from: :ascii, 25 | gzip: false, 26 | only: AsciiWeb.static_paths() 27 | 28 | # Code reloading can be explicitly enabled under the 29 | # :code_reloader configuration of your endpoint. 30 | if code_reloading? do 31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 32 | plug Phoenix.LiveReloader 33 | plug Phoenix.CodeReloader 34 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :ascii 35 | end 36 | 37 | plug Plug.RequestId 38 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 39 | 40 | plug Plug.Parsers, 41 | parsers: [:urlencoded, :multipart, :json], 42 | pass: ["*/*"], 43 | json_decoder: Phoenix.json_library() 44 | 45 | plug Plug.MethodOverride 46 | plug Plug.Head 47 | plug Plug.Session, @session_options 48 | plug AsciiWeb.Router 49 | end 50 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.Router do 2 | use AsciiWeb, :router 3 | 4 | alias Hermes.Server.Transport.StreamableHTTP 5 | 6 | pipeline :browser do 7 | plug :accepts, ["html"] 8 | plug :fetch_session 9 | plug :fetch_live_flash 10 | plug :put_root_layout, html: {AsciiWeb.Layouts, :root} 11 | plug :protect_from_forgery 12 | plug :put_secure_browser_headers 13 | end 14 | 15 | pipeline :api do 16 | plug :accepts, ["json"] 17 | end 18 | 19 | scope "/" do 20 | pipe_through :api 21 | 22 | forward "/mcp", StreamableHTTP.Plug, server: Ascii.MCPServer 23 | end 24 | 25 | scope "/", AsciiWeb do 26 | pipe_through :browser 27 | 28 | live "/", AsciiLive, :index 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /priv/dev/ascii/lib/ascii_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | sum("phoenix.socket_drain.count"), 47 | summary("phoenix.channel_joined.duration", 48 | unit: {:native, :millisecond} 49 | ), 50 | summary("phoenix.channel_handled_in.duration", 51 | tags: [:event], 52 | unit: {:native, :millisecond} 53 | ), 54 | 55 | # Database Metrics 56 | summary("ascii.repo.query.total_time", 57 | unit: {:native, :millisecond}, 58 | description: "The sum of the other measurements" 59 | ), 60 | summary("ascii.repo.query.decode_time", 61 | unit: {:native, :millisecond}, 62 | description: "The time spent decoding the data received from the database" 63 | ), 64 | summary("ascii.repo.query.query_time", 65 | unit: {:native, :millisecond}, 66 | description: "The time spent executing the query" 67 | ), 68 | summary("ascii.repo.query.queue_time", 69 | unit: {:native, :millisecond}, 70 | description: "The time spent waiting for a database connection" 71 | ), 72 | summary("ascii.repo.query.idle_time", 73 | unit: {:native, :millisecond}, 74 | description: 75 | "The time the connection spent waiting before being checked out for the query" 76 | ), 77 | 78 | # VM Metrics 79 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 80 | summary("vm.total_run_queue_lengths.total"), 81 | summary("vm.total_run_queue_lengths.cpu"), 82 | summary("vm.total_run_queue_lengths.io") 83 | ] 84 | end 85 | 86 | defp periodic_measurements do 87 | [ 88 | # A module, function and arguments to be invoked periodically. 89 | # This function must call :telemetry.execute/3 and a metric must be added above. 90 | # {AsciiWeb, :count_users, []} 91 | ] 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /priv/dev/ascii/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Ascii.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ascii, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {Ascii.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.7.21"}, 36 | {:phoenix_ecto, "~> 4.5"}, 37 | {:ecto_sql, "~> 3.10"}, 38 | {:ecto_sqlite3, ">= 0.0.0"}, 39 | {:phoenix_html, "~> 4.1"}, 40 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 41 | {:phoenix_live_view, "~> 1.0"}, 42 | {:floki, ">= 0.30.0", only: :test}, 43 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 44 | {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, 45 | {:heroicons, 46 | github: "tailwindlabs/heroicons", 47 | tag: "v2.1.1", 48 | sparse: "optimized", 49 | app: false, 50 | compile: false, 51 | depth: 1}, 52 | {:telemetry_metrics, "~> 1.0"}, 53 | {:telemetry_poller, "~> 1.0"}, 54 | {:jason, "~> 1.2"}, 55 | {:dns_cluster, "~> 0.1.1"}, 56 | {:bandit, "~> 1.5"}, 57 | {:hermes_mcp, path: "../../../"} 58 | ] 59 | end 60 | 61 | # Aliases are shortcuts or tasks specific to the current project. 62 | # For example, to install project dependencies and perform other setup tasks, run: 63 | # 64 | # $ mix setup 65 | # 66 | # See the documentation for `Mix` for more info on aliases. 67 | defp aliases do 68 | [ 69 | setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], 70 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 71 | "ecto.reset": ["ecto.drop", "ecto.setup"], 72 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 73 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 74 | "assets.build": ["tailwind ascii", "esbuild ascii"], 75 | "assets.deploy": [ 76 | "tailwind ascii --minify", 77 | "esbuild ascii --minify", 78 | "phx.digest" 79 | ] 80 | ] 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /priv/dev/ascii/priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/dev/ascii/priv/repo/migrations/20250606143630_create_art_history.exs: -------------------------------------------------------------------------------- 1 | defmodule Ascii.Repo.Migrations.CreateArtHistory do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:art_history) do 6 | add :text, :string, null: false 7 | add :font, :string, null: false 8 | add :result, :text, null: false 9 | 10 | timestamps(type: :utc_datetime) 11 | end 12 | 13 | create index(:art_history, [:inserted_at]) 14 | create index(:art_history, [:text]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/dev/ascii/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Ascii.Repo.insert!(%Ascii.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /priv/dev/ascii/priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudwalk/hermes-mcp/9fca055e38187aa3aaeaed2084c57d41d63936c2/priv/dev/ascii/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/dev/ascii/priv/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /priv/dev/ascii/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /priv/dev/ascii/test/ascii_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.ErrorHTMLTest do 2 | use AsciiWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(AsciiWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(AsciiWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/dev/ascii/test/ascii_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.ErrorJSONTest do 2 | use AsciiWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert AsciiWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert AsciiWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/dev/ascii/test/ascii_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.PageControllerTest do 2 | use AsciiWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, ~p"/") 6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /priv/dev/ascii/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule AsciiWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use AsciiWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint AsciiWeb.Endpoint 24 | 25 | use AsciiWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import AsciiWeb.ConnCase 31 | end 32 | end 33 | 34 | setup tags do 35 | Ascii.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /priv/dev/ascii/test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Ascii.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Ascii.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Ascii.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Ascii.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | Ascii.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Ascii.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /priv/dev/ascii/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Ascii.Repo, :manual) 3 | -------------------------------------------------------------------------------- /priv/dev/calculator/go.mod: -------------------------------------------------------------------------------- 1 | module calculator 2 | 3 | go 1.23.5 4 | 5 | require ( 6 | github.com/google/uuid v1.6.0 // indirect 7 | github.com/mark3labs/mcp-go v0.8.5 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /priv/dev/calculator/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 2 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | github.com/mark3labs/mcp-go v0.8.5 h1:s5oRwQfs83Jim3ZAcQMyUQNHzCEVIuGD12GV8vhJqqc= 4 | github.com/mark3labs/mcp-go v0.8.5/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE= 5 | -------------------------------------------------------------------------------- /priv/dev/calculator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | ) 12 | 13 | func main() { 14 | s := server.NewMCPServer("Calculator", "0.1.0", server.WithLogging()) 15 | 16 | tool := mcp.NewTool("calculate", 17 | mcp.WithDescription("Perform basic arithmetic operations"), 18 | mcp.WithString("operation", 19 | mcp.Required(), 20 | mcp.Description("The operation to perform (add, subtract, multiply, divide)"), 21 | mcp.Enum("add", "subtract", "multiply", "divide"), 22 | ), 23 | mcp.WithNumber("x", 24 | mcp.Required(), 25 | mcp.Description("First number"), 26 | ), 27 | mcp.WithNumber("y", 28 | mcp.Required(), 29 | mcp.Description("Second number"), 30 | ), 31 | ) 32 | 33 | s.AddTool(tool, handle_calculate_tool) 34 | 35 | var transport string 36 | flag.StringVar(&transport, "t", "stdio", "Transport type (stdio or sse)") 37 | flag.StringVar( 38 | &transport, 39 | "transport", 40 | "stdio", 41 | "Transport type (stdio or sse)", 42 | ) 43 | flag.Parse() 44 | 45 | switch transport { 46 | case "stdio": 47 | if err := server.ServeStdio(s); err != nil { 48 | log.Fatalf("Server error: %v", err) 49 | } 50 | case "sse": 51 | sse := server.NewSSEServer(s, "") 52 | log.Printf("SSE server listening on :8000") 53 | 54 | if err := sse.Start(":8000"); err != nil { 55 | log.Fatalf("Server error: %v", err) 56 | } 57 | default: 58 | log.Fatalf( 59 | "Invalid transport type: %s. Must be 'stdio' or 'sse'", 60 | transport, 61 | ) 62 | } 63 | } 64 | 65 | func handle_calculate_tool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 66 | op := request.Params.Arguments["operation"].(string) 67 | x := request.Params.Arguments["x"].(float64) 68 | y := request.Params.Arguments["y"].(float64) 69 | 70 | if op == "div" && y == 0 { 71 | return mcp.NewToolResultError("Cannot divide by zero"), nil 72 | } 73 | 74 | if op == "add" { 75 | return mcp.NewToolResultText(fmt.Sprintf("%v", x+y)), nil 76 | } 77 | 78 | if op == "mult" { 79 | return mcp.NewToolResultText(fmt.Sprintf("%v", x*y)), nil 80 | } 81 | 82 | if op == "sub" { 83 | return mcp.NewToolResultText(fmt.Sprintf("%v", x-y)), nil 84 | } 85 | 86 | if op == "div" { 87 | return mcp.NewToolResultText(fmt.Sprintf("%v", x/y)), nil 88 | } 89 | 90 | return mcp.NewToolResultError(fmt.Sprintf("operation %s isn't supported", op)), nil 91 | } 92 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix, :hermes_mcp], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | echo-*.tar 27 | 28 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/README.md: -------------------------------------------------------------------------------- 1 | # Echo 2 | 3 | To start your Phoenix server: 4 | 5 | * Run `mix setup` to install and setup dependencies 6 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 7 | 8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 9 | 10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 11 | 12 | ## Learn more 13 | 14 | * Official website: https://www.phoenixframework.org/ 15 | * Guides: https://hexdocs.pm/phoenix/overview.html 16 | * Docs: https://hexdocs.pm/phoenix 17 | * Forum: https://elixirforum.com/c/phoenix-forum 18 | * Source: https://github.com/phoenixframework/phoenix 19 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :echo, 4 | generators: [timestamp_type: :utc_datetime] 5 | 6 | config :echo, EchoWeb.Endpoint, 7 | url: [host: "localhost"], 8 | adapter: Bandit.PhoenixAdapter, 9 | render_errors: [ 10 | formats: [json: EchoWeb.ErrorJSON], 11 | layout: false 12 | ], 13 | pubsub_server: Echo.PubSub, 14 | live_view: [signing_salt: "LpHUsU88"] 15 | 16 | config :logger, :console, 17 | format: "$time $metadata[$level] $message\n", 18 | metadata: [:request_id] 19 | 20 | config :phoenix, :json_library, JSON 21 | 22 | config :mime, :types, %{ 23 | "text/event-stream" => ["event-stream"], 24 | } 25 | 26 | import_config "#{config_env()}.exs" 27 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :echo, EchoWeb.Endpoint, 4 | http: [ip: {0, 0, 0, 0}, port: 4000], 5 | check_origin: false, 6 | code_reloader: true, 7 | debug_errors: true, 8 | secret_key_base: "TfLxrJKWMpgSv68tfCDQc26jz0tZ4Hlz+kB3WgDhh1LiMov+JlOV6cx+LvDetMSX", 9 | watchers: [] 10 | 11 | # Enable dev routes for dashboard and mailbox 12 | config :echo, dev_routes: true 13 | 14 | # Set a higher stacktrace during development. Avoid configuring such 15 | # in production as building large stacktraces may be expensive. 16 | config :phoenix, :stacktrace_depth, 20 17 | 18 | # Initialize plugs at runtime for faster development compilation 19 | config :phoenix, :plug_init_mode, :runtime 20 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Do not print debug messages in production 4 | config :logger, level: :info 5 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if System.get_env("PHX_SERVER") do 4 | config :echo, EchoWeb.Endpoint, server: true 5 | end 6 | 7 | if config_env() == :prod do 8 | secret_key_base = 9 | System.get_env("SECRET_KEY_BASE") || 10 | raise """ 11 | environment variable SECRET_KEY_BASE is missing. 12 | You can generate one by calling: mix phx.gen.secret 13 | """ 14 | 15 | host = System.get_env("PHX_HOST") || "example.com" 16 | port = String.to_integer(System.get_env("PORT") || "4000") 17 | 18 | config :echo, EchoWeb.Endpoint, 19 | url: [host: host, port: 443, scheme: "https"], 20 | http: [ 21 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 22 | port: port 23 | ], 24 | secret_key_base: secret_key_base 25 | end 26 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :echo, EchoWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "/DS+/sht01z+3iswMfL6gKCQ/nXTQOnFdiLq56hjfZufBLfgJw8pZJr0/NNUAbsx", 8 | server: false 9 | 10 | # Print only warnings and errors during test 11 | config :logger, level: :warning 12 | 13 | # Initialize plugs at runtime for faster test compilation 14 | config :phoenix, :plug_init_mode, :runtime 15 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/lib/echo.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo do 2 | @moduledoc """ 3 | Echo keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | 10 | @doc """ 11 | Returns the version of the Specialist library. 12 | """ 13 | @spec version() :: String.t() | nil 14 | def version do 15 | if vsn = Application.spec(:specialist)[:vsn] do 16 | List.to_string(vsn) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/lib/echo/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | children = [ 9 | {Phoenix.PubSub, name: Echo.PubSub}, 10 | EchoWeb.Endpoint, 11 | Hermes.Server.Registry, 12 | {EchoMCP.Server, transport: {:sse, base_url: "/mcp", post_path: "/message"}} 13 | ] 14 | 15 | opts = [strategy: :one_for_one, name: Echo.Supervisor] 16 | Supervisor.start_link(children, opts) 17 | end 18 | 19 | @impl true 20 | def config_change(changed, _new, removed) do 21 | EchoWeb.Endpoint.config_change(changed, removed) 22 | :ok 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/lib/echo_mcp/server.ex: -------------------------------------------------------------------------------- 1 | defmodule EchoMCP.Server do 2 | @moduledoc false 3 | 4 | use Hermes.Server, name: "Echo Server", version: Echo.version(), capabilities: [:tools] 5 | 6 | def start_link(opts) do 7 | Hermes.Server.start_link(__MODULE__, :ok, opts) 8 | end 9 | 10 | component(EchoMCP.Tools.Echo) 11 | 12 | @impl true 13 | def init(:ok, frame) do 14 | {:ok, frame} 15 | end 16 | 17 | @impl true 18 | def handle_notification(_, frame) do 19 | {:noreply, frame} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/lib/echo_mcp/tools/echo.ex: -------------------------------------------------------------------------------- 1 | defmodule EchoMCP.Tools.Echo do 2 | @moduledoc """ 3 | Use this tool to repeat 4 | """ 5 | 6 | use Hermes.Server.Component, type: :tool 7 | 8 | alias Hermes.Server.Response 9 | 10 | schema do 11 | field(:text, {:required, :string}, description: "The text string to be echoed") 12 | end 13 | 14 | @impl true 15 | def execute(%{text: text}, frame) do 16 | dbg(frame) 17 | {:reply, Response.text(Response.tool(), text), frame} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/lib/echo_web.ex: -------------------------------------------------------------------------------- 1 | defmodule EchoWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use EchoWeb, :controller 9 | use EchoWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | end 30 | end 31 | 32 | def channel do 33 | quote do 34 | use Phoenix.Channel 35 | end 36 | end 37 | 38 | def controller do 39 | quote do 40 | use Phoenix.Controller, 41 | formats: [:html, :json], 42 | layouts: [html: EchoWeb.Layouts] 43 | 44 | import Plug.Conn 45 | 46 | unquote(verified_routes()) 47 | end 48 | end 49 | 50 | def verified_routes do 51 | quote do 52 | use Phoenix.VerifiedRoutes, 53 | endpoint: EchoWeb.Endpoint, 54 | router: EchoWeb.Router, 55 | statics: EchoWeb.static_paths() 56 | end 57 | end 58 | 59 | @doc """ 60 | When used, dispatch to the appropriate controller/live_view/etc. 61 | """ 62 | defmacro __using__(which) when is_atom(which) do 63 | apply(__MODULE__, which, []) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/lib/echo_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule EchoWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/lib/echo_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule EchoWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :echo 3 | 4 | @session_options [ 5 | store: :cookie, 6 | key: "_echo_key", 7 | signing_salt: "jggawrb5", 8 | same_site: "Lax" 9 | ] 10 | 11 | plug Plug.Static, 12 | at: "/", 13 | from: :echo, 14 | gzip: false, 15 | only: EchoWeb.static_paths() 16 | 17 | if code_reloading? do 18 | plug Phoenix.CodeReloader 19 | end 20 | 21 | plug Plug.RequestId, assign_as: :request_id 22 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 23 | 24 | plug Plug.Parsers, 25 | parsers: [:urlencoded, :multipart, :json], 26 | pass: ["*/*"], 27 | json_decoder: Phoenix.json_library() 28 | 29 | plug Plug.MethodOverride 30 | plug Plug.Head 31 | plug Plug.Session, @session_options 32 | plug EchoWeb.Router 33 | end 34 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/lib/echo_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule EchoWeb.Router do 2 | use EchoWeb, :router 3 | 4 | alias Hermes.Server.Transport.SSE 5 | 6 | pipeline :sse do 7 | plug :accepts, ["json", "event-stream"] 8 | end 9 | 10 | scope "/mcp" do 11 | pipe_through :sse 12 | 13 | get "/sse", SSE.Plug, server: EchoMCP.Server, mode: :sse 14 | post "/message", SSE.Plug, server: EchoMCP.Server, mode: :post 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Echo.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :echo, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | def application do 17 | [ 18 | mod: {Echo.Application, []}, 19 | extra_applications: [:logger, :runtime_tools, :wx, :observer] 20 | ] 21 | end 22 | 23 | defp elixirc_paths(:test), do: ["lib", "test/support"] 24 | defp elixirc_paths(_), do: ["lib"] 25 | 26 | defp deps do 27 | [ 28 | {:phoenix, "~> 1.7.21"}, 29 | {:bandit, "~> 1.5"}, 30 | {:hermes_mcp, path: "../../../"} 31 | ] 32 | end 33 | 34 | defp aliases do 35 | [ 36 | setup: ["deps.get", "compile --force --warning-as-errors"] 37 | ] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | User-agent: * 5 | Disallow: / 6 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | PHX_SERVER=true exec ./echo start 6 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\echo" start 3 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/test/echo_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EchoWeb.ErrorJSONTest do 2 | use EchoWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert EchoWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert EchoWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule EchoWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use EchoWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint EchoWeb.Endpoint 24 | 25 | use EchoWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import EchoWeb.ConnCase 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /priv/dev/echo-elixir/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /priv/dev/echo/.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /priv/dev/echo/index.py: -------------------------------------------------------------------------------- 1 | from mcp.server.fastmcp import FastMCP 2 | 3 | mcp = FastMCP("Echo") 4 | 5 | @mcp.resource("echo://{message}") 6 | def echo_resource(message: str) -> str: 7 | """Echo a message as a resource""" 8 | return f"Resource echo: {message}" 9 | 10 | @mcp.tool() 11 | def echo_tool(message: str) -> str: 12 | """Echo a message as a tool""" 13 | return f"Tool echo: {message}" 14 | 15 | @mcp.prompt() 16 | def echo_prompt(message: str) -> str: 17 | """Create an echo prompt""" 18 | return f"Please process this message: {message}" 19 | 20 | if __name__ == "__main__": 21 | mcp.run(transport='stdio') 22 | -------------------------------------------------------------------------------- /priv/dev/echo/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "echo" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "httpx>=0.28.1", 9 | "mcp[cli]>=1.3.0", 10 | ] 11 | -------------------------------------------------------------------------------- /priv/dev/upcase/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:plug, :hermes_mcp] 5 | ] 6 | -------------------------------------------------------------------------------- /priv/dev/upcase/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | upcase-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | -------------------------------------------------------------------------------- /priv/dev/upcase/README.md: -------------------------------------------------------------------------------- 1 | # Upcase MCP Server 2 | 3 | A simple MCP server that converts text to uppercase using the Hermes MCP library for both client and server. 4 | 5 | ## Description 6 | 7 | This server provides a tool called `upcase` which takes a string parameter `text` and returns the uppercase version of that text. 8 | 9 | ## Building 10 | 11 | ```bash 12 | cd priv/dev/upcase && mix assemble 13 | ``` 14 | 15 | ## Running (needs Erlang on PATH) 16 | 17 | ```bash 18 | ./upcase 19 | ``` 20 | 21 | ## Example client code 22 | 23 | ```elixir 24 | # Initialize the client 25 | {:ok, client} = Hermes.Client.start_link(transport: :stdio) 26 | 27 | # List available tools 28 | {:ok, %{"tools" => tools}} = Hermes.Client.list_tools(client) 29 | 30 | # Call the upcase tool 31 | {:ok, %{"content" => [%{"text" => result}]}} = 32 | Hermes.Client.call_tool(client, "upcase", %{text: "Hello, World!"}) 33 | 34 | # Output: "HELLO, WORLD!" 35 | IO.puts(result) 36 | ``` 37 | -------------------------------------------------------------------------------- /priv/dev/upcase/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :hermes_mcp, log: true 4 | 5 | if config_env() == :dev do 6 | config :logger, level: :debug 7 | end 8 | -------------------------------------------------------------------------------- /priv/dev/upcase/lib/upcase/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Upcase.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | Hermes.Server.Registry, 12 | {Upcase.Server, transport: {:streamable_http, []}}, 13 | {Bandit, plug: Upcase.Router} 14 | ] 15 | 16 | # See https://hexdocs.pm/elixir/Supervisor.html 17 | # for other strategies and supported options 18 | opts = [strategy: :one_for_one, name: Upcase.Supervisor] 19 | Supervisor.start_link(children, opts) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/dev/upcase/lib/upcase/prompts/text_transform.ex: -------------------------------------------------------------------------------- 1 | defmodule Upcase.Prompts.TextTransform do 2 | @moduledoc "Generate prompts for various text transformation requests" 3 | 4 | use Hermes.Server.Component, type: :prompt 5 | alias Hermes.Server.Response 6 | 7 | schema do 8 | %{ 9 | text: {:required, :string}, 10 | transformations: { 11 | {:list, {:enum, ["uppercase", "lowercase", "titlecase", "reverse", "remove_spaces"]}}, 12 | {:default, ["uppercase"]} 13 | }, 14 | explain: {:boolean, {:default, false}} 15 | } 16 | end 17 | 18 | @impl true 19 | def get_messages(%{text: text, transformations: transforms, explain: explain?}, frame) do 20 | transform_list = Enum.join(transforms, ", ") 21 | 22 | base_message = """ 23 | Please apply the following transformations to this text: #{transform_list} 24 | 25 | Text: "#{text}" 26 | """ 27 | 28 | explanation = 29 | if explain? do 30 | "\n\nAlso explain what each transformation does and show the result after each step." 31 | else 32 | "" 33 | end 34 | 35 | {:reply, Response.user_message(Response.prompt(), base_message <> explanation), frame} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /priv/dev/upcase/lib/upcase/resources/examples.ex: -------------------------------------------------------------------------------- 1 | defmodule Upcase.Resources.Examples do 2 | @moduledoc "Provides example texts for transformation" 3 | 4 | use Hermes.Server.Component, 5 | type: :resource, 6 | uri: "upcase://examples", 7 | mime_type: "application/json" 8 | 9 | alias Hermes.Server.Response 10 | 11 | @impl true 12 | def read(_params, frame) do 13 | examples = %{ 14 | "examples" => [ 15 | %{ 16 | "title" => "Basic Text", 17 | "text" => "hello world", 18 | "description" => "Simple lowercase text for basic transformations" 19 | }, 20 | %{ 21 | "title" => "Mixed Case", 22 | "text" => "ThE QuIcK BrOwN FoX", 23 | "description" => "Text with mixed casing" 24 | }, 25 | %{ 26 | "title" => "With Punctuation", 27 | "text" => "Hello, World! How are you?", 28 | "description" => "Text with punctuation marks" 29 | }, 30 | %{ 31 | "title" => "Multi-line", 32 | "text" => "First line\nSecond line\nThird line", 33 | "description" => "Text spanning multiple lines" 34 | }, 35 | %{ 36 | "title" => "Special Characters", 37 | "text" => "café résumé naïve", 38 | "description" => "Text with accented characters" 39 | } 40 | ], 41 | "metadata" => %{ 42 | "version" => "1.0.0", 43 | "last_updated" => DateTime.utc_now() |> DateTime.to_iso8601() 44 | } 45 | } 46 | 47 | {:reply, Response.json(Response.resource(), examples), frame} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /priv/dev/upcase/lib/upcase/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Upcase.Router do 2 | use Plug.Router 3 | 4 | alias Hermes.Server.Transport.StreamableHTTP 5 | 6 | plug Plug.Logger 7 | plug :match 8 | plug :dispatch 9 | 10 | forward "/mcp", to: StreamableHTTP.Plug, init_opts: [server: Upcase.Server] 11 | 12 | match _ do 13 | send_resp(conn, 404, "not found") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/dev/upcase/lib/upcase/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Upcase.Server do 2 | @moduledoc """ 3 | A simple MCP server that upcases input text. 4 | """ 5 | 6 | use Hermes.Server, 7 | name: "Upcase MCP Server", 8 | version: "1.0.0", 9 | capabilities: [:tools, :prompts, :resources] 10 | 11 | def start_link(opts \\ []) do 12 | Hermes.Server.start_link(__MODULE__, :ok, opts) 13 | end 14 | 15 | component(Upcase.Tools.Upcase) 16 | component(Upcase.Tools.AnalyzeText) 17 | component(Upcase.Prompts.TextTransform) 18 | component(Upcase.Resources.Examples) 19 | 20 | @impl true 21 | def init(:ok, frame) do 22 | {:ok, frame} 23 | end 24 | 25 | @impl true 26 | def handle_notification(_notification, state) do 27 | {:noreply, state} 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /priv/dev/upcase/lib/upcase/tools/analyze_text.ex: -------------------------------------------------------------------------------- 1 | defmodule Upcase.Tools.AnalyzeText do 2 | @moduledoc "Analyzes text and returns structured data" 3 | 4 | use Hermes.Server.Component, type: :tool 5 | alias Hermes.Server.Response 6 | 7 | schema do 8 | %{text: {:required, :string}} 9 | end 10 | 11 | @impl true 12 | def execute(%{text: text}, frame) do 13 | analysis = %{ 14 | original: text, 15 | length: String.length(text), 16 | word_count: length(String.split(text, ~r/\s+/, trim: true)), 17 | character_stats: %{ 18 | uppercase: count_chars(text, &(&1 in ?A..?Z)), 19 | lowercase: count_chars(text, &(&1 in ?a..?z)), 20 | digits: count_chars(text, &(&1 in ?0..?9)), 21 | spaces: count_chars(text, &(&1 == ?\s)) 22 | }, 23 | transformations: %{ 24 | uppercase: String.upcase(text), 25 | lowercase: String.downcase(text), 26 | reversed: String.reverse(text) 27 | } 28 | } 29 | 30 | {:reply, Response.json(Response.tool(), analysis), frame} 31 | end 32 | 33 | defp count_chars(text, predicate) do 34 | text 35 | |> String.to_charlist() 36 | |> Enum.count(predicate) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /priv/dev/upcase/lib/upcase/tools/upcase.ex: -------------------------------------------------------------------------------- 1 | defmodule Upcase.Tools.Upcase do 2 | @moduledoc "Converts text to upcase" 3 | 4 | use Hermes.Server.Component, type: :tool 5 | alias Hermes.Server.Response 6 | 7 | schema do 8 | %{text: {:required, :string}} 9 | end 10 | 11 | @impl true 12 | def execute(%{text: text}, frame) do 13 | {:reply, Response.text(Response.tool(), String.upcase(text)), frame} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/dev/upcase/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Upcase.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :upcase, 7 | version: "0.1.0", 8 | elixir: "~> 1.18", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger, :wx, :observer], 18 | mod: {Upcase.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:hermes_mcp, path: "../../../"}, 26 | {:plug, "~> 1.18"}, 27 | {:bandit, "~> 1.6"} 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /priv/dev/upcase/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, 3 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 4 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 5 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 6 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 7 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 8 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 9 | "peri": {:hex, :peri, "0.4.0", "eaa0c0bcf878f70d0bea71c63102f667ee0568f02ec0a97a98a8b30d8563f3aa", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "ce1835dc5e202b6c7608100ee32df569965fa5775a75100ada7a82260d46c1a8"}, 10 | "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"}, 11 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 12 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 13 | "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, 14 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 15 | } 16 | -------------------------------------------------------------------------------- /priv/dev/upcase/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/hermes/mcp/response_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hermes.MCP.ResponseTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Hermes.MCP.Response 5 | 6 | @moduletag capture_log: true 7 | 8 | doctest Hermes.MCP.Response 9 | 10 | describe "from_json_rpc/1" do 11 | test "creates a response from a JSON-RPC response" do 12 | json_response = %{ 13 | "jsonrpc" => "2.0", 14 | "result" => %{"data" => "value"}, 15 | "id" => "req_123" 16 | } 17 | 18 | response = Response.from_json_rpc(json_response) 19 | 20 | assert response.result == %{"data" => "value"} 21 | assert response.id == "req_123" 22 | assert response.is_error == false 23 | end 24 | 25 | test "detects domain errors with isError field" do 26 | json_response = %{ 27 | "jsonrpc" => "2.0", 28 | "result" => %{"isError" => true, "reason" => "not_found"}, 29 | "id" => "req_123" 30 | } 31 | 32 | response = Response.from_json_rpc(json_response) 33 | 34 | assert response.result == %{"isError" => true, "reason" => "not_found"} 35 | assert response.id == "req_123" 36 | assert response.is_error == true 37 | end 38 | 39 | test "handles non-map results" do 40 | # This should not happen in practice with MCP, but testing for robustness 41 | json_response = %{ 42 | "jsonrpc" => "2.0", 43 | "result" => "string_result", 44 | "id" => "req_123" 45 | } 46 | 47 | response = Response.from_json_rpc(json_response) 48 | 49 | assert response.result == "string_result" 50 | assert response.id == "req_123" 51 | assert response.is_error == false 52 | end 53 | end 54 | 55 | describe "unwrap/1" do 56 | test "returns the raw result for any response" do 57 | success_response = %Response{ 58 | result: %{"data" => "value"}, 59 | id: "req_123", 60 | is_error: false 61 | } 62 | 63 | error_response = %Response{ 64 | result: %{"isError" => true, "reason" => "not_found"}, 65 | id: "req_123", 66 | is_error: true 67 | } 68 | 69 | assert Response.unwrap(success_response) == %{"data" => "value"} 70 | assert Response.unwrap(error_response) == %{"isError" => true, "reason" => "not_found"} 71 | end 72 | end 73 | 74 | describe "success?/1" do 75 | test "returns true for successful responses" do 76 | response = %Response{ 77 | result: %{"data" => "value"}, 78 | id: "req_123", 79 | is_error: false 80 | } 81 | 82 | assert Response.success?(response) == true 83 | end 84 | 85 | test "returns false for domain errors" do 86 | response = %Response{ 87 | result: %{"isError" => true}, 88 | id: "req_123", 89 | is_error: true 90 | } 91 | 92 | assert Response.success?(response) == false 93 | end 94 | end 95 | 96 | describe "error?/1" do 97 | test "returns false for successful responses" do 98 | response = %Response{ 99 | result: %{"data" => "value"}, 100 | id: "req_123", 101 | is_error: false 102 | } 103 | 104 | assert Response.error?(response) == false 105 | end 106 | 107 | test "returns true for domain errors" do 108 | response = %Response{ 109 | result: %{"isError" => true}, 110 | id: "req_123", 111 | is_error: true 112 | } 113 | 114 | assert Response.error?(response) == true 115 | end 116 | end 117 | 118 | describe "get_result/1" do 119 | test "returns the raw result regardless of error status" do 120 | success_response = %Response{ 121 | result: %{"data" => "value"}, 122 | id: "req_123", 123 | is_error: false 124 | } 125 | 126 | error_response = %Response{ 127 | result: %{"isError" => true, "reason" => "not_found"}, 128 | id: "req_123", 129 | is_error: true 130 | } 131 | 132 | assert Response.get_result(success_response) == %{"data" => "value"} 133 | assert Response.get_result(error_response) == %{"isError" => true, "reason" => "not_found"} 134 | end 135 | end 136 | 137 | describe "get_id/1" do 138 | test "returns the request ID" do 139 | response = %Response{ 140 | result: %{}, 141 | id: "req_123", 142 | is_error: false 143 | } 144 | 145 | assert Response.get_id(response) == "req_123" 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/hermes/server/base_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.BaseTest do 2 | use Hermes.MCP.Case, async: true 3 | 4 | alias Hermes.MCP.Message 5 | alias Hermes.Server.Base 6 | 7 | require Message 8 | 9 | @moduletag capture_log: true 10 | 11 | describe "start_link/1" do 12 | test "starts a server with valid options" do 13 | transport = start_supervised!(StubTransport) 14 | 15 | assert {:ok, pid} = 16 | Base.start_link( 17 | module: StubServer, 18 | name: :named_server, 19 | init_arg: :ok, 20 | transport: [layer: StubTransport, name: transport] 21 | ) 22 | 23 | assert Process.alive?(pid) 24 | end 25 | 26 | test "starts a named server" do 27 | transport = start_supervised!({StubTransport, []}, id: :named_transport) 28 | 29 | assert {:ok, _pid} = 30 | Base.start_link( 31 | module: StubServer, 32 | name: :named_server, 33 | init_arg: :ok, 34 | transport: [layer: StubTransport, name: transport] 35 | ) 36 | 37 | assert pid = Process.whereis(:named_server) 38 | assert Process.alive?(pid) 39 | end 40 | end 41 | 42 | describe "handle_call/3 for messages" do 43 | setup :initialized_server 44 | 45 | @tag skip: true 46 | test "handles errors", %{server: server} do 47 | error = build_error(-32_000, "got wrong", 1) 48 | assert {:ok, _} = GenServer.call(server, {:request, error, "123", %{}}) 49 | end 50 | 51 | test "rejects requests when not initialized", %{server: server} do 52 | request = build_request("tools/list") 53 | assert {:ok, _} = GenServer.call(server, {:request, request, "not_initialized", %{}}) 54 | end 55 | 56 | test "accept ping requests when not initialized", %{server: server, session_id: session_id} do 57 | request = build_request("ping") 58 | assert {:ok, _} = GenServer.call(server, {:request, request, session_id, %{}}) 59 | end 60 | end 61 | 62 | describe "handle_cast/2 for notifications" do 63 | setup :initialized_server 64 | 65 | test "handles notifications", %{server: server, session_id: session_id} do 66 | notification = build_notification("notifications/cancelled", %{"requestId" => 1}) 67 | assert :ok = GenServer.cast(server, {:notification, notification, session_id, %{}}) 68 | end 69 | 70 | test "handles initialize notification", %{server: server, session_id: session_id} do 71 | notification = build_notification("notifications/initialized", %{}) 72 | assert :ok = GenServer.cast(server, {:notification, notification, session_id, %{}}) 73 | end 74 | end 75 | 76 | describe "send_notification/3" do 77 | setup :initialized_server 78 | 79 | test "sends notification to transport", %{server: server} do 80 | params = %{"logger" => "database", "level" => "error", "data" => %{}} 81 | assert :ok = Base.send_notification(server, "notifications/message", params) 82 | # TODO(zoedsoupe): assert on StubTransport 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/hermes/server/component_prompt_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.ComponentPromptTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias TestPrompts.FieldPrompt 5 | alias TestPrompts.LegacyPrompt 6 | alias TestPrompts.NestedPrompt 7 | 8 | describe "prompt with field metadata" do 9 | test "generates correct arguments with descriptions" do 10 | arguments = FieldPrompt.arguments() 11 | 12 | assert length(arguments) == 3 13 | 14 | assert %{ 15 | "name" => "code", 16 | "description" => "The code to review", 17 | "required" => true 18 | } in arguments 19 | 20 | assert %{ 21 | "name" => "language", 22 | "description" => "Programming language", 23 | "required" => true 24 | } in arguments 25 | 26 | assert %{ 27 | "name" => "focus_areas", 28 | "description" => "Areas to focus on (optional)", 29 | "required" => false 30 | } in arguments 31 | end 32 | 33 | test "supports nested fields in prompts" do 34 | arguments = NestedPrompt.arguments() 35 | 36 | assert [%{"name" => "config", "description" => "Configuration options", "required" => false}] = arguments 37 | end 38 | 39 | test "backward compatibility with legacy prompt schemas" do 40 | arguments = LegacyPrompt.arguments() 41 | 42 | assert length(arguments) == 2 43 | 44 | assert %{ 45 | "name" => "query", 46 | "description" => "Required string parameter", 47 | "required" => true 48 | } in arguments 49 | 50 | assert %{ 51 | "name" => "max_results", 52 | "description" => "Optional integer parameter (default: 10)", 53 | "required" => false 54 | } in arguments 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/hermes/server/transport/stdio_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.Transport.STDIOTest do 2 | use Hermes.MCP.Case, async: false 3 | 4 | import ExUnit.CaptureIO 5 | 6 | alias Hermes.Server.Transport.STDIO 7 | 8 | @moduletag capture_log: true, capture_io: true, skip: true 9 | 10 | setup :server_with_stdio_transport 11 | 12 | describe "start_link/1" do 13 | test "starts successfully with valid options", %{server: server} do 14 | name = :"test_stdio_transport_#{:rand.uniform(1_000_000)}" 15 | opts = [server: server, name: name] 16 | 17 | assert {:ok, pid} = STDIO.start_link(opts) 18 | assert Process.alive?(pid) 19 | assert Process.whereis(name) == pid 20 | 21 | assert :ok = STDIO.shutdown(pid) 22 | wait_for_process_exit(pid) 23 | end 24 | end 25 | 26 | describe "send_message/2" do 27 | @tag skip: true 28 | test "sends message via cast", %{server: server} do 29 | name = :"test_send_message_#{:rand.uniform(1_000_000)}" 30 | {:ok, pid} = STDIO.start_link(server: server, name: name) 31 | 32 | message = "test message" 33 | 34 | assert capture_io(pid, fn -> 35 | assert :ok = STDIO.send_message(pid, message) 36 | Process.sleep(50) 37 | end) =~ "test message" 38 | 39 | assert :ok = STDIO.shutdown(pid) 40 | wait_for_process_exit(pid) 41 | end 42 | end 43 | 44 | describe "shutdown/1" do 45 | test "shuts down the transport gracefully", %{server: server} do 46 | name = :"shutdown_test_#{:rand.uniform(1_000_000)}" 47 | {:ok, pid} = STDIO.start_link(server: server, name: name) 48 | 49 | ref = Process.monitor(pid) 50 | 51 | assert :ok = STDIO.shutdown(pid) 52 | assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 1000 53 | 54 | refute Process.whereis(name) 55 | end 56 | end 57 | 58 | describe "basic functionality" do 59 | test "starts and stops cleanly", %{server: server} do 60 | name = :"basic_test_#{:rand.uniform(1_000_000)}" 61 | 62 | assert {:ok, pid} = STDIO.start_link(server: server, name: name) 63 | assert Process.alive?(pid) 64 | 65 | assert :ok = STDIO.shutdown(pid) 66 | wait_for_process_exit(pid) 67 | end 68 | 69 | test "manages reading tasks correctly", %{server: server} do 70 | name = :"async_test_#{:rand.uniform(1_000_000)}" 71 | {:ok, pid} = STDIO.start_link(server: server, name: name) 72 | 73 | assert Process.alive?(pid) 74 | 75 | STDIO.shutdown(pid) 76 | wait_for_process_exit(pid) 77 | end 78 | end 79 | 80 | defp wait_for_process_exit(pid) do 81 | ref = Process.monitor(pid) 82 | 83 | receive do 84 | {:DOWN, ^ref, :process, ^pid, _} -> :ok 85 | after 86 | 500 -> :error 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/hermes/server/transport/streamable_http_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Server.Transport.StreamableHTTPTest do 2 | use Hermes.MCP.Case, async: true 3 | 4 | alias Hermes.Server.Transport.StreamableHTTP 5 | 6 | @moduletag skip: true 7 | 8 | describe "start_link/1" do 9 | test "starts with valid options" do 10 | server = Hermes.Server.Registry.server(StubServer) 11 | name = Hermes.Server.Registry.transport(StubServer, :streamable_http) 12 | 13 | assert {:ok, pid} = StreamableHTTP.start_link(server: server, name: name) 14 | assert Process.alive?(pid) 15 | assert Hermes.Server.Registry.whereis_transport(StubServer, :streamable_http) == pid 16 | end 17 | 18 | test "requires server option" do 19 | assert_raise Peri.InvalidSchema, fn -> 20 | StreamableHTTP.start_link(name: :test) 21 | end 22 | end 23 | end 24 | 25 | describe "with running transport" do 26 | setup do 27 | registry = Hermes.Server.Registry 28 | name = registry.transport(StubServer, :streamable_http) 29 | {:ok, transport} = start_supervised({StreamableHTTP, server: StubServer, name: name, registry: registry}) 30 | 31 | %{transport: transport, server: StubServer} 32 | end 33 | 34 | test "registers and unregisters SSE handlers", %{transport: transport} do 35 | session_id = "test-session-123" 36 | handler_pid = self() 37 | 38 | assert :ok = StreamableHTTP.register_sse_handler(transport, session_id) 39 | assert ^handler_pid = StreamableHTTP.get_sse_handler(transport, session_id) 40 | assert :ok = StreamableHTTP.unregister_sse_handler(transport, session_id) 41 | refute StreamableHTTP.get_sse_handler(transport, session_id) 42 | end 43 | 44 | test "handle_message_for_sse fails when server is not in registry", %{transport: transport} do 45 | session_id = "test-session-456" 46 | 47 | assert :ok = StreamableHTTP.register_sse_handler(transport, session_id) 48 | message = build_request("ping", %{}) 49 | 50 | StreamableHTTP.handle_message_for_sse(transport, session_id, message, %{}) 51 | end 52 | 53 | test "routes messages to sessions", %{transport: transport} do 54 | session_id = "test-session-789" 55 | 56 | assert :ok = StreamableHTTP.register_sse_handler(transport, session_id) 57 | 58 | message = "test message" 59 | assert :ok = StreamableHTTP.route_to_session(transport, session_id, message) 60 | 61 | assert_receive {:sse_message, ^message} 62 | end 63 | 64 | test "cleans up handlers when they crash", %{transport: transport} do 65 | session_id = "test-session-crash" 66 | test_pid = self() 67 | 68 | handler_pid = 69 | spawn(fn -> 70 | StreamableHTTP.register_sse_handler(transport, session_id) 71 | send(test_pid, :registered) 72 | 73 | receive do 74 | :crash -> exit(:boom) 75 | end 76 | end) 77 | 78 | assert_receive :registered, 1000 79 | 80 | handler = StreamableHTTP.get_sse_handler(transport, session_id) 81 | assert is_pid(handler) 82 | 83 | send(handler_pid, :crash) 84 | Process.sleep(100) 85 | 86 | refute StreamableHTTP.get_sse_handler(transport, session_id) 87 | end 88 | 89 | test "send_message/2 works", %{transport: transport} do 90 | message = "test message" 91 | assert :ok = StreamableHTTP.send_message(transport, message) 92 | end 93 | 94 | test "shutdown/1 gracefully shuts down", %{transport: transport} do 95 | assert Process.alive?(transport) 96 | assert :ok = StreamableHTTP.shutdown(transport) 97 | Process.sleep(100) 98 | refute Process.alive?(transport) 99 | end 100 | end 101 | 102 | describe "supported_protocol_versions/0" do 103 | test "returns supported versions" do 104 | versions = StreamableHTTP.supported_protocol_versions() 105 | assert is_list(versions) 106 | assert "2025-03-26" in versions 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/hermes/transport/stdio_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hermes.Transport.STDIOTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Hermes.Transport.STDIO 5 | 6 | @moduletag capture_log: true 7 | 8 | setup do 9 | start_supervised!(StubClient) 10 | 11 | command = if :os.type() == {:win32, :nt}, do: "cmd", else: "echo" 12 | 13 | %{command: command} 14 | end 15 | 16 | describe "start_link/1" do 17 | test "successfully starts transport", %{command: command} do 18 | opts = [ 19 | client: StubClient, 20 | command: command, 21 | args: ["hello"], 22 | name: :test_transport 23 | ] 24 | 25 | assert {:ok, pid} = STDIO.start_link(opts) 26 | assert Process.whereis(:test_transport) != nil 27 | 28 | safe_stop(pid) 29 | end 30 | 31 | test "fails when command not found" do 32 | opts = [ 33 | client: StubClient, 34 | command: "", 35 | name: :should_fail 36 | ] 37 | 38 | assert {:ok, pid} = STDIO.start_link(opts) 39 | Process.flag(:trap_exit, true) 40 | assert_receive {:EXIT, ^pid, {:error, _}} 41 | end 42 | end 43 | 44 | describe "send_message/2" do 45 | setup %{command: command} do 46 | {:ok, pid} = 47 | STDIO.start_link( 48 | client: StubClient, 49 | command: command, 50 | args: ["test"], 51 | name: :test_send_transport 52 | ) 53 | 54 | on_exit(fn -> safe_stop(pid) end) 55 | 56 | %{transport_pid: pid} 57 | end 58 | 59 | test "sends message successfully", %{transport_pid: pid} do 60 | assert :ok = STDIO.send_message(pid, "test message") 61 | end 62 | end 63 | 64 | describe "client message handling" do 65 | setup do 66 | command = if :os.type() == {:win32, :nt}, do: "cmd", else: "cat" 67 | 68 | {:ok, pid} = 69 | STDIO.start_link( 70 | client: StubClient, 71 | command: command, 72 | name: :test_echo_transport 73 | ) 74 | 75 | on_exit(fn -> safe_stop(pid) end) 76 | 77 | %{transport_pid: pid} 78 | end 79 | 80 | test "forwards data to client", %{transport_pid: pid} do 81 | :ok = StubClient.clear_messages() 82 | 83 | STDIO.send_message(pid, "echo test\n") 84 | 85 | Process.sleep(100) 86 | 87 | messages = StubClient.get_messages() 88 | assert length(messages) > 0 89 | end 90 | end 91 | 92 | describe "port behavior" do 93 | test "handles port restart on close" do 94 | command = if :os.type() == {:win32, :nt}, do: "cmd", else: "echo" 95 | 96 | transport_name = :restart_test_transport 97 | 98 | {:ok, pid} = 99 | STDIO.start_link( 100 | client: StubClient, 101 | command: command, 102 | name: transport_name 103 | ) 104 | 105 | original_pid = Process.whereis(transport_name) 106 | assert original_pid != nil 107 | 108 | ref = Process.monitor(original_pid) 109 | 110 | safe_stop(pid) 111 | 112 | assert_receive {:DOWN, ^ref, :process, ^original_pid, _}, 1000 113 | end 114 | end 115 | 116 | describe "environment variables" do 117 | test "uses environment variables" do 118 | command = if :os.type() == {:win32, :nt}, do: "cmd", else: "echo" 119 | 120 | :ok = StubClient.clear_messages() 121 | 122 | {:ok, pid} = 123 | STDIO.start_link( 124 | client: StubClient, 125 | command: command, 126 | args: ["TEST_CUSTOM_VAR=test_value"], 127 | env: %{"TEST_CUSTOM_VAR" => "test_value"}, 128 | name: :env_test_transport 129 | ) 130 | 131 | Process.sleep(100) 132 | 133 | messages = StubClient.get_messages() 134 | assert length(messages) > 0 135 | 136 | safe_stop(pid) 137 | end 138 | end 139 | 140 | defp safe_stop(pid) do 141 | if is_pid(pid) && Process.alive?(pid) do 142 | try do 143 | STDIO.shutdown(pid) 144 | Process.sleep(50) 145 | if Process.alive?(pid), do: GenServer.stop(pid, :normal, 100) 146 | catch 147 | :exit, _ -> :ok 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /test/hermes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HermesTest do 2 | use ExUnit.Case 3 | 4 | @moduletag capture_log: true 5 | end 6 | -------------------------------------------------------------------------------- /test/support/mcp/assertions.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.MCP.Assertions do 2 | @moduledoc false 3 | 4 | import ExUnit.Assertions, only: [assert: 2, assert: 1] 5 | 6 | def assert_client_initialized(client) when is_pid(client) do 7 | state = :sys.get_state(client) 8 | assert state.server_capabilities, "Expected server capabilities to be set" 9 | end 10 | 11 | def assert_server_initialized(server) when is_pid(server) do 12 | state = :sys.get_state(server) 13 | assert {session_id, _} = state.sessions |> Map.to_list() |> List.first() 14 | assert session = Hermes.Server.Registry.whereis_server_session(StubServer, session_id) 15 | state = :sys.get_state(session) 16 | assert state.initialized, "Expected server to be initialized" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/mcp/case.ex: -------------------------------------------------------------------------------- 1 | defmodule Hermes.MCP.Case do 2 | @moduledoc """ 3 | Test case template for MCP protocol testing. 4 | 5 | Provides a consistent setup and common imports for MCP tests. 6 | """ 7 | 8 | use ExUnit.CaseTemplate 9 | 10 | using opts do 11 | async = Keyword.get(opts, :async, false) 12 | 13 | quote do 14 | use ExUnit.Case, async: unquote(async) 15 | 16 | import Hermes.MCP.Assertions 17 | import Hermes.MCP.Builders 18 | import Hermes.MCP.Setup 19 | 20 | require Hermes.MCP.Message 21 | 22 | @moduletag capture_log: true 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/mock_transport.ex: -------------------------------------------------------------------------------- 1 | defmodule MockTransport do 2 | @moduledoc false 3 | @behaviour Hermes.Transport.Behaviour 4 | 5 | @impl true 6 | def start_link(_opts), do: {:ok, self()} 7 | 8 | @impl true 9 | def send_message(_, _), do: :ok 10 | 11 | @impl true 12 | def shutdown(_), do: :ok 13 | 14 | @impl true 15 | def supported_protocol_versions, do: ["2025-03-26", "2024-11-05"] 16 | end 17 | -------------------------------------------------------------------------------- /test/support/stub_client.ex: -------------------------------------------------------------------------------- 1 | defmodule StubClient do 2 | @moduledoc false 3 | use GenServer 4 | 5 | def start_link(_opts \\ []) do 6 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 7 | end 8 | 9 | def init(_) do 10 | {:ok, []} 11 | end 12 | 13 | def get_messages do 14 | GenServer.call(__MODULE__, :get_messages) 15 | end 16 | 17 | def clear_messages do 18 | GenServer.call(__MODULE__, :clear_messages) 19 | end 20 | 21 | def handle_call(:get_messages, _from, messages) do 22 | {:reply, Enum.reverse(messages), messages} 23 | end 24 | 25 | def handle_call(:clear_messages, _from, _messages) do 26 | {:reply, :ok, []} 27 | end 28 | 29 | def handle_cast(msg, messages), do: handle_info(msg, messages) 30 | 31 | def handle_info(:initialize, messages), do: {:noreply, messages} 32 | 33 | def handle_info({:response, data}, messages) do 34 | {:noreply, [data | messages]} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/stub_server.ex: -------------------------------------------------------------------------------- 1 | defmodule StubServer do 2 | @moduledoc """ 3 | Minimal test server that implements only the required callbacks. 4 | 5 | Used for testing low-level server functionality (Base server tests). 6 | This server has no components and provides only the bare minimum implementation. 7 | """ 8 | 9 | use Hermes.Server, name: "Test Server", version: "1.0.0", capabilities: [:tools, :prompts, :resources] 10 | 11 | alias Hermes.MCP.Error 12 | alias Hermes.Server.Response 13 | 14 | @tools [ 15 | %{ 16 | "name" => "greet", 17 | "description" => "greets someone", 18 | "inputSchema" => %{ 19 | "type" => "object", 20 | "properties" => %{"name" => %{"type" => "string", "description" => "for whom to greet"}}, 21 | "required" => ["name"] 22 | } 23 | } 24 | ] 25 | 26 | @resources [ 27 | %{ 28 | "uri" => "config://test", 29 | "description" => "config test", 30 | "name" => "config.test", 31 | "mimeType" => "text/plain" 32 | } 33 | ] 34 | 35 | @prompts [ 36 | %{ 37 | "name" => "beauty", 38 | "description" => "asks the llm to say if you're beautiful or not", 39 | "arguments" => [ 40 | %{ 41 | "name" => "who", 42 | "description" => "who to judge beauty", 43 | "required" => false 44 | } 45 | ] 46 | } 47 | ] 48 | 49 | def start_link(opts) do 50 | Hermes.Server.start_link(__MODULE__, :ok, opts) 51 | end 52 | 53 | @impl true 54 | def init(:ok, frame) do 55 | {:ok, frame} 56 | end 57 | 58 | @impl true 59 | def handle_request(%{"method" => "ping"}, frame) do 60 | {:reply, %{}, frame} 61 | end 62 | 63 | def handle_request(%{"method" => "tools/" <> action, "params" => params}, frame) do 64 | case action do 65 | "list" -> {:reply, %{"tools" => @tools}, frame} 66 | "call" -> handle_tool_call(params, frame) 67 | end 68 | end 69 | 70 | def handle_request(%{"method" => "prompts/" <> action, "params" => params}, frame) do 71 | case action do 72 | "list" -> {:reply, %{"prompts" => @prompts}, frame} 73 | "get" -> handle_prompt_get(params, frame) 74 | end 75 | end 76 | 77 | def handle_request(%{"method" => "resources/" <> action, "params" => params}, frame) do 78 | case action do 79 | "list" -> {:reply, %{"resources" => @resources}, frame} 80 | "read" -> handle_resource_read(params, frame) 81 | end 82 | end 83 | 84 | def handle_request(%{"method" => _}, frame) do 85 | {:error, Error.protocol(:method_not_found), frame} 86 | end 87 | 88 | @impl true 89 | def handle_notification(_notification, frame) do 90 | {:noreply, frame} 91 | end 92 | 93 | defp handle_tool_call(%{"arguments" => %{"name" => name}, "name" => "greet"}, frame) do 94 | Response.tool() 95 | |> Response.text("Hello #{name}!") 96 | |> Response.to_protocol() 97 | |> then(&{:reply, &1, frame}) 98 | end 99 | 100 | defp handle_tool_call(%{"name" => name}, frame) do 101 | {:error, Error.protocol(:invalid_request, %{message: "tool #{name} not found"}), frame} 102 | end 103 | 104 | defp handle_resource_read(%{"uri" => uri}, frame) do 105 | Response.resource() 106 | |> Response.text("some teste config") 107 | |> Response.to_protocol(uri, "text/plain") 108 | |> then(&%{"contents" => [&1]}) 109 | |> then(&{:reply, &1, frame}) 110 | end 111 | 112 | defp handle_prompt_get(%{"arguments" => %{"who" => who}}, frame) do 113 | Response.prompt() 114 | |> Response.user_message("says that #{who} is beuatiful") 115 | |> Response.to_protocol() 116 | |> then(&{:reply, &1, frame}) 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:mimic) 2 | 3 | Mox.defmock(Hermes.MockTransport, for: Hermes.Transport.Behaviour) 4 | 5 | if Code.ensure_loaded?(:gun), do: Mimic.copy(:gun) 6 | 7 | ExUnit.start() 8 | --------------------------------------------------------------------------------