added
.shellcheckrc +1 -0 | new file mode 120000 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +/Users/k33g/kDrive/Rickub/bots-garden/mini-me/.qlty/configs/.shellcheckrc | |
| \ No newline at end of file | ||
| new file mode 120000 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +/Users/k33g/kDrive/Rickub/bots-garden/mini-me/.qlty/configs/.shellcheckrc | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
demo/MCP-GITHUB.md +8 -9 | @@ -1,5 +1,4 @@ | ||
| 1 | - | |
| 2 | - | |
| 1 | +# Mini Me and GitHub MCP server | |
| 3 | 2 | |
| 4 | 3 | ```bash |
| 5 | 4 | # 1. Installer GitHub CLI |
| @@ -16,28 +15,28 @@ gh auth status --show-token | ||
| 16 | 15 | cat ~/.config/gh/hosts.yml | grep -oP 'token: \K.*' |
| 17 | 16 | ``` |
| 18 | 17 | |
| 18 | +## Vérifier que le token fonctionne | |
| 19 | 19 | |
| 20 | -# Vérifier que le token fonctionne | |
| 20 | +```bash | |
| 21 | 21 | curl -H "Authorization: Bearer $GITHUB_OAUTH_TOKEN" \ |
| 22 | 22 | https://api.github.com/user |
| 23 | +``` | |
| 23 | 24 | |
| 25 | +```bash | |
| 24 | 26 | mm --tui agent.dmr.github-oauth.yaml |
| 27 | +``` | |
| 25 | 28 | |
| 26 | -Pour mini-me dans sbx : Utilisez sbx mcp add github + sbx mcp auth github (méthode recommandée) Pour mini-me hors sbx : export GITHUB_OAUTH_TOKEN=ghp_xxx | |
| 29 | +## Mini Me and GitHub MCP server in `sbx` | |
| 27 | 30 | |
| 28 | 31 | |
| 29 | 32 | |
| 30 | -# 2. Register MCP servers: sbx mcp add github --url https://mcp.github.com/mcp | |
| 31 | -# 3. Authenticate: sbx mcp auth github | |
| 32 | -# 4. Start sandbox: sbx run mm --name my-session --config agent.dmr.sbx.yaml | |
| 33 | -# | |
| 34 | 33 | |
| 35 | 34 | sbx mcp add github --url https://api.githubcopilot.com/mcp/ |
| 36 | 35 | sbx mcp auth github |
| 37 | 36 | sbx mcp auth status --all |
| 38 | 37 | sbx mcp inspect github |
| 39 | 38 | |
| 40 | -sbx run k33g/mm-tui:0.1.4 . --name mm-mcp-github -- agent.dmr.sbx.yaml --static-mcp github | |
| 39 | +sbx run k33g/mm-tui:0.1.5 . --name mm-mcp-github -- agent.dmr.sbx.yaml --static-mcp github | |
| 41 | 40 | |
| 42 | 41 | sbx mcp load github --sandbox mm-mcp-github |
| 43 | 42 | |
| @@ -1,5 +1,4 @@ | |||
| 1 | - | 1 | +# Mini Me and GitHub MCP server |
| 2 | - | ||
| 3 | 2 | ||
| 4 | ```bash | 3 | ```bash |
| 5 | # 1. Installer GitHub CLI | 4 | # 1. Installer GitHub CLI |
| @@ -16,28 +15,28 @@ gh auth status --show-token | |||
| 16 | cat ~/.config/gh/hosts.yml | grep -oP 'token: \K.*' | 15 | cat ~/.config/gh/hosts.yml | grep -oP 'token: \K.*' |
| 17 | ``` | 16 | ``` |
| 18 | 17 | ||
| 18 | +## Vérifier que le token fonctionne | ||
| 19 | 19 | ||
| 20 | -# Vérifier que le token fonctionne | 20 | +```bash |
| 21 | curl -H "Authorization: Bearer $GITHUB_OAUTH_TOKEN" \ | 21 | curl -H "Authorization: Bearer $GITHUB_OAUTH_TOKEN" \ |
| 22 | https://api.github.com/user | 22 | https://api.github.com/user |
| 23 | +``` | ||
| 23 | 24 | ||
| 25 | +```bash | ||
| 24 | mm --tui agent.dmr.github-oauth.yaml | 26 | mm --tui agent.dmr.github-oauth.yaml |
| 27 | +``` | ||
| 25 | 28 | ||
| 26 | -Pour mini-me dans sbx : Utilisez sbx mcp add github + sbx mcp auth github (méthode recommandée) Pour mini-me hors sbx : export GITHUB_OAUTH_TOKEN=ghp_xxx | 29 | +## Mini Me and GitHub MCP server in `sbx` |
| 27 | 30 | ||
| 28 | 31 | ||
| 29 | 32 | ||
| 30 | -# 2. Register MCP servers: sbx mcp add github --url https://mcp.github.com/mcp | ||
| 31 | -# 3. Authenticate: sbx mcp auth github | ||
| 32 | -# 4. Start sandbox: sbx run mm --name my-session --config agent.dmr.sbx.yaml | ||
| 33 | -# | ||
| 34 | 33 | ||
| 35 | sbx mcp add github --url https://api.githubcopilot.com/mcp/ | 34 | sbx mcp add github --url https://api.githubcopilot.com/mcp/ |
| 36 | sbx mcp auth github | 35 | sbx mcp auth github |
| 37 | sbx mcp auth status --all | 36 | sbx mcp auth status --all |
| 38 | sbx mcp inspect github | 37 | sbx mcp inspect github |
| 39 | 38 | ||
| 40 | -sbx run k33g/mm-tui:0.1.4 . --name mm-mcp-github -- agent.dmr.sbx.yaml --static-mcp github | 39 | +sbx run k33g/mm-tui:0.1.5 . --name mm-mcp-github -- agent.dmr.sbx.yaml --static-mcp github |
| 41 | 40 | ||
| 42 | sbx mcp load github --sandbox mm-mcp-github | 41 | sbx mcp load github --sandbox mm-mcp-github |
| 43 | 42 | ||
modified
demo/agent.dmr.sbx.yaml +6 -4 | @@ -6,12 +6,14 @@ | ||
| 6 | 6 | # |
| 7 | 7 | # To use this config: |
| 8 | 8 | # 1. Run: sbx login |
| 9 | -# 2. Register MCP servers: sbx mcp add github --url https://mcp.github.com/mcp | |
| 9 | +# 2. Register MCP servers: sbx mcp add github --url https://api.githubcopilot.com/mcp/ | |
| 10 | 10 | # 3. Authenticate: sbx mcp auth github |
| 11 | -# 4. Start sandbox: sbx run mm --name my-session --config agent.dmr.sbx.yaml | |
| 11 | +# 4. Start a NEW sandbox (--static-mcp is an sbx flag: before "--"): | |
| 12 | +# sbx run --name mm-mcp-github --static-mcp github k33g/mm-tui:<version> . -- agent.dmr.sbx.yaml | |
| 12 | 13 | # |
| 13 | -# The gateway URL and tokens are automatically injected by sbx. | |
| 14 | -# Mini-me will discover tools from all servers registered with sbx. | |
| 14 | +# sbx bakes MCP_GATEWAY_URL and MCP_SENTINEL_TOKEN_NAME into the container at | |
| 15 | +# creation; mm then adds a server named "sbx-gateway" (tools: sbx-gateway_<tool>). | |
| 16 | +# Needs an mm that opens an MCP session (initialize + Mcp-Session-Id). | |
| 15 | 17 | |
| 16 | 18 | provider: dmr |
| 17 | 19 | |
| @@ -6,12 +6,14 @@ | |||
| 6 | # | 6 | # |
| 7 | # To use this config: | 7 | # To use this config: |
| 8 | # 1. Run: sbx login | 8 | # 1. Run: sbx login |
| 9 | -# 2. Register MCP servers: sbx mcp add github --url https://mcp.github.com/mcp | 9 | +# 2. Register MCP servers: sbx mcp add github --url https://api.githubcopilot.com/mcp/ |
| 10 | # 3. Authenticate: sbx mcp auth github | 10 | # 3. Authenticate: sbx mcp auth github |
| 11 | -# 4. Start sandbox: sbx run mm --name my-session --config agent.dmr.sbx.yaml | 11 | +# 4. Start a NEW sandbox (--static-mcp is an sbx flag: before "--"): |
| 12 | +# sbx run --name mm-mcp-github --static-mcp github k33g/mm-tui:<version> . -- agent.dmr.sbx.yaml | ||
| 12 | # | 13 | # |
| 13 | -# The gateway URL and tokens are automatically injected by sbx. | 14 | +# sbx bakes MCP_GATEWAY_URL and MCP_SENTINEL_TOKEN_NAME into the container at |
| 14 | -# Mini-me will discover tools from all servers registered with sbx. | 15 | +# creation; mm then adds a server named "sbx-gateway" (tools: sbx-gateway_<tool>). |
| 16 | +# Needs an mm that opens an MCP session (initialize + Mcp-Session-Id). | ||
| 15 | 17 | ||
| 16 | provider: dmr | 18 | provider: dmr |
| 17 | 19 | ||
modified
docs/en/how-to/run-in-a-docker-sandbox.md +36 -0 | @@ -123,6 +123,42 @@ export DOCKER_PAT=dckr_pat_… # an access token with read/write scope | ||
| 123 | 123 | |
| 124 | 124 | With both variables set, the script logs in with them and logs out when it is done; with neither, it reuses your existing `docker login`. Its two halves run alone as `PUSH=1 ./docker-sbx/build.sh v0.0.3` (image) and `./docker-sbx/push-kits.sh v0.0.3` (kits, `SBX=/path/to/sbx` if the CLI is not on the PATH). Both call `sync-kits.sh` first, which rewrites the kits' `version`, default `tag` and image owner, so a published kit always names the image pushed with it — commit the two `spec.yaml` afterwards. `sbx kit push` talks to the registry directly and authenticates from the Docker credential store, no sandbox daemon involved. `IMAGE_OWNER=<handle>` publishes under another account. |
| 125 | 125 | |
| 126 | +## MCP servers through the sbx gateway (GitHub as the example) | |
| 127 | + | |
| 128 | +Inside a sandbox, `mm` does not talk to MCP servers itself and holds no token. `sbx` starts one MCP gateway per sandbox, on the host. When it creates the sandbox, it bakes two variables into the container: `MCP_GATEWAY_URL` (`http://mcp-gateway.docker.internal/mcp`) and `MCP_SENTINEL_TOKEN_NAME`, a placeholder that the sandbox proxy swaps for the real credential. When `mm` finds `MCP_GATEWAY_URL` and `mcp.enabled: true`, it adds a server called `sbx-gateway`, and its tools show up as `sbx-gateway_<tool>`. The kit needs no network rule for it, because the proxy routes that host itself. | |
| 129 | + | |
| 130 | +1. Register the server and authorize it once, on the host: | |
| 131 | + | |
| 132 | + ```bash | |
| 133 | + sbx mcp add github --url https://api.githubcopilot.com/mcp/ | |
| 134 | + sbx mcp auth github | |
| 135 | + sbx mcp auth status --all # github must show as authorized | |
| 136 | + ``` | |
| 137 | + | |
| 138 | +2. Create a **new** sandbox with that server in its fixed set. `--static-mcp` is an `sbx` flag: it goes **before** `--`, since everything after `--` goes to `mm`. It only applies at creation, so remove an older sandbox of the same name first: | |
| 139 | + | |
| 140 | + ```bash | |
| 141 | + cd /path/to/project | |
| 142 | + sbx rm mm-mcp-github 2>/dev/null | |
| 143 | + sbx run --name mm-mcp-github --static-mcp github k33g/mm-tui:<version> . -- agent.dmr.sbx.yaml | |
| 144 | + ``` | |
| 145 | + | |
| 146 | +3. The configuration only needs MCP turned on. `demo/agent.dmr.sbx.yaml` has: | |
| 147 | + | |
| 148 | + ```yaml | |
| 149 | + mcp: | |
| 150 | + enabled: true | |
| 151 | + permissions: ask | |
| 152 | + discoveryTimeout: 10s | |
| 153 | + servers: [] # the gateway is added by mm when MCP_GATEWAY_URL is set | |
| 154 | + ``` | |
| 155 | + | |
| 156 | + The banner then lists `sbx-gateway_get_me`, `sbx-gateway_list_issues`, and so on in `tools:`. | |
| 157 | + | |
| 158 | +A kit image built before this change cannot use the gateway. It discovers nothing and warns `method "tools/list" is invalid during session initialization`. The gateway follows MCP's Streamable HTTP transport strictly: it needs `initialize` first, then the `Mcp-Session-Id` it returns on every request. Older `mm` skipped both. That was harmless against `api.githubcopilot.com` but fatal here. Rebuild and publish the kit after upgrading. | |
| 159 | + | |
| 160 | +If the gateway says `github` cannot be reached, the problem is the registration, not `mm`. Run `sbx mcp inspect github`. GitHub's authorization server offers no dynamic client registration, so depending on your gateway mode, `sbx mcp add` may need `--client-id` for a GitHub OAuth App. `sbx mcp add --help` describes both that and the `--header 'Authorization: Bearer ${token}'` form with `sbx secret set mcp:github:token`. | |
| 161 | + | |
| 126 | 162 | ## Variants |
| 127 | 163 | |
| 128 | 164 | - **Another configuration for one editor session.** Add `"-e", "AGENT_CONFIG=agent.lsp.yaml"` before the sandbox name in `"args"`; `sbx exec -e` overrides the baked-in value for that run only. |
| @@ -123,6 +123,42 @@ export DOCKER_PAT=dckr_pat_… # an access token with read/write scope | |||
| 123 | 123 | ||
| 124 | With both variables set, the script logs in with them and logs out when it is done; with neither, it reuses your existing `docker login`. Its two halves run alone as `PUSH=1 ./docker-sbx/build.sh v0.0.3` (image) and `./docker-sbx/push-kits.sh v0.0.3` (kits, `SBX=/path/to/sbx` if the CLI is not on the PATH). Both call `sync-kits.sh` first, which rewrites the kits' `version`, default `tag` and image owner, so a published kit always names the image pushed with it — commit the two `spec.yaml` afterwards. `sbx kit push` talks to the registry directly and authenticates from the Docker credential store, no sandbox daemon involved. `IMAGE_OWNER=<handle>` publishes under another account. | 124 | With both variables set, the script logs in with them and logs out when it is done; with neither, it reuses your existing `docker login`. Its two halves run alone as `PUSH=1 ./docker-sbx/build.sh v0.0.3` (image) and `./docker-sbx/push-kits.sh v0.0.3` (kits, `SBX=/path/to/sbx` if the CLI is not on the PATH). Both call `sync-kits.sh` first, which rewrites the kits' `version`, default `tag` and image owner, so a published kit always names the image pushed with it — commit the two `spec.yaml` afterwards. `sbx kit push` talks to the registry directly and authenticates from the Docker credential store, no sandbox daemon involved. `IMAGE_OWNER=<handle>` publishes under another account. |
| 125 | 125 | ||
| 126 | +## MCP servers through the sbx gateway (GitHub as the example) | ||
| 127 | + | ||
| 128 | +Inside a sandbox, `mm` does not talk to MCP servers itself and holds no token. `sbx` starts one MCP gateway per sandbox, on the host. When it creates the sandbox, it bakes two variables into the container: `MCP_GATEWAY_URL` (`http://mcp-gateway.docker.internal/mcp`) and `MCP_SENTINEL_TOKEN_NAME`, a placeholder that the sandbox proxy swaps for the real credential. When `mm` finds `MCP_GATEWAY_URL` and `mcp.enabled: true`, it adds a server called `sbx-gateway`, and its tools show up as `sbx-gateway_<tool>`. The kit needs no network rule for it, because the proxy routes that host itself. | ||
| 129 | + | ||
| 130 | +1. Register the server and authorize it once, on the host: | ||
| 131 | + | ||
| 132 | + ```bash | ||
| 133 | + sbx mcp add github --url https://api.githubcopilot.com/mcp/ | ||
| 134 | + sbx mcp auth github | ||
| 135 | + sbx mcp auth status --all # github must show as authorized | ||
| 136 | + ``` | ||
| 137 | + | ||
| 138 | +2. Create a **new** sandbox with that server in its fixed set. `--static-mcp` is an `sbx` flag: it goes **before** `--`, since everything after `--` goes to `mm`. It only applies at creation, so remove an older sandbox of the same name first: | ||
| 139 | + | ||
| 140 | + ```bash | ||
| 141 | + cd /path/to/project | ||
| 142 | + sbx rm mm-mcp-github 2>/dev/null | ||
| 143 | + sbx run --name mm-mcp-github --static-mcp github k33g/mm-tui:<version> . -- agent.dmr.sbx.yaml | ||
| 144 | + ``` | ||
| 145 | + | ||
| 146 | +3. The configuration only needs MCP turned on. `demo/agent.dmr.sbx.yaml` has: | ||
| 147 | + | ||
| 148 | + ```yaml | ||
| 149 | + mcp: | ||
| 150 | + enabled: true | ||
| 151 | + permissions: ask | ||
| 152 | + discoveryTimeout: 10s | ||
| 153 | + servers: [] # the gateway is added by mm when MCP_GATEWAY_URL is set | ||
| 154 | + ``` | ||
| 155 | + | ||
| 156 | + The banner then lists `sbx-gateway_get_me`, `sbx-gateway_list_issues`, and so on in `tools:`. | ||
| 157 | + | ||
| 158 | +A kit image built before this change cannot use the gateway. It discovers nothing and warns `method "tools/list" is invalid during session initialization`. The gateway follows MCP's Streamable HTTP transport strictly: it needs `initialize` first, then the `Mcp-Session-Id` it returns on every request. Older `mm` skipped both. That was harmless against `api.githubcopilot.com` but fatal here. Rebuild and publish the kit after upgrading. | ||
| 159 | + | ||
| 160 | +If the gateway says `github` cannot be reached, the problem is the registration, not `mm`. Run `sbx mcp inspect github`. GitHub's authorization server offers no dynamic client registration, so depending on your gateway mode, `sbx mcp add` may need `--client-id` for a GitHub OAuth App. `sbx mcp add --help` describes both that and the `--header 'Authorization: Bearer ${token}'` form with `sbx secret set mcp:github:token`. | ||
| 161 | + | ||
| 126 | ## Variants | 162 | ## Variants |
| 127 | 163 | ||
| 128 | - **Another configuration for one editor session.** Add `"-e", "AGENT_CONFIG=agent.lsp.yaml"` before the sandbox name in `"args"`; `sbx exec -e` overrides the baked-in value for that run only. | 164 | - **Another configuration for one editor session.** Add `"-e", "AGENT_CONFIG=agent.lsp.yaml"` before the sandbox name in `"args"`; `sbx exec -e` overrides the baked-in value for that run only. |
modified
docs/fr/how-to/run-in-a-docker-sandbox.md +36 -0 | @@ -123,6 +123,42 @@ export DOCKER_PAT=dckr_pat_… # un jeton d'accès en lecture/écriture | ||
| 123 | 123 | |
| 124 | 124 | Avec les deux variables, le script se connecte avec elles et se déconnecte à la fin ; sans elles, il réutilise votre `docker login` existant. Ses deux moitiés se lancent seules : `PUSH=1 ./docker-sbx/build.sh v0.0.3` (l'image) et `./docker-sbx/push-kits.sh v0.0.3` (les kits, `SBX=/chemin/vers/sbx` si la CLI n'est pas sur le PATH). Les deux appellent d'abord `sync-kits.sh`, qui réécrit `version`, le `tag` par défaut et le propriétaire de l'image des kits, de sorte qu'un kit publié nomme toujours l'image poussée avec lui — commitez ensuite les deux `spec.yaml`. `sbx kit push` parle directement au registre et s'authentifie depuis le magasin d'identifiants Docker, sans démon sandbox. `IMAGE_OWNER=<compte>` publie sous un autre compte. |
| 125 | 125 | |
| 126 | +## Serveurs MCP via la passerelle sbx (exemple : GitHub) | |
| 127 | + | |
| 128 | +Dans un sandbox, `mm` ne parle pas lui-même aux serveurs MCP et ne détient aucun jeton. `sbx` démarre une passerelle MCP par sandbox, sur l'hôte. À la création du sandbox, il inscrit deux variables dans le conteneur : `MCP_GATEWAY_URL` (`http://mcp-gateway.docker.internal/mcp`) et `MCP_SENTINEL_TOKEN_NAME`, un substitut que le proxy du sandbox remplace par le vrai identifiant. Quand `mm` trouve `MCP_GATEWAY_URL` et `mcp.enabled: true`, il ajoute un serveur nommé `sbx-gateway`, dont les outils apparaissent sous la forme `sbx-gateway_<outil>`. Le kit n'a besoin d'aucune règle réseau pour cela, car le proxy route cet hôte lui-même. | |
| 129 | + | |
| 130 | +1. Enregistrez le serveur et autorisez-le une fois, sur l'hôte : | |
| 131 | + | |
| 132 | + ```bash | |
| 133 | + sbx mcp add github --url https://api.githubcopilot.com/mcp/ | |
| 134 | + sbx mcp auth github | |
| 135 | + sbx mcp auth status --all # github doit apparaître comme autorisé | |
| 136 | + ``` | |
| 137 | + | |
| 138 | +2. Créez un **nouveau** sandbox avec ce serveur dans son ensemble fixe. `--static-mcp` est une option de `sbx` : elle se place **avant** `--`, car tout ce qui suit `--` va à `mm`. Elle ne s'applique qu'à la création, donc supprimez d'abord un ancien sandbox du même nom : | |
| 139 | + | |
| 140 | + ```bash | |
| 141 | + cd /chemin/du/projet | |
| 142 | + sbx rm mm-mcp-github 2>/dev/null | |
| 143 | + sbx run --name mm-mcp-github --static-mcp github k33g/mm-tui:<version> . -- agent.dmr.sbx.yaml | |
| 144 | + ``` | |
| 145 | + | |
| 146 | +3. La configuration doit seulement activer MCP. `demo/agent.dmr.sbx.yaml` contient : | |
| 147 | + | |
| 148 | + ```yaml | |
| 149 | + mcp: | |
| 150 | + enabled: true | |
| 151 | + permissions: ask | |
| 152 | + discoveryTimeout: 10s | |
| 153 | + servers: [] # mm ajoute la passerelle quand MCP_GATEWAY_URL est défini | |
| 154 | + ``` | |
| 155 | + | |
| 156 | + La bannière liste alors `sbx-gateway_get_me`, `sbx-gateway_list_issues`, etc. dans `tools:`. | |
| 157 | + | |
| 158 | +Une image de kit construite avant ce changement ne peut pas utiliser la passerelle. Elle ne découvre rien et affiche `method "tools/list" is invalid during session initialization`. La passerelle suit strictement le transport Streamable HTTP de MCP : elle exige d'abord `initialize`, puis le `Mcp-Session-Id` renvoyé sur chaque requête. L'ancien `mm` sautait les deux. C'était sans effet face à `api.githubcopilot.com`, mais bloquant ici. Reconstruisez et republiez le kit après la mise à jour. | |
| 159 | + | |
| 160 | +Si la passerelle dit que `github` est injoignable, le problème vient de l'enregistrement, pas de `mm`. Lancez `sbx mcp inspect github`. Le serveur d'autorisation de GitHub ne propose pas l'enregistrement dynamique de client, donc selon votre mode de passerelle, `sbx mcp add` peut exiger `--client-id` pour une OAuth App GitHub. `sbx mcp add --help` décrit cette option et la forme `--header 'Authorization: Bearer ${token}'` avec `sbx secret set mcp:github:token`. | |
| 161 | + | |
| 126 | 162 | ## Variantes |
| 127 | 163 | |
| 128 | 164 | - **Une autre configuration pour une session d'éditeur.** Ajoutez `"-e", "AGENT_CONFIG=agent.lsp.yaml"` avant le nom du sandbox dans `"args"` ; `sbx exec -e` remplace la valeur intégrée pour cette exécution seulement. |
| @@ -123,6 +123,42 @@ export DOCKER_PAT=dckr_pat_… # un jeton d'accès en lecture/écriture | |||
| 123 | 123 | ||
| 124 | Avec les deux variables, le script se connecte avec elles et se déconnecte à la fin ; sans elles, il réutilise votre `docker login` existant. Ses deux moitiés se lancent seules : `PUSH=1 ./docker-sbx/build.sh v0.0.3` (l'image) et `./docker-sbx/push-kits.sh v0.0.3` (les kits, `SBX=/chemin/vers/sbx` si la CLI n'est pas sur le PATH). Les deux appellent d'abord `sync-kits.sh`, qui réécrit `version`, le `tag` par défaut et le propriétaire de l'image des kits, de sorte qu'un kit publié nomme toujours l'image poussée avec lui — commitez ensuite les deux `spec.yaml`. `sbx kit push` parle directement au registre et s'authentifie depuis le magasin d'identifiants Docker, sans démon sandbox. `IMAGE_OWNER=<compte>` publie sous un autre compte. | 124 | Avec les deux variables, le script se connecte avec elles et se déconnecte à la fin ; sans elles, il réutilise votre `docker login` existant. Ses deux moitiés se lancent seules : `PUSH=1 ./docker-sbx/build.sh v0.0.3` (l'image) et `./docker-sbx/push-kits.sh v0.0.3` (les kits, `SBX=/chemin/vers/sbx` si la CLI n'est pas sur le PATH). Les deux appellent d'abord `sync-kits.sh`, qui réécrit `version`, le `tag` par défaut et le propriétaire de l'image des kits, de sorte qu'un kit publié nomme toujours l'image poussée avec lui — commitez ensuite les deux `spec.yaml`. `sbx kit push` parle directement au registre et s'authentifie depuis le magasin d'identifiants Docker, sans démon sandbox. `IMAGE_OWNER=<compte>` publie sous un autre compte. |
| 125 | 125 | ||
| 126 | +## Serveurs MCP via la passerelle sbx (exemple : GitHub) | ||
| 127 | + | ||
| 128 | +Dans un sandbox, `mm` ne parle pas lui-même aux serveurs MCP et ne détient aucun jeton. `sbx` démarre une passerelle MCP par sandbox, sur l'hôte. À la création du sandbox, il inscrit deux variables dans le conteneur : `MCP_GATEWAY_URL` (`http://mcp-gateway.docker.internal/mcp`) et `MCP_SENTINEL_TOKEN_NAME`, un substitut que le proxy du sandbox remplace par le vrai identifiant. Quand `mm` trouve `MCP_GATEWAY_URL` et `mcp.enabled: true`, il ajoute un serveur nommé `sbx-gateway`, dont les outils apparaissent sous la forme `sbx-gateway_<outil>`. Le kit n'a besoin d'aucune règle réseau pour cela, car le proxy route cet hôte lui-même. | ||
| 129 | + | ||
| 130 | +1. Enregistrez le serveur et autorisez-le une fois, sur l'hôte : | ||
| 131 | + | ||
| 132 | + ```bash | ||
| 133 | + sbx mcp add github --url https://api.githubcopilot.com/mcp/ | ||
| 134 | + sbx mcp auth github | ||
| 135 | + sbx mcp auth status --all # github doit apparaître comme autorisé | ||
| 136 | + ``` | ||
| 137 | + | ||
| 138 | +2. Créez un **nouveau** sandbox avec ce serveur dans son ensemble fixe. `--static-mcp` est une option de `sbx` : elle se place **avant** `--`, car tout ce qui suit `--` va à `mm`. Elle ne s'applique qu'à la création, donc supprimez d'abord un ancien sandbox du même nom : | ||
| 139 | + | ||
| 140 | + ```bash | ||
| 141 | + cd /chemin/du/projet | ||
| 142 | + sbx rm mm-mcp-github 2>/dev/null | ||
| 143 | + sbx run --name mm-mcp-github --static-mcp github k33g/mm-tui:<version> . -- agent.dmr.sbx.yaml | ||
| 144 | + ``` | ||
| 145 | + | ||
| 146 | +3. La configuration doit seulement activer MCP. `demo/agent.dmr.sbx.yaml` contient : | ||
| 147 | + | ||
| 148 | + ```yaml | ||
| 149 | + mcp: | ||
| 150 | + enabled: true | ||
| 151 | + permissions: ask | ||
| 152 | + discoveryTimeout: 10s | ||
| 153 | + servers: [] # mm ajoute la passerelle quand MCP_GATEWAY_URL est défini | ||
| 154 | + ``` | ||
| 155 | + | ||
| 156 | + La bannière liste alors `sbx-gateway_get_me`, `sbx-gateway_list_issues`, etc. dans `tools:`. | ||
| 157 | + | ||
| 158 | +Une image de kit construite avant ce changement ne peut pas utiliser la passerelle. Elle ne découvre rien et affiche `method "tools/list" is invalid during session initialization`. La passerelle suit strictement le transport Streamable HTTP de MCP : elle exige d'abord `initialize`, puis le `Mcp-Session-Id` renvoyé sur chaque requête. L'ancien `mm` sautait les deux. C'était sans effet face à `api.githubcopilot.com`, mais bloquant ici. Reconstruisez et republiez le kit après la mise à jour. | ||
| 159 | + | ||
| 160 | +Si la passerelle dit que `github` est injoignable, le problème vient de l'enregistrement, pas de `mm`. Lancez `sbx mcp inspect github`. Le serveur d'autorisation de GitHub ne propose pas l'enregistrement dynamique de client, donc selon votre mode de passerelle, `sbx mcp add` peut exiger `--client-id` pour une OAuth App GitHub. `sbx mcp add --help` décrit cette option et la forme `--header 'Authorization: Bearer ${token}'` avec `sbx secret set mcp:github:token`. | ||
| 161 | + | ||
| 126 | ## Variantes | 162 | ## Variantes |
| 127 | 163 | ||
| 128 | - **Une autre configuration pour une session d'éditeur.** Ajoutez `"-e", "AGENT_CONFIG=agent.lsp.yaml"` avant le nom du sandbox dans `"args"` ; `sbx exec -e` remplace la valeur intégrée pour cette exécution seulement. | 164 | - **Une autre configuration pour une session d'éditeur.** Ajoutez `"-e", "AGENT_CONFIG=agent.lsp.yaml"` avant le nom du sandbox dans `"args"` ; `sbx exec -e` remplace la valeur intégrée pour cette exécution seulement. |
modified
internal/mcp/client.go +8 -318 | @@ -4,16 +4,9 @@ | ||
| 4 | 4 | package mcp |
| 5 | 5 | |
| 6 | 6 | import ( |
| 7 | - "bufio" | |
| 8 | - "bytes" | |
| 9 | 7 | "context" |
| 10 | - "encoding/json" | |
| 11 | 8 | "fmt" |
| 12 | - "io" | |
| 13 | - "net/http" | |
| 14 | - "os" | |
| 15 | 9 | "strings" |
| 16 | - "time" | |
| 17 | 10 | |
| 18 | 11 | "mm/internal/config" |
| 19 | 12 | ) |
| @@ -38,22 +31,22 @@ type Tool struct { | ||
| 38 | 31 | |
| 39 | 32 | // ToolInputSchema describes the input parameters for a tool. |
| 40 | 33 | type ToolInputSchema struct { |
| 41 | - Type string `json:"type"` | |
| 42 | - Properties map[string]Param `json:"properties"` | |
| 43 | - Required []string `json:"required,omitempty"` | |
| 34 | + Type string `json:"type"` | |
| 35 | + Properties map[string]Param `json:"properties"` | |
| 36 | + Required []string `json:"required,omitempty"` | |
| 44 | 37 | } |
| 45 | 38 | |
| 46 | 39 | // ToolOutputSchema describes the output of a tool. |
| 47 | 40 | type ToolOutputSchema struct { |
| 48 | - Type string `json:"type"` | |
| 49 | - Properties map[string]Param `json:"properties"` | |
| 41 | + Type string `json:"type"` | |
| 42 | + Properties map[string]Param `json:"properties"` | |
| 50 | 43 | } |
| 51 | 44 | |
| 52 | 45 | // ToolIcon represents an icon for a tool (used for UI display). |
| 53 | 46 | type ToolIcon struct { |
| 54 | - Src string `json:"src"` // Base64-encoded image data or URL | |
| 47 | + Src string `json:"src"` // Base64-encoded image data or URL | |
| 55 | 48 | MimeType string `json:"mimeType"` // MIME type of the icon (e.g., "image/png") |
| 56 | - Theme string `json:"theme"` // Theme variant (e.g., "light", "dark") | |
| 49 | + Theme string `json:"theme"` // Theme variant (e.g., "light", "dark") | |
| 57 | 50 | } |
| 58 | 51 | |
| 59 | 52 | // Param describes a single parameter or output field. |
| @@ -102,7 +95,7 @@ type Client interface { | ||
| 102 | 95 | // CallToolResult is the result of a tool call. |
| 103 | 96 | type CallToolResult struct { |
| 104 | 97 | Content []Content `json:"content"` |
| 105 | - IsError bool `json:"isError"` | |
| 98 | + IsError bool `json:"isError"` | |
| 106 | 99 | } |
| 107 | 100 | |
| 108 | 101 | // Content represents a content block in a tool result. |
| @@ -218,306 +211,3 @@ func CloseAll(clients map[string]*ServerClient) { | ||
| 218 | 211 | sc.Close() |
| 219 | 212 | } |
| 220 | 213 | } |
| 221 | - | |
| 222 | -// ============================================================================= | |
| 223 | -// HTTP Client Implementation | |
| 224 | -// ============================================================================= | |
| 225 | - | |
| 226 | -// HTTPClient implements Client for HTTP transport (streamable MCP). | |
| 227 | -type HTTPClient struct { | |
| 228 | - server config.MCPServer | |
| 229 | - httpClient *http.Client | |
| 230 | - baseURL string | |
| 231 | -} | |
| 232 | - | |
| 233 | -// NewHTTPClient creates a new HTTP client for the given server. | |
| 234 | -func NewHTTPClient(server config.MCPServer) (*HTTPClient, error) { | |
| 235 | - // Validate URL | |
| 236 | - if server.URL == "" { | |
| 237 | - return nil, fmt.Errorf("http transport requires a url") | |
| 238 | - } | |
| 239 | - | |
| 240 | - // Ensure URL has a path component | |
| 241 | - url := strings.TrimSpace(server.URL) | |
| 242 | - if !strings.Contains(url, "/") { | |
| 243 | - // Assume it's just a host and append /mcp | |
| 244 | - url = url + "/mcp" | |
| 245 | - } | |
| 246 | - | |
| 247 | - return &HTTPClient{ | |
| 248 | - server: server, | |
| 249 | - httpClient: &http.Client{ | |
| 250 | - Timeout: 30 * time.Second, | |
| 251 | - }, | |
| 252 | - baseURL: url, | |
| 253 | - }, nil | |
| 254 | -} | |
| 255 | - | |
| 256 | -// mcpRequest represents an MCP JSON-RPC request. | |
| 257 | -type mcpRequest struct { | |
| 258 | - JSONRPC string `json:"jsonrpc"` | |
| 259 | - ID int `json:"id"` | |
| 260 | - Method string `json:"method"` | |
| 261 | - Params interface{} `json:"params,omitempty"` | |
| 262 | -} | |
| 263 | - | |
| 264 | -// mcpResponse represents an MCP JSON-RPC response. | |
| 265 | -type mcpResponse struct { | |
| 266 | - JSONRPC string `json:"jsonrpc"` | |
| 267 | - ID int `json:"id"` | |
| 268 | - Result json.RawMessage `json:"result,omitempty"` | |
| 269 | - Error *mcpError `json:"error,omitempty"` | |
| 270 | -} | |
| 271 | - | |
| 272 | -// mcpError represents an MCP error. | |
| 273 | -type mcpError struct { | |
| 274 | - Code int `json:"code"` | |
| 275 | - Message string `json:"message"` | |
| 276 | -} | |
| 277 | - | |
| 278 | -// toolsListRequest represents a tools/list request. | |
| 279 | -type toolsListRequest struct { | |
| 280 | -} | |
| 281 | - | |
| 282 | -// toolsListResponse represents a tools/list response. | |
| 283 | -type toolsListResponse struct { | |
| 284 | - Tools []Tool `json:"tools"` | |
| 285 | -} | |
| 286 | - | |
| 287 | -// callToolRequest represents a tools/call request. | |
| 288 | -type callToolRequest struct { | |
| 289 | - Name string `json:"name"` | |
| 290 | - Args map[string]any `json:"arguments"` | |
| 291 | -} | |
| 292 | - | |
| 293 | -// callToolResponse represents a tools/call response. | |
| 294 | -type callToolResponse struct { | |
| 295 | - Content []Content `json:"content"` | |
| 296 | - IsError bool `json:"isError"` | |
| 297 | -} | |
| 298 | - | |
| 299 | -// ListTools returns all tools from the HTTP server. | |
| 300 | -func (c *HTTPClient) ListTools(ctx context.Context) ([]Tool, error) { | |
| 301 | - request := mcpRequest{ | |
| 302 | - JSONRPC: "2.0", | |
| 303 | - ID: 1, | |
| 304 | - Method: "tools/list", | |
| 305 | - Params: toolsListRequest{}, | |
| 306 | - } | |
| 307 | - | |
| 308 | - resp, err := c.doRequest(ctx, request) | |
| 309 | - if err != nil { | |
| 310 | - return nil, err | |
| 311 | - } | |
| 312 | - | |
| 313 | - var listResp toolsListResponse | |
| 314 | - if err := json.Unmarshal(resp.Result, &listResp); err != nil { | |
| 315 | - return nil, fmt.Errorf("failed to parse tools/list response: %w", err) | |
| 316 | - } | |
| 317 | - | |
| 318 | - return listResp.Tools, nil | |
| 319 | -} | |
| 320 | - | |
| 321 | -// CallTool invokes a tool via HTTP. | |
| 322 | -func (c *HTTPClient) CallTool(ctx context.Context, toolName string, args map[string]any) (CallToolResult, error) { | |
| 323 | - request := mcpRequest{ | |
| 324 | - JSONRPC: "2.0", | |
| 325 | - ID: 2, | |
| 326 | - Method: "tools/call", | |
| 327 | - Params: callToolRequest{ | |
| 328 | - Name: toolName, | |
| 329 | - Args: args, | |
| 330 | - }, | |
| 331 | - } | |
| 332 | - | |
| 333 | - resp, err := c.doRequest(ctx, request) | |
| 334 | - if err != nil { | |
| 335 | - return CallToolResult{IsError: true}, err | |
| 336 | - } | |
| 337 | - | |
| 338 | - // Parse the response - we need to handle both single content and array | |
| 339 | - var callResp callToolResponse | |
| 340 | - if err := json.Unmarshal(resp.Result, &callResp); err != nil { | |
| 341 | - // Try parsing as a single content item wrapped in an object | |
| 342 | - var singleContent struct { | |
| 343 | - Content Content `json:"content"` | |
| 344 | - IsError bool `json:"isError"` | |
| 345 | - } | |
| 346 | - if parseErr := json.Unmarshal(resp.Result, &singleContent); parseErr == nil { | |
| 347 | - return CallToolResult{ | |
| 348 | - Content: []Content{singleContent.Content}, | |
| 349 | - IsError: singleContent.IsError, | |
| 350 | - }, nil | |
| 351 | - } | |
| 352 | - return CallToolResult{IsError: true}, fmt.Errorf("failed to parse tools/call response: %w", err) | |
| 353 | - } | |
| 354 | - | |
| 355 | - return CallToolResult{ | |
| 356 | - Content: callResp.Content, | |
| 357 | - IsError: callResp.IsError, | |
| 358 | - }, nil | |
| 359 | -} | |
| 360 | - | |
| 361 | -// doRequest sends a JSON-RPC request and returns the response. | |
| 362 | -// For streamable HTTP MCP, the response is a Server-Sent Events (SSE) stream. | |
| 363 | -func (c *HTTPClient) doRequest(ctx context.Context, req mcpRequest) (*mcpResponse, error) { | |
| 364 | - // Marshal request | |
| 365 | - body, err := json.Marshal(req) | |
| 366 | - if err != nil { | |
| 367 | - return nil, err | |
| 368 | - } | |
| 369 | - | |
| 370 | - // Create HTTP request | |
| 371 | - httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body)) | |
| 372 | - if err != nil { | |
| 373 | - return nil, err | |
| 374 | - } | |
| 375 | - | |
| 376 | - // Set headers | |
| 377 | - httpReq.Header.Set("Content-Type", "application/json") | |
| 378 | - | |
| 379 | - // Set Accept header - support both JSON and event-stream for streamable HTTP MCP | |
| 380 | - // If custom Accept header is provided in config, it will override this | |
| 381 | - httpReq.Header.Set("Accept", "application/json, text/event-stream") | |
| 382 | - | |
| 383 | - // Add auth headers | |
| 384 | - if err := c.addAuthHeaders(httpReq); err != nil { | |
| 385 | - return nil, err | |
| 386 | - } | |
| 387 | - | |
| 388 | - // Add custom headers (these will override defaults) | |
| 389 | - for k, v := range c.server.Headers { | |
| 390 | - httpReq.Header.Set(k, v) | |
| 391 | - } | |
| 392 | - | |
| 393 | - // Send request | |
| 394 | - httpResp, err := c.httpClient.Do(httpReq) | |
| 395 | - if err != nil { | |
| 396 | - return nil, err | |
| 397 | - } | |
| 398 | - defer httpResp.Body.Close() | |
| 399 | - | |
| 400 | - // Check content type to determine if it's SSE or plain JSON | |
| 401 | - contentType := httpResp.Header.Get("Content-Type") | |
| 402 | - | |
| 403 | - // For streamable HTTP MCP, the response is a Server-Sent Events (SSE) stream | |
| 404 | - // We need to parse the SSE events to extract the JSON-RPC messages | |
| 405 | - // Check if the request Accept header includes text/event-stream | |
| 406 | - acceptHeader := httpReq.Header.Get("Accept") | |
| 407 | - if strings.Contains(contentType, "text/event-stream") || | |
| 408 | - strings.Contains(acceptHeader, "text/event-stream") { | |
| 409 | - return c.parseSSEResponse(httpResp.Body) | |
| 410 | - } | |
| 411 | - | |
| 412 | - // For regular JSON responses, read and parse directly | |
| 413 | - respBody, err := io.ReadAll(httpResp.Body) | |
| 414 | - if err != nil { | |
| 415 | - return nil, err | |
| 416 | - } | |
| 417 | - | |
| 418 | - // Parse response | |
| 419 | - var mcpResp mcpResponse | |
| 420 | - if err := json.Unmarshal(respBody, &mcpResp); err != nil { | |
| 421 | - return nil, fmt.Errorf("failed to parse response: %w", err) | |
| 422 | - } | |
| 423 | - | |
| 424 | - // Check for error | |
| 425 | - if mcpResp.Error != nil { | |
| 426 | - return &mcpResp, fmt.Errorf("mcp error: %s (code: %d)", mcpResp.Error.Message, mcpResp.Error.Code) | |
| 427 | - } | |
| 428 | - | |
| 429 | - return &mcpResp, nil | |
| 430 | -} | |
| 431 | - | |
| 432 | -// parseSSEResponse parses a Server-Sent Events stream and returns the first JSON-RPC response. | |
| 433 | -// For streamable HTTP MCP, responses are sent as SSE events with "event: message" and "data: <json>" | |
| 434 | -// Note: Some MCP servers (like GitHub) send very large responses with base64-encoded icons, | |
| 435 | -// so we use a custom scanner with a larger buffer to handle long lines. | |
| 436 | -func (c *HTTPClient) parseSSEResponse(body io.Reader) (*mcpResponse, error) { | |
| 437 | - // Create a reader with a larger buffer to handle long data lines | |
| 438 | - // GitHub MCP server sends large responses with base64-encoded icons | |
| 439 | - const maxLineSize = 10 * 1024 * 1024 // 10 MB max line size | |
| 440 | - reader := bufio.NewReaderSize(body, maxLineSize) | |
| 441 | - | |
| 442 | - for { | |
| 443 | - // Read a line manually | |
| 444 | - line, err := reader.ReadString('\n') | |
| 445 | - if err != nil { | |
| 446 | - if err == io.EOF { | |
| 447 | - break | |
| 448 | - } | |
| 449 | - return nil, fmt.Errorf("failed to read SSE stream: %w", err) | |
| 450 | - } | |
| 451 | - | |
| 452 | - line = strings.TrimSpace(line) | |
| 453 | - | |
| 454 | - // Skip empty lines | |
| 455 | - if line == "" { | |
| 456 | - continue | |
| 457 | - } | |
| 458 | - | |
| 459 | - // Check if this is a data line (SSE format: "data: <json>") | |
| 460 | - if strings.HasPrefix(line, "data: ") { | |
| 461 | - // Extract the JSON data after "data: " prefix | |
| 462 | - jsonData := strings.TrimPrefix(line, "data: ") | |
| 463 | - | |
| 464 | - // Parse the JSON-RPC response | |
| 465 | - var mcpResp mcpResponse | |
| 466 | - if err := json.Unmarshal([]byte(jsonData), &mcpResp); err != nil { | |
| 467 | - // Try parsing the line directly in case it's a complete JSON object | |
| 468 | - // (some SSE implementations send JSON directly without data: prefix) | |
| 469 | - if err2 := json.Unmarshal([]byte(line), &mcpResp); err2 != nil { | |
| 470 | - continue // Not valid JSON, skip this line | |
| 471 | - } | |
| 472 | - } | |
| 473 | - | |
| 474 | - // Check for error in response | |
| 475 | - if mcpResp.Error != nil { | |
| 476 | - return &mcpResp, fmt.Errorf("mcp error: %s (code: %d)", mcpResp.Error.Message, mcpResp.Error.Code) | |
| 477 | - } | |
| 478 | - | |
| 479 | - // Successfully parsed a response | |
| 480 | - return &mcpResp, nil | |
| 481 | - } | |
| 482 | - } | |
| 483 | - | |
| 484 | - return nil, fmt.Errorf("no valid JSON-RPC response found in SSE stream") | |
| 485 | -} | |
| 486 | - | |
| 487 | -// addAuthHeaders adds authentication headers to the request. | |
| 488 | -func (c *HTTPClient) addAuthHeaders(req *http.Request) error { | |
| 489 | - switch c.server.Auth.Type { | |
| 490 | - case "none", "": | |
| 491 | - return nil | |
| 492 | - case "bearer", "oauth2": | |
| 493 | - // OAuth2 for GitHub MCP uses bearer token authentication | |
| 494 | - // Both "bearer" and "oauth2" types work the same way for GitHub | |
| 495 | - tokenEnv := c.server.Auth.TokenEnv | |
| 496 | - if tokenEnv == "" { | |
| 497 | - tokenEnv = "GITHUB_OAUTH_TOKEN" // Default for oauth2 if not specified | |
| 498 | - } | |
| 499 | - token := os.Getenv(tokenEnv) | |
| 500 | - if token == "" { | |
| 501 | - return fmt.Errorf("missing token for env var %s", tokenEnv) | |
| 502 | - } | |
| 503 | - req.Header.Set("Authorization", "Bearer "+token) | |
| 504 | - case "api_key": | |
| 505 | - key := os.Getenv(c.server.Auth.APIKeyEnv) | |
| 506 | - if key == "" { | |
| 507 | - return fmt.Errorf("missing API key for env var %s", c.server.Auth.APIKeyEnv) | |
| 508 | - } | |
| 509 | - header := c.server.Auth.APIKeyHeader | |
| 510 | - if header == "" { | |
| 511 | - header = "Authorization" | |
| 512 | - } | |
| 513 | - req.Header.Set(header, key) | |
| 514 | - default: | |
| 515 | - return fmt.Errorf("unknown auth type: %s", c.server.Auth.Type) | |
| 516 | - } | |
| 517 | - return nil | |
| 518 | -} | |
| 519 | - | |
| 520 | -// Close cleans up the HTTP client. | |
| 521 | -func (c *HTTPClient) Close() error { | |
| 522 | - return nil | |
| 523 | -} | |
| @@ -4,16 +4,9 @@ | |||
| 4 | package mcp | 4 | package mcp |
| 5 | 5 | ||
| 6 | import ( | 6 | import ( |
| 7 | - "bufio" | ||
| 8 | - "bytes" | ||
| 9 | "context" | 7 | "context" |
| 10 | - "encoding/json" | ||
| 11 | "fmt" | 8 | "fmt" |
| 12 | - "io" | ||
| 13 | - "net/http" | ||
| 14 | - "os" | ||
| 15 | "strings" | 9 | "strings" |
| 16 | - "time" | ||
| 17 | 10 | ||
| 18 | "mm/internal/config" | 11 | "mm/internal/config" |
| 19 | ) | 12 | ) |
| @@ -38,22 +31,22 @@ type Tool struct { | |||
| 38 | 31 | ||
| 39 | // ToolInputSchema describes the input parameters for a tool. | 32 | // ToolInputSchema describes the input parameters for a tool. |
| 40 | type ToolInputSchema struct { | 33 | type ToolInputSchema struct { |
| 41 | - Type string `json:"type"` | 34 | + Type string `json:"type"` |
| 42 | - Properties map[string]Param `json:"properties"` | 35 | + Properties map[string]Param `json:"properties"` |
| 43 | - Required []string `json:"required,omitempty"` | 36 | + Required []string `json:"required,omitempty"` |
| 44 | } | 37 | } |
| 45 | 38 | ||
| 46 | // ToolOutputSchema describes the output of a tool. | 39 | // ToolOutputSchema describes the output of a tool. |
| 47 | type ToolOutputSchema struct { | 40 | type ToolOutputSchema struct { |
| 48 | - Type string `json:"type"` | 41 | + Type string `json:"type"` |
| 49 | - Properties map[string]Param `json:"properties"` | 42 | + Properties map[string]Param `json:"properties"` |
| 50 | } | 43 | } |
| 51 | 44 | ||
| 52 | // ToolIcon represents an icon for a tool (used for UI display). | 45 | // ToolIcon represents an icon for a tool (used for UI display). |
| 53 | type ToolIcon struct { | 46 | type ToolIcon struct { |
| 54 | - Src string `json:"src"` // Base64-encoded image data or URL | 47 | + Src string `json:"src"` // Base64-encoded image data or URL |
| 55 | MimeType string `json:"mimeType"` // MIME type of the icon (e.g., "image/png") | 48 | MimeType string `json:"mimeType"` // MIME type of the icon (e.g., "image/png") |
| 56 | - Theme string `json:"theme"` // Theme variant (e.g., "light", "dark") | 49 | + Theme string `json:"theme"` // Theme variant (e.g., "light", "dark") |
| 57 | } | 50 | } |
| 58 | 51 | ||
| 59 | // Param describes a single parameter or output field. | 52 | // Param describes a single parameter or output field. |
| @@ -102,7 +95,7 @@ type Client interface { | |||
| 102 | // CallToolResult is the result of a tool call. | 95 | // CallToolResult is the result of a tool call. |
| 103 | type CallToolResult struct { | 96 | type CallToolResult struct { |
| 104 | Content []Content `json:"content"` | 97 | Content []Content `json:"content"` |
| 105 | - IsError bool `json:"isError"` | 98 | + IsError bool `json:"isError"` |
| 106 | } | 99 | } |
| 107 | 100 | ||
| 108 | // Content represents a content block in a tool result. | 101 | // Content represents a content block in a tool result. |
| @@ -218,306 +211,3 @@ func CloseAll(clients map[string]*ServerClient) { | |||
| 218 | sc.Close() | 211 | sc.Close() |
| 219 | } | 212 | } |
| 220 | } | 213 | } |
| 221 | - | ||
| 222 | -// ============================================================================= | ||
| 223 | -// HTTP Client Implementation | ||
| 224 | -// ============================================================================= | ||
| 225 | - | ||
| 226 | -// HTTPClient implements Client for HTTP transport (streamable MCP). | ||
| 227 | -type HTTPClient struct { | ||
| 228 | - server config.MCPServer | ||
| 229 | - httpClient *http.Client | ||
| 230 | - baseURL string | ||
| 231 | -} | ||
| 232 | - | ||
| 233 | -// NewHTTPClient creates a new HTTP client for the given server. | ||
| 234 | -func NewHTTPClient(server config.MCPServer) (*HTTPClient, error) { | ||
| 235 | - // Validate URL | ||
| 236 | - if server.URL == "" { | ||
| 237 | - return nil, fmt.Errorf("http transport requires a url") | ||
| 238 | - } | ||
| 239 | - | ||
| 240 | - // Ensure URL has a path component | ||
| 241 | - url := strings.TrimSpace(server.URL) | ||
| 242 | - if !strings.Contains(url, "/") { | ||
| 243 | - // Assume it's just a host and append /mcp | ||
| 244 | - url = url + "/mcp" | ||
| 245 | - } | ||
| 246 | - | ||
| 247 | - return &HTTPClient{ | ||
| 248 | - server: server, | ||
| 249 | - httpClient: &http.Client{ | ||
| 250 | - Timeout: 30 * time.Second, | ||
| 251 | - }, | ||
| 252 | - baseURL: url, | ||
| 253 | - }, nil | ||
| 254 | -} | ||
| 255 | - | ||
| 256 | -// mcpRequest represents an MCP JSON-RPC request. | ||
| 257 | -type mcpRequest struct { | ||
| 258 | - JSONRPC string `json:"jsonrpc"` | ||
| 259 | - ID int `json:"id"` | ||
| 260 | - Method string `json:"method"` | ||
| 261 | - Params interface{} `json:"params,omitempty"` | ||
| 262 | -} | ||
| 263 | - | ||
| 264 | -// mcpResponse represents an MCP JSON-RPC response. | ||
| 265 | -type mcpResponse struct { | ||
| 266 | - JSONRPC string `json:"jsonrpc"` | ||
| 267 | - ID int `json:"id"` | ||
| 268 | - Result json.RawMessage `json:"result,omitempty"` | ||
| 269 | - Error *mcpError `json:"error,omitempty"` | ||
| 270 | -} | ||
| 271 | - | ||
| 272 | -// mcpError represents an MCP error. | ||
| 273 | -type mcpError struct { | ||
| 274 | - Code int `json:"code"` | ||
| 275 | - Message string `json:"message"` | ||
| 276 | -} | ||
| 277 | - | ||
| 278 | -// toolsListRequest represents a tools/list request. | ||
| 279 | -type toolsListRequest struct { | ||
| 280 | -} | ||
| 281 | - | ||
| 282 | -// toolsListResponse represents a tools/list response. | ||
| 283 | -type toolsListResponse struct { | ||
| 284 | - Tools []Tool `json:"tools"` | ||
| 285 | -} | ||
| 286 | - | ||
| 287 | -// callToolRequest represents a tools/call request. | ||
| 288 | -type callToolRequest struct { | ||
| 289 | - Name string `json:"name"` | ||
| 290 | - Args map[string]any `json:"arguments"` | ||
| 291 | -} | ||
| 292 | - | ||
| 293 | -// callToolResponse represents a tools/call response. | ||
| 294 | -type callToolResponse struct { | ||
| 295 | - Content []Content `json:"content"` | ||
| 296 | - IsError bool `json:"isError"` | ||
| 297 | -} | ||
| 298 | - | ||
| 299 | -// ListTools returns all tools from the HTTP server. | ||
| 300 | -func (c *HTTPClient) ListTools(ctx context.Context) ([]Tool, error) { | ||
| 301 | - request := mcpRequest{ | ||
| 302 | - JSONRPC: "2.0", | ||
| 303 | - ID: 1, | ||
| 304 | - Method: "tools/list", | ||
| 305 | - Params: toolsListRequest{}, | ||
| 306 | - } | ||
| 307 | - | ||
| 308 | - resp, err := c.doRequest(ctx, request) | ||
| 309 | - if err != nil { | ||
| 310 | - return nil, err | ||
| 311 | - } | ||
| 312 | - | ||
| 313 | - var listResp toolsListResponse | ||
| 314 | - if err := json.Unmarshal(resp.Result, &listResp); err != nil { | ||
| 315 | - return nil, fmt.Errorf("failed to parse tools/list response: %w", err) | ||
| 316 | - } | ||
| 317 | - | ||
| 318 | - return listResp.Tools, nil | ||
| 319 | -} | ||
| 320 | - | ||
| 321 | -// CallTool invokes a tool via HTTP. | ||
| 322 | -func (c *HTTPClient) CallTool(ctx context.Context, toolName string, args map[string]any) (CallToolResult, error) { | ||
| 323 | - request := mcpRequest{ | ||
| 324 | - JSONRPC: "2.0", | ||
| 325 | - ID: 2, | ||
| 326 | - Method: "tools/call", | ||
| 327 | - Params: callToolRequest{ | ||
| 328 | - Name: toolName, | ||
| 329 | - Args: args, | ||
| 330 | - }, | ||
| 331 | - } | ||
| 332 | - | ||
| 333 | - resp, err := c.doRequest(ctx, request) | ||
| 334 | - if err != nil { | ||
| 335 | - return CallToolResult{IsError: true}, err | ||
| 336 | - } | ||
| 337 | - | ||
| 338 | - // Parse the response - we need to handle both single content and array | ||
| 339 | - var callResp callToolResponse | ||
| 340 | - if err := json.Unmarshal(resp.Result, &callResp); err != nil { | ||
| 341 | - // Try parsing as a single content item wrapped in an object | ||
| 342 | - var singleContent struct { | ||
| 343 | - Content Content `json:"content"` | ||
| 344 | - IsError bool `json:"isError"` | ||
| 345 | - } | ||
| 346 | - if parseErr := json.Unmarshal(resp.Result, &singleContent); parseErr == nil { | ||
| 347 | - return CallToolResult{ | ||
| 348 | - Content: []Content{singleContent.Content}, | ||
| 349 | - IsError: singleContent.IsError, | ||
| 350 | - }, nil | ||
| 351 | - } | ||
| 352 | - return CallToolResult{IsError: true}, fmt.Errorf("failed to parse tools/call response: %w", err) | ||
| 353 | - } | ||
| 354 | - | ||
| 355 | - return CallToolResult{ | ||
| 356 | - Content: callResp.Content, | ||
| 357 | - IsError: callResp.IsError, | ||
| 358 | - }, nil | ||
| 359 | -} | ||
| 360 | - | ||
| 361 | -// doRequest sends a JSON-RPC request and returns the response. | ||
| 362 | -// For streamable HTTP MCP, the response is a Server-Sent Events (SSE) stream. | ||
| 363 | -func (c *HTTPClient) doRequest(ctx context.Context, req mcpRequest) (*mcpResponse, error) { | ||
| 364 | - // Marshal request | ||
| 365 | - body, err := json.Marshal(req) | ||
| 366 | - if err != nil { | ||
| 367 | - return nil, err | ||
| 368 | - } | ||
| 369 | - | ||
| 370 | - // Create HTTP request | ||
| 371 | - httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body)) | ||
| 372 | - if err != nil { | ||
| 373 | - return nil, err | ||
| 374 | - } | ||
| 375 | - | ||
| 376 | - // Set headers | ||
| 377 | - httpReq.Header.Set("Content-Type", "application/json") | ||
| 378 | - | ||
| 379 | - // Set Accept header - support both JSON and event-stream for streamable HTTP MCP | ||
| 380 | - // If custom Accept header is provided in config, it will override this | ||
| 381 | - httpReq.Header.Set("Accept", "application/json, text/event-stream") | ||
| 382 | - | ||
| 383 | - // Add auth headers | ||
| 384 | - if err := c.addAuthHeaders(httpReq); err != nil { | ||
| 385 | - return nil, err | ||
| 386 | - } | ||
| 387 | - | ||
| 388 | - // Add custom headers (these will override defaults) | ||
| 389 | - for k, v := range c.server.Headers { | ||
| 390 | - httpReq.Header.Set(k, v) | ||
| 391 | - } | ||
| 392 | - | ||
| 393 | - // Send request | ||
| 394 | - httpResp, err := c.httpClient.Do(httpReq) | ||
| 395 | - if err != nil { | ||
| 396 | - return nil, err | ||
| 397 | - } | ||
| 398 | - defer httpResp.Body.Close() | ||
| 399 | - | ||
| 400 | - // Check content type to determine if it's SSE or plain JSON | ||
| 401 | - contentType := httpResp.Header.Get("Content-Type") | ||
| 402 | - | ||
| 403 | - // For streamable HTTP MCP, the response is a Server-Sent Events (SSE) stream | ||
| 404 | - // We need to parse the SSE events to extract the JSON-RPC messages | ||
| 405 | - // Check if the request Accept header includes text/event-stream | ||
| 406 | - acceptHeader := httpReq.Header.Get("Accept") | ||
| 407 | - if strings.Contains(contentType, "text/event-stream") || | ||
| 408 | - strings.Contains(acceptHeader, "text/event-stream") { | ||
| 409 | - return c.parseSSEResponse(httpResp.Body) | ||
| 410 | - } | ||
| 411 | - | ||
| 412 | - // For regular JSON responses, read and parse directly | ||
| 413 | - respBody, err := io.ReadAll(httpResp.Body) | ||
| 414 | - if err != nil { | ||
| 415 | - return nil, err | ||
| 416 | - } | ||
| 417 | - | ||
| 418 | - // Parse response | ||
| 419 | - var mcpResp mcpResponse | ||
| 420 | - if err := json.Unmarshal(respBody, &mcpResp); err != nil { | ||
| 421 | - return nil, fmt.Errorf("failed to parse response: %w", err) | ||
| 422 | - } | ||
| 423 | - | ||
| 424 | - // Check for error | ||
| 425 | - if mcpResp.Error != nil { | ||
| 426 | - return &mcpResp, fmt.Errorf("mcp error: %s (code: %d)", mcpResp.Error.Message, mcpResp.Error.Code) | ||
| 427 | - } | ||
| 428 | - | ||
| 429 | - return &mcpResp, nil | ||
| 430 | -} | ||
| 431 | - | ||
| 432 | -// parseSSEResponse parses a Server-Sent Events stream and returns the first JSON-RPC response. | ||
| 433 | -// For streamable HTTP MCP, responses are sent as SSE events with "event: message" and "data: <json>" | ||
| 434 | -// Note: Some MCP servers (like GitHub) send very large responses with base64-encoded icons, | ||
| 435 | -// so we use a custom scanner with a larger buffer to handle long lines. | ||
| 436 | -func (c *HTTPClient) parseSSEResponse(body io.Reader) (*mcpResponse, error) { | ||
| 437 | - // Create a reader with a larger buffer to handle long data lines | ||
| 438 | - // GitHub MCP server sends large responses with base64-encoded icons | ||
| 439 | - const maxLineSize = 10 * 1024 * 1024 // 10 MB max line size | ||
| 440 | - reader := bufio.NewReaderSize(body, maxLineSize) | ||
| 441 | - | ||
| 442 | - for { | ||
| 443 | - // Read a line manually | ||
| 444 | - line, err := reader.ReadString('\n') | ||
| 445 | - if err != nil { | ||
| 446 | - if err == io.EOF { | ||
| 447 | - break | ||
| 448 | - } | ||
| 449 | - return nil, fmt.Errorf("failed to read SSE stream: %w", err) | ||
| 450 | - } | ||
| 451 | - | ||
| 452 | - line = strings.TrimSpace(line) | ||
| 453 | - | ||
| 454 | - // Skip empty lines | ||
| 455 | - if line == "" { | ||
| 456 | - continue | ||
| 457 | - } | ||
| 458 | - | ||
| 459 | - // Check if this is a data line (SSE format: "data: <json>") | ||
| 460 | - if strings.HasPrefix(line, "data: ") { | ||
| 461 | - // Extract the JSON data after "data: " prefix | ||
| 462 | - jsonData := strings.TrimPrefix(line, "data: ") | ||
| 463 | - | ||
| 464 | - // Parse the JSON-RPC response | ||
| 465 | - var mcpResp mcpResponse | ||
| 466 | - if err := json.Unmarshal([]byte(jsonData), &mcpResp); err != nil { | ||
| 467 | - // Try parsing the line directly in case it's a complete JSON object | ||
| 468 | - // (some SSE implementations send JSON directly without data: prefix) | ||
| 469 | - if err2 := json.Unmarshal([]byte(line), &mcpResp); err2 != nil { | ||
| 470 | - continue // Not valid JSON, skip this line | ||
| 471 | - } | ||
| 472 | - } | ||
| 473 | - | ||
| 474 | - // Check for error in response | ||
| 475 | - if mcpResp.Error != nil { | ||
| 476 | - return &mcpResp, fmt.Errorf("mcp error: %s (code: %d)", mcpResp.Error.Message, mcpResp.Error.Code) | ||
| 477 | - } | ||
| 478 | - | ||
| 479 | - // Successfully parsed a response | ||
| 480 | - return &mcpResp, nil | ||
| 481 | - } | ||
| 482 | - } | ||
| 483 | - | ||
| 484 | - return nil, fmt.Errorf("no valid JSON-RPC response found in SSE stream") | ||
| 485 | -} | ||
| 486 | - | ||
| 487 | -// addAuthHeaders adds authentication headers to the request. | ||
| 488 | -func (c *HTTPClient) addAuthHeaders(req *http.Request) error { | ||
| 489 | - switch c.server.Auth.Type { | ||
| 490 | - case "none", "": | ||
| 491 | - return nil | ||
| 492 | - case "bearer", "oauth2": | ||
| 493 | - // OAuth2 for GitHub MCP uses bearer token authentication | ||
| 494 | - // Both "bearer" and "oauth2" types work the same way for GitHub | ||
| 495 | - tokenEnv := c.server.Auth.TokenEnv | ||
| 496 | - if tokenEnv == "" { | ||
| 497 | - tokenEnv = "GITHUB_OAUTH_TOKEN" // Default for oauth2 if not specified | ||
| 498 | - } | ||
| 499 | - token := os.Getenv(tokenEnv) | ||
| 500 | - if token == "" { | ||
| 501 | - return fmt.Errorf("missing token for env var %s", tokenEnv) | ||
| 502 | - } | ||
| 503 | - req.Header.Set("Authorization", "Bearer "+token) | ||
| 504 | - case "api_key": | ||
| 505 | - key := os.Getenv(c.server.Auth.APIKeyEnv) | ||
| 506 | - if key == "" { | ||
| 507 | - return fmt.Errorf("missing API key for env var %s", c.server.Auth.APIKeyEnv) | ||
| 508 | - } | ||
| 509 | - header := c.server.Auth.APIKeyHeader | ||
| 510 | - if header == "" { | ||
| 511 | - header = "Authorization" | ||
| 512 | - } | ||
| 513 | - req.Header.Set(header, key) | ||
| 514 | - default: | ||
| 515 | - return fmt.Errorf("unknown auth type: %s", c.server.Auth.Type) | ||
| 516 | - } | ||
| 517 | - return nil | ||
| 518 | -} | ||
| 519 | - | ||
| 520 | -// Close cleans up the HTTP client. | ||
| 521 | -func (c *HTTPClient) Close() error { | ||
| 522 | - return nil | ||
| 523 | -} | ||
added
internal/mcp/http_client.go +346 -0 | new file mode 100644 | ||
| @@ -0,0 +1,346 @@ | ||
| 1 | +package mcp | |
| 2 | + | |
| 3 | +// HTTP transport: MCP "Streamable HTTP". | |
| 4 | +// | |
| 5 | +// A conforming client opens a session before anything else: | |
| 6 | +// | |
| 7 | +// 1. POST initialize → the server may answer with an Mcp-Session-Id header | |
| 8 | +// 2. POST notifications/initialized | |
| 9 | +// 3. every later POST carries Mcp-Session-Id and MCP-Protocol-Version | |
| 10 | +// | |
| 11 | +// Some servers (api.githubcopilot.com) tolerate a bare tools/list without | |
| 12 | +// this handshake. Stateful ones do not: the Docker Sandboxes MCP gateway is | |
| 13 | +// the official Go SDK's StreamableHTTPHandler, which opens a fresh session for | |
| 14 | +// any POST without a session ID and rejects every call on it until initialize | |
| 15 | +// has run ("method "tools/list" is invalid during session initialization"). | |
| 16 | + | |
| 17 | +import ( | |
| 18 | + "bytes" | |
| 19 | + "context" | |
| 20 | + "encoding/json" | |
| 21 | + "fmt" | |
| 22 | + "io" | |
| 23 | + "net/http" | |
| 24 | + "strconv" | |
| 25 | + "strings" | |
| 26 | + "sync" | |
| 27 | + "time" | |
| 28 | + | |
| 29 | + "mm/internal/config" | |
| 30 | +) | |
| 31 | + | |
| 32 | +const ( | |
| 33 | + // clientProtocolVersion is the MCP revision mm asks for in initialize. | |
| 34 | + clientProtocolVersion = "2025-06-18" | |
| 35 | + sessionIDHeader = "Mcp-Session-Id" | |
| 36 | + protocolVersionHeader = "MCP-Protocol-Version" | |
| 37 | + // maxSSELine bounds one SSE line: GitHub's tools/list embeds base64 icons. | |
| 38 | + maxSSELine = 10 * 1024 * 1024 | |
| 39 | + // jsonrpcMethodNotFound is the JSON-RPC code for an unknown method. | |
| 40 | + jsonrpcMethodNotFound = -32601 | |
| 41 | +) | |
| 42 | + | |
| 43 | +// HTTPClient implements Client for HTTP transport (streamable MCP). | |
| 44 | +type HTTPClient struct { | |
| 45 | + server config.MCPServer | |
| 46 | + httpClient *http.Client | |
| 47 | + baseURL string | |
| 48 | + | |
| 49 | + mu sync.Mutex | |
| 50 | + nextID int | |
| 51 | + initialized bool | |
| 52 | + sessionID string | |
| 53 | + protocol string // negotiated protocol version, empty until initialize | |
| 54 | +} | |
| 55 | + | |
| 56 | +// NewHTTPClient creates a new HTTP client for the given server. | |
| 57 | +// The transport is Go's default one, so HTTP(S)_PROXY is honoured — which | |
| 58 | +// is how requests leave a Docker sandbox. | |
| 59 | +func NewHTTPClient(server config.MCPServer) (*HTTPClient, error) { | |
| 60 | + if server.URL == "" { | |
| 61 | + return nil, fmt.Errorf("http transport requires a url") | |
| 62 | + } | |
| 63 | + url := strings.TrimSpace(server.URL) | |
| 64 | + if !strings.Contains(url, "/") { | |
| 65 | + url = url + "/mcp" // just a host: assume the conventional path | |
| 66 | + } | |
| 67 | + return &HTTPClient{ | |
| 68 | + server: server, | |
| 69 | + httpClient: &http.Client{Timeout: 30 * time.Second}, | |
| 70 | + baseURL: url, | |
| 71 | + }, nil | |
| 72 | +} | |
| 73 | + | |
| 74 | +// mcpRequest is a JSON-RPC request. A notification has no ID. | |
| 75 | +type mcpRequest struct { | |
| 76 | + JSONRPC string `json:"jsonrpc"` | |
| 77 | + ID *int `json:"id,omitempty"` | |
| 78 | + Method string `json:"method"` | |
| 79 | + Params any `json:"params,omitempty"` | |
| 80 | +} | |
| 81 | + | |
| 82 | +// mcpResponse is a JSON-RPC message read back from the server. ID stays raw: | |
| 83 | +// server notifications and requests share the stream and carry no or a | |
| 84 | +// foreign ID. | |
| 85 | +type mcpResponse struct { | |
| 86 | + JSONRPC string `json:"jsonrpc"` | |
| 87 | + ID json.RawMessage `json:"id,omitempty"` | |
| 88 | + Method string `json:"method,omitempty"` | |
| 89 | + Result json.RawMessage `json:"result,omitempty"` | |
| 90 | + Error *mcpError `json:"error,omitempty"` | |
| 91 | +} | |
| 92 | + | |
| 93 | +// answers reports whether this message is the response to request id. | |
| 94 | +func (r *mcpResponse) answers(id int) bool { | |
| 95 | + return r.Method == "" && strings.TrimSpace(string(r.ID)) == strconv.Itoa(id) | |
| 96 | +} | |
| 97 | + | |
| 98 | +// mcpError represents an MCP error. | |
| 99 | +type mcpError struct { | |
| 100 | + Code int `json:"code"` | |
| 101 | + Message string `json:"message"` | |
| 102 | +} | |
| 103 | + | |
| 104 | +func (e *mcpError) Error() string { | |
| 105 | + return fmt.Sprintf("mcp error: %s (code: %d)", e.Message, e.Code) | |
| 106 | +} | |
| 107 | + | |
| 108 | +// httpStatusError is a non-2xx answer from the server. | |
| 109 | +type httpStatusError struct { | |
| 110 | + Status int | |
| 111 | + Body string | |
| 112 | +} | |
| 113 | + | |
| 114 | +func (e *httpStatusError) Error() string { | |
| 115 | + return fmt.Sprintf("http %d: %s", e.Status, e.Body) | |
| 116 | +} | |
| 117 | + | |
| 118 | +type toolsListResponse struct { | |
| 119 | + Tools []Tool `json:"tools"` | |
| 120 | +} | |
| 121 | + | |
| 122 | +type callToolRequest struct { | |
| 123 | + Name string `json:"name"` | |
| 124 | + Args map[string]any `json:"arguments"` | |
| 125 | +} | |
| 126 | + | |
| 127 | +type callToolResponse struct { | |
| 128 | + Content []Content `json:"content"` | |
| 129 | + IsError bool `json:"isError"` | |
| 130 | +} | |
| 131 | + | |
| 132 | +type initializeParams struct { | |
| 133 | + ProtocolVersion string `json:"protocolVersion"` | |
| 134 | + Capabilities map[string]any `json:"capabilities"` | |
| 135 | + ClientInfo clientInfo `json:"clientInfo"` | |
| 136 | +} | |
| 137 | + | |
| 138 | +type clientInfo struct { | |
| 139 | + Name string `json:"name"` | |
| 140 | + Version string `json:"version"` | |
| 141 | +} | |
| 142 | + | |
| 143 | +type initializeResult struct { | |
| 144 | + ProtocolVersion string `json:"protocolVersion"` | |
| 145 | +} | |
| 146 | + | |
| 147 | +// ListTools returns all tools from the HTTP server. | |
| 148 | +func (c *HTTPClient) ListTools(ctx context.Context) ([]Tool, error) { | |
| 149 | + resp, err := c.call(ctx, "tools/list", map[string]any{}) | |
| 150 | + if err != nil { | |
| 151 | + return nil, err | |
| 152 | + } | |
| 153 | + var list toolsListResponse | |
| 154 | + if err := json.Unmarshal(resp.Result, &list); err != nil { | |
| 155 | + return nil, fmt.Errorf("failed to parse tools/list response: %w", err) | |
| 156 | + } | |
| 157 | + return list.Tools, nil | |
| 158 | +} | |
| 159 | + | |
| 160 | +// CallTool invokes a tool via HTTP. | |
| 161 | +func (c *HTTPClient) CallTool(ctx context.Context, toolName string, args map[string]any) (CallToolResult, error) { | |
| 162 | + resp, err := c.call(ctx, "tools/call", callToolRequest{Name: toolName, Args: args}) | |
| 163 | + if err != nil { | |
| 164 | + return CallToolResult{IsError: true}, err | |
| 165 | + } | |
| 166 | + return parseCallResult(resp.Result) | |
| 167 | +} | |
| 168 | + | |
| 169 | +// parseCallResult accepts both a content array and a single content object. | |
| 170 | +func parseCallResult(raw json.RawMessage) (CallToolResult, error) { | |
| 171 | + var many callToolResponse | |
| 172 | + if err := json.Unmarshal(raw, &many); err == nil { | |
| 173 | + return CallToolResult{Content: many.Content, IsError: many.IsError}, nil | |
| 174 | + } | |
| 175 | + var single struct { | |
| 176 | + Content Content `json:"content"` | |
| 177 | + IsError bool `json:"isError"` | |
| 178 | + } | |
| 179 | + if err := json.Unmarshal(raw, &single); err != nil { | |
| 180 | + return CallToolResult{IsError: true}, fmt.Errorf("failed to parse tools/call response: %w", err) | |
| 181 | + } | |
| 182 | + return CallToolResult{Content: []Content{single.Content}, IsError: single.IsError}, nil | |
| 183 | +} | |
| 184 | + | |
| 185 | +// call runs one request inside a session. A server that has forgotten the | |
| 186 | +// session answers 404: the spec says to start a new one, so mm re-initializes | |
| 187 | +// once and retries. | |
| 188 | +func (c *HTTPClient) call(ctx context.Context, method string, params any) (*mcpResponse, error) { | |
| 189 | + c.mu.Lock() | |
| 190 | + defer c.mu.Unlock() | |
| 191 | + | |
| 192 | + if err := c.ensureSession(ctx); err != nil { | |
| 193 | + return nil, err | |
| 194 | + } | |
| 195 | + resp, err := c.request(ctx, method, params) | |
| 196 | + if !isSessionLost(err, c.sessionID) { | |
| 197 | + return resp, err | |
| 198 | + } | |
| 199 | + c.resetSession() | |
| 200 | + if err := c.ensureSession(ctx); err != nil { | |
| 201 | + return nil, err | |
| 202 | + } | |
| 203 | + return c.request(ctx, method, params) | |
| 204 | +} | |
| 205 | + | |
| 206 | +func isSessionLost(err error, sessionID string) bool { | |
| 207 | + se, ok := err.(*httpStatusError) | |
| 208 | + return ok && se.Status == http.StatusNotFound && sessionID != "" | |
| 209 | +} | |
| 210 | + | |
| 211 | +func (c *HTTPClient) resetSession() { | |
| 212 | + c.initialized = false | |
| 213 | + c.sessionID = "" | |
| 214 | + c.protocol = "" | |
| 215 | +} | |
| 216 | + | |
| 217 | +// ensureSession performs the initialize handshake once. A server that does | |
| 218 | +// not know initialize (JSON-RPC "method not found") is used without a session, | |
| 219 | +// as mm did before. | |
| 220 | +func (c *HTTPClient) ensureSession(ctx context.Context) error { | |
| 221 | + if c.initialized { | |
| 222 | + return nil | |
| 223 | + } | |
| 224 | + resp, err := c.request(ctx, "initialize", initializeParams{ | |
| 225 | + ProtocolVersion: clientProtocolVersion, | |
| 226 | + Capabilities: map[string]any{}, | |
| 227 | + ClientInfo: clientInfo{Name: "mini-me", Version: "1"}, | |
| 228 | + }) | |
| 229 | + if me, ok := err.(*mcpError); ok && me.Code == jsonrpcMethodNotFound { | |
| 230 | + c.initialized = true | |
| 231 | + return nil | |
| 232 | + } | |
| 233 | + if err != nil { | |
| 234 | + return fmt.Errorf("initialize: %w", err) | |
| 235 | + } | |
| 236 | + var result initializeResult | |
| 237 | + if json.Unmarshal(resp.Result, &result) == nil { | |
| 238 | + c.protocol = result.ProtocolVersion | |
| 239 | + } | |
| 240 | + if err := c.notify(ctx, "notifications/initialized"); err != nil { | |
| 241 | + return fmt.Errorf("initialized notification: %w", err) | |
| 242 | + } | |
| 243 | + c.initialized = true | |
| 244 | + return nil | |
| 245 | +} | |
| 246 | + | |
| 247 | +// request sends a JSON-RPC request and returns the matching response. | |
| 248 | +func (c *HTTPClient) request(ctx context.Context, method string, params any) (*mcpResponse, error) { | |
| 249 | + c.nextID++ | |
| 250 | + id := c.nextID | |
| 251 | + httpResp, err := c.post(ctx, mcpRequest{JSONRPC: "2.0", ID: &id, Method: method, Params: params}) | |
| 252 | + if err != nil { | |
| 253 | + return nil, err | |
| 254 | + } | |
| 255 | + defer httpResp.Body.Close() | |
| 256 | + | |
| 257 | + resp, err := readResponse(httpResp, id) | |
| 258 | + if err != nil { | |
| 259 | + return nil, err | |
| 260 | + } | |
| 261 | + if resp.Error != nil { | |
| 262 | + return resp, resp.Error | |
| 263 | + } | |
| 264 | + return resp, nil | |
| 265 | +} | |
| 266 | + | |
| 267 | +// notify sends a JSON-RPC notification; the server answers 202 with no body. | |
| 268 | +func (c *HTTPClient) notify(ctx context.Context, method string) error { | |
| 269 | + httpResp, err := c.post(ctx, mcpRequest{JSONRPC: "2.0", Method: method}) | |
| 270 | + if err != nil { | |
| 271 | + return err | |
| 272 | + } | |
| 273 | + _, _ = io.Copy(io.Discard, httpResp.Body) | |
| 274 | + return httpResp.Body.Close() | |
| 275 | +} | |
| 276 | + | |
| 277 | +// post sends one message with the session headers, and records the session | |
| 278 | +// ID the server hands out. A non-2xx status becomes an httpStatusError. | |
| 279 | +func (c *HTTPClient) post(ctx context.Context, msg mcpRequest) (*http.Response, error) { | |
| 280 | + req, err := c.newPost(ctx, msg) | |
| 281 | + if err != nil { | |
| 282 | + return nil, err | |
| 283 | + } | |
| 284 | + resp, err := c.httpClient.Do(req) | |
| 285 | + if err != nil { | |
| 286 | + return nil, err | |
| 287 | + } | |
| 288 | + if id := resp.Header.Get(sessionIDHeader); id != "" { | |
| 289 | + c.sessionID = id | |
| 290 | + } | |
| 291 | + return checkStatus(resp) | |
| 292 | +} | |
| 293 | + | |
| 294 | +// newPost builds the POST carrying msg, with every header set. | |
| 295 | +func (c *HTTPClient) newPost(ctx context.Context, msg mcpRequest) (*http.Request, error) { | |
| 296 | + body, err := json.Marshal(msg) | |
| 297 | + if err != nil { | |
| 298 | + return nil, err | |
| 299 | + } | |
| 300 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(body)) | |
| 301 | + if err == nil { | |
| 302 | + err = c.setHeaders(req) | |
| 303 | + } | |
| 304 | + return req, err | |
| 305 | +} | |
| 306 | + | |
| 307 | +// setHeaders sets the protocol headers, then auth, then the configured | |
| 308 | +// headers (which override the defaults). | |
| 309 | +func (c *HTTPClient) setHeaders(req *http.Request) error { | |
| 310 | + req.Header.Set("Content-Type", "application/json") | |
| 311 | + req.Header.Set("Accept", "application/json, text/event-stream") | |
| 312 | + if c.sessionID != "" { | |
| 313 | + req.Header.Set(sessionIDHeader, c.sessionID) | |
| 314 | + } | |
| 315 | + if c.protocol != "" { | |
| 316 | + req.Header.Set(protocolVersionHeader, c.protocol) | |
| 317 | + } | |
| 318 | + if err := c.addAuthHeaders(req); err != nil { | |
| 319 | + return err | |
| 320 | + } | |
| 321 | + for k, v := range c.server.Headers { | |
| 322 | + req.Header.Set(k, v) | |
| 323 | + } | |
| 324 | + return nil | |
| 325 | +} | |
| 326 | + | |
| 327 | +// Close ends the session on the server (DELETE, best effort). | |
| 328 | +func (c *HTTPClient) Close() error { | |
| 329 | + c.mu.Lock() | |
| 330 | + defer c.mu.Unlock() | |
| 331 | + if c.sessionID == "" { | |
| 332 | + return nil | |
| 333 | + } | |
| 334 | + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 335 | + defer cancel() | |
| 336 | + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL, nil) | |
| 337 | + if err != nil { | |
| 338 | + return nil | |
| 339 | + } | |
| 340 | + _ = c.setHeaders(req) | |
| 341 | + if resp, err := c.httpClient.Do(req); err == nil { | |
| 342 | + resp.Body.Close() | |
| 343 | + } | |
| 344 | + c.resetSession() | |
| 345 | + return nil | |
| 346 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,346 @@ | |||
| 1 | +package mcp | ||
| 2 | + | ||
| 3 | +// HTTP transport: MCP "Streamable HTTP". | ||
| 4 | +// | ||
| 5 | +// A conforming client opens a session before anything else: | ||
| 6 | +// | ||
| 7 | +// 1. POST initialize → the server may answer with an Mcp-Session-Id header | ||
| 8 | +// 2. POST notifications/initialized | ||
| 9 | +// 3. every later POST carries Mcp-Session-Id and MCP-Protocol-Version | ||
| 10 | +// | ||
| 11 | +// Some servers (api.githubcopilot.com) tolerate a bare tools/list without | ||
| 12 | +// this handshake. Stateful ones do not: the Docker Sandboxes MCP gateway is | ||
| 13 | +// the official Go SDK's StreamableHTTPHandler, which opens a fresh session for | ||
| 14 | +// any POST without a session ID and rejects every call on it until initialize | ||
| 15 | +// has run ("method "tools/list" is invalid during session initialization"). | ||
| 16 | + | ||
| 17 | +import ( | ||
| 18 | + "bytes" | ||
| 19 | + "context" | ||
| 20 | + "encoding/json" | ||
| 21 | + "fmt" | ||
| 22 | + "io" | ||
| 23 | + "net/http" | ||
| 24 | + "strconv" | ||
| 25 | + "strings" | ||
| 26 | + "sync" | ||
| 27 | + "time" | ||
| 28 | + | ||
| 29 | + "mm/internal/config" | ||
| 30 | +) | ||
| 31 | + | ||
| 32 | +const ( | ||
| 33 | + // clientProtocolVersion is the MCP revision mm asks for in initialize. | ||
| 34 | + clientProtocolVersion = "2025-06-18" | ||
| 35 | + sessionIDHeader = "Mcp-Session-Id" | ||
| 36 | + protocolVersionHeader = "MCP-Protocol-Version" | ||
| 37 | + // maxSSELine bounds one SSE line: GitHub's tools/list embeds base64 icons. | ||
| 38 | + maxSSELine = 10 * 1024 * 1024 | ||
| 39 | + // jsonrpcMethodNotFound is the JSON-RPC code for an unknown method. | ||
| 40 | + jsonrpcMethodNotFound = -32601 | ||
| 41 | +) | ||
| 42 | + | ||
| 43 | +// HTTPClient implements Client for HTTP transport (streamable MCP). | ||
| 44 | +type HTTPClient struct { | ||
| 45 | + server config.MCPServer | ||
| 46 | + httpClient *http.Client | ||
| 47 | + baseURL string | ||
| 48 | + | ||
| 49 | + mu sync.Mutex | ||
| 50 | + nextID int | ||
| 51 | + initialized bool | ||
| 52 | + sessionID string | ||
| 53 | + protocol string // negotiated protocol version, empty until initialize | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +// NewHTTPClient creates a new HTTP client for the given server. | ||
| 57 | +// The transport is Go's default one, so HTTP(S)_PROXY is honoured — which | ||
| 58 | +// is how requests leave a Docker sandbox. | ||
| 59 | +func NewHTTPClient(server config.MCPServer) (*HTTPClient, error) { | ||
| 60 | + if server.URL == "" { | ||
| 61 | + return nil, fmt.Errorf("http transport requires a url") | ||
| 62 | + } | ||
| 63 | + url := strings.TrimSpace(server.URL) | ||
| 64 | + if !strings.Contains(url, "/") { | ||
| 65 | + url = url + "/mcp" // just a host: assume the conventional path | ||
| 66 | + } | ||
| 67 | + return &HTTPClient{ | ||
| 68 | + server: server, | ||
| 69 | + httpClient: &http.Client{Timeout: 30 * time.Second}, | ||
| 70 | + baseURL: url, | ||
| 71 | + }, nil | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +// mcpRequest is a JSON-RPC request. A notification has no ID. | ||
| 75 | +type mcpRequest struct { | ||
| 76 | + JSONRPC string `json:"jsonrpc"` | ||
| 77 | + ID *int `json:"id,omitempty"` | ||
| 78 | + Method string `json:"method"` | ||
| 79 | + Params any `json:"params,omitempty"` | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +// mcpResponse is a JSON-RPC message read back from the server. ID stays raw: | ||
| 83 | +// server notifications and requests share the stream and carry no or a | ||
| 84 | +// foreign ID. | ||
| 85 | +type mcpResponse struct { | ||
| 86 | + JSONRPC string `json:"jsonrpc"` | ||
| 87 | + ID json.RawMessage `json:"id,omitempty"` | ||
| 88 | + Method string `json:"method,omitempty"` | ||
| 89 | + Result json.RawMessage `json:"result,omitempty"` | ||
| 90 | + Error *mcpError `json:"error,omitempty"` | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +// answers reports whether this message is the response to request id. | ||
| 94 | +func (r *mcpResponse) answers(id int) bool { | ||
| 95 | + return r.Method == "" && strings.TrimSpace(string(r.ID)) == strconv.Itoa(id) | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +// mcpError represents an MCP error. | ||
| 99 | +type mcpError struct { | ||
| 100 | + Code int `json:"code"` | ||
| 101 | + Message string `json:"message"` | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +func (e *mcpError) Error() string { | ||
| 105 | + return fmt.Sprintf("mcp error: %s (code: %d)", e.Message, e.Code) | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +// httpStatusError is a non-2xx answer from the server. | ||
| 109 | +type httpStatusError struct { | ||
| 110 | + Status int | ||
| 111 | + Body string | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +func (e *httpStatusError) Error() string { | ||
| 115 | + return fmt.Sprintf("http %d: %s", e.Status, e.Body) | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +type toolsListResponse struct { | ||
| 119 | + Tools []Tool `json:"tools"` | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +type callToolRequest struct { | ||
| 123 | + Name string `json:"name"` | ||
| 124 | + Args map[string]any `json:"arguments"` | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +type callToolResponse struct { | ||
| 128 | + Content []Content `json:"content"` | ||
| 129 | + IsError bool `json:"isError"` | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +type initializeParams struct { | ||
| 133 | + ProtocolVersion string `json:"protocolVersion"` | ||
| 134 | + Capabilities map[string]any `json:"capabilities"` | ||
| 135 | + ClientInfo clientInfo `json:"clientInfo"` | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +type clientInfo struct { | ||
| 139 | + Name string `json:"name"` | ||
| 140 | + Version string `json:"version"` | ||
| 141 | +} | ||
| 142 | + | ||
| 143 | +type initializeResult struct { | ||
| 144 | + ProtocolVersion string `json:"protocolVersion"` | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +// ListTools returns all tools from the HTTP server. | ||
| 148 | +func (c *HTTPClient) ListTools(ctx context.Context) ([]Tool, error) { | ||
| 149 | + resp, err := c.call(ctx, "tools/list", map[string]any{}) | ||
| 150 | + if err != nil { | ||
| 151 | + return nil, err | ||
| 152 | + } | ||
| 153 | + var list toolsListResponse | ||
| 154 | + if err := json.Unmarshal(resp.Result, &list); err != nil { | ||
| 155 | + return nil, fmt.Errorf("failed to parse tools/list response: %w", err) | ||
| 156 | + } | ||
| 157 | + return list.Tools, nil | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +// CallTool invokes a tool via HTTP. | ||
| 161 | +func (c *HTTPClient) CallTool(ctx context.Context, toolName string, args map[string]any) (CallToolResult, error) { | ||
| 162 | + resp, err := c.call(ctx, "tools/call", callToolRequest{Name: toolName, Args: args}) | ||
| 163 | + if err != nil { | ||
| 164 | + return CallToolResult{IsError: true}, err | ||
| 165 | + } | ||
| 166 | + return parseCallResult(resp.Result) | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +// parseCallResult accepts both a content array and a single content object. | ||
| 170 | +func parseCallResult(raw json.RawMessage) (CallToolResult, error) { | ||
| 171 | + var many callToolResponse | ||
| 172 | + if err := json.Unmarshal(raw, &many); err == nil { | ||
| 173 | + return CallToolResult{Content: many.Content, IsError: many.IsError}, nil | ||
| 174 | + } | ||
| 175 | + var single struct { | ||
| 176 | + Content Content `json:"content"` | ||
| 177 | + IsError bool `json:"isError"` | ||
| 178 | + } | ||
| 179 | + if err := json.Unmarshal(raw, &single); err != nil { | ||
| 180 | + return CallToolResult{IsError: true}, fmt.Errorf("failed to parse tools/call response: %w", err) | ||
| 181 | + } | ||
| 182 | + return CallToolResult{Content: []Content{single.Content}, IsError: single.IsError}, nil | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +// call runs one request inside a session. A server that has forgotten the | ||
| 186 | +// session answers 404: the spec says to start a new one, so mm re-initializes | ||
| 187 | +// once and retries. | ||
| 188 | +func (c *HTTPClient) call(ctx context.Context, method string, params any) (*mcpResponse, error) { | ||
| 189 | + c.mu.Lock() | ||
| 190 | + defer c.mu.Unlock() | ||
| 191 | + | ||
| 192 | + if err := c.ensureSession(ctx); err != nil { | ||
| 193 | + return nil, err | ||
| 194 | + } | ||
| 195 | + resp, err := c.request(ctx, method, params) | ||
| 196 | + if !isSessionLost(err, c.sessionID) { | ||
| 197 | + return resp, err | ||
| 198 | + } | ||
| 199 | + c.resetSession() | ||
| 200 | + if err := c.ensureSession(ctx); err != nil { | ||
| 201 | + return nil, err | ||
| 202 | + } | ||
| 203 | + return c.request(ctx, method, params) | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +func isSessionLost(err error, sessionID string) bool { | ||
| 207 | + se, ok := err.(*httpStatusError) | ||
| 208 | + return ok && se.Status == http.StatusNotFound && sessionID != "" | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +func (c *HTTPClient) resetSession() { | ||
| 212 | + c.initialized = false | ||
| 213 | + c.sessionID = "" | ||
| 214 | + c.protocol = "" | ||
| 215 | +} | ||
| 216 | + | ||
| 217 | +// ensureSession performs the initialize handshake once. A server that does | ||
| 218 | +// not know initialize (JSON-RPC "method not found") is used without a session, | ||
| 219 | +// as mm did before. | ||
| 220 | +func (c *HTTPClient) ensureSession(ctx context.Context) error { | ||
| 221 | + if c.initialized { | ||
| 222 | + return nil | ||
| 223 | + } | ||
| 224 | + resp, err := c.request(ctx, "initialize", initializeParams{ | ||
| 225 | + ProtocolVersion: clientProtocolVersion, | ||
| 226 | + Capabilities: map[string]any{}, | ||
| 227 | + ClientInfo: clientInfo{Name: "mini-me", Version: "1"}, | ||
| 228 | + }) | ||
| 229 | + if me, ok := err.(*mcpError); ok && me.Code == jsonrpcMethodNotFound { | ||
| 230 | + c.initialized = true | ||
| 231 | + return nil | ||
| 232 | + } | ||
| 233 | + if err != nil { | ||
| 234 | + return fmt.Errorf("initialize: %w", err) | ||
| 235 | + } | ||
| 236 | + var result initializeResult | ||
| 237 | + if json.Unmarshal(resp.Result, &result) == nil { | ||
| 238 | + c.protocol = result.ProtocolVersion | ||
| 239 | + } | ||
| 240 | + if err := c.notify(ctx, "notifications/initialized"); err != nil { | ||
| 241 | + return fmt.Errorf("initialized notification: %w", err) | ||
| 242 | + } | ||
| 243 | + c.initialized = true | ||
| 244 | + return nil | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +// request sends a JSON-RPC request and returns the matching response. | ||
| 248 | +func (c *HTTPClient) request(ctx context.Context, method string, params any) (*mcpResponse, error) { | ||
| 249 | + c.nextID++ | ||
| 250 | + id := c.nextID | ||
| 251 | + httpResp, err := c.post(ctx, mcpRequest{JSONRPC: "2.0", ID: &id, Method: method, Params: params}) | ||
| 252 | + if err != nil { | ||
| 253 | + return nil, err | ||
| 254 | + } | ||
| 255 | + defer httpResp.Body.Close() | ||
| 256 | + | ||
| 257 | + resp, err := readResponse(httpResp, id) | ||
| 258 | + if err != nil { | ||
| 259 | + return nil, err | ||
| 260 | + } | ||
| 261 | + if resp.Error != nil { | ||
| 262 | + return resp, resp.Error | ||
| 263 | + } | ||
| 264 | + return resp, nil | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +// notify sends a JSON-RPC notification; the server answers 202 with no body. | ||
| 268 | +func (c *HTTPClient) notify(ctx context.Context, method string) error { | ||
| 269 | + httpResp, err := c.post(ctx, mcpRequest{JSONRPC: "2.0", Method: method}) | ||
| 270 | + if err != nil { | ||
| 271 | + return err | ||
| 272 | + } | ||
| 273 | + _, _ = io.Copy(io.Discard, httpResp.Body) | ||
| 274 | + return httpResp.Body.Close() | ||
| 275 | +} | ||
| 276 | + | ||
| 277 | +// post sends one message with the session headers, and records the session | ||
| 278 | +// ID the server hands out. A non-2xx status becomes an httpStatusError. | ||
| 279 | +func (c *HTTPClient) post(ctx context.Context, msg mcpRequest) (*http.Response, error) { | ||
| 280 | + req, err := c.newPost(ctx, msg) | ||
| 281 | + if err != nil { | ||
| 282 | + return nil, err | ||
| 283 | + } | ||
| 284 | + resp, err := c.httpClient.Do(req) | ||
| 285 | + if err != nil { | ||
| 286 | + return nil, err | ||
| 287 | + } | ||
| 288 | + if id := resp.Header.Get(sessionIDHeader); id != "" { | ||
| 289 | + c.sessionID = id | ||
| 290 | + } | ||
| 291 | + return checkStatus(resp) | ||
| 292 | +} | ||
| 293 | + | ||
| 294 | +// newPost builds the POST carrying msg, with every header set. | ||
| 295 | +func (c *HTTPClient) newPost(ctx context.Context, msg mcpRequest) (*http.Request, error) { | ||
| 296 | + body, err := json.Marshal(msg) | ||
| 297 | + if err != nil { | ||
| 298 | + return nil, err | ||
| 299 | + } | ||
| 300 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(body)) | ||
| 301 | + if err == nil { | ||
| 302 | + err = c.setHeaders(req) | ||
| 303 | + } | ||
| 304 | + return req, err | ||
| 305 | +} | ||
| 306 | + | ||
| 307 | +// setHeaders sets the protocol headers, then auth, then the configured | ||
| 308 | +// headers (which override the defaults). | ||
| 309 | +func (c *HTTPClient) setHeaders(req *http.Request) error { | ||
| 310 | + req.Header.Set("Content-Type", "application/json") | ||
| 311 | + req.Header.Set("Accept", "application/json, text/event-stream") | ||
| 312 | + if c.sessionID != "" { | ||
| 313 | + req.Header.Set(sessionIDHeader, c.sessionID) | ||
| 314 | + } | ||
| 315 | + if c.protocol != "" { | ||
| 316 | + req.Header.Set(protocolVersionHeader, c.protocol) | ||
| 317 | + } | ||
| 318 | + if err := c.addAuthHeaders(req); err != nil { | ||
| 319 | + return err | ||
| 320 | + } | ||
| 321 | + for k, v := range c.server.Headers { | ||
| 322 | + req.Header.Set(k, v) | ||
| 323 | + } | ||
| 324 | + return nil | ||
| 325 | +} | ||
| 326 | + | ||
| 327 | +// Close ends the session on the server (DELETE, best effort). | ||
| 328 | +func (c *HTTPClient) Close() error { | ||
| 329 | + c.mu.Lock() | ||
| 330 | + defer c.mu.Unlock() | ||
| 331 | + if c.sessionID == "" { | ||
| 332 | + return nil | ||
| 333 | + } | ||
| 334 | + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | ||
| 335 | + defer cancel() | ||
| 336 | + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL, nil) | ||
| 337 | + if err != nil { | ||
| 338 | + return nil | ||
| 339 | + } | ||
| 340 | + _ = c.setHeaders(req) | ||
| 341 | + if resp, err := c.httpClient.Do(req); err == nil { | ||
| 342 | + resp.Body.Close() | ||
| 343 | + } | ||
| 344 | + c.resetSession() | ||
| 345 | + return nil | ||
| 346 | +} | ||
added
internal/mcp/http_client_test.go +224 -0 | new file mode 100644 | ||
| @@ -0,0 +1,224 @@ | ||
| 1 | +package mcp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "strings" | |
| 10 | + "sync" | |
| 11 | + "testing" | |
| 12 | + | |
| 13 | + "mm/internal/config" | |
| 14 | +) | |
| 15 | + | |
| 16 | +// statefulServer mimics the rules of the Go SDK's StreamableHTTPHandler, the | |
| 17 | +// server behind the Docker Sandboxes MCP gateway: a POST without a session | |
| 18 | +// opens one, and every call on a session is refused until initialize. | |
| 19 | +type statefulServer struct { | |
| 20 | + mu sync.Mutex | |
| 21 | + sessions map[string]bool // id -> initialized | |
| 22 | + next int | |
| 23 | + methods []string | |
| 24 | + lastAuth string | |
| 25 | + lastProto string | |
| 26 | + deleted []string | |
| 27 | + sse bool // answer as an SSE stream, with a notification first | |
| 28 | +} | |
| 29 | + | |
| 30 | +func newStatefulServer(sse bool) *statefulServer { | |
| 31 | + return &statefulServer{sessions: map[string]bool{}, sse: sse} | |
| 32 | +} | |
| 33 | + | |
| 34 | +func (s *statefulServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 35 | + s.mu.Lock() | |
| 36 | + defer s.mu.Unlock() | |
| 37 | + s.lastAuth = r.Header.Get("Authorization") | |
| 38 | + sid := r.Header.Get(sessionIDHeader) | |
| 39 | + | |
| 40 | + if r.Method == http.MethodDelete { | |
| 41 | + s.deleted = append(s.deleted, sid) | |
| 42 | + delete(s.sessions, sid) | |
| 43 | + w.WriteHeader(http.StatusNoContent) | |
| 44 | + return | |
| 45 | + } | |
| 46 | + if !strings.Contains(r.Header.Get("Accept"), "text/event-stream") { | |
| 47 | + http.Error(w, "Accept must contain both", http.StatusBadRequest) | |
| 48 | + return | |
| 49 | + } | |
| 50 | + | |
| 51 | + var msg struct { | |
| 52 | + ID *int `json:"id"` | |
| 53 | + Method string `json:"method"` | |
| 54 | + } | |
| 55 | + _ = json.NewDecoder(r.Body).Decode(&msg) | |
| 56 | + s.methods = append(s.methods, msg.Method) | |
| 57 | + | |
| 58 | + if sid != "" { | |
| 59 | + if _, ok := s.sessions[sid]; !ok { | |
| 60 | + http.Error(w, "session not found", http.StatusNotFound) | |
| 61 | + return | |
| 62 | + } | |
| 63 | + s.lastProto = r.Header.Get(protocolVersionHeader) | |
| 64 | + } else { | |
| 65 | + s.next++ | |
| 66 | + sid = fmt.Sprintf("sess-%d", s.next) | |
| 67 | + s.sessions[sid] = false | |
| 68 | + } | |
| 69 | + w.Header().Set(sessionIDHeader, sid) | |
| 70 | + | |
| 71 | + switch { | |
| 72 | + case msg.ID == nil: // notification | |
| 73 | + if msg.Method == "notifications/initialized" { | |
| 74 | + s.sessions[sid] = true | |
| 75 | + } | |
| 76 | + w.WriteHeader(http.StatusAccepted) | |
| 77 | + case msg.Method == "initialize": | |
| 78 | + s.reply(w, *msg.ID, `{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"fake","version":"0"}}`, "") | |
| 79 | + case !s.sessions[sid]: | |
| 80 | + s.reply(w, *msg.ID, "", fmt.Sprintf(`{"code":-32600,"message":"method %q is invalid during session initialization"}`, msg.Method)) | |
| 81 | + case msg.Method == "tools/list": | |
| 82 | + s.reply(w, *msg.ID, `{"tools":[{"name":"get_me","description":"me","inputSchema":{"type":"object","properties":{}}}]}`, "") | |
| 83 | + case msg.Method == "tools/call": | |
| 84 | + s.reply(w, *msg.ID, `{"content":[{"type":"text","text":"octocat"}],"isError":false}`, "") | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +func (s *statefulServer) reply(w http.ResponseWriter, id int, result, errObj string) { | |
| 89 | + body := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":%s}`, id, result) | |
| 90 | + if errObj != "" { | |
| 91 | + body = fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"error":%s}`, id, errObj) | |
| 92 | + } | |
| 93 | + if !s.sse { | |
| 94 | + w.Header().Set("Content-Type", "application/json") | |
| 95 | + _, _ = w.Write([]byte(body)) | |
| 96 | + return | |
| 97 | + } | |
| 98 | + w.Header().Set("Content-Type", "text/event-stream") | |
| 99 | + // A server notification on the same stream must not be taken for the answer. | |
| 100 | + fmt.Fprint(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{\"level\":\"info\"}}\n\n") | |
| 101 | + fmt.Fprintf(w, "event: message\ndata: %s\n\n", body) | |
| 102 | +} | |
| 103 | + | |
| 104 | +func newTestClient(t *testing.T, url string) *HTTPClient { | |
| 105 | + t.Helper() | |
| 106 | + t.Setenv("MM_TEST_TOKEN", "sentinel-123") | |
| 107 | + c, err := NewHTTPClient(config.MCPServer{ | |
| 108 | + Name: "gw", Transport: "http", URL: url, | |
| 109 | + Auth: config.MCPAuthConfig{Type: "bearer", TokenEnv: "MM_TEST_TOKEN"}, | |
| 110 | + }) | |
| 111 | + if err != nil { | |
| 112 | + t.Fatal(err) | |
| 113 | + } | |
| 114 | + return c | |
| 115 | +} | |
| 116 | + | |
| 117 | +func TestHTTPClientInitializesBeforeListingTools(t *testing.T) { | |
| 118 | + for _, sse := range []bool{false, true} { | |
| 119 | + t.Run(fmt.Sprintf("sse=%v", sse), func(t *testing.T) { | |
| 120 | + srv := newStatefulServer(sse) | |
| 121 | + ts := httptest.NewServer(srv) | |
| 122 | + defer ts.Close() | |
| 123 | + c := newTestClient(t, ts.URL+"/mcp") | |
| 124 | + | |
| 125 | + tools, err := c.ListTools(context.Background()) | |
| 126 | + if err != nil { | |
| 127 | + t.Fatalf("ListTools: %v", err) | |
| 128 | + } | |
| 129 | + if len(tools) != 1 || tools[0].Name != "get_me" { | |
| 130 | + t.Fatalf("tools = %+v", tools) | |
| 131 | + } | |
| 132 | + res, err := c.CallTool(context.Background(), "get_me", map[string]any{}) | |
| 133 | + if err != nil || len(res.Content) != 1 || res.Content[0].Text != "octocat" { | |
| 134 | + t.Fatalf("CallTool = %+v, %v", res, err) | |
| 135 | + } | |
| 136 | + | |
| 137 | + want := []string{"initialize", "notifications/initialized", "tools/list", "tools/call"} | |
| 138 | + if strings.Join(srv.methods, ",") != strings.Join(want, ",") { | |
| 139 | + t.Errorf("methods = %v, want %v", srv.methods, want) | |
| 140 | + } | |
| 141 | + if len(srv.sessions) != 1 { | |
| 142 | + t.Errorf("sessions opened = %d, want 1 (the session ID must be reused)", len(srv.sessions)) | |
| 143 | + } | |
| 144 | + if srv.lastAuth != "Bearer sentinel-123" { | |
| 145 | + t.Errorf("Authorization = %q", srv.lastAuth) | |
| 146 | + } | |
| 147 | + if srv.lastProto != "2025-06-18" { | |
| 148 | + t.Errorf("MCP-Protocol-Version = %q", srv.lastProto) | |
| 149 | + } | |
| 150 | + }) | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +func TestHTTPClientReinitializesWhenTheSessionIsLost(t *testing.T) { | |
| 155 | + srv := newStatefulServer(true) | |
| 156 | + ts := httptest.NewServer(srv) | |
| 157 | + defer ts.Close() | |
| 158 | + c := newTestClient(t, ts.URL) | |
| 159 | + | |
| 160 | + if _, err := c.ListTools(context.Background()); err != nil { | |
| 161 | + t.Fatal(err) | |
| 162 | + } | |
| 163 | + srv.mu.Lock() | |
| 164 | + srv.sessions = map[string]bool{} // gateway restarted: the session is gone | |
| 165 | + srv.mu.Unlock() | |
| 166 | + | |
| 167 | + if _, err := c.ListTools(context.Background()); err != nil { | |
| 168 | + t.Fatalf("ListTools after session loss: %v", err) | |
| 169 | + } | |
| 170 | + if c.sessionID != "sess-2" { | |
| 171 | + t.Errorf("session = %q, want a new one", c.sessionID) | |
| 172 | + } | |
| 173 | +} | |
| 174 | + | |
| 175 | +func TestHTTPClientCloseEndsTheSession(t *testing.T) { | |
| 176 | + srv := newStatefulServer(false) | |
| 177 | + ts := httptest.NewServer(srv) | |
| 178 | + defer ts.Close() | |
| 179 | + c := newTestClient(t, ts.URL) | |
| 180 | + | |
| 181 | + if _, err := c.ListTools(context.Background()); err != nil { | |
| 182 | + t.Fatal(err) | |
| 183 | + } | |
| 184 | + _ = c.Close() | |
| 185 | + if len(srv.deleted) != 1 || srv.deleted[0] != "sess-1" { | |
| 186 | + t.Errorf("DELETE sessions = %v", srv.deleted) | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +// A server that answers initialize with "method not found" is used without a | |
| 191 | +// session, the way mm talked to every server before. | |
| 192 | +func TestHTTPClientFallsBackWithoutInitialize(t *testing.T) { | |
| 193 | + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 194 | + var msg struct { | |
| 195 | + ID int `json:"id"` | |
| 196 | + Method string `json:"method"` | |
| 197 | + } | |
| 198 | + _ = json.NewDecoder(r.Body).Decode(&msg) | |
| 199 | + w.Header().Set("Content-Type", "application/json") | |
| 200 | + if msg.Method == "initialize" { | |
| 201 | + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32601,"message":"method not found"}}`, msg.ID) | |
| 202 | + return | |
| 203 | + } | |
| 204 | + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[{"name":"a"}]}}`, msg.ID) | |
| 205 | + })) | |
| 206 | + defer ts.Close() | |
| 207 | + | |
| 208 | + tools, err := newTestClient(t, ts.URL).ListTools(context.Background()) | |
| 209 | + if err != nil || len(tools) != 1 { | |
| 210 | + t.Fatalf("tools = %v, err = %v", tools, err) | |
| 211 | + } | |
| 212 | +} | |
| 213 | + | |
| 214 | +func TestHTTPClientReportsHTTPStatus(t *testing.T) { | |
| 215 | + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 216 | + http.Error(w, "bad credentials", http.StatusUnauthorized) | |
| 217 | + })) | |
| 218 | + defer ts.Close() | |
| 219 | + | |
| 220 | + _, err := newTestClient(t, ts.URL).ListTools(context.Background()) | |
| 221 | + if err == nil || !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "bad credentials") { | |
| 222 | + t.Fatalf("err = %v, want the status and body", err) | |
| 223 | + } | |
| 224 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,224 @@ | |||
| 1 | +package mcp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "fmt" | ||
| 7 | + "net/http" | ||
| 8 | + "net/http/httptest" | ||
| 9 | + "strings" | ||
| 10 | + "sync" | ||
| 11 | + "testing" | ||
| 12 | + | ||
| 13 | + "mm/internal/config" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +// statefulServer mimics the rules of the Go SDK's StreamableHTTPHandler, the | ||
| 17 | +// server behind the Docker Sandboxes MCP gateway: a POST without a session | ||
| 18 | +// opens one, and every call on a session is refused until initialize. | ||
| 19 | +type statefulServer struct { | ||
| 20 | + mu sync.Mutex | ||
| 21 | + sessions map[string]bool // id -> initialized | ||
| 22 | + next int | ||
| 23 | + methods []string | ||
| 24 | + lastAuth string | ||
| 25 | + lastProto string | ||
| 26 | + deleted []string | ||
| 27 | + sse bool // answer as an SSE stream, with a notification first | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +func newStatefulServer(sse bool) *statefulServer { | ||
| 31 | + return &statefulServer{sessions: map[string]bool{}, sse: sse} | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +func (s *statefulServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 35 | + s.mu.Lock() | ||
| 36 | + defer s.mu.Unlock() | ||
| 37 | + s.lastAuth = r.Header.Get("Authorization") | ||
| 38 | + sid := r.Header.Get(sessionIDHeader) | ||
| 39 | + | ||
| 40 | + if r.Method == http.MethodDelete { | ||
| 41 | + s.deleted = append(s.deleted, sid) | ||
| 42 | + delete(s.sessions, sid) | ||
| 43 | + w.WriteHeader(http.StatusNoContent) | ||
| 44 | + return | ||
| 45 | + } | ||
| 46 | + if !strings.Contains(r.Header.Get("Accept"), "text/event-stream") { | ||
| 47 | + http.Error(w, "Accept must contain both", http.StatusBadRequest) | ||
| 48 | + return | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + var msg struct { | ||
| 52 | + ID *int `json:"id"` | ||
| 53 | + Method string `json:"method"` | ||
| 54 | + } | ||
| 55 | + _ = json.NewDecoder(r.Body).Decode(&msg) | ||
| 56 | + s.methods = append(s.methods, msg.Method) | ||
| 57 | + | ||
| 58 | + if sid != "" { | ||
| 59 | + if _, ok := s.sessions[sid]; !ok { | ||
| 60 | + http.Error(w, "session not found", http.StatusNotFound) | ||
| 61 | + return | ||
| 62 | + } | ||
| 63 | + s.lastProto = r.Header.Get(protocolVersionHeader) | ||
| 64 | + } else { | ||
| 65 | + s.next++ | ||
| 66 | + sid = fmt.Sprintf("sess-%d", s.next) | ||
| 67 | + s.sessions[sid] = false | ||
| 68 | + } | ||
| 69 | + w.Header().Set(sessionIDHeader, sid) | ||
| 70 | + | ||
| 71 | + switch { | ||
| 72 | + case msg.ID == nil: // notification | ||
| 73 | + if msg.Method == "notifications/initialized" { | ||
| 74 | + s.sessions[sid] = true | ||
| 75 | + } | ||
| 76 | + w.WriteHeader(http.StatusAccepted) | ||
| 77 | + case msg.Method == "initialize": | ||
| 78 | + s.reply(w, *msg.ID, `{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"fake","version":"0"}}`, "") | ||
| 79 | + case !s.sessions[sid]: | ||
| 80 | + s.reply(w, *msg.ID, "", fmt.Sprintf(`{"code":-32600,"message":"method %q is invalid during session initialization"}`, msg.Method)) | ||
| 81 | + case msg.Method == "tools/list": | ||
| 82 | + s.reply(w, *msg.ID, `{"tools":[{"name":"get_me","description":"me","inputSchema":{"type":"object","properties":{}}}]}`, "") | ||
| 83 | + case msg.Method == "tools/call": | ||
| 84 | + s.reply(w, *msg.ID, `{"content":[{"type":"text","text":"octocat"}],"isError":false}`, "") | ||
| 85 | + } | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +func (s *statefulServer) reply(w http.ResponseWriter, id int, result, errObj string) { | ||
| 89 | + body := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":%s}`, id, result) | ||
| 90 | + if errObj != "" { | ||
| 91 | + body = fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"error":%s}`, id, errObj) | ||
| 92 | + } | ||
| 93 | + if !s.sse { | ||
| 94 | + w.Header().Set("Content-Type", "application/json") | ||
| 95 | + _, _ = w.Write([]byte(body)) | ||
| 96 | + return | ||
| 97 | + } | ||
| 98 | + w.Header().Set("Content-Type", "text/event-stream") | ||
| 99 | + // A server notification on the same stream must not be taken for the answer. | ||
| 100 | + fmt.Fprint(w, "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{\"level\":\"info\"}}\n\n") | ||
| 101 | + fmt.Fprintf(w, "event: message\ndata: %s\n\n", body) | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +func newTestClient(t *testing.T, url string) *HTTPClient { | ||
| 105 | + t.Helper() | ||
| 106 | + t.Setenv("MM_TEST_TOKEN", "sentinel-123") | ||
| 107 | + c, err := NewHTTPClient(config.MCPServer{ | ||
| 108 | + Name: "gw", Transport: "http", URL: url, | ||
| 109 | + Auth: config.MCPAuthConfig{Type: "bearer", TokenEnv: "MM_TEST_TOKEN"}, | ||
| 110 | + }) | ||
| 111 | + if err != nil { | ||
| 112 | + t.Fatal(err) | ||
| 113 | + } | ||
| 114 | + return c | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +func TestHTTPClientInitializesBeforeListingTools(t *testing.T) { | ||
| 118 | + for _, sse := range []bool{false, true} { | ||
| 119 | + t.Run(fmt.Sprintf("sse=%v", sse), func(t *testing.T) { | ||
| 120 | + srv := newStatefulServer(sse) | ||
| 121 | + ts := httptest.NewServer(srv) | ||
| 122 | + defer ts.Close() | ||
| 123 | + c := newTestClient(t, ts.URL+"/mcp") | ||
| 124 | + | ||
| 125 | + tools, err := c.ListTools(context.Background()) | ||
| 126 | + if err != nil { | ||
| 127 | + t.Fatalf("ListTools: %v", err) | ||
| 128 | + } | ||
| 129 | + if len(tools) != 1 || tools[0].Name != "get_me" { | ||
| 130 | + t.Fatalf("tools = %+v", tools) | ||
| 131 | + } | ||
| 132 | + res, err := c.CallTool(context.Background(), "get_me", map[string]any{}) | ||
| 133 | + if err != nil || len(res.Content) != 1 || res.Content[0].Text != "octocat" { | ||
| 134 | + t.Fatalf("CallTool = %+v, %v", res, err) | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + want := []string{"initialize", "notifications/initialized", "tools/list", "tools/call"} | ||
| 138 | + if strings.Join(srv.methods, ",") != strings.Join(want, ",") { | ||
| 139 | + t.Errorf("methods = %v, want %v", srv.methods, want) | ||
| 140 | + } | ||
| 141 | + if len(srv.sessions) != 1 { | ||
| 142 | + t.Errorf("sessions opened = %d, want 1 (the session ID must be reused)", len(srv.sessions)) | ||
| 143 | + } | ||
| 144 | + if srv.lastAuth != "Bearer sentinel-123" { | ||
| 145 | + t.Errorf("Authorization = %q", srv.lastAuth) | ||
| 146 | + } | ||
| 147 | + if srv.lastProto != "2025-06-18" { | ||
| 148 | + t.Errorf("MCP-Protocol-Version = %q", srv.lastProto) | ||
| 149 | + } | ||
| 150 | + }) | ||
| 151 | + } | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +func TestHTTPClientReinitializesWhenTheSessionIsLost(t *testing.T) { | ||
| 155 | + srv := newStatefulServer(true) | ||
| 156 | + ts := httptest.NewServer(srv) | ||
| 157 | + defer ts.Close() | ||
| 158 | + c := newTestClient(t, ts.URL) | ||
| 159 | + | ||
| 160 | + if _, err := c.ListTools(context.Background()); err != nil { | ||
| 161 | + t.Fatal(err) | ||
| 162 | + } | ||
| 163 | + srv.mu.Lock() | ||
| 164 | + srv.sessions = map[string]bool{} // gateway restarted: the session is gone | ||
| 165 | + srv.mu.Unlock() | ||
| 166 | + | ||
| 167 | + if _, err := c.ListTools(context.Background()); err != nil { | ||
| 168 | + t.Fatalf("ListTools after session loss: %v", err) | ||
| 169 | + } | ||
| 170 | + if c.sessionID != "sess-2" { | ||
| 171 | + t.Errorf("session = %q, want a new one", c.sessionID) | ||
| 172 | + } | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +func TestHTTPClientCloseEndsTheSession(t *testing.T) { | ||
| 176 | + srv := newStatefulServer(false) | ||
| 177 | + ts := httptest.NewServer(srv) | ||
| 178 | + defer ts.Close() | ||
| 179 | + c := newTestClient(t, ts.URL) | ||
| 180 | + | ||
| 181 | + if _, err := c.ListTools(context.Background()); err != nil { | ||
| 182 | + t.Fatal(err) | ||
| 183 | + } | ||
| 184 | + _ = c.Close() | ||
| 185 | + if len(srv.deleted) != 1 || srv.deleted[0] != "sess-1" { | ||
| 186 | + t.Errorf("DELETE sessions = %v", srv.deleted) | ||
| 187 | + } | ||
| 188 | +} | ||
| 189 | + | ||
| 190 | +// A server that answers initialize with "method not found" is used without a | ||
| 191 | +// session, the way mm talked to every server before. | ||
| 192 | +func TestHTTPClientFallsBackWithoutInitialize(t *testing.T) { | ||
| 193 | + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 194 | + var msg struct { | ||
| 195 | + ID int `json:"id"` | ||
| 196 | + Method string `json:"method"` | ||
| 197 | + } | ||
| 198 | + _ = json.NewDecoder(r.Body).Decode(&msg) | ||
| 199 | + w.Header().Set("Content-Type", "application/json") | ||
| 200 | + if msg.Method == "initialize" { | ||
| 201 | + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32601,"message":"method not found"}}`, msg.ID) | ||
| 202 | + return | ||
| 203 | + } | ||
| 204 | + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[{"name":"a"}]}}`, msg.ID) | ||
| 205 | + })) | ||
| 206 | + defer ts.Close() | ||
| 207 | + | ||
| 208 | + tools, err := newTestClient(t, ts.URL).ListTools(context.Background()) | ||
| 209 | + if err != nil || len(tools) != 1 { | ||
| 210 | + t.Fatalf("tools = %v, err = %v", tools, err) | ||
| 211 | + } | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +func TestHTTPClientReportsHTTPStatus(t *testing.T) { | ||
| 215 | + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 216 | + http.Error(w, "bad credentials", http.StatusUnauthorized) | ||
| 217 | + })) | ||
| 218 | + defer ts.Close() | ||
| 219 | + | ||
| 220 | + _, err := newTestClient(t, ts.URL).ListTools(context.Background()) | ||
| 221 | + if err == nil || !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "bad credentials") { | ||
| 222 | + t.Fatalf("err = %v, want the status and body", err) | ||
| 223 | + } | ||
| 224 | +} | ||
added
internal/mcp/http_wire.go +110 -0 | new file mode 100644 | ||
| @@ -0,0 +1,110 @@ | ||
| 1 | +package mcp | |
| 2 | + | |
| 3 | +// The wire side of the HTTP transport: reading what the server sent back | |
| 4 | +// (plain JSON or an SSE stream), mapping HTTP statuses to errors, and the | |
| 5 | +// credentials a request carries. Split from http_client.go, which keeps the | |
| 6 | +// session logic. | |
| 7 | + | |
| 8 | +import ( | |
| 9 | + "bufio" | |
| 10 | + "encoding/json" | |
| 11 | + "fmt" | |
| 12 | + "io" | |
| 13 | + "net/http" | |
| 14 | + "os" | |
| 15 | + "strings" | |
| 16 | +) | |
| 17 | + | |
| 18 | +// checkStatus turns a non-2xx answer into an httpStatusError carrying the | |
| 19 | +// start of the body, and closes it. | |
| 20 | +func checkStatus(resp *http.Response) (*http.Response, error) { | |
| 21 | + if resp.StatusCode >= 200 && resp.StatusCode <= 299 { | |
| 22 | + return resp, nil | |
| 23 | + } | |
| 24 | + defer resp.Body.Close() | |
| 25 | + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) | |
| 26 | + return nil, &httpStatusError{Status: resp.StatusCode, Body: strings.TrimSpace(string(snippet))} | |
| 27 | +} | |
| 28 | + | |
| 29 | +// readResponse reads the answer to request id, as plain JSON or as an SSE | |
| 30 | +// stream, whichever the server chose. | |
| 31 | +func readResponse(resp *http.Response, id int) (*mcpResponse, error) { | |
| 32 | + if strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") { | |
| 33 | + return parseSSEResponse(resp.Body, id) | |
| 34 | + } | |
| 35 | + raw, err := io.ReadAll(resp.Body) | |
| 36 | + if err != nil { | |
| 37 | + return nil, err | |
| 38 | + } | |
| 39 | + var msg mcpResponse | |
| 40 | + if err := json.Unmarshal(raw, &msg); err != nil { | |
| 41 | + return nil, fmt.Errorf("failed to parse response: %w", err) | |
| 42 | + } | |
| 43 | + return &msg, nil | |
| 44 | +} | |
| 45 | + | |
| 46 | +// parseSSEResponse returns the message answering request id. Notifications | |
| 47 | +// and server requests sent on the same stream (progress, logging, | |
| 48 | +// list_changed…) are skipped. | |
| 49 | +func parseSSEResponse(body io.Reader, id int) (*mcpResponse, error) { | |
| 50 | + reader := bufio.NewReaderSize(body, maxSSELine) | |
| 51 | + for { | |
| 52 | + line, err := reader.ReadString('\n') | |
| 53 | + if msg := sseMessage(line); msg != nil && msg.answers(id) { | |
| 54 | + return msg, nil | |
| 55 | + } | |
| 56 | + if err == io.EOF { | |
| 57 | + return nil, fmt.Errorf("no JSON-RPC response to request %d in SSE stream", id) | |
| 58 | + } | |
| 59 | + if err != nil { | |
| 60 | + return nil, fmt.Errorf("failed to read SSE stream: %w", err) | |
| 61 | + } | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +// sseMessage decodes one SSE "data:" line, or returns nil. | |
| 66 | +func sseMessage(line string) *mcpResponse { | |
| 67 | + line = strings.TrimSpace(line) | |
| 68 | + data, ok := strings.CutPrefix(line, "data:") | |
| 69 | + if !ok { | |
| 70 | + return nil | |
| 71 | + } | |
| 72 | + var msg mcpResponse | |
| 73 | + if json.Unmarshal([]byte(strings.TrimSpace(data)), &msg) != nil { | |
| 74 | + return nil | |
| 75 | + } | |
| 76 | + return &msg | |
| 77 | +} | |
| 78 | + | |
| 79 | +// addAuthHeaders adds authentication headers to the request. | |
| 80 | +func (c *HTTPClient) addAuthHeaders(req *http.Request) error { | |
| 81 | + switch c.server.Auth.Type { | |
| 82 | + case "none", "": | |
| 83 | + return nil | |
| 84 | + case "bearer", "oauth2": | |
| 85 | + // OAuth2 for GitHub MCP uses bearer token authentication | |
| 86 | + // Both "bearer" and "oauth2" types work the same way for GitHub | |
| 87 | + tokenEnv := c.server.Auth.TokenEnv | |
| 88 | + if tokenEnv == "" { | |
| 89 | + tokenEnv = "GITHUB_OAUTH_TOKEN" // Default for oauth2 if not specified | |
| 90 | + } | |
| 91 | + token := os.Getenv(tokenEnv) | |
| 92 | + if token == "" { | |
| 93 | + return fmt.Errorf("missing token for env var %s", tokenEnv) | |
| 94 | + } | |
| 95 | + req.Header.Set("Authorization", "Bearer "+token) | |
| 96 | + case "api_key": | |
| 97 | + key := os.Getenv(c.server.Auth.APIKeyEnv) | |
| 98 | + if key == "" { | |
| 99 | + return fmt.Errorf("missing API key for env var %s", c.server.Auth.APIKeyEnv) | |
| 100 | + } | |
| 101 | + header := c.server.Auth.APIKeyHeader | |
| 102 | + if header == "" { | |
| 103 | + header = "Authorization" | |
| 104 | + } | |
| 105 | + req.Header.Set(header, key) | |
| 106 | + default: | |
| 107 | + return fmt.Errorf("unknown auth type: %s", c.server.Auth.Type) | |
| 108 | + } | |
| 109 | + return nil | |
| 110 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,110 @@ | |||
| 1 | +package mcp | ||
| 2 | + | ||
| 3 | +// The wire side of the HTTP transport: reading what the server sent back | ||
| 4 | +// (plain JSON or an SSE stream), mapping HTTP statuses to errors, and the | ||
| 5 | +// credentials a request carries. Split from http_client.go, which keeps the | ||
| 6 | +// session logic. | ||
| 7 | + | ||
| 8 | +import ( | ||
| 9 | + "bufio" | ||
| 10 | + "encoding/json" | ||
| 11 | + "fmt" | ||
| 12 | + "io" | ||
| 13 | + "net/http" | ||
| 14 | + "os" | ||
| 15 | + "strings" | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +// checkStatus turns a non-2xx answer into an httpStatusError carrying the | ||
| 19 | +// start of the body, and closes it. | ||
| 20 | +func checkStatus(resp *http.Response) (*http.Response, error) { | ||
| 21 | + if resp.StatusCode >= 200 && resp.StatusCode <= 299 { | ||
| 22 | + return resp, nil | ||
| 23 | + } | ||
| 24 | + defer resp.Body.Close() | ||
| 25 | + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) | ||
| 26 | + return nil, &httpStatusError{Status: resp.StatusCode, Body: strings.TrimSpace(string(snippet))} | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// readResponse reads the answer to request id, as plain JSON or as an SSE | ||
| 30 | +// stream, whichever the server chose. | ||
| 31 | +func readResponse(resp *http.Response, id int) (*mcpResponse, error) { | ||
| 32 | + if strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") { | ||
| 33 | + return parseSSEResponse(resp.Body, id) | ||
| 34 | + } | ||
| 35 | + raw, err := io.ReadAll(resp.Body) | ||
| 36 | + if err != nil { | ||
| 37 | + return nil, err | ||
| 38 | + } | ||
| 39 | + var msg mcpResponse | ||
| 40 | + if err := json.Unmarshal(raw, &msg); err != nil { | ||
| 41 | + return nil, fmt.Errorf("failed to parse response: %w", err) | ||
| 42 | + } | ||
| 43 | + return &msg, nil | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +// parseSSEResponse returns the message answering request id. Notifications | ||
| 47 | +// and server requests sent on the same stream (progress, logging, | ||
| 48 | +// list_changed…) are skipped. | ||
| 49 | +func parseSSEResponse(body io.Reader, id int) (*mcpResponse, error) { | ||
| 50 | + reader := bufio.NewReaderSize(body, maxSSELine) | ||
| 51 | + for { | ||
| 52 | + line, err := reader.ReadString('\n') | ||
| 53 | + if msg := sseMessage(line); msg != nil && msg.answers(id) { | ||
| 54 | + return msg, nil | ||
| 55 | + } | ||
| 56 | + if err == io.EOF { | ||
| 57 | + return nil, fmt.Errorf("no JSON-RPC response to request %d in SSE stream", id) | ||
| 58 | + } | ||
| 59 | + if err != nil { | ||
| 60 | + return nil, fmt.Errorf("failed to read SSE stream: %w", err) | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +// sseMessage decodes one SSE "data:" line, or returns nil. | ||
| 66 | +func sseMessage(line string) *mcpResponse { | ||
| 67 | + line = strings.TrimSpace(line) | ||
| 68 | + data, ok := strings.CutPrefix(line, "data:") | ||
| 69 | + if !ok { | ||
| 70 | + return nil | ||
| 71 | + } | ||
| 72 | + var msg mcpResponse | ||
| 73 | + if json.Unmarshal([]byte(strings.TrimSpace(data)), &msg) != nil { | ||
| 74 | + return nil | ||
| 75 | + } | ||
| 76 | + return &msg | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +// addAuthHeaders adds authentication headers to the request. | ||
| 80 | +func (c *HTTPClient) addAuthHeaders(req *http.Request) error { | ||
| 81 | + switch c.server.Auth.Type { | ||
| 82 | + case "none", "": | ||
| 83 | + return nil | ||
| 84 | + case "bearer", "oauth2": | ||
| 85 | + // OAuth2 for GitHub MCP uses bearer token authentication | ||
| 86 | + // Both "bearer" and "oauth2" types work the same way for GitHub | ||
| 87 | + tokenEnv := c.server.Auth.TokenEnv | ||
| 88 | + if tokenEnv == "" { | ||
| 89 | + tokenEnv = "GITHUB_OAUTH_TOKEN" // Default for oauth2 if not specified | ||
| 90 | + } | ||
| 91 | + token := os.Getenv(tokenEnv) | ||
| 92 | + if token == "" { | ||
| 93 | + return fmt.Errorf("missing token for env var %s", tokenEnv) | ||
| 94 | + } | ||
| 95 | + req.Header.Set("Authorization", "Bearer "+token) | ||
| 96 | + case "api_key": | ||
| 97 | + key := os.Getenv(c.server.Auth.APIKeyEnv) | ||
| 98 | + if key == "" { | ||
| 99 | + return fmt.Errorf("missing API key for env var %s", c.server.Auth.APIKeyEnv) | ||
| 100 | + } | ||
| 101 | + header := c.server.Auth.APIKeyHeader | ||
| 102 | + if header == "" { | ||
| 103 | + header = "Authorization" | ||
| 104 | + } | ||
| 105 | + req.Header.Set(header, key) | ||
| 106 | + default: | ||
| 107 | + return fmt.Errorf("unknown auth type: %s", c.server.Auth.Type) | ||
| 108 | + } | ||
| 109 | + return nil | ||
| 110 | +} | ||