bots-garden/mini-mepublic Fork
⑂ edf0f41
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Merge feature/mcp-gateway-session into main

rickub committed 2026-09-23T19:40:48Z Browse files
edf0f41 parents: ef5c701 01ac10c
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
32
43 ```bash
54 # 1. Installer GitHub CLI
@@ -16,28 +15,28 @@ gh auth status --show-token
1615 cat ~/.config/gh/hosts.yml | grep -oP 'token: \K.*'
1716 ```
1817
18+## Vérifier que le token fonctionne
1919
20-# Vérifier que le token fonctionne
20+```bash
2121 curl -H "Authorization: Bearer $GITHUB_OAUTH_TOKEN" \
2222 https://api.github.com/user
23+```
2324
25+```bash
2426 mm --tui agent.dmr.github-oauth.yaml
27+```
2528
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`
2730
2831
2932
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-#
3433
3534 sbx mcp add github --url https://api.githubcopilot.com/mcp/
3635 sbx mcp auth github
3736 sbx mcp auth status --all
3837 sbx mcp inspect github
3938
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
4140
4241 sbx mcp load github --sandbox mm-mcp-github
4342
@@ -1,5 +1,4 @@
1-1+# Mini Me and GitHub MCP server
2-
3 2
4 ```bash3 ```bash
5 # 1. Installer GitHub CLI4 # 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 fonctionne20+```bash
21 curl -H "Authorization: Bearer $GITHUB_OAUTH_TOKEN" \21 curl -H "Authorization: Bearer $GITHUB_OAUTH_TOKEN" \
22 https://api.github.com/user22 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_xxx29+## 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 github35 sbx mcp auth github
37 sbx mcp auth status --all36 sbx mcp auth status --all
38 sbx mcp inspect github37 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 github39+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-github41 sbx mcp load github --sandbox mm-mcp-github
43 42
modified demo/agent.dmr.sbx.yaml +6 -4
@@ -6,12 +6,14 @@
66 #
77 # To use this config:
88 # 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/
1010 # 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
1213 #
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).
1517
1618 provider: dmr
1719
@@ -6,12 +6,14 @@
6 #6 #
7 # To use this config:7 # To use this config:
8 # 1. Run: sbx login8 # 1. Run: sbx login
9-# 2. Register MCP servers: sbx mcp add github --url https://mcp.github.com/mcp9+# 2. Register MCP servers: sbx mcp add github --url https://api.githubcopilot.com/mcp/
10 # 3. Authenticate: sbx mcp auth github10 # 3. Authenticate: sbx mcp auth github
11-# 4. Start sandbox: sbx run mm --name my-session --config agent.dmr.sbx.yaml11+# 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: dmr18 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
123123
124124 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.
125125
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+
126162 ## Variants
127163
128164 - **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 ## Variants162 ## 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
123123
124124 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.
125125
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+
126162 ## Variantes
127163
128164 - **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 ## Variantes162 ## 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 @@
44 package mcp
55
66 import (
7- "bufio"
8- "bytes"
97 "context"
10- "encoding/json"
118 "fmt"
12- "io"
13- "net/http"
14- "os"
159 "strings"
16- "time"
1710
1811 "mm/internal/config"
1912 )
@@ -38,22 +31,22 @@ type Tool struct {
3831
3932 // ToolInputSchema describes the input parameters for a tool.
4033 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"`
4437 }
4538
4639 // ToolOutputSchema describes the output of a tool.
4740 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"`
5043 }
5144
5245 // ToolIcon represents an icon for a tool (used for UI display).
5346 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
5548 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")
5750 }
5851
5952 // Param describes a single parameter or output field.
@@ -102,7 +95,7 @@ type Client interface {
10295 // CallToolResult is the result of a tool call.
10396 type CallToolResult struct {
10497 Content []Content `json:"content"`
105- IsError bool `json:"isError"`
98+ IsError bool `json:"isError"`
10699 }
107100
108101 // Content represents a content block in a tool result.
@@ -218,306 +211,3 @@ func CloseAll(clients map[string]*ServerClient) {
218211 sc.Close()
219212 }
220213 }
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 mcp4 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 URL47+ 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+}