Compare commits
48 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0de2a2b351 | |||
| 72fb48f59e | |||
| 23fff88ccb | |||
| 235727106d | |||
| 1e0bdfc42d | |||
| 51dda82cd0 | |||
| fbaea341c7 | |||
| ebd3a64522 | |||
| 801d7fbb95 | |||
| fe9e70b61a | |||
| be2b7e631b | |||
| be33b467a6 | |||
| a87db60345 | |||
| 9b90332b7c | |||
| 9c6f8b70d4 | |||
| b6276776fd | |||
| 678b966425 | |||
| 226d636ca2 | |||
| a19b35cbea | |||
| 21808ddce0 | |||
| ec3774138e | |||
| 647a03a10c | |||
| 7315f2658c | |||
| 46c94a1a12 | |||
| ba42be8d77 | |||
|
|
fcee5a0a36 | ||
|
|
8c88084181 | ||
|
|
b32b6c8a55 | ||
|
|
3ba3475753 | ||
|
|
9b0bd7e175 | ||
|
|
7998e049cb | ||
|
|
88818641e4 | ||
|
|
781a5985ab | ||
|
|
8a448be942 | ||
|
|
ddecb1eb7f | ||
|
|
1da92a8240 | ||
|
|
8c79db73d7 | ||
|
|
d1fd485fb2 | ||
|
|
2aa5e92b50 | ||
|
|
460868f4de | ||
|
|
15ea1e11ab | ||
|
|
1dbb9e15d9 | ||
|
|
ffb28dbc52 | ||
|
|
f10b797640 | ||
|
|
92fc30cb2d | ||
|
|
5f9e0e3a5a | ||
|
|
1472d7a107 | ||
|
|
5dbc073e5c |
27 changed files with 4177 additions and 902 deletions
203
.forgejo/workflows/release.yml
Normal file
203
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
BINARY_NAME: email-mcp
|
||||
BUILD_PATH: build/email-mcp-linux-amd64
|
||||
CHECKSUM_PATH: build/email-mcp-linux-amd64.sha256
|
||||
MANIFEST_PATH: mcp.toml
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build linux amd64 binary
|
||||
run: make build GOOS=linux GOARCH=amd64
|
||||
|
||||
- name: Generate binary checksum
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
asset_name="$(basename "${BUILD_PATH}")"
|
||||
checksum_value="$(sha256sum "${BUILD_PATH}" | cut -d' ' -f1)"
|
||||
printf '%s %s\n' "${checksum_value}" "${asset_name}" > "${CHECKSUM_PATH}"
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${TAG_NAME}" ]; then
|
||||
echo "missing tag name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${TAG_NAME}^" 2>/dev/null || true)"
|
||||
|
||||
{
|
||||
printf 'body<<EOF\n'
|
||||
|
||||
if [ -n "${previous_tag}" ]; then
|
||||
printf '## Commits since %s\n\n' "${previous_tag}"
|
||||
git log --reverse --pretty='- %h %s' "${previous_tag}..${TAG_NAME}"
|
||||
else
|
||||
printf '## Commits\n\n'
|
||||
git log --reverse --pretty='- %h %s' "${TAG_NAME}"
|
||||
fi
|
||||
|
||||
printf '\nEOF\n'
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Create or fetch release
|
||||
id: release
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
API_URL: ${{ github.api_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_BODY: ${{ steps.release_notes.outputs.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${TAG_NAME}" ]; then
|
||||
echo "missing tag name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
owner="${REPOSITORY%%/*}"
|
||||
repo="${REPOSITORY#*/}"
|
||||
release_url="${API_URL}/repos/${owner}/${repo}/releases/tags/${TAG_NAME}"
|
||||
create_url="${API_URL}/repos/${owner}/${repo}/releases"
|
||||
payload="$(python3 -c 'import json, os; print(json.dumps({"tag_name": os.environ["TAG_NAME"], "name": os.environ["TAG_NAME"], "body": os.environ["RELEASE_BODY"], "prerelease": False, "draft": False}))')"
|
||||
|
||||
http_code="$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${release_url}")"
|
||||
|
||||
if [ "${http_code}" = "404" ]; then
|
||||
http_code="$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${payload}" \
|
||||
"${create_url}")"
|
||||
elif [ "${http_code}" -ge 200 ] && [ "${http_code}" -lt 300 ]; then
|
||||
release_id="$(python3 -c 'import json; print(json.load(open("release.json", "r", encoding="utf-8"))["id"])')"
|
||||
update_url="${API_URL}/repos/${owner}/${repo}/releases/${release_id}"
|
||||
|
||||
http_code="$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-X PATCH \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${payload}" \
|
||||
"${update_url}")"
|
||||
fi
|
||||
|
||||
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||
echo "release API call failed with status ${http_code}" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_id="$(python3 -c 'import json; print(json.load(open("release.json", "r", encoding="utf-8"))["id"])')"
|
||||
|
||||
echo "release_id=${release_id}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload release asset
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
API_URL: ${{ github.api_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
owner="${REPOSITORY%%/*}"
|
||||
repo="${REPOSITORY#*/}"
|
||||
asset_name="$(basename "${BUILD_PATH}")"
|
||||
upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}"
|
||||
|
||||
http_code="$(curl -sS -o asset.json -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${BUILD_PATH}" \
|
||||
"${upload_url}")"
|
||||
|
||||
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||
echo "asset upload failed with status ${http_code}" >&2
|
||||
cat asset.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload manifest asset
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
API_URL: ${{ github.api_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
owner="${REPOSITORY%%/*}"
|
||||
repo="${REPOSITORY#*/}"
|
||||
asset_name="$(basename "${MANIFEST_PATH}")"
|
||||
upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}"
|
||||
|
||||
http_code="$(curl -sS -o asset.json -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${MANIFEST_PATH}" \
|
||||
"${upload_url}")"
|
||||
|
||||
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||
echo "asset upload failed with status ${http_code}" >&2
|
||||
cat asset.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload checksum asset
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
API_URL: ${{ github.api_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
owner="${REPOSITORY%%/*}"
|
||||
repo="${REPOSITORY#*/}"
|
||||
asset_name="$(basename "${CHECKSUM_PATH}")"
|
||||
upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}"
|
||||
|
||||
http_code="$(curl -sS -o asset.json -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${CHECKSUM_PATH}" \
|
||||
"${upload_url}")"
|
||||
|
||||
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||
echo "asset upload failed with status ${http_code}" >&2
|
||||
cat asset.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
BINARY_NAME: email-mcp
|
||||
BUILD_PATH: build/email-mcp-linux-amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build linux amd64 binary
|
||||
run: make build GOOS=linux GOARCH=amd64
|
||||
|
||||
- name: Create or fetch release
|
||||
id: release
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
API_URL: ${{ github.api_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${TAG_NAME}" ]; then
|
||||
echo "missing tag name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
owner="${REPOSITORY%%/*}"
|
||||
repo="${REPOSITORY#*/}"
|
||||
release_url="${API_URL}/repos/${owner}/${repo}/releases/tags/${TAG_NAME}"
|
||||
create_url="${API_URL}/repos/${owner}/${repo}/releases"
|
||||
|
||||
http_code="$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${release_url}")"
|
||||
|
||||
if [ "${http_code}" = "404" ]; then
|
||||
payload="$(printf '{"tag_name":"%s","name":"%s","prerelease":false,"draft":false}' "${TAG_NAME}" "${TAG_NAME}")"
|
||||
http_code="$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${payload}" \
|
||||
"${create_url}")"
|
||||
fi
|
||||
|
||||
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||
echo "release API call failed with status ${http_code}" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_id="$(python3 -c 'import json; print(json.load(open("release.json", "r", encoding="utf-8"))["id"])')"
|
||||
|
||||
echo "release_id=${release_id}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload release asset
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
API_URL: ${{ github.api_url }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
owner="${REPOSITORY%%/*}"
|
||||
repo="${REPOSITORY#*/}"
|
||||
asset_name="$(basename "${BUILD_PATH}")"
|
||||
upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}"
|
||||
|
||||
http_code="$(curl -sS -o asset.json -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${BUILD_PATH}" \
|
||||
"${upload_url}")"
|
||||
|
||||
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||
echo "asset upload failed with status ${http_code}" >&2
|
||||
cat asset.json >&2
|
||||
exit 1
|
||||
fi
|
||||
13
Makefile
13
Makefile
|
|
@ -1,6 +1,7 @@
|
|||
BINARY_NAME := email-mcp
|
||||
BUILD_DIR := build
|
||||
GOCACHE ?= /tmp/$(BINARY_NAME)-gocache
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
|
||||
GOOS ?= $(shell go env GOOS)
|
||||
GOARCH ?= $(shell go env GOARCH)
|
||||
|
|
@ -13,12 +14,20 @@ endif
|
|||
|
||||
OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
|
||||
|
||||
.PHONY: build test
|
||||
.PHONY: build test generate generate-check
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR) $(GOCACHE)
|
||||
GOCACHE=$(GOCACHE) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(OUTPUT) ./cmd/email-mcp
|
||||
GOCACHE=$(GOCACHE) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X main.version=$(VERSION)" -o $(OUTPUT) ./cmd/email-mcp
|
||||
|
||||
test:
|
||||
@mkdir -p $(GOCACHE)
|
||||
GOCACHE=$(GOCACHE) go test ./...
|
||||
|
||||
generate:
|
||||
@mkdir -p $(GOCACHE)
|
||||
GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate
|
||||
|
||||
generate-check:
|
||||
@mkdir -p $(GOCACHE)
|
||||
GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate --check
|
||||
|
|
|
|||
219
README.md
219
README.md
|
|
@ -1,72 +1,173 @@
|
|||
# email-mcp
|
||||
|
||||
Serveur MCP local pour lire une boîte mail via IMAP. Le projet expose trois outils :
|
||||
Serveur MCP local pour lire une boîte mail via IMAP. Le serveur parle le protocole MCP standard sur `stdio`, avec des messages **JSON-RPC 2.0** (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`).
|
||||
|
||||
- **`list_mailboxes`** — lister les boîtes IMAP visibles
|
||||
- **`list_messages`** — lister les messages récents d'une boîte
|
||||
- **`get_message`** — récupérer un message par UID IMAP
|
||||
Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
|
||||
|
||||
Le stockage des credentials repose sur **KDE Wallet** via **D-Bus**. La V1 cible Linux avec session KDE Wallet disponible.
|
||||
- la gestion de profils CLI
|
||||
- le stockage JSON de configuration dans `os.UserConfigDir()`
|
||||
- le stockage du mot de passe dans Bitwarden via `bw`
|
||||
- le manifeste `mcp.toml`
|
||||
- les helpers Go générés depuis `mcp.toml` (`mcpgen/`)
|
||||
- l’auto-update via `email-mcp update`
|
||||
|
||||
## Sommaire
|
||||
## Commandes
|
||||
|
||||
- [Prérequis](#prérequis)
|
||||
- [Configuration](#configuration)
|
||||
- [Étape 1 : enregistrer les credentials](#étape-1--enregistrer-les-credentials)
|
||||
- [Étape 2 : lancer le serveur MCP](#étape-2--lancer-le-serveur-mcp)
|
||||
- [Installation](#installation)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [Configuration JSON manuelle](#configuration-json-manuelle)
|
||||
- [Compiler depuis les sources](#compiler-depuis-les-sources)
|
||||
- [Outils](#outils)
|
||||
- [list_mailboxes](#list_mailboxes)
|
||||
- [list_messages](#list_messages)
|
||||
- [get_message](#get_message)
|
||||
- `email-mcp setup` : configure (ou met à jour) un profil IMAP
|
||||
- `email-mcp config show` : affiche la configuration IMAP résolue et la provenance
|
||||
- `email-mcp config test` : lance les checks de configuration/connectivité (équivalent de `doctor`)
|
||||
- `email-mcp config delete` : supprime un profil local et son mot de passe stocké
|
||||
- `email-mcp mcp` : lance le serveur MCP sur `stdin/stdout`
|
||||
- `email-mcp doctor` : diagnostique la configuration locale, Bitwarden, le manifeste et l’accès IMAP
|
||||
- `email-mcp update` : met à jour le binaire courant depuis la dernière release
|
||||
- `email-mcp version` : affiche la version du binaire
|
||||
|
||||
## Prérequis
|
||||
La commande `email-mcp help` (ou `-h` / `--help`) affiche l’aide globale.
|
||||
|
||||
- Linux
|
||||
- une session D-Bus utilisateur active
|
||||
- KDE Wallet accessible sur cette session
|
||||
- Go 1.25+
|
||||
- un compte IMAP fonctionnel
|
||||
## Outils MCP
|
||||
|
||||
- `list_mailboxes` : lister les boîtes IMAP visibles
|
||||
- `list_messages` : lister les messages récents d’une boîte
|
||||
- `get_message` : récupérer un message par UID IMAP
|
||||
|
||||
## Configuration
|
||||
|
||||
### Étape 1 : enregistrer les credentials
|
||||
La configuration est séparée en deux parties :
|
||||
|
||||
Le setup est interactif :
|
||||
- `host` et `username` sont stockés dans `config.json`
|
||||
- `password` est stocké dans Bitwarden via le CLI `bw`
|
||||
|
||||
Le profil actif est résolu dans cet ordre :
|
||||
|
||||
1. `--profile`
|
||||
2. `EMAIL_MCP_PROFILE`
|
||||
3. `current_profile` dans `config.json`
|
||||
4. `[profiles].default` dans `mcp.toml`
|
||||
5. `default`
|
||||
|
||||
Les credentials IMAP sont résolus ensuite via les champs `[[config.fields]]` du manifeste et les helpers générés par le framework :
|
||||
|
||||
1. `host` : `EMAIL_MCP_HOST` puis `config.json`
|
||||
2. `username` : `EMAIL_MCP_USERNAME` puis `config.json`
|
||||
3. `password` : `EMAIL_MCP_PASSWORD` puis secret Bitwarden `imap-password/<profile>`
|
||||
|
||||
### Configurer un profil
|
||||
|
||||
```sh
|
||||
./email-mcp setup
|
||||
```
|
||||
|
||||
Pour un profil nommé :
|
||||
|
||||
```sh
|
||||
./email-mcp setup --profile work
|
||||
```
|
||||
|
||||
Le binaire demande ensuite :
|
||||
|
||||
1. l'hôte IMAP
|
||||
2. le nom d'utilisateur
|
||||
1. l’hôte IMAP
|
||||
2. le nom d’utilisateur
|
||||
3. le mot de passe
|
||||
|
||||
Les credentials sont stockés dans KDE Wallet sous le profil `default`.
|
||||
Si un mot de passe existe déjà dans Bitwarden, laisser le champ vide le conserve.
|
||||
|
||||
Si KDE Wallet n'est pas disponible, le setup échoue explicitement et n'écrit rien ailleurs.
|
||||
Le backend de secrets déclaré dans `mcp.toml` est `bitwarden-cli`. Le CLI `bw` doit être installé, connecté et déverrouillé avec `BW_SESSION` disponible dans l’environnement. `EMAIL_MCP_PASSWORD` reste accepté pour fournir le mot de passe sans lire Bitwarden.
|
||||
|
||||
### Étape 2 : lancer le serveur MCP
|
||||
|
||||
Le serveur MCP s'exécute sur `stdin/stdout` :
|
||||
### Lancer le serveur MCP
|
||||
|
||||
```sh
|
||||
./email-mcp mcp
|
||||
```
|
||||
|
||||
Si aucun credential n'a été configuré, le serveur renvoie l'erreur :
|
||||
Pour un profil nommé :
|
||||
|
||||
```sh
|
||||
./email-mcp mcp --profile work
|
||||
```
|
||||
|
||||
Si aucun credential n’a été configuré pour le profil résolu, le serveur renvoie l’erreur :
|
||||
|
||||
```text
|
||||
credentials not configured; run `email-mcp setup`
|
||||
```
|
||||
|
||||
### Inspecter la configuration résolue
|
||||
|
||||
```sh
|
||||
./email-mcp config show
|
||||
./email-mcp config show --profile work
|
||||
```
|
||||
|
||||
### Tester la configuration résolue
|
||||
|
||||
```sh
|
||||
./email-mcp config test
|
||||
./email-mcp config test --profile work
|
||||
```
|
||||
|
||||
## Auto-update
|
||||
|
||||
`email-mcp update` lit `mcp.toml` depuis le répertoire du binaire courant, puis remonte les répertoires parents. Si aucun manifeste n’est trouvé, la commande échoue.
|
||||
|
||||
```sh
|
||||
./email-mcp update
|
||||
```
|
||||
|
||||
Le manifeste de ce repo utilise le driver Gitea du framework :
|
||||
|
||||
```toml
|
||||
binary_name = "email-mcp"
|
||||
|
||||
[update]
|
||||
source_name = "email-mcp releases"
|
||||
driver = "gitea"
|
||||
repository = "AI/email-mcp"
|
||||
base_url = "https://gitea.lclr.dev"
|
||||
asset_name_template = "{binary}-{os}-{arch}{ext}"
|
||||
checksum_asset_name = "{asset}.sha256"
|
||||
checksum_required = true
|
||||
token_env_names = ["GITEA_TOKEN"]
|
||||
```
|
||||
|
||||
## Diagnostic
|
||||
|
||||
`email-mcp doctor` vérifie :
|
||||
|
||||
- la lisibilité du fichier de configuration
|
||||
- le profil IMAP résolu
|
||||
- la disponibilité du backend Bitwarden
|
||||
- la présence du mot de passe stocké
|
||||
- la validité du manifeste `mcp.toml`
|
||||
- la connectivité IMAP avec les credentials résolus
|
||||
|
||||
```sh
|
||||
./email-mcp doctor
|
||||
./email-mcp doctor --profile work
|
||||
```
|
||||
|
||||
La commande retourne un code de sortie non nul si au moins un check échoue.
|
||||
|
||||
Pour l’update, la validation du manifeste accepte :
|
||||
|
||||
- soit `update.latest_release_url`
|
||||
- soit un couple driver/référentiel (`update.driver`, `update.repository`) avec les champs requis (ex. `update.base_url` pour Gitea)
|
||||
|
||||
## Installation
|
||||
|
||||
### Installateur interactif
|
||||
|
||||
Le repo inclut un assistant interactif `install.sh` :
|
||||
|
||||
```sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Tu peux aussi l’exécuter directement depuis la branche `main` :
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gitea.lclr.dev/AI/email-mcp/raw/branch/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Claude Code CLI
|
||||
|
||||
Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` :
|
||||
|
|
@ -75,7 +176,7 @@ Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` :
|
|||
claude mcp add email-mcp -- /absolute/path/to/bin/email-mcp mcp
|
||||
```
|
||||
|
||||
Le `setup` doit être exécuté une fois séparément avant d'utiliser le serveur.
|
||||
La configuration se fait une fois séparément via `email-mcp setup`.
|
||||
|
||||
### Configuration JSON manuelle
|
||||
|
||||
|
|
@ -96,14 +197,11 @@ Ajoute le bloc suivant à ta configuration MCP (`~/.claude.json` côté utilisat
|
|||
|
||||
Une release est générée automatiquement quand tu pousses un tag `v*` sur le repo Gitea.
|
||||
|
||||
Exemple pour démarrer en `v1.0` :
|
||||
Les assets publiés sont :
|
||||
|
||||
```sh
|
||||
git tag v1.0
|
||||
git push origin v1.0
|
||||
```
|
||||
|
||||
Le workflow build alors `email-mcp` pour `linux/amd64` et joint le binaire `build/email-mcp-linux-amd64` comme asset de la release.
|
||||
- `build/email-mcp-linux-amd64`
|
||||
- `build/email-mcp-linux-amd64.sha256`
|
||||
- `mcp.toml`
|
||||
|
||||
## Compiler depuis les sources
|
||||
|
||||
|
|
@ -111,7 +209,7 @@ Le workflow build alors `email-mcp` pour `linux/amd64` et joint le binaire `buil
|
|||
make build
|
||||
```
|
||||
|
||||
Le binaire est généré dans `build/email-mcp-<goos>-<goarch>`.
|
||||
Le binaire est généré dans `build/email-mcp-<goos>-<goarch>` avec une version injectée depuis `git describe`.
|
||||
|
||||
Pour cross-compiler :
|
||||
|
||||
|
|
@ -125,34 +223,9 @@ Pour lancer les tests :
|
|||
make test
|
||||
```
|
||||
|
||||
## Outils
|
||||
Pour régénérer la glue framework après une modification de `mcp.toml` :
|
||||
|
||||
### list_mailboxes
|
||||
|
||||
Liste les boîtes IMAP visibles pour le compte configuré. Aucun paramètre.
|
||||
|
||||
### list_messages
|
||||
|
||||
Liste les messages récents d'une boîte.
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|---|---|---|
|
||||
| `mailbox` | string | **Requis.** Nom de la boîte IMAP |
|
||||
| `limit` | number | Nombre maximum de messages à retourner. Par défaut : `20` |
|
||||
|
||||
Retourne des résumés de messages avec un `uid` IMAP stable, le sujet et l'expéditeur.
|
||||
|
||||
### get_message
|
||||
|
||||
Récupère un message précis à partir de sa boîte et de son UID IMAP.
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|---|---|---|
|
||||
| `mailbox` | string | **Requis.** Nom de la boîte IMAP |
|
||||
| `uid` | number | **Requis.** UID IMAP du message |
|
||||
|
||||
Retourne :
|
||||
|
||||
- les métadonnées du message
|
||||
- les headers dans l'ordre d'origine
|
||||
- un body texte décodé de manière conservative pour les contenus MIME courants
|
||||
```sh
|
||||
make generate
|
||||
make generate-check
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import (
|
|||
"email-mcp/internal/cli"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
app := cli.BuildApp()
|
||||
app := cli.BuildApp(version)
|
||||
os.Exit(cli.Execute(app, os.Args[1:], os.Stderr))
|
||||
}
|
||||
|
|
|
|||
14
go.mod
14
go.mod
|
|
@ -3,12 +3,22 @@ module email-mcp
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
forge.lclr.dev/AI/mcp-framework v1.13.0
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
||||
github.com/99designs/keyring v1.2.2 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/danieljoos/wincred v1.1.2 // indirect
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||
github.com/mtibben/percent v0.2.1 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
)
|
||||
|
|
|
|||
52
go.sum
52
go.sum
|
|
@ -1,11 +1,50 @@
|
|||
forge.lclr.dev/AI/mcp-framework v1.9.0 h1:8i2CHQlQo/mRG1BE2UArHptAa/HC7AOhZBIqz8md8Vk=
|
||||
forge.lclr.dev/AI/mcp-framework v1.9.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||
forge.lclr.dev/AI/mcp-framework v1.10.0 h1:RrTy7K/hSruaVS9Z/oaRpkLs2U5WGs4H3tox7PiErak=
|
||||
forge.lclr.dev/AI/mcp-framework v1.10.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||
forge.lclr.dev/AI/mcp-framework v1.12.0 h1:pu1cfWcL62BF+f7DBe4IbkigHLcK6YOJ3vEBz1495AY=
|
||||
forge.lclr.dev/AI/mcp-framework v1.12.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||
forge.lclr.dev/AI/mcp-framework v1.13.0 h1:YfC/AqdzTHGRgtZxMl7CfDN+duFezyQ4nkX9uTD+HX0=
|
||||
forge.lclr.dev/AI/mcp-framework v1.13.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
||||
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
||||
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
|
||||
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
|
||||
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
|
||||
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
|
|
@ -21,14 +60,17 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
|
|
@ -39,3 +81,9 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
625
install.sh
Executable file
625
install.sh
Executable file
|
|
@ -0,0 +1,625 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BINARY_NAME="email-mcp"
|
||||
DEFAULT_PROFILE="default"
|
||||
PROFILE_ENV="EMAIL_MCP_PROFILE"
|
||||
RELEASE_BASE_URL="https://forge.lclr.dev"
|
||||
RELEASE_REPOSITORY="AI/email-mcp"
|
||||
INSTALLED_BINARY_PATH=""
|
||||
PREFILL_SERVER_NAME=""
|
||||
PREFILL_PROFILE_VALUE=""
|
||||
PREFILL_COMMAND_PATH=""
|
||||
|
||||
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_RESET="$(printf '\033[0m')"
|
||||
C_BOLD="$(printf '\033[1m')"
|
||||
C_DIM="$(printf '\033[2m')"
|
||||
C_RED="$(printf '\033[31m')"
|
||||
C_GREEN="$(printf '\033[32m')"
|
||||
C_YELLOW="$(printf '\033[33m')"
|
||||
C_BLUE="$(printf '\033[34m')"
|
||||
C_MAGENTA="$(printf '\033[35m')"
|
||||
C_CYAN="$(printf '\033[36m')"
|
||||
else
|
||||
C_RESET=""
|
||||
C_BOLD=""
|
||||
C_DIM=""
|
||||
C_RED=""
|
||||
C_GREEN=""
|
||||
C_YELLOW=""
|
||||
C_BLUE=""
|
||||
C_MAGENTA=""
|
||||
C_CYAN=""
|
||||
fi
|
||||
|
||||
ui_line() {
|
||||
printf "%b%s%b\n" "$C_DIM" "------------------------------------------------------------" "$C_RESET" >&2
|
||||
}
|
||||
|
||||
ui_title() {
|
||||
printf "\n%b%s%b\n" "$C_BOLD$C_CYAN" "$1" "$C_RESET" >&2
|
||||
}
|
||||
|
||||
ui_info() {
|
||||
printf "%b[info]%b %s\n" "$C_BLUE" "$C_RESET" "$1" >&2
|
||||
}
|
||||
|
||||
ui_success() {
|
||||
printf "%b[ok]%b %s\n" "$C_GREEN" "$C_RESET" "$1" >&2
|
||||
}
|
||||
|
||||
ui_warn() {
|
||||
printf "%b[warn]%b %s\n" "$C_YELLOW" "$C_RESET" "$1" >&2
|
||||
}
|
||||
|
||||
ui_error() {
|
||||
printf "%b[error]%b %s\n" "$C_RED" "$C_RESET" "$1" >&2
|
||||
}
|
||||
|
||||
prompt() {
|
||||
local label="$1"
|
||||
local default_value="$2"
|
||||
local answer=""
|
||||
|
||||
if [ -n "$default_value" ]; then
|
||||
printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2
|
||||
else
|
||||
printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2
|
||||
fi
|
||||
|
||||
if [ -t 2 ] && [ -r /dev/tty ]; then
|
||||
if ! IFS= read -r answer < /dev/tty 2>/dev/null; then
|
||||
IFS= read -r answer || answer=""
|
||||
fi
|
||||
else
|
||||
IFS= read -r answer || answer=""
|
||||
fi
|
||||
|
||||
if [ -z "$answer" ]; then
|
||||
printf "%s" "$default_value"
|
||||
return
|
||||
fi
|
||||
|
||||
printf "%s" "$answer"
|
||||
}
|
||||
|
||||
tty_prompt_available() {
|
||||
[ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]
|
||||
}
|
||||
|
||||
menu_select() {
|
||||
local title="$1"
|
||||
shift
|
||||
local options=("$@")
|
||||
local count="${#options[@]}"
|
||||
local index=0
|
||||
local key=""
|
||||
local i=0
|
||||
local rows=$((count + 3))
|
||||
local rendered=0
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! tty_prompt_available; then
|
||||
ui_title "$title"
|
||||
i=0
|
||||
while [ "$i" -lt "$count" ]; do
|
||||
printf " %d) %s\n" "$((i + 1))" "${options[$i]}" >&2
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
while true; do
|
||||
local raw_choice
|
||||
raw_choice="$(prompt "Choix" "1")"
|
||||
case "$raw_choice" in
|
||||
''|*[!0-9]*)
|
||||
ui_warn "Choix invalide: $raw_choice"
|
||||
;;
|
||||
*)
|
||||
if [ "$raw_choice" -ge 1 ] && [ "$raw_choice" -le "$count" ]; then
|
||||
printf "%s" "${options[$((raw_choice - 1))]}"
|
||||
return 0
|
||||
fi
|
||||
ui_warn "Choix invalide: $raw_choice"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
while true; do
|
||||
if [ "$rendered" -eq 1 ]; then
|
||||
printf "\033[%dA\033[J" "$rows" >&2 2>/dev/null || true
|
||||
fi
|
||||
ui_title "$title"
|
||||
i=0
|
||||
while [ "$i" -lt "$count" ]; do
|
||||
if [ "$i" -eq "$index" ]; then
|
||||
printf " %b› %s%b\n" "$C_BOLD$C_CYAN" "${options[$i]}" "$C_RESET" >&2
|
||||
else
|
||||
printf " %s\n" "${options[$i]}" >&2
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
printf "%bUtilise ↑/↓ puis Entrée.%b\n" "$C_DIM" "$C_RESET" >&2
|
||||
rendered=1
|
||||
|
||||
if ! IFS= read -rsn1 key < /dev/tty; then
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$key" in
|
||||
"")
|
||||
printf "%s" "${options[$index]}"
|
||||
return 0
|
||||
;;
|
||||
$'\x1b')
|
||||
if IFS= read -rsn2 key < /dev/tty; then
|
||||
case "$key" in
|
||||
"[A")
|
||||
if [ "$index" -eq 0 ]; then
|
||||
index=$((count - 1))
|
||||
else
|
||||
index=$((index - 1))
|
||||
fi
|
||||
;;
|
||||
"[B")
|
||||
if [ "$index" -eq $((count - 1)) ]; then
|
||||
index=0
|
||||
else
|
||||
index=$((index + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
sanitize_server_name() {
|
||||
local raw="$1"
|
||||
local sanitized
|
||||
sanitized="$(printf "%s" "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//')"
|
||||
if [ -z "$sanitized" ]; then
|
||||
sanitized="$BINARY_NAME"
|
||||
fi
|
||||
printf "%s" "$sanitized"
|
||||
}
|
||||
|
||||
toml_escape() {
|
||||
printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
go_bin_dir() {
|
||||
local gobin
|
||||
gobin="$(go env GOBIN 2>/dev/null || true)"
|
||||
if [ -n "$gobin" ]; then
|
||||
printf "%s\n" "$gobin"
|
||||
return
|
||||
fi
|
||||
|
||||
go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}'
|
||||
}
|
||||
|
||||
resolve_binary_path() {
|
||||
if [ -n "$INSTALLED_BINARY_PATH" ] && [ -x "$INSTALLED_BINARY_PATH" ]; then
|
||||
printf "%s\n" "$INSTALLED_BINARY_PATH"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
|
||||
command -v "$BINARY_NAME"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
local bin_dir
|
||||
bin_dir="$(go_bin_dir)"
|
||||
if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then
|
||||
printf "%s\n" "$bin_dir/$BINARY_NAME"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s\n" "$HOME/.local/bin/$BINARY_NAME"
|
||||
}
|
||||
|
||||
ensure_cli() {
|
||||
local cli_name="$1"
|
||||
if command -v "$cli_name" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
ui_error "Commande introuvable: $cli_name"
|
||||
exit 1
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
case "$(uname -s | tr '[:upper:]' '[:lower:]')" in
|
||||
linux) printf "linux" ;;
|
||||
darwin) printf "darwin" ;;
|
||||
*)
|
||||
ui_error "OS non supporte: $(uname -s)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) printf "amd64" ;;
|
||||
aarch64|arm64) printf "arm64" ;;
|
||||
*)
|
||||
ui_error "Architecture non supportee: $(uname -m)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
release_api_url() {
|
||||
printf "%s/api/v1/repos/%s/releases/latest\n" "${RELEASE_BASE_URL%/}" "$RELEASE_REPOSITORY"
|
||||
}
|
||||
|
||||
extract_asset_url() {
|
||||
local json="$1"
|
||||
local asset_name="$2"
|
||||
|
||||
printf "%s\n" "$json" \
|
||||
| grep -o '"browser_download_url":"[^"]*"' \
|
||||
| sed 's/"browser_download_url":"//;s/"$//' \
|
||||
| sed 's#\\/#/#g' \
|
||||
| grep "/${asset_name}$" \
|
||||
| head -n 1
|
||||
}
|
||||
|
||||
verify_checksum() {
|
||||
local binary_path="$1"
|
||||
local checksum_path="$2"
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
( cd "$(dirname "$binary_path")" && sha256sum -c "$(basename "$checksum_path")" )
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
local expected
|
||||
local actual
|
||||
expected="$(awk '{print $1}' "$checksum_path")"
|
||||
actual="$(shasum -a 256 "$binary_path" | awk '{print $1}')"
|
||||
if [ "$expected" != "$actual" ]; then
|
||||
ui_error "Checksum invalide pour $binary_path"
|
||||
exit 1
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
ui_warn "Aucun utilitaire checksum trouve (sha256sum/shasum), verification ignoree."
|
||||
}
|
||||
|
||||
download_latest_release_binary() {
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
ui_error "curl est requis pour telecharger la release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local os_name
|
||||
local arch_name
|
||||
local ext=""
|
||||
os_name="$(detect_os)"
|
||||
arch_name="$(detect_arch)"
|
||||
if [ "$os_name" = "windows" ]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
|
||||
local asset_name
|
||||
local checksum_name
|
||||
asset_name="${BINARY_NAME}-${os_name}-${arch_name}${ext}"
|
||||
checksum_name="${asset_name}.sha256"
|
||||
|
||||
ui_info "Recuperation de la derniere release..."
|
||||
local release_json
|
||||
release_json="$(curl -fsSL "$(release_api_url)")"
|
||||
|
||||
local asset_url
|
||||
asset_url="$(extract_asset_url "$release_json" "$asset_name")"
|
||||
if [ -z "$asset_url" ]; then
|
||||
ui_error "Asset introuvable dans la derniere release: $asset_name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local checksum_url
|
||||
checksum_url="$(extract_asset_url "$release_json" "$checksum_name" || true)"
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'if [ -n "${tmp_dir:-}" ] && [ -d "${tmp_dir:-}" ]; then rm -rf "${tmp_dir}"; fi' EXIT INT TERM
|
||||
|
||||
curl -fsSL "$asset_url" -o "$tmp_dir/$asset_name"
|
||||
chmod +x "$tmp_dir/$asset_name"
|
||||
|
||||
if [ -n "$checksum_url" ]; then
|
||||
curl -fsSL "$checksum_url" -o "$tmp_dir/$checksum_name"
|
||||
verify_checksum "$tmp_dir/$asset_name" "$tmp_dir/$checksum_name"
|
||||
fi
|
||||
|
||||
local target_dir
|
||||
target_dir="$(prompt "Repertoire d'installation" "$HOME/.local/bin")"
|
||||
mkdir -p "$target_dir"
|
||||
install -m 0755 "$tmp_dir/$asset_name" "$target_dir/$BINARY_NAME"
|
||||
INSTALLED_BINARY_PATH="$target_dir/$BINARY_NAME"
|
||||
|
||||
ui_success "Binaire installe: $INSTALLED_BINARY_PATH"
|
||||
ui_info "Ajoute ce dossier au PATH si necessaire."
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
|
||||
INSTALLED_BINARY_PATH="$(command -v "$BINARY_NAME")"
|
||||
ui_success "Binaire detecte: $INSTALLED_BINARY_PATH"
|
||||
local reinstall
|
||||
reinstall="$(prompt "Forcer une reinstall depuis la derniere release ? (y/N)" "N")"
|
||||
case "$reinstall" in
|
||||
y|Y|yes|YES)
|
||||
download_latest_release_binary
|
||||
return
|
||||
;;
|
||||
*)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
download_latest_release_binary
|
||||
}
|
||||
|
||||
run_setup_wizard() {
|
||||
install_binary
|
||||
|
||||
local profile
|
||||
profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")"
|
||||
local binary_path
|
||||
binary_path="$(resolve_binary_path)"
|
||||
|
||||
if [ ! -x "$binary_path" ]; then
|
||||
ui_error "Binaire introuvable/executable: $binary_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ui_info "Déverrouillage Bitwarden avant le setup..."
|
||||
if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
env "${PROFILE_ENV}=${profile}" "$binary_path" login < /dev/tty > /dev/tty
|
||||
else
|
||||
env "${PROFILE_ENV}=${profile}" "$binary_path" login
|
||||
fi
|
||||
|
||||
ui_info "Lancement de $BINARY_NAME setup"
|
||||
if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty
|
||||
else
|
||||
env "${PROFILE_ENV}=${profile}" "$binary_path" setup
|
||||
fi
|
||||
ui_success "Setup termine pour le profil \"$profile\"."
|
||||
|
||||
PREFILL_SERVER_NAME="$(sanitize_server_name "$BINARY_NAME")"
|
||||
PREFILL_PROFILE_VALUE="$profile"
|
||||
PREFILL_COMMAND_PATH="$binary_path"
|
||||
}
|
||||
|
||||
collect_server_inputs() {
|
||||
local default_name
|
||||
default_name="$(sanitize_server_name "${PREFILL_SERVER_NAME:-$BINARY_NAME}")"
|
||||
SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")"
|
||||
SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")"
|
||||
|
||||
PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "${PREFILL_PROFILE_VALUE:-$DEFAULT_PROFILE}")"
|
||||
|
||||
local default_command
|
||||
if [ -n "${PREFILL_COMMAND_PATH:-}" ]; then
|
||||
default_command="$PREFILL_COMMAND_PATH"
|
||||
else
|
||||
default_command="$(resolve_binary_path)"
|
||||
fi
|
||||
COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")"
|
||||
}
|
||||
|
||||
choose_scope() {
|
||||
local selected
|
||||
selected="$(menu_select "Scope de configuration" "global (user)" "project (projet courant)")"
|
||||
case "$selected" in
|
||||
"global (user)")
|
||||
printf "global"
|
||||
;;
|
||||
*)
|
||||
printf "project"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
apply_claude_mcp() {
|
||||
ensure_cli "claude"
|
||||
collect_server_inputs
|
||||
|
||||
local scope_choice
|
||||
scope_choice="$(choose_scope)"
|
||||
local claude_scope
|
||||
if [ "$scope_choice" = "global" ]; then
|
||||
claude_scope="user"
|
||||
else
|
||||
claude_scope="project"
|
||||
fi
|
||||
|
||||
ui_info "Application de la configuration Claude ($claude_scope)..."
|
||||
claude mcp remove --scope "$claude_scope" "$SERVER_NAME" >/dev/null 2>&1 || true
|
||||
claude mcp add \
|
||||
--transport stdio \
|
||||
--scope "$claude_scope" \
|
||||
"$SERVER_NAME" \
|
||||
--env "${PROFILE_ENV}=${PROFILE_VALUE}" \
|
||||
-- "$COMMAND_PATH" mcp
|
||||
|
||||
ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)."
|
||||
}
|
||||
|
||||
rewrite_codex_project_config() {
|
||||
local project_dir="$1"
|
||||
local config_file="$project_dir/.codex/config.toml"
|
||||
local section_prefix="[mcp_servers.${SERVER_NAME}"
|
||||
|
||||
mkdir -p "$project_dir/.codex"
|
||||
touch "$config_file"
|
||||
|
||||
local tmp_file
|
||||
tmp_file="$(mktemp)"
|
||||
|
||||
awk -v prefix="$section_prefix" '
|
||||
function is_target(line, p, next_char) {
|
||||
if (index(line, p) != 1) {
|
||||
return 0
|
||||
}
|
||||
next_char = substr(line, length(p) + 1, 1)
|
||||
return next_char == "]" || next_char == "."
|
||||
}
|
||||
/^\[.*\]$/ {
|
||||
if (is_target($0, prefix)) {
|
||||
skip = 1
|
||||
next
|
||||
}
|
||||
if (skip == 1) {
|
||||
skip = 0
|
||||
}
|
||||
}
|
||||
{
|
||||
if (skip != 1) {
|
||||
print $0
|
||||
}
|
||||
}
|
||||
' "$config_file" > "$tmp_file"
|
||||
|
||||
mv "$tmp_file" "$config_file"
|
||||
|
||||
{
|
||||
printf "\n[mcp_servers.%s]\n" "$SERVER_NAME"
|
||||
printf "command = \"%s\"\n" "$(toml_escape "$COMMAND_PATH")"
|
||||
printf "args = [\"mcp\"]\n\n"
|
||||
printf "[mcp_servers.%s.env]\n" "$SERVER_NAME"
|
||||
printf "%s = \"%s\"\n" "$PROFILE_ENV" "$(toml_escape "$PROFILE_VALUE")"
|
||||
} >> "$config_file"
|
||||
}
|
||||
|
||||
apply_codex_mcp() {
|
||||
ensure_cli "codex"
|
||||
collect_server_inputs
|
||||
|
||||
local scope_choice
|
||||
scope_choice="$(choose_scope)"
|
||||
|
||||
if [ "$scope_choice" = "global" ]; then
|
||||
ui_info "Application via codex mcp add (scope global)..."
|
||||
codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true
|
||||
codex mcp add \
|
||||
"$SERVER_NAME" \
|
||||
--env "${PROFILE_ENV}=${PROFILE_VALUE}" \
|
||||
-- "$COMMAND_PATH" mcp
|
||||
ui_success "Serveur \"$SERVER_NAME\" configure dans le scope global Codex."
|
||||
return
|
||||
fi
|
||||
|
||||
local default_project_dir
|
||||
default_project_dir="$(pwd)"
|
||||
local project_dir
|
||||
project_dir="$(prompt "Dossier projet cible pour .codex/config.toml" "$default_project_dir")"
|
||||
rewrite_codex_project_config "$project_dir"
|
||||
ui_success "Configuration projet ecrite dans $project_dir/.codex/config.toml"
|
||||
}
|
||||
|
||||
print_mcp_json() {
|
||||
collect_server_inputs
|
||||
cat <<JSON
|
||||
{
|
||||
"mcpServers": {
|
||||
"${SERVER_NAME}": {
|
||||
"command": "${COMMAND_PATH}",
|
||||
"args": ["mcp"],
|
||||
"env": {
|
||||
"${PROFILE_ENV}": "${PROFILE_VALUE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
print_header() {
|
||||
ui_line
|
||||
printf "%bMCP Install Wizard%b for %b%s%b\n" "$C_BOLD$C_MAGENTA" "$C_RESET" "$C_BOLD" "$BINARY_NAME" "$C_RESET" >&2
|
||||
printf "%bRelease repo:%b %s\n" "$C_DIM" "$C_RESET" "$RELEASE_REPOSITORY" >&2
|
||||
ui_line
|
||||
printf "%bSelectionne une action dans le menu interactif.%b\n" "$C_DIM" "$C_RESET" >&2
|
||||
}
|
||||
|
||||
post_setup_configure_mcp() {
|
||||
ui_title "Configuration MCP apres setup"
|
||||
local next_action
|
||||
next_action="$(menu_select \
|
||||
"Configurer le MCP maintenant ?" \
|
||||
"Configurer Claude Code (apply direct)" \
|
||||
"Configurer Codex (apply direct)" \
|
||||
"Generer JSON MCP manuel" \
|
||||
"Terminer sans config MCP")"
|
||||
printf "\n" >&2
|
||||
|
||||
case "$next_action" in
|
||||
"Configurer Claude Code (apply direct)")
|
||||
apply_claude_mcp
|
||||
;;
|
||||
"Configurer Codex (apply direct)")
|
||||
apply_codex_mcp
|
||||
;;
|
||||
"Generer JSON MCP manuel")
|
||||
ui_info "JSON MCP genere sur stdout."
|
||||
print_mcp_json
|
||||
;;
|
||||
*)
|
||||
ui_info "Setup termine sans configuration MCP additionnelle."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main() {
|
||||
print_header
|
||||
|
||||
local action
|
||||
action="$(menu_select \
|
||||
"Choisis une action" \
|
||||
"Installer/mettre a jour le binaire + setup" \
|
||||
"Configurer Claude Code (apply direct)" \
|
||||
"Configurer Codex (apply direct)" \
|
||||
"Generer JSON MCP manuel" \
|
||||
"Quitter")"
|
||||
printf "\n" >&2
|
||||
|
||||
case "$action" in
|
||||
"Installer/mettre a jour le binaire + setup")
|
||||
run_setup_wizard
|
||||
post_setup_configure_mcp
|
||||
;;
|
||||
"Configurer Claude Code (apply direct)")
|
||||
apply_claude_mcp
|
||||
;;
|
||||
"Configurer Codex (apply direct)")
|
||||
apply_codex_mcp
|
||||
;;
|
||||
"Generer JSON MCP manuel")
|
||||
ui_info "JSON MCP genere sur stdout."
|
||||
print_mcp_json
|
||||
;;
|
||||
*)
|
||||
ui_warn "Annule."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -3,83 +3,680 @@ package cli
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"email-mcp/mcpgen"
|
||||
frameworkbootstrap "forge.lclr.dev/AI/mcp-framework/bootstrap"
|
||||
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
frameworkupdate "forge.lclr.dev/AI/mcp-framework/update"
|
||||
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
)
|
||||
|
||||
const (
|
||||
binaryName = mcpgen.BinaryName
|
||||
defaultProfileEnv = "EMAIL_MCP_PROFILE"
|
||||
hostEnv = "EMAIL_MCP_HOST"
|
||||
usernameEnv = "EMAIL_MCP_USERNAME"
|
||||
passwordEnv = "EMAIL_MCP_PASSWORD"
|
||||
fallbackProfile = "default"
|
||||
)
|
||||
|
||||
type MCPRunner interface {
|
||||
Run(ctx context.Context) error
|
||||
}
|
||||
|
||||
type ConfigPrompter interface {
|
||||
PromptCredential(ctx context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error)
|
||||
}
|
||||
|
||||
type profileConfigStore interface {
|
||||
LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error)
|
||||
SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error)
|
||||
}
|
||||
|
||||
type secretStore = frameworksecretstore.Store
|
||||
|
||||
type manifestLoader func(startDir string) (frameworkmanifest.File, string, error)
|
||||
type executableResolver func() (string, error)
|
||||
|
||||
type ProfileConfig struct {
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
prompter SetupPrompter
|
||||
store secretstore.Store
|
||||
runner MCPRunner
|
||||
stderr io.Writer
|
||||
prompter ConfigPrompter
|
||||
configStore profileConfigStore
|
||||
openSecretStore func() (secretStore, error)
|
||||
newMailService func() mcpserver.MailService
|
||||
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
||||
loadManifest manifestLoader
|
||||
resolveExecutable executableResolver
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
version string
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
return BuildApp()
|
||||
return BuildApp("dev")
|
||||
}
|
||||
|
||||
func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App {
|
||||
func NewAppWithDependencies(
|
||||
prompter ConfigPrompter,
|
||||
configStore profileConfigStore,
|
||||
openSecretStore func() (secretStore, error),
|
||||
newMailService func() mcpserver.MailService,
|
||||
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner,
|
||||
loadManifest manifestLoader,
|
||||
resolveExecutable executableResolver,
|
||||
stdin io.Reader,
|
||||
stdout io.Writer,
|
||||
stderr io.Writer,
|
||||
version string,
|
||||
) *App {
|
||||
if stdin == nil {
|
||||
stdin = strings.NewReader("")
|
||||
}
|
||||
if stdout == nil {
|
||||
stdout = io.Discard
|
||||
}
|
||||
if stderr == nil {
|
||||
stderr = io.Discard
|
||||
}
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
return &App{
|
||||
prompter: prompter,
|
||||
store: store,
|
||||
runner: runner,
|
||||
stderr: stderr,
|
||||
prompter: prompter,
|
||||
configStore: configStore,
|
||||
openSecretStore: openSecretStore,
|
||||
newMailService: newMailService,
|
||||
newRunner: newRunner,
|
||||
loadManifest: loadManifest,
|
||||
resolveExecutable: resolveExecutable,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: email-mcp <setup|mcp>")
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "setup":
|
||||
return a.runSetup(context.Background())
|
||||
case "mcp":
|
||||
return a.runMCP(context.Background())
|
||||
default:
|
||||
return fmt.Errorf("unknown command: %s", args[0])
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
}
|
||||
return a.runBootstrap(context.Background(), args)
|
||||
}
|
||||
|
||||
func (a *App) runSetup(ctx context.Context) error {
|
||||
func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
||||
metadata := a.runtimeMetadata()
|
||||
|
||||
return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{
|
||||
BinaryName: metadata.BinaryName,
|
||||
Description: metadata.Description,
|
||||
Version: a.version,
|
||||
EnableDoctorAlias: true,
|
||||
Args: args,
|
||||
Stdin: a.stdin,
|
||||
Stdout: a.stdout,
|
||||
Stderr: a.stderr,
|
||||
Hooks: frameworkbootstrap.Hooks{
|
||||
Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args)
|
||||
},
|
||||
Login: frameworkbootstrap.BitwardenLoginHandler(metadata.BinaryName),
|
||||
MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runMCP(ctx, inv.Args)
|
||||
},
|
||||
ConfigShow: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runConfigShow(ctx, inv.Args)
|
||||
},
|
||||
ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runDoctor(ctx, inv.Args)
|
||||
},
|
||||
ConfigDelete: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runConfigDelete(ctx, inv.Args)
|
||||
},
|
||||
Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
|
||||
return a.runUpdate(ctx, inv.Args)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) runConfig(ctx context.Context, command string, args []string) error {
|
||||
if a.prompter == nil {
|
||||
return fmt.Errorf("setup prompter is not configured")
|
||||
return fmt.Errorf("config prompter is not configured")
|
||||
}
|
||||
if a.store == nil {
|
||||
if a.configStore == nil {
|
||||
return fmt.Errorf("config store is not configured")
|
||||
}
|
||||
if a.openSecretStore == nil {
|
||||
return fmt.Errorf("secret store is not configured")
|
||||
}
|
||||
|
||||
cred, err := a.prompter.PromptSetup(ctx)
|
||||
profileFlag, err := parseProfileArgs(command, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
secrets, err := a.openSecretStore()
|
||||
if err != nil {
|
||||
return mapAppError(err)
|
||||
}
|
||||
|
||||
storedPassword, hasStoredPassword, err := loadStoredPassword(secrets, profileName)
|
||||
if err != nil {
|
||||
return mapAppError(err)
|
||||
}
|
||||
|
||||
cred, err := a.prompter.PromptCredential(ctx, secretstore.Credential{
|
||||
Host: profile.Host,
|
||||
Username: profile.Username,
|
||||
Password: storedPassword,
|
||||
}, hasStoredPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cred.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.store.Save(ctx, secretstore.DefaultAccountKey, cred); err != nil {
|
||||
|
||||
if shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) {
|
||||
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
||||
if strings.TrimSpace(os.Getenv(passwordEnv)) == "" {
|
||||
return newUserFacingError(
|
||||
fmt.Sprintf("secret backend is read-only; set %s and rerun `email-mcp setup`", passwordEnv),
|
||||
err,
|
||||
)
|
||||
}
|
||||
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; password is provided via %s\n", passwordEnv); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
default:
|
||||
return mapAppError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]ProfileConfig{}
|
||||
}
|
||||
cfg.CurrentProfile = profileName
|
||||
cfg.Profiles[profileName] = ProfileConfig{
|
||||
Host: cred.Host,
|
||||
Username: cred.Username,
|
||||
}
|
||||
configPath, err := a.configStore.SaveDefault(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(a.stdout, "profile %q saved to %s\n", profileName, configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) runConfigShow(ctx context.Context, args []string) error {
|
||||
if a.configStore == nil {
|
||||
return fmt.Errorf("config store is not configured")
|
||||
}
|
||||
if a.openSecretStore == nil {
|
||||
return fmt.Errorf("secret store is not configured")
|
||||
}
|
||||
|
||||
profileFlag, err := parseProfileArgs("config show", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
secrets, err := a.openSecretStore()
|
||||
if err != nil {
|
||||
return mapAppError(err)
|
||||
}
|
||||
|
||||
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
|
||||
if err != nil {
|
||||
var missingErr *frameworkcli.MissingRequiredValuesError
|
||||
if !errors.As(err, &missingErr) {
|
||||
return mapAppError(err)
|
||||
}
|
||||
}
|
||||
|
||||
host, _ := resolution.Get("host")
|
||||
username, _ := resolution.Get("username")
|
||||
password, _ := resolution.Get("password")
|
||||
|
||||
if _, err := fmt.Fprintf(a.stdout, "profile: %s\n", profileName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(a.stdout, "host: %s (%s)\n", renderVisibleField(host), renderSource(host)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(a.stdout, "username: %s (%s)\n", renderVisibleField(username), renderSource(username)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(a.stdout, "password: %s (%s)\n", renderSecretField(password), renderSource(password)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) runConfigDelete(_ context.Context, args []string) error {
|
||||
if a.configStore == nil {
|
||||
return fmt.Errorf("config store is not configured")
|
||||
}
|
||||
if a.openSecretStore == nil {
|
||||
return fmt.Errorf("secret store is not configured")
|
||||
}
|
||||
|
||||
profileFlag, err := parseProfileArgs("config delete", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
secrets, err := a.openSecretStore()
|
||||
if err != nil {
|
||||
return mapAppError(err)
|
||||
}
|
||||
if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, frameworksecretstore.ErrNotFound):
|
||||
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
||||
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; %s cannot be deleted automatically\n", passwordEnv); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
default:
|
||||
return mapAppError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Profiles != nil {
|
||||
delete(cfg.Profiles, profileName)
|
||||
}
|
||||
if strings.TrimSpace(cfg.CurrentProfile) == profileName {
|
||||
cfg.CurrentProfile = nextCurrentProfile(cfg.Profiles, a.runtimeMetadata().DefaultProfile)
|
||||
}
|
||||
|
||||
configPath, err := a.configStore.SaveDefault(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(a.stdout, "profile %q deleted from %s\n", profileName, configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.CurrentProfile != "" {
|
||||
if _, err := fmt.Fprintf(a.stdout, "current profile: %s\n", cfg.CurrentProfile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) runMCP(ctx context.Context) error {
|
||||
if a.runner == nil {
|
||||
func (a *App) runMCP(ctx context.Context, args []string) error {
|
||||
if a.newRunner == nil {
|
||||
return fmt.Errorf("mcp runner is not configured")
|
||||
}
|
||||
return mapAppError(a.runner.Run(ctx))
|
||||
if a.newMailService == nil {
|
||||
return fmt.Errorf("mail service is not configured")
|
||||
}
|
||||
|
||||
profileFlag, err := parseProfileArgs("mcp", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cred, err := a.loadCredential(profileFlag)
|
||||
if err != nil {
|
||||
return mapAppError(err)
|
||||
}
|
||||
|
||||
runner := a.newRunner(cred, a.newMailService(), a.stdin, a.stdout, a.stderr)
|
||||
if runner == nil {
|
||||
return fmt.Errorf("mcp runner is not configured")
|
||||
}
|
||||
|
||||
return mapAppError(runner.Run(ctx))
|
||||
}
|
||||
|
||||
func (a *App) runUpdate(ctx context.Context, args []string) error {
|
||||
if a.loadManifest == nil {
|
||||
return fmt.Errorf("manifest loader is not configured")
|
||||
}
|
||||
if a.resolveExecutable == nil {
|
||||
return fmt.Errorf("executable resolver is not configured")
|
||||
}
|
||||
|
||||
if err := parseUpdateArgs(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
executablePath, err := a.resolveExecutable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve executable path: %w", err)
|
||||
}
|
||||
|
||||
options, err := mcpgen.UpdateOptionsFrom(filepath.Dir(executablePath), a.version, a.stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options.ExecutablePath = executablePath
|
||||
|
||||
return frameworkupdate.Run(ctx, options)
|
||||
}
|
||||
|
||||
func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) {
|
||||
searchDirs := []string{filepath.Dir(executablePath), "."}
|
||||
var firstErr error
|
||||
|
||||
for _, dir := range searchDirs {
|
||||
file, _, err := a.loadManifest(dir)
|
||||
if err == nil {
|
||||
return file, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return frameworkmanifest.File{}, fmt.Errorf("load manifest: %w", firstErr)
|
||||
}
|
||||
|
||||
func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error) {
|
||||
if a.configStore == nil {
|
||||
return secretstore.Credential{}, fmt.Errorf("config store is not configured")
|
||||
}
|
||||
if a.openSecretStore == nil {
|
||||
return secretstore.Credential{}, fmt.Errorf("secret store is not configured")
|
||||
}
|
||||
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
|
||||
secrets, err := a.openSecretStore()
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
|
||||
if err != nil {
|
||||
var missingErr *frameworkcli.MissingRequiredValuesError
|
||||
if errors.As(err, &missingErr) {
|
||||
return secretstore.Credential{}, fmt.Errorf(
|
||||
"%w: profile %q is incomplete (missing: %s)",
|
||||
mcpserver.ErrCredentialsNotConfigured,
|
||||
profileName,
|
||||
strings.Join(missingErr.Fields, ", "),
|
||||
)
|
||||
}
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
cred, err := credentialFromResolution(resolution)
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
if err := cred.Validate(); err != nil {
|
||||
return secretstore.Credential{}, fmt.Errorf("%w: profile %q is incomplete", mcpserver.ErrCredentialsNotConfigured, profileName)
|
||||
}
|
||||
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func profileFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
||||
specs := mcpgen.ResolveFieldSpecs(profileName)
|
||||
profileSpecs := make([]frameworkcli.FieldSpec, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if spec.Name == "host" || spec.Name == "username" {
|
||||
profileSpecs = append(profileSpecs, spec)
|
||||
}
|
||||
}
|
||||
return profileSpecs
|
||||
}
|
||||
|
||||
func passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
||||
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
|
||||
if spec.Name == "password" {
|
||||
return []frameworkcli.FieldSpec{spec}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) {
|
||||
configValues := map[string]string{
|
||||
"host": profile.Host,
|
||||
"username": profile.Username,
|
||||
}
|
||||
|
||||
return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{
|
||||
Fields: fields,
|
||||
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
|
||||
Env: frameworkcli.EnvLookup(os.LookupEnv),
|
||||
Config: frameworkcli.ConfigMap(configValues),
|
||||
Secret: frameworkcli.SecretStore(store),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
func credentialFromResolution(resolution frameworkcli.Resolution) (secretstore.Credential, error) {
|
||||
host, ok := resolution.Get("host")
|
||||
if !ok {
|
||||
return secretstore.Credential{}, fmt.Errorf("resolve credential: host field is missing from resolution")
|
||||
}
|
||||
|
||||
username, ok := resolution.Get("username")
|
||||
if !ok {
|
||||
return secretstore.Credential{}, fmt.Errorf("resolve credential: username field is missing from resolution")
|
||||
}
|
||||
|
||||
password, ok := resolution.Get("password")
|
||||
if !ok {
|
||||
return secretstore.Credential{}, fmt.Errorf("resolve credential: password field is missing from resolution")
|
||||
}
|
||||
|
||||
return secretstore.Credential{
|
||||
Host: host.Value,
|
||||
Username: username.Value,
|
||||
Password: password.Value,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadStoredPassword(store secretStore, profileName string) (string, bool, error) {
|
||||
password, err := store.GetSecret(passwordSecretName(profileName))
|
||||
if err != nil {
|
||||
if errors.Is(err, frameworksecretstore.ErrNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
return password, true, nil
|
||||
}
|
||||
|
||||
func passwordSecretName(profileName string) string {
|
||||
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
|
||||
if spec.Name == "password" && strings.TrimSpace(spec.SecretKey) != "" {
|
||||
return spec.SecretKey
|
||||
}
|
||||
}
|
||||
return "imap-password/" + strings.TrimSpace(profileName)
|
||||
}
|
||||
|
||||
func shouldPersistPassword(hasStoredPassword bool, storedPassword, newPassword string) bool {
|
||||
if !hasStoredPassword {
|
||||
return true
|
||||
}
|
||||
return storedPassword != newPassword
|
||||
}
|
||||
|
||||
func parseProfileArgs(command string, args []string) (string, error) {
|
||||
flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
flagSet.SetOutput(io.Discard)
|
||||
|
||||
profile := flagSet.String("profile", "", "")
|
||||
if err := flagSet.Parse(args); err != nil {
|
||||
return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command)
|
||||
}
|
||||
if flagSet.NArg() != 0 {
|
||||
return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(*profile), nil
|
||||
}
|
||||
|
||||
func parseUpdateArgs(args []string) error {
|
||||
flagSet := flag.NewFlagSet("update", flag.ContinueOnError)
|
||||
flagSet.SetOutput(io.Discard)
|
||||
|
||||
if err := flagSet.Parse(args); err != nil {
|
||||
return fmt.Errorf("usage: email-mcp update")
|
||||
}
|
||||
if flagSet.NArg() != 0 {
|
||||
return fmt.Errorf("usage: email-mcp update")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type runtimeMetadata struct {
|
||||
BinaryName string
|
||||
Description string
|
||||
DefaultProfile string
|
||||
}
|
||||
|
||||
func (a *App) runtimeMetadata() runtimeMetadata {
|
||||
metadata := runtimeMetadata{
|
||||
BinaryName: mcpgen.BinaryName,
|
||||
Description: mcpgen.DefaultDescription,
|
||||
DefaultProfile: fallbackProfile,
|
||||
}
|
||||
|
||||
if a.loadManifest == nil {
|
||||
return metadata
|
||||
}
|
||||
|
||||
file, err := a.loadRuntimeManifest()
|
||||
if err != nil {
|
||||
return metadata
|
||||
}
|
||||
|
||||
bootstrap := file.BootstrapInfo()
|
||||
if bootstrap.BinaryName != "" {
|
||||
metadata.BinaryName = bootstrap.BinaryName
|
||||
}
|
||||
if bootstrap.Description != "" {
|
||||
metadata.Description = bootstrap.Description
|
||||
}
|
||||
if bootstrap.DefaultProfile != "" {
|
||||
metadata.DefaultProfile = bootstrap.DefaultProfile
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (a *App) loadRuntimeManifest() (frameworkmanifest.File, error) {
|
||||
if a.loadManifest == nil {
|
||||
return frameworkmanifest.File{}, fmt.Errorf("manifest loader is not configured")
|
||||
}
|
||||
|
||||
if a.resolveExecutable != nil {
|
||||
executablePath, err := a.resolveExecutable()
|
||||
if err == nil {
|
||||
file, loadErr := a.loadManifestForExecutable(executablePath)
|
||||
if loadErr == nil {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file, _, err := a.loadManifest(".")
|
||||
if err != nil {
|
||||
return frameworkmanifest.File{}, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (a *App) resolveProfileName(profileFlag, currentProfile string) string {
|
||||
resolvedCurrent := strings.TrimSpace(currentProfile)
|
||||
if resolvedCurrent == "" {
|
||||
resolvedCurrent = strings.TrimSpace(a.runtimeMetadata().DefaultProfile)
|
||||
}
|
||||
|
||||
return frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), resolvedCurrent)
|
||||
}
|
||||
|
||||
func nextCurrentProfile(profiles map[string]ProfileConfig, preferred string) string {
|
||||
if len(profiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalizedPreferred := strings.TrimSpace(preferred)
|
||||
if normalizedPreferred != "" {
|
||||
if _, ok := profiles[normalizedPreferred]; ok {
|
||||
return normalizedPreferred
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := profiles[fallbackProfile]; ok {
|
||||
return fallbackProfile
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(profiles))
|
||||
for name := range profiles {
|
||||
if trimmed := strings.TrimSpace(name); trimmed != "" {
|
||||
names = append(names, trimmed)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
return names[0]
|
||||
}
|
||||
|
||||
func mapAppError(err error) error {
|
||||
|
|
@ -90,19 +687,36 @@ func mapAppError(err error) error {
|
|||
switch {
|
||||
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
|
||||
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
|
||||
case errors.Is(err, kwallet.ErrKWalletUnavailable):
|
||||
return newUserFacingError("kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running", err)
|
||||
case errors.Is(err, kwallet.ErrKWalletDisabled):
|
||||
return newUserFacingError("kwallet is disabled in this KDE session", err)
|
||||
case errors.Is(err, kwallet.ErrKWalletOpenFailed):
|
||||
return newUserFacingError("kwallet could not be opened; unlock the wallet and try again", err)
|
||||
case errors.Is(err, kwallet.ErrCredentialNotFound):
|
||||
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
|
||||
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
|
||||
return newUserFacingError(strings.TrimSpace(err.Error()), err)
|
||||
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
||||
return newUserFacingError("secret backend is read-only", err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func renderSource(field frameworkcli.ResolvedField) string {
|
||||
if !field.Found {
|
||||
return "missing"
|
||||
}
|
||||
return string(field.Source)
|
||||
}
|
||||
|
||||
func renderVisibleField(field frameworkcli.ResolvedField) string {
|
||||
if !field.Found || strings.TrimSpace(field.Value) == "" {
|
||||
return "<missing>"
|
||||
}
|
||||
return field.Value
|
||||
}
|
||||
|
||||
func renderSecretField(field frameworkcli.ResolvedField) string {
|
||||
if !field.Found || strings.TrimSpace(field.Value) == "" {
|
||||
return "<missing>"
|
||||
}
|
||||
return "<set>"
|
||||
}
|
||||
|
||||
type userFacingError struct {
|
||||
message string
|
||||
err error
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
)
|
||||
|
||||
type errorStoreStub struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (s errorStoreStub) Save(context.Context, string, secretstore.Credential) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s errorStoreStub) Load(context.Context, string) (secretstore.Credential, error) {
|
||||
return secretstore.Credential{}, s.err
|
||||
}
|
||||
|
||||
func TestAppRunSetupMapsUnavailableWalletError(t *testing.T) {
|
||||
app := NewAppWithDependencies(&promptStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
}, errorStoreStub{
|
||||
err: fmt.Errorf("%w: session bus missing", kwallet.ErrKWalletUnavailable),
|
||||
}, nil, &bytes.Buffer{})
|
||||
|
||||
err := app.Run([]string{"setup"})
|
||||
if err == nil {
|
||||
t.Fatal("expected setup to fail")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "kwallet is not available") {
|
||||
t.Fatalf("expected mapped kwallet error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
|
||||
err := mapAppError(fmt.Errorf("%w: missing entry", kwallet.ErrCredentialNotFound))
|
||||
if err == nil {
|
||||
t.Fatal("expected mapped error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "run `email-mcp setup`") {
|
||||
t.Fatalf("expected setup guidance, got %v", err)
|
||||
}
|
||||
if !errors.Is(err, kwallet.ErrCredentialNotFound) {
|
||||
t.Fatalf("expected typed error to be preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapAppErrorPreservesUnavailableTypedError(t *testing.T) {
|
||||
err := mapAppError(fmt.Errorf("%w: session bus missing", kwallet.ErrKWalletUnavailable))
|
||||
if err == nil {
|
||||
t.Fatal("expected mapped error")
|
||||
}
|
||||
if !errors.Is(err, kwallet.ErrKWalletUnavailable) {
|
||||
t.Fatalf("expected typed error to be preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapAppErrorLeavesUnknownErrorsUntouched(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
|
||||
err := mapAppError(wantErr)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected original error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"email-mcp/internal/mcpserver"
|
||||
)
|
||||
|
||||
func TestMapAppErrorMapsMCPMissingCredentialError(t *testing.T) {
|
||||
err := mapAppError(mcpserver.ErrCredentialsNotConfigured)
|
||||
if err == nil {
|
||||
t.Fatal("expected mapped error")
|
||||
}
|
||||
if err.Error() != "credentials not configured; run `email-mcp setup`" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
131
internal/cli/doctor.go
Normal file
131
internal/cli/doctor.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"email-mcp/mcpgen"
|
||||
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
)
|
||||
|
||||
func (a *App) runDoctor(ctx context.Context, args []string) error {
|
||||
profileFlag, err := parseProfileArgs("doctor", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.configStore == nil {
|
||||
return fmt.Errorf("config store is not configured")
|
||||
}
|
||||
if a.openSecretStore == nil {
|
||||
return fmt.Errorf("secret store is not configured")
|
||||
}
|
||||
if a.newMailService == nil {
|
||||
return fmt.Errorf("mail service is not configured")
|
||||
}
|
||||
|
||||
report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{
|
||||
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)),
|
||||
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
|
||||
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
|
||||
ExtraChecks: []frameworkcli.DoctorCheck{
|
||||
a.doctorRequiredProfileFieldsCheck(profileFlag),
|
||||
},
|
||||
})
|
||||
if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil {
|
||||
return err
|
||||
}
|
||||
if report.HasFailures() {
|
||||
return fmt.Errorf("doctor checks failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.DoctorCheck {
|
||||
var (
|
||||
profileValues map[string]string
|
||||
loadErr error
|
||||
)
|
||||
|
||||
check := frameworkcli.RequiredResolvedFieldsCheck(frameworkcli.ResolveOptions{
|
||||
Fields: profileFieldSpecs(a.resolveDoctorProfileName(profileFlag)),
|
||||
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
|
||||
Env: frameworkcli.EnvLookup(os.LookupEnv),
|
||||
Config: func(key string) (string, bool, error) {
|
||||
if loadErr != nil {
|
||||
return "", false, loadErr
|
||||
}
|
||||
if profileValues == nil {
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
loadErr = err
|
||||
return "", false, loadErr
|
||||
}
|
||||
|
||||
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
profile := cfg.Profiles[profileName]
|
||||
profileValues = map[string]string{
|
||||
"host": profile.Host,
|
||||
"username": profile.Username,
|
||||
}
|
||||
}
|
||||
|
||||
return frameworkcli.MapLookup(profileValues)(key)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
return func(ctx context.Context) frameworkcli.DoctorResult {
|
||||
result := check(ctx)
|
||||
result.Name = "profile"
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) doctorConnectivityCheck(profileFlag string) frameworkcli.DoctorCheck {
|
||||
return func(parent context.Context) frameworkcli.DoctorResult {
|
||||
ctx, cancel := context.WithTimeout(parent, 35*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cred, err := a.loadCredential(profileFlag)
|
||||
if err != nil {
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: frameworkcli.DoctorStatusFail,
|
||||
Summary: "cannot load IMAP credentials",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := a.newMailService().ListMailboxes(ctx, cred); err != nil {
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: frameworkcli.DoctorStatusFail,
|
||||
Summary: "IMAP server is unreachable or rejected authentication",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return frameworkcli.DoctorResult{
|
||||
Name: "connectivity",
|
||||
Status: frameworkcli.DoctorStatusOK,
|
||||
Summary: "IMAP server is reachable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) resolveDoctorProfileName(profileFlag string) string {
|
||||
if a.configStore == nil {
|
||||
return a.resolveProfileName(profileFlag, "")
|
||||
}
|
||||
|
||||
cfg, _, err := a.configStore.LoadDefault()
|
||||
if err != nil {
|
||||
return a.resolveProfileName(profileFlag, "")
|
||||
}
|
||||
|
||||
return a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
)
|
||||
|
||||
type entrypointPromptStub struct {
|
||||
credential secretstore.Credential
|
||||
}
|
||||
|
||||
func (p *entrypointPromptStub) PromptSetup(context.Context) (secretstore.Credential, error) {
|
||||
return p.credential, nil
|
||||
}
|
||||
|
||||
type entrypointStoreStub struct {
|
||||
saveErr error
|
||||
loadErr error
|
||||
}
|
||||
|
||||
func (s *entrypointStoreStub) Save(context.Context, string, secretstore.Credential) error {
|
||||
return s.saveErr
|
||||
}
|
||||
|
||||
func (s *entrypointStoreStub) Load(context.Context, string) (secretstore.Credential, error) {
|
||||
return secretstore.Credential{}, s.loadErr
|
||||
}
|
||||
|
||||
type entrypointMailServiceStub struct{}
|
||||
|
||||
func (entrypointMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (entrypointMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (entrypointMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
|
||||
return imapclient.Message{}, nil
|
||||
}
|
||||
|
||||
func TestExecuteSetupWritesWalletGuidanceAndReturnsExitCodeOne(t *testing.T) {
|
||||
app := NewAppWithDependencies(
|
||||
&entrypointPromptStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
&entrypointStoreStub{saveErr: kwallet.ErrKWalletUnavailable},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
stderr := &bytes.Buffer{}
|
||||
if code := Execute(app, []string{"setup"}, stderr); code != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", code)
|
||||
}
|
||||
if got := stderr.String(); got != "kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running\n" {
|
||||
t.Fatalf("unexpected stderr: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMCPWritesMissingCredentialGuidanceAndReturnsExitCodeOne(t *testing.T) {
|
||||
store := &entrypointStoreStub{loadErr: kwallet.ErrCredentialNotFound}
|
||||
mail := entrypointMailServiceStub{}
|
||||
runner := mcpserver.NewRunner(mcpserver.New(store, mail), nil, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
app := NewAppWithDependencies(nil, store, runner, nil)
|
||||
|
||||
stderr := &bytes.Buffer{}
|
||||
if code := Execute(app, []string{"mcp"}, stderr); code != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", code)
|
||||
}
|
||||
if got := stderr.String(); got != "credentials not configured; run `email-mcp setup`\n" {
|
||||
t.Fatalf("unexpected stderr: %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,40 +7,20 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"email-mcp/mcpgen"
|
||||
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
|
||||
"email-mcp/internal/secretstore"
|
||||
)
|
||||
|
||||
type SetupPrompter interface {
|
||||
PromptSetup(ctx context.Context) (secretstore.Credential, error)
|
||||
type InteractiveConfigPrompter struct {
|
||||
reader *bufio.Reader
|
||||
stdinFile *os.File
|
||||
output io.Writer
|
||||
}
|
||||
|
||||
type PasswordReader interface {
|
||||
ReadPassword(prompt string) (string, error)
|
||||
}
|
||||
|
||||
type InteractiveSetupPrompter struct {
|
||||
input *bufio.Reader
|
||||
rawInput io.Reader
|
||||
output io.Writer
|
||||
passwordReader PasswordReader
|
||||
}
|
||||
|
||||
type terminalPasswordReader struct {
|
||||
input io.Reader
|
||||
output io.Writer
|
||||
isTerminal func(io.Reader) bool
|
||||
readHiddenPassword func(*os.File) ([]byte, error)
|
||||
readFallbackPassword func(string) (string, error)
|
||||
}
|
||||
|
||||
func NewInteractiveSetupPrompter(input io.Reader, output io.Writer) *InteractiveSetupPrompter {
|
||||
return NewInteractiveSetupPrompterWithPasswordReader(input, output, nil)
|
||||
}
|
||||
|
||||
func NewInteractiveSetupPrompterWithPasswordReader(input io.Reader, output io.Writer, passwordReader PasswordReader) *InteractiveSetupPrompter {
|
||||
func NewInteractiveConfigPrompter(input io.Reader, output io.Writer) *InteractiveConfigPrompter {
|
||||
if input == nil {
|
||||
input = strings.NewReader("")
|
||||
}
|
||||
|
|
@ -48,29 +28,33 @@ func NewInteractiveSetupPrompterWithPasswordReader(input io.Reader, output io.Wr
|
|||
output = io.Discard
|
||||
}
|
||||
|
||||
prompter := &InteractiveSetupPrompter{
|
||||
input: bufio.NewReader(input),
|
||||
rawInput: input,
|
||||
output: output,
|
||||
prompter := &InteractiveConfigPrompter{
|
||||
reader: bufio.NewReader(input),
|
||||
output: output,
|
||||
}
|
||||
if passwordReader == nil {
|
||||
passwordReader = newTerminalPasswordReader(input, output, prompter.prompt)
|
||||
if file, ok := input.(*os.File); ok {
|
||||
prompter.stdinFile = file
|
||||
}
|
||||
prompter.passwordReader = passwordReader
|
||||
|
||||
return prompter
|
||||
}
|
||||
|
||||
func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Credential, error) {
|
||||
host, err := p.prompt("IMAP host: ")
|
||||
func (p *InteractiveConfigPrompter) PromptCredential(_ context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) {
|
||||
if p.stdinFile != nil {
|
||||
return p.promptCredentialWithSetupEngine(existing, hasStoredPassword)
|
||||
}
|
||||
|
||||
host, err := frameworkcli.PromptLine(p.reader, p.output, "IMAP host", existing.Host)
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
username, err := p.prompt("Username: ")
|
||||
|
||||
username, err := frameworkcli.PromptLine(p.reader, p.output, "Username", existing.Username)
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
password, err := p.passwordReader.ReadPassword("Password: ")
|
||||
|
||||
password, err := p.promptPassword(existing.Password, hasStoredPassword)
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
|
@ -83,101 +67,80 @@ func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Cre
|
|||
if err := cred.Validate(); err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func (p *InteractiveSetupPrompter) prompt(label string) (string, error) {
|
||||
if _, err := fmt.Fprint(p.output, label); err != nil {
|
||||
return "", err
|
||||
func (p *InteractiveConfigPrompter) promptCredentialWithSetupEngine(existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) {
|
||||
password := ""
|
||||
if hasStoredPassword {
|
||||
password = existing.Password
|
||||
}
|
||||
|
||||
value, err := p.input.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
|
||||
func newTerminalPasswordReader(input io.Reader, output io.Writer, fallback func(string) (string, error)) PasswordReader {
|
||||
return terminalPasswordReader{
|
||||
input: input,
|
||||
output: output,
|
||||
isTerminal: isTerminalReader,
|
||||
readHiddenPassword: readHiddenPassword,
|
||||
readFallbackPassword: fallback,
|
||||
}
|
||||
}
|
||||
|
||||
func (r terminalPasswordReader) ReadPassword(prompt string) (string, error) {
|
||||
if r.isTerminal != nil && r.isTerminal(r.input) {
|
||||
file, ok := r.input.(*os.File)
|
||||
if ok {
|
||||
if _, err := fmt.Fprint(r.output, prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
password, err := r.readHiddenPassword(file)
|
||||
if _, printErr := fmt.Fprintln(r.output); err == nil && printErr != nil {
|
||||
return "", printErr
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(password)), nil
|
||||
fields := mcpgen.SetupFields(map[string]string{"password": password})
|
||||
for i := range fields {
|
||||
switch fields[i].Name {
|
||||
case "host":
|
||||
fields[i].Default = existing.Host
|
||||
case "username":
|
||||
fields[i].Default = existing.Username
|
||||
}
|
||||
}
|
||||
|
||||
return r.readFallbackPassword(prompt)
|
||||
}
|
||||
|
||||
func isTerminalReader(reader io.Reader) bool {
|
||||
file, ok := reader.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isTerminalFile(file)
|
||||
}
|
||||
|
||||
func isTerminalFile(file *os.File) bool {
|
||||
_, err := getTermios(file.Fd())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readHiddenPassword(file *os.File) ([]byte, error) {
|
||||
state, err := getTermios(file.Fd())
|
||||
result, err := frameworkcli.RunSetup(frameworkcli.SetupOptions{
|
||||
Stdin: p.stdinFile,
|
||||
Stdout: p.output,
|
||||
Fields: fields,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
next := *state
|
||||
next.Lflag &^= syscall.ECHO
|
||||
if err := setTermios(file.Fd(), &next); err != nil {
|
||||
return nil, err
|
||||
host, ok := result.Get("host")
|
||||
if !ok {
|
||||
return secretstore.Credential{}, fmt.Errorf("setup result is missing host")
|
||||
}
|
||||
username, ok := result.Get("username")
|
||||
if !ok {
|
||||
return secretstore.Credential{}, fmt.Errorf("setup result is missing username")
|
||||
}
|
||||
secret, ok := result.Get("password")
|
||||
if !ok {
|
||||
return secretstore.Credential{}, fmt.Errorf("setup result is missing password")
|
||||
}
|
||||
defer func() {
|
||||
_ = setTermios(file.Fd(), state)
|
||||
}()
|
||||
|
||||
reader := bufio.NewReader(file)
|
||||
value, err := reader.ReadBytes('\n')
|
||||
cred := secretstore.Credential{
|
||||
Host: host.String,
|
||||
Username: username.String,
|
||||
Password: secret.String,
|
||||
}
|
||||
if err := cred.Validate(); err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func (p *InteractiveConfigPrompter) promptPassword(storedPassword string, hasStoredPassword bool) (string, error) {
|
||||
if p.stdinFile != nil {
|
||||
return frameworkcli.PromptSecret(p.stdinFile, p.output, "Password", hasStoredPassword, storedPassword)
|
||||
}
|
||||
|
||||
if hasStoredPassword {
|
||||
fmt.Fprint(p.output, "Password [stored, leave blank to keep]: ")
|
||||
} else {
|
||||
fmt.Fprint(p.output, "Password: ")
|
||||
}
|
||||
|
||||
line, err := p.reader.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
return []byte(strings.TrimRight(string(value), "\r\n")), nil
|
||||
}
|
||||
|
||||
func getTermios(fd uintptr) (*syscall.Termios, error) {
|
||||
state := &syscall.Termios{}
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(state)))
|
||||
if errno != 0 {
|
||||
return nil, errno
|
||||
password := strings.TrimSpace(line)
|
||||
if password == "" && hasStoredPassword {
|
||||
return storedPassword, nil
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func setTermios(fd uintptr, state *syscall.Termios) error {
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCSETS), uintptr(unsafe.Pointer(state)))
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
return password, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,21 @@ package cli
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"email-mcp/internal/secretstore"
|
||||
)
|
||||
|
||||
func TestInteractiveSetupPrompterPromptSetupCollectsCredential(t *testing.T) {
|
||||
input := strings.NewReader(" imap.example.com \n alice \n secret \n")
|
||||
func TestInteractiveConfigPrompterPromptCredentialCollectsValues(t *testing.T) {
|
||||
input := strings.NewReader("imap.example.com\nalice\nsecret\n")
|
||||
output := &bytes.Buffer{}
|
||||
prompter := NewInteractiveSetupPrompter(input, output)
|
||||
prompter := NewInteractiveConfigPrompter(input, output)
|
||||
|
||||
cred, err := prompter.PromptSetup(context.Background())
|
||||
cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("PromptSetup returned error: %v", err)
|
||||
t.Fatalf("PromptCredential returned error: %v", err)
|
||||
}
|
||||
|
||||
if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" {
|
||||
|
|
@ -28,122 +28,85 @@ func TestInteractiveSetupPrompterPromptSetupCollectsCredential(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInteractiveSetupPrompterPromptSetupUsesPasswordReader(t *testing.T) {
|
||||
func TestInteractiveConfigPrompterPromptCredentialKeepsStoredPassword(t *testing.T) {
|
||||
input := strings.NewReader("imap.example.com\nalice\n\n")
|
||||
output := &bytes.Buffer{}
|
||||
passwordReader := &passwordReaderStub{password: "secret"}
|
||||
prompter := NewInteractiveSetupPrompterWithPasswordReader(
|
||||
strings.NewReader("imap.example.com\nalice\n"),
|
||||
output,
|
||||
passwordReader,
|
||||
)
|
||||
prompter := NewInteractiveConfigPrompter(input, output)
|
||||
|
||||
cred, err := prompter.PromptSetup(context.Background())
|
||||
cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "stored-secret",
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("PromptSetup returned error: %v", err)
|
||||
t.Fatalf("PromptCredential returned error: %v", err)
|
||||
}
|
||||
|
||||
if cred.Password != "secret" {
|
||||
t.Fatalf("expected password from password reader, got %#v", cred)
|
||||
if cred.Password != "stored-secret" {
|
||||
t.Fatalf("expected stored password to be preserved, got %q", cred.Password)
|
||||
}
|
||||
if passwordReader.prompt != "Password: " {
|
||||
t.Fatalf("unexpected password prompt %q", passwordReader.prompt)
|
||||
if got := output.String(); !strings.Contains(got, "Password [stored, leave blank to keep]: ") {
|
||||
t.Fatalf("unexpected prompts: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveSetupPrompterPromptSetupRejectsMissingFields(t *testing.T) {
|
||||
input := strings.NewReader("imap.example.com\nalice\n \n")
|
||||
prompter := NewInteractiveSetupPrompter(input, &bytes.Buffer{})
|
||||
func TestInteractiveConfigPrompterPromptCredentialUsesSetupEngineWithFileInput(t *testing.T) {
|
||||
input := setupInputFile(t, "imap.example.com\nalice\nsecret\n")
|
||||
output := &bytes.Buffer{}
|
||||
prompter := NewInteractiveConfigPrompter(input, output)
|
||||
|
||||
_, err := prompter.PromptSetup(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "password is required") {
|
||||
t.Fatalf("expected password validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalPasswordReaderReadPasswordUsesHiddenInputForTTY(t *testing.T) {
|
||||
hiddenCalls := 0
|
||||
fallbackCalls := 0
|
||||
reader := terminalPasswordReader{
|
||||
input: os.Stdin,
|
||||
output: &bytes.Buffer{},
|
||||
isTerminal: func(io.Reader) bool {
|
||||
return true
|
||||
},
|
||||
readHiddenPassword: func(file *os.File) ([]byte, error) {
|
||||
hiddenCalls++
|
||||
if file != os.Stdin {
|
||||
t.Fatalf("expected os.Stdin, got %v", file)
|
||||
}
|
||||
return []byte("secret"), nil
|
||||
},
|
||||
readFallbackPassword: func(string) (string, error) {
|
||||
fallbackCalls++
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
|
||||
password, err := reader.ReadPassword("Password: ")
|
||||
cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPassword returned error: %v", err)
|
||||
t.Fatalf("PromptCredential returned error: %v", err)
|
||||
}
|
||||
|
||||
if password != "secret" {
|
||||
t.Fatalf("expected hidden password, got %q", password)
|
||||
if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" {
|
||||
t.Fatalf("unexpected credential: %#v", cred)
|
||||
}
|
||||
if hiddenCalls != 1 {
|
||||
t.Fatalf("expected hidden reader to be called once, got %d", hiddenCalls)
|
||||
}
|
||||
if fallbackCalls != 0 {
|
||||
t.Fatalf("expected fallback reader not to be called, got %d", fallbackCalls)
|
||||
if got := output.String(); got != "IMAP host: Username: Password: " {
|
||||
t.Fatalf("unexpected prompts: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalPasswordReaderReadPasswordFallsBackWhenInputIsNotTTY(t *testing.T) {
|
||||
hiddenCalls := 0
|
||||
reader := terminalPasswordReader{
|
||||
input: strings.NewReader("ignored\n"),
|
||||
output: &bytes.Buffer{},
|
||||
isTerminal: func(io.Reader) bool {
|
||||
return false
|
||||
},
|
||||
readHiddenPassword: func(*os.File) ([]byte, error) {
|
||||
hiddenCalls++
|
||||
return nil, errors.New("should not be called")
|
||||
},
|
||||
readFallbackPassword: func(prompt string) (string, error) {
|
||||
if prompt != "Password: " {
|
||||
t.Fatalf("unexpected prompt %q", prompt)
|
||||
}
|
||||
return "secret", nil
|
||||
},
|
||||
}
|
||||
func TestInteractiveConfigPrompterPromptCredentialKeepsStoredPasswordWithSetupEngine(t *testing.T) {
|
||||
input := setupInputFile(t, "imap.example.com\nalice\n\n")
|
||||
output := &bytes.Buffer{}
|
||||
prompter := NewInteractiveConfigPrompter(input, output)
|
||||
|
||||
password, err := reader.ReadPassword("Password: ")
|
||||
cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "stored-secret",
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPassword returned error: %v", err)
|
||||
t.Fatalf("PromptCredential returned error: %v", err)
|
||||
}
|
||||
|
||||
if password != "secret" {
|
||||
t.Fatalf("expected fallback password, got %q", password)
|
||||
if cred.Password != "stored-secret" {
|
||||
t.Fatalf("expected stored password to be preserved, got %q", cred.Password)
|
||||
}
|
||||
if hiddenCalls != 0 {
|
||||
t.Fatalf("expected hidden reader not to be called, got %d", hiddenCalls)
|
||||
if got := output.String(); !strings.Contains(got, "Password [stored, leave blank to keep]: ") {
|
||||
t.Fatalf("unexpected prompts: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type passwordReaderStub struct {
|
||||
password string
|
||||
err error
|
||||
prompt string
|
||||
}
|
||||
func setupInputFile(t *testing.T, content string) *os.File {
|
||||
t.Helper()
|
||||
|
||||
func (p *passwordReaderStub) ReadPassword(prompt string) (string, error) {
|
||||
p.prompt = prompt
|
||||
if p.err != nil {
|
||||
return "", p.err
|
||||
input, err := os.CreateTemp(t.TempDir(), "setup-input-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp returned error: %v", err)
|
||||
}
|
||||
return p.password, nil
|
||||
t.Cleanup(func() {
|
||||
_ = input.Close()
|
||||
})
|
||||
|
||||
if _, err := input.WriteString(content); err != nil {
|
||||
t.Fatalf("WriteString returned error: %v", err)
|
||||
}
|
||||
if _, err := input.Seek(0, 0); err != nil {
|
||||
t.Fatalf("Seek returned error: %v", err)
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,85 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"email-mcp/mcpgen"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
)
|
||||
|
||||
type runtimeFactories struct {
|
||||
newPrompter func(io.Reader, io.Writer) SetupPrompter
|
||||
newWalletClient func() kwallet.Client
|
||||
newStore func(kwallet.Client) secretstore.Store
|
||||
newMailService func() mcpserver.MailService
|
||||
newRunner func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
||||
newPrompter func(io.Reader, io.Writer) ConfigPrompter
|
||||
newConfigStore func() profileConfigStore
|
||||
openSecretStore func() (secretStore, error)
|
||||
newMailService func() mcpserver.MailService
|
||||
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
||||
loadManifest manifestLoader
|
||||
resolveExecutable executableResolver
|
||||
}
|
||||
|
||||
func BuildApp() *App {
|
||||
return buildApp(os.Stdin, os.Stdout, os.Stderr, runtimeFactories{})
|
||||
func BuildApp(version string) *App {
|
||||
return buildApp(os.Stdin, os.Stdout, os.Stderr, version, runtimeFactories{})
|
||||
}
|
||||
|
||||
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, factories runtimeFactories) *App {
|
||||
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, version string, factories runtimeFactories) *App {
|
||||
factories = factories.withDefaults()
|
||||
|
||||
prompter := factories.newPrompter(stdin, stderr)
|
||||
store := factories.newStore(factories.newWalletClient())
|
||||
mailService := factories.newMailService()
|
||||
runner := factories.newRunner(store, mailService, stdin, stdout, stderr)
|
||||
|
||||
return NewAppWithDependencies(prompter, store, runner, stderr)
|
||||
return NewAppWithDependencies(
|
||||
factories.newPrompter(stdin, stderr),
|
||||
factories.newConfigStore(),
|
||||
factories.openSecretStore,
|
||||
factories.newMailService,
|
||||
factories.newRunner,
|
||||
factories.loadManifest,
|
||||
factories.resolveExecutable,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
version,
|
||||
)
|
||||
}
|
||||
|
||||
func (f runtimeFactories) withDefaults() runtimeFactories {
|
||||
useGeneratedManifest := f.loadManifest == nil
|
||||
if f.newPrompter == nil {
|
||||
f.newPrompter = func(input io.Reader, output io.Writer) SetupPrompter {
|
||||
return NewInteractiveSetupPrompter(input, output)
|
||||
f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter {
|
||||
return NewInteractiveConfigPrompter(input, output)
|
||||
}
|
||||
}
|
||||
if f.newWalletClient == nil {
|
||||
f.newWalletClient = kwallet.NewDefaultWalletClient
|
||||
if f.newConfigStore == nil {
|
||||
f.newConfigStore = func() profileConfigStore {
|
||||
return frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)
|
||||
}
|
||||
}
|
||||
if f.newStore == nil {
|
||||
f.newStore = func(client kwallet.Client) secretstore.Store {
|
||||
return kwallet.NewStore(client)
|
||||
if f.loadManifest == nil {
|
||||
f.loadManifest = mcpgen.LoadManifest
|
||||
}
|
||||
if f.resolveExecutable == nil {
|
||||
f.resolveExecutable = os.Executable
|
||||
}
|
||||
if f.openSecretStore == nil {
|
||||
f.openSecretStore = func() (secretStore, error) {
|
||||
if useGeneratedManifest {
|
||||
return mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
|
||||
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
|
||||
LookupEnv: profilePasswordLookupEnv,
|
||||
})
|
||||
}
|
||||
|
||||
return frameworksecretstore.OpenFromManifest(frameworksecretstore.OpenFromManifestOptions{
|
||||
ServiceName: mcpgen.BinaryName,
|
||||
ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest),
|
||||
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
|
||||
LookupEnv: profilePasswordLookupEnv,
|
||||
})
|
||||
}
|
||||
}
|
||||
if f.newMailService == nil {
|
||||
|
|
@ -53,10 +88,30 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
|
|||
}
|
||||
}
|
||||
if f.newRunner == nil {
|
||||
f.newRunner = func(store secretstore.Store, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner {
|
||||
return mcpserver.NewRunner(mcpserver.New(store, mail), input, output, errOut)
|
||||
f.newRunner = func(cred secretstore.Credential, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner {
|
||||
return mcpserver.NewRunner(mcpserver.New(staticCredentialStore{credential: cred}, mail), input, output, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func profilePasswordLookupEnv(name string) (string, bool) {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if strings.HasPrefix(trimmedName, "imap-password/") {
|
||||
return os.LookupEnv(passwordEnv)
|
||||
}
|
||||
return os.LookupEnv(trimmedName)
|
||||
}
|
||||
|
||||
type staticCredentialStore struct {
|
||||
credential secretstore.Credential
|
||||
}
|
||||
|
||||
func (s staticCredentialStore) Save(_ context.Context, _ string, _ secretstore.Credential) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s staticCredentialStore) Load(_ context.Context, _ string) (secretstore.Credential, error) {
|
||||
return s.credential, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,160 +1,82 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
type walletClientStub struct{}
|
||||
|
||||
func (walletClientStub) IsAvailable(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (walletClientStub) Open(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (walletClientStub) WriteEntry(context.Context, string, []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (walletClientStub) ReadEntry(context.Context, string) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type wireStoreStub struct {
|
||||
saved secretstore.Credential
|
||||
savedKey string
|
||||
saveCalled bool
|
||||
}
|
||||
|
||||
func (s *wireStoreStub) Save(_ context.Context, key string, cred secretstore.Credential) error {
|
||||
s.saveCalled = true
|
||||
s.savedKey = key
|
||||
s.saved = cred
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wireStoreStub) Load(context.Context, string) (secretstore.Credential, error) {
|
||||
return secretstore.Credential{}, nil
|
||||
}
|
||||
|
||||
type wireMailServiceStub struct{}
|
||||
|
||||
func (wireMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (wireMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (wireMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
|
||||
return imapclient.Message{}, nil
|
||||
}
|
||||
|
||||
func TestBuildAppWiresSetupAndMCPCommands(t *testing.T) {
|
||||
stdin := strings.NewReader("unused")
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
|
||||
prompter := &promptStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
}
|
||||
store := &wireStoreStub{}
|
||||
mail := wireMailServiceStub{}
|
||||
runner := &runnerStub{}
|
||||
walletClient := &walletClientStub{}
|
||||
|
||||
var gotStoreClient kwallet.Client
|
||||
var gotRunnerStore secretstore.Store
|
||||
var gotRunnerMail mcpserver.MailService
|
||||
|
||||
app := buildApp(stdin, stdout, stderr, runtimeFactories{
|
||||
newPrompter: func(in io.Reader, errOut io.Writer) SetupPrompter {
|
||||
if in != stdin {
|
||||
t.Fatalf("expected stdin to be forwarded to prompter")
|
||||
}
|
||||
if errOut != stderr {
|
||||
t.Fatalf("expected stderr to be forwarded to prompter")
|
||||
}
|
||||
return prompter
|
||||
},
|
||||
newWalletClient: func() kwallet.Client {
|
||||
return walletClient
|
||||
},
|
||||
newStore: func(client kwallet.Client) secretstore.Store {
|
||||
gotStoreClient = client
|
||||
return store
|
||||
},
|
||||
newMailService: func() mcpserver.MailService {
|
||||
return mail
|
||||
},
|
||||
newRunner: func(store secretstore.Store, mail mcpserver.MailService, in io.Reader, out io.Writer, errOut io.Writer) MCPRunner {
|
||||
gotRunnerStore = store
|
||||
gotRunnerMail = mail
|
||||
if in != stdin {
|
||||
t.Fatalf("expected stdin to be forwarded to runner")
|
||||
}
|
||||
if out != stdout {
|
||||
t.Fatalf("expected stdout to be forwarded to runner")
|
||||
}
|
||||
if errOut != stderr {
|
||||
t.Fatalf("expected stderr to be forwarded to runner")
|
||||
}
|
||||
return runner
|
||||
},
|
||||
})
|
||||
|
||||
if err := app.Run([]string{"setup"}); err != nil {
|
||||
t.Fatalf("setup returned error: %v", err)
|
||||
}
|
||||
if !prompter.called {
|
||||
t.Fatal("expected setup prompter to be called")
|
||||
}
|
||||
if !store.saveCalled {
|
||||
t.Fatal("expected store Save to be called")
|
||||
}
|
||||
if store.savedKey != secretstore.DefaultAccountKey {
|
||||
t.Fatalf("expected setup to save %q, got %q", secretstore.DefaultAccountKey, store.savedKey)
|
||||
}
|
||||
if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" {
|
||||
t.Fatalf("unexpected saved credential: %#v", store.saved)
|
||||
}
|
||||
|
||||
if err := app.Run([]string{"mcp"}); err != nil {
|
||||
t.Fatalf("mcp returned error: %v", err)
|
||||
}
|
||||
if !runner.called {
|
||||
t.Fatal("expected MCP runner to be called")
|
||||
}
|
||||
if gotStoreClient != walletClient {
|
||||
t.Fatal("expected wallet client to be passed to the store factory")
|
||||
}
|
||||
if gotRunnerStore != store {
|
||||
t.Fatal("expected runner to receive the assembled store")
|
||||
}
|
||||
if gotRunnerMail != mail {
|
||||
t.Fatal("expected runner to receive the assembled mail service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppReturnsConfiguredApp(t *testing.T) {
|
||||
app := BuildApp()
|
||||
app := BuildApp("dev")
|
||||
if app == nil {
|
||||
t.Fatal("expected app instance")
|
||||
}
|
||||
if app.prompter == nil {
|
||||
t.Fatal("expected config prompter to be configured")
|
||||
}
|
||||
if app.configStore == nil {
|
||||
t.Fatal("expected config store to be configured")
|
||||
}
|
||||
if app.openSecretStore == nil {
|
||||
t.Fatal("expected secret store opener to be configured")
|
||||
}
|
||||
if app.newMailService == nil {
|
||||
t.Fatal("expected mail service factory to be configured")
|
||||
}
|
||||
if app.newRunner == nil {
|
||||
t.Fatal("expected runner factory to be configured")
|
||||
}
|
||||
if app.loadManifest == nil {
|
||||
t.Fatal("expected manifest loader to be configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppOpenSecretStoreMapsProfilePasswordToEnvironment(t *testing.T) {
|
||||
t.Setenv(passwordEnv, "env-secret")
|
||||
|
||||
app := buildApp(nil, nil, nil, "dev", runtimeFactories{
|
||||
loadManifest: func(string) (frameworkmanifest.File, string, error) {
|
||||
return frameworkmanifest.File{
|
||||
SecretStore: frameworkmanifest.SecretStore{
|
||||
BackendPolicy: "env-only",
|
||||
},
|
||||
}, "/tmp/mcp.toml", nil
|
||||
},
|
||||
resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil },
|
||||
})
|
||||
|
||||
store, err := app.openSecretStore()
|
||||
if err != nil {
|
||||
t.Fatalf("openSecretStore returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("imap-password/work")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "env-secret" {
|
||||
t.Fatalf("GetSecret = %q, want %q", value, "env-secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppOpenSecretStoreReturnsErrorOnInvalidManifestPolicy(t *testing.T) {
|
||||
app := buildApp(nil, nil, nil, "dev", runtimeFactories{
|
||||
loadManifest: func(string) (frameworkmanifest.File, string, error) {
|
||||
return frameworkmanifest.File{
|
||||
SecretStore: frameworkmanifest.SecretStore{
|
||||
BackendPolicy: "invalid-policy",
|
||||
},
|
||||
}, "/tmp/mcp.toml", nil
|
||||
},
|
||||
resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil },
|
||||
})
|
||||
|
||||
_, err := app.openSecretStore()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid secret store policy error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid secret_store.backend_policy") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package mcpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
|
@ -15,6 +16,27 @@ import (
|
|||
|
||||
var ErrCredentialsNotConfigured = errors.New("credentials not configured; run `email-mcp setup`")
|
||||
|
||||
const (
|
||||
jsonRPCVersion = "2.0"
|
||||
mcpServerName = "email-mcp"
|
||||
mcpServerVersion = "dev"
|
||||
mcpMethodInitialize = "initialize"
|
||||
mcpMethodInitialized = "notifications/initialized"
|
||||
mcpMethodPing = "ping"
|
||||
mcpMethodToolsList = "tools/list"
|
||||
mcpMethodToolsCall = "tools/call"
|
||||
jsonRPCParseErrorCode = -32700
|
||||
jsonRPCInvalidRequestCode = -32600
|
||||
jsonRPCMethodNotFoundCode = -32601
|
||||
jsonRPCInvalidParamsCode = -32602
|
||||
jsonRPCInternalErrorCode = -32603
|
||||
)
|
||||
|
||||
var supportedProtocolVersions = []string{
|
||||
"2025-03-26",
|
||||
"2024-11-05",
|
||||
}
|
||||
|
||||
type MailService interface {
|
||||
ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error)
|
||||
ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error)
|
||||
|
|
@ -29,7 +51,7 @@ type Server struct {
|
|||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"input_schema,omitempty"`
|
||||
InputSchema map[string]any `json:"inputSchema,omitempty"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
|
|
@ -44,6 +66,52 @@ type toolRequest struct {
|
|||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcErrorObject `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcErrorObject struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type initializeParams struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities map[string]any `json:"capabilities"`
|
||||
ClientInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"clientInfo"`
|
||||
}
|
||||
|
||||
type toolsCallParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type invalidParamsError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *invalidParamsError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *invalidParamsError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
type listMessagesArguments struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
|
|
@ -82,6 +150,9 @@ func (s Server) Tools() []Tool {
|
|||
{
|
||||
Name: "list_mailboxes",
|
||||
Description: "List visible IMAP mailboxes for the configured account.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_messages",
|
||||
|
|
@ -153,29 +224,22 @@ func (s Server) GetMessage(ctx context.Context, mailbox string, uid uint32) (ima
|
|||
}
|
||||
|
||||
func (r Runner) Run(ctx context.Context) error {
|
||||
cred, err := r.server.loadCredential(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stopCancelRead := r.closeInputOnCancel(ctx)
|
||||
defer stopCancelRead()
|
||||
|
||||
encoder := json.NewEncoder(r.out)
|
||||
if err := encoder.Encode(map[string]any{"tools": r.server.Tools()}); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.in == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.in)
|
||||
session := runnerSession{}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var request toolRequest
|
||||
var request rpcRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
|
|
@ -183,20 +247,167 @@ func (r Runner) Run(ctx context.Context) error {
|
|||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
if writeErr := writeRPCError(encoder, nil, jsonRPCParseErrorCode, err.Error(), nil); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := r.server.handleTool(ctx, cred, request.Tool, request.Arguments)
|
||||
if err != nil {
|
||||
if encodeErr := encoder.Encode(map[string]any{"error": err.Error()}); encodeErr != nil {
|
||||
return encodeErr
|
||||
if err := r.handleRPCRequest(ctx, encoder, &session, request); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
if writeErr := writeRPCError(encoder, request.ID, jsonRPCInternalErrorCode, err.Error(), nil); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := encoder.Encode(map[string]any{"result": result}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
type runnerSession struct {
|
||||
initialized bool
|
||||
ready bool
|
||||
protocolVersion string
|
||||
}
|
||||
|
||||
func (r Runner) handleRPCRequest(ctx context.Context, encoder *json.Encoder, session *runnerSession, request rpcRequest) error {
|
||||
if request.JSONRPC != jsonRPCVersion {
|
||||
return writeRPCError(encoder, request.ID, jsonRPCInvalidRequestCode, "jsonrpc must be 2.0", nil)
|
||||
}
|
||||
if request.Method == "" {
|
||||
return writeRPCError(encoder, request.ID, jsonRPCInvalidRequestCode, "method is required", nil)
|
||||
}
|
||||
|
||||
isNotification := len(request.ID) == 0
|
||||
if !session.initialized {
|
||||
switch request.Method {
|
||||
case mcpMethodInitialize:
|
||||
if isNotification {
|
||||
return writeRPCError(encoder, nil, jsonRPCInvalidRequestCode, "initialize must be a request", nil)
|
||||
}
|
||||
return r.handleInitialize(encoder, session, request)
|
||||
case mcpMethodPing:
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
return writeRPCResult(encoder, request.ID, map[string]any{})
|
||||
default:
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
return writeRPCError(encoder, request.ID, jsonRPCInvalidRequestCode, "server not initialized", nil)
|
||||
}
|
||||
}
|
||||
|
||||
switch request.Method {
|
||||
case mcpMethodInitialized:
|
||||
session.ready = true
|
||||
return nil
|
||||
case mcpMethodPing:
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
return writeRPCResult(encoder, request.ID, map[string]any{})
|
||||
case mcpMethodToolsList:
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
return writeRPCResult(encoder, request.ID, map[string]any{"tools": r.server.Tools()})
|
||||
case mcpMethodToolsCall:
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
return r.handleToolsCall(ctx, encoder, request)
|
||||
default:
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
return writeRPCError(encoder, request.ID, jsonRPCMethodNotFoundCode, fmt.Sprintf("method not found: %s", request.Method), nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (r Runner) handleInitialize(encoder *json.Encoder, session *runnerSession, request rpcRequest) error {
|
||||
var params initializeParams
|
||||
if err := decodeParams(request.Params, ¶ms); err != nil {
|
||||
return writeRPCError(encoder, request.ID, jsonRPCInvalidParamsCode, err.Error(), nil)
|
||||
}
|
||||
if params.ProtocolVersion == "" {
|
||||
return writeRPCError(encoder, request.ID, jsonRPCInvalidParamsCode, "protocolVersion is required", nil)
|
||||
}
|
||||
|
||||
negotiatedVersion := negotiateProtocolVersion(params.ProtocolVersion)
|
||||
|
||||
session.initialized = true
|
||||
session.protocolVersion = negotiatedVersion
|
||||
|
||||
return writeRPCResult(encoder, request.ID, map[string]any{
|
||||
"protocolVersion": negotiatedVersion,
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{
|
||||
"listChanged": true,
|
||||
},
|
||||
},
|
||||
"serverInfo": map[string]any{
|
||||
"name": mcpServerName,
|
||||
"version": mcpServerVersion,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (r Runner) handleToolsCall(ctx context.Context, encoder *json.Encoder, request rpcRequest) error {
|
||||
var params toolsCallParams
|
||||
if err := decodeParams(request.Params, ¶ms); err != nil {
|
||||
return writeRPCError(encoder, request.ID, jsonRPCInvalidParamsCode, err.Error(), nil)
|
||||
}
|
||||
if strings.TrimSpace(params.Name) == "" {
|
||||
return writeRPCError(encoder, request.ID, jsonRPCInvalidParamsCode, "name is required", nil)
|
||||
}
|
||||
|
||||
cred, err := r.server.loadCredential(ctx)
|
||||
if err != nil {
|
||||
return writeRPCResult(encoder, request.ID, map[string]any{
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": err.Error(),
|
||||
},
|
||||
},
|
||||
"isError": true,
|
||||
})
|
||||
}
|
||||
|
||||
result, err := r.server.handleTool(ctx, cred, params.Name, params.Arguments)
|
||||
if err != nil {
|
||||
var invalidErr *invalidParamsError
|
||||
if errors.As(err, &invalidErr) {
|
||||
return writeRPCError(encoder, request.ID, jsonRPCInvalidParamsCode, invalidErr.Error(), nil)
|
||||
}
|
||||
return writeRPCResult(encoder, request.ID, map[string]any{
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": err.Error(),
|
||||
},
|
||||
},
|
||||
"isError": true,
|
||||
})
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeRPCResult(encoder, request.ID, map[string]any{
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": string(payload),
|
||||
},
|
||||
},
|
||||
"isError": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (s Server) handleTool(ctx context.Context, cred secretstore.Credential, name string, rawArgs json.RawMessage) (any, error) {
|
||||
|
|
@ -206,25 +417,33 @@ func (s Server) handleTool(ctx context.Context, cred secretstore.Credential, nam
|
|||
case "list_messages":
|
||||
var args listMessagesArguments
|
||||
if err := decodeArguments(rawArgs, &args); err != nil {
|
||||
return nil, err
|
||||
return nil, &invalidParamsError{err: err}
|
||||
}
|
||||
mailbox, err := validateMailbox(args.Mailbox)
|
||||
if err != nil {
|
||||
return nil, &invalidParamsError{err: err}
|
||||
}
|
||||
limit, err := normalizeListMessagesLimit(args.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, &invalidParamsError{err: err}
|
||||
}
|
||||
return s.listMessages(ctx, cred, args.Mailbox, limit)
|
||||
return s.listMessages(ctx, cred, mailbox, limit)
|
||||
case "get_message":
|
||||
var args getMessageArguments
|
||||
if err := decodeArguments(rawArgs, &args); err != nil {
|
||||
return nil, err
|
||||
return nil, &invalidParamsError{err: err}
|
||||
}
|
||||
mailbox, err := validateMailbox(args.Mailbox)
|
||||
if err != nil {
|
||||
return nil, &invalidParamsError{err: err}
|
||||
}
|
||||
uid, err := validateMessageUID(args.UID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, &invalidParamsError{err: err}
|
||||
}
|
||||
return s.getMessage(ctx, cred, args.Mailbox, uid)
|
||||
return s.getMessage(ctx, cred, mailbox, uid)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||
return nil, &invalidParamsError{err: fmt.Errorf("unknown tool: %s", name)}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -278,18 +497,45 @@ func (s Server) loadCredential(ctx context.Context) (secretstore.Credential, err
|
|||
return cred, nil
|
||||
}
|
||||
|
||||
func decodeParams(raw json.RawMessage, dest any) error {
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
func decodeArguments(raw json.RawMessage, dest any) error {
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dest); err != nil {
|
||||
return fmt.Errorf("invalid tool arguments: %w", err)
|
||||
return normalizeDecodeError(err)
|
||||
}
|
||||
if decoder.More() {
|
||||
return fmt.Errorf("invalid JSON object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDecodeError(err error) error {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &syntaxErr):
|
||||
return fmt.Errorf("invalid JSON arguments")
|
||||
case errors.As(err, &typeErr):
|
||||
if typeErr.Field != "" {
|
||||
return fmt.Errorf("%s has an invalid type", typeErr.Field)
|
||||
}
|
||||
return fmt.Errorf("arguments have an invalid type")
|
||||
default:
|
||||
return fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateMailbox(mailbox string) (string, error) {
|
||||
mailbox = strings.TrimSpace(mailbox)
|
||||
if mailbox == "" {
|
||||
|
|
@ -337,3 +583,32 @@ func (r Runner) closeInputOnCancel(ctx context.Context) func() {
|
|||
close(done)
|
||||
}
|
||||
}
|
||||
|
||||
func negotiateProtocolVersion(requested string) string {
|
||||
for _, supported := range supportedProtocolVersions {
|
||||
if requested == supported {
|
||||
return supported
|
||||
}
|
||||
}
|
||||
return supportedProtocolVersions[0]
|
||||
}
|
||||
|
||||
func writeRPCResult(encoder *json.Encoder, id json.RawMessage, result any) error {
|
||||
return encoder.Encode(rpcResponse{
|
||||
JSONRPC: jsonRPCVersion,
|
||||
ID: id,
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
|
||||
func writeRPCError(encoder *json.Encoder, id json.RawMessage, code int, message string, data any) error {
|
||||
return encoder.Encode(rpcResponse{
|
||||
JSONRPC: jsonRPCVersion,
|
||||
ID: id,
|
||||
Error: &rpcErrorObject{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ func TestRunnerRunWritesToolManifestAndHandlesRequests(t *testing.T) {
|
|||
Password: "secret",
|
||||
},
|
||||
}
|
||||
input := bytes.NewBufferString("{\"tool\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\",\"limit\":5}}\n")
|
||||
input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\",\"limit\":5}}}\n")
|
||||
output := &bytes.Buffer{}
|
||||
runner := NewRunner(New(store, serviceStub{
|
||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
|
|
@ -200,29 +200,151 @@ func TestRunnerRunWritesToolManifestAndHandlesRequests(t *testing.T) {
|
|||
|
||||
decoder := json.NewDecoder(output)
|
||||
|
||||
var manifest struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tools"`
|
||||
var initializeResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities map[string]any `json:"capabilities"`
|
||||
ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"serverInfo"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
t.Fatalf("failed to decode manifest: %v", err)
|
||||
if err := decoder.Decode(&initializeResponse); err != nil {
|
||||
t.Fatalf("failed to decode initialize response: %v", err)
|
||||
}
|
||||
if len(manifest.Tools) != 3 {
|
||||
t.Fatalf("expected 3 tools, got %#v", manifest.Tools)
|
||||
if initializeResponse.JSONRPC != "2.0" {
|
||||
t.Fatalf("expected jsonrpc 2.0, got %#v", initializeResponse)
|
||||
}
|
||||
if manifest.Tools[0].Name != "list_mailboxes" || manifest.Tools[1].Name != "list_messages" || manifest.Tools[2].Name != "get_message" {
|
||||
t.Fatalf("unexpected tool manifest: %#v", manifest.Tools)
|
||||
if initializeResponse.ID != 1 {
|
||||
t.Fatalf("expected initialize response id 1, got %#v", initializeResponse)
|
||||
}
|
||||
if initializeResponse.Result.ProtocolVersion != "2025-03-26" {
|
||||
t.Fatalf("expected negotiated protocol version, got %#v", initializeResponse.Result)
|
||||
}
|
||||
if _, ok := initializeResponse.Result.Capabilities["tools"]; !ok {
|
||||
t.Fatalf("expected tools capability, got %#v", initializeResponse.Result.Capabilities)
|
||||
}
|
||||
toolsCapability, ok := initializeResponse.Result.Capabilities["tools"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected tools capability object, got %#v", initializeResponse.Result.Capabilities["tools"])
|
||||
}
|
||||
if listChanged, ok := toolsCapability["listChanged"].(bool); !ok || !listChanged {
|
||||
t.Fatalf("expected tools.listChanged=true, got %#v", toolsCapability)
|
||||
}
|
||||
if initializeResponse.Result.ServerInfo.Name == "" || initializeResponse.Result.ServerInfo.Version == "" {
|
||||
t.Fatalf("expected server info, got %#v", initializeResponse.Result.ServerInfo)
|
||||
}
|
||||
|
||||
var listResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result struct {
|
||||
Tools []map[string]any `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := decoder.Decode(&listResponse); err != nil {
|
||||
t.Fatalf("failed to decode tools/list response: %v", err)
|
||||
}
|
||||
if listResponse.JSONRPC != "2.0" || listResponse.ID != 2 {
|
||||
t.Fatalf("unexpected tools/list response envelope: %#v", listResponse)
|
||||
}
|
||||
if len(listResponse.Result.Tools) != 3 {
|
||||
t.Fatalf("expected 3 tools, got %#v", listResponse.Result.Tools)
|
||||
}
|
||||
if listResponse.Result.Tools[0]["name"] != "list_mailboxes" || listResponse.Result.Tools[1]["name"] != "list_messages" || listResponse.Result.Tools[2]["name"] != "get_message" {
|
||||
t.Fatalf("unexpected tool manifest: %#v", listResponse.Result.Tools)
|
||||
}
|
||||
if _, ok := listResponse.Result.Tools[1]["inputSchema"]; !ok {
|
||||
t.Fatalf("expected inputSchema field in tools/list response, got %#v", listResponse.Result.Tools[1])
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Result []imapclient.MessageSummary `json:"result"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if len(response.Result) != 1 || response.Result[0].UID != 42 {
|
||||
t.Fatalf("unexpected response: %#v", response.Result)
|
||||
if response.JSONRPC != "2.0" || response.ID != 3 {
|
||||
t.Fatalf("unexpected tools/call response envelope: %#v", response)
|
||||
}
|
||||
if response.Result.IsError {
|
||||
t.Fatalf("expected successful tools/call result, got %#v", response.Result)
|
||||
}
|
||||
if len(response.Result.Content) != 1 || response.Result.Content[0].Type != "text" {
|
||||
t.Fatalf("unexpected tools/call content: %#v", response.Result.Content)
|
||||
}
|
||||
|
||||
var messages []imapclient.MessageSummary
|
||||
if err := json.Unmarshal([]byte(response.Result.Content[0].Text), &messages); err != nil {
|
||||
t.Fatalf("failed to decode tools/call text payload: %v", err)
|
||||
}
|
||||
if len(messages) != 1 || messages[0].UID != 42 {
|
||||
t.Fatalf("unexpected response: %#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunAcceptsClaudeCodeProtocolVersion(t *testing.T) {
|
||||
store := &storeStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
}
|
||||
input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"claude-code\",\"version\":\"1.0.0\"}}}\n")
|
||||
output := &bytes.Buffer{}
|
||||
runner := NewRunner(New(store, serviceStub{
|
||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
t.Fatal("ListMailboxes should not be called")
|
||||
return nil, nil
|
||||
},
|
||||
listMessages: func(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
||||
t.Fatal("ListMessages should not be called")
|
||||
return nil, nil
|
||||
},
|
||||
getMessage: func(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
|
||||
t.Fatal("GetMessage should not be called")
|
||||
return imapclient.Message{}, nil
|
||||
},
|
||||
}), input, output, &bytes.Buffer{})
|
||||
|
||||
if err := runner.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(output).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode initialize response: %v", err)
|
||||
}
|
||||
if response.Error != nil {
|
||||
t.Fatalf("expected initialize to succeed, got error %#v", response.Error)
|
||||
}
|
||||
if response.JSONRPC != "2.0" || response.ID != 1 {
|
||||
t.Fatalf("unexpected response envelope: %#v", response)
|
||||
}
|
||||
if response.Result.ProtocolVersion != "2024-11-05" {
|
||||
t.Fatalf("expected negotiated protocol version 2024-11-05, got %#v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +352,11 @@ func TestRunnerRunReturnsFriendlyMissingCredentialError(t *testing.T) {
|
|||
store := &storeStub{
|
||||
loadErr: kwallet.ErrCredentialNotFound,
|
||||
}
|
||||
input := bytes.NewBufferString(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0.0\"}}}\n" +
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" +
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_mailboxes\"}}\n",
|
||||
)
|
||||
output := &bytes.Buffer{}
|
||||
runner := NewRunner(New(store, serviceStub{
|
||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
|
|
@ -244,14 +371,38 @@ func TestRunnerRunReturnsFriendlyMissingCredentialError(t *testing.T) {
|
|||
t.Fatal("GetMessage should not be called")
|
||||
return imapclient.Message{}, nil
|
||||
},
|
||||
}), bytes.NewBuffer(nil), output, &bytes.Buffer{})
|
||||
}), input, output, &bytes.Buffer{})
|
||||
|
||||
err := runner.Run(context.Background())
|
||||
if !errors.Is(err, ErrCredentialsNotConfigured) {
|
||||
t.Fatalf("expected missing credential error, got %v", err)
|
||||
if err := runner.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("expected no output when credentials are missing, got %q", output.String())
|
||||
|
||||
decoder := json.NewDecoder(output)
|
||||
|
||||
// Skip initialize response
|
||||
var initResp json.RawMessage
|
||||
if err := decoder.Decode(&initResp); err != nil {
|
||||
t.Fatalf("failed to decode initialize response: %v", err)
|
||||
}
|
||||
|
||||
// Check tool call response contains credential error
|
||||
var toolResp struct {
|
||||
ID int `json:"id"`
|
||||
Result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := decoder.Decode(&toolResp); err != nil {
|
||||
t.Fatalf("failed to decode tool call response: %v", err)
|
||||
}
|
||||
if !toolResp.Result.IsError {
|
||||
t.Fatal("expected isError true for missing credentials")
|
||||
}
|
||||
if len(toolResp.Result.Content) == 0 || toolResp.Result.Content[0].Text != ErrCredentialsNotConfigured.Error() {
|
||||
t.Fatalf("expected credential error message, got %#v", toolResp.Result)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +410,11 @@ func TestRunnerRunReturnsFriendlyMissingCredentialErrorWhenStoreAlreadyTranslate
|
|||
store := &storeStub{
|
||||
loadErr: ErrCredentialsNotConfigured,
|
||||
}
|
||||
input := bytes.NewBufferString(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0.0\"}}}\n" +
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" +
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_mailboxes\"}}\n",
|
||||
)
|
||||
output := &bytes.Buffer{}
|
||||
runner := NewRunner(New(store, serviceStub{
|
||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
|
|
@ -273,14 +429,38 @@ func TestRunnerRunReturnsFriendlyMissingCredentialErrorWhenStoreAlreadyTranslate
|
|||
t.Fatal("GetMessage should not be called")
|
||||
return imapclient.Message{}, nil
|
||||
},
|
||||
}), bytes.NewBuffer(nil), output, &bytes.Buffer{})
|
||||
}), input, output, &bytes.Buffer{})
|
||||
|
||||
err := runner.Run(context.Background())
|
||||
if !errors.Is(err, ErrCredentialsNotConfigured) {
|
||||
t.Fatalf("expected missing credential error, got %v", err)
|
||||
if err := runner.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatalf("expected no output when credentials are missing, got %q", output.String())
|
||||
|
||||
decoder := json.NewDecoder(output)
|
||||
|
||||
// Skip initialize response
|
||||
var initResp json.RawMessage
|
||||
if err := decoder.Decode(&initResp); err != nil {
|
||||
t.Fatalf("failed to decode initialize response: %v", err)
|
||||
}
|
||||
|
||||
// Check tool call response contains credential error
|
||||
var toolResp struct {
|
||||
ID int `json:"id"`
|
||||
Result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := decoder.Decode(&toolResp); err != nil {
|
||||
t.Fatalf("failed to decode tool call response: %v", err)
|
||||
}
|
||||
if !toolResp.Result.IsError {
|
||||
t.Fatal("expected isError true for missing credentials")
|
||||
}
|
||||
if len(toolResp.Result.Content) == 0 || toolResp.Result.Content[0].Text != ErrCredentialsNotConfigured.Error() {
|
||||
t.Fatalf("expected credential error message, got %#v", toolResp.Result)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -355,7 +535,7 @@ func TestRunnerRunReturnsValidationErrorsForInvalidRequests(t *testing.T) {
|
|||
Password: "secret",
|
||||
},
|
||||
}
|
||||
input := bytes.NewBufferString("{\"tool\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\",\"limit\":0}}\n{\"tool\":\"get_message\",\"arguments\":{\"mailbox\":\"INBOX\"}}\n")
|
||||
input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\",\"limit\":0}}}\n{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"get_message\",\"arguments\":{\"mailbox\":\"INBOX\"}}}\n")
|
||||
output := &bytes.Buffer{}
|
||||
runner := NewRunner(New(store, serviceStub{
|
||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
|
|
@ -378,28 +558,51 @@ func TestRunnerRunReturnsValidationErrorsForInvalidRequests(t *testing.T) {
|
|||
|
||||
decoder := json.NewDecoder(output)
|
||||
if err := decoder.Decode(&struct {
|
||||
Tools []Tool `json:"tools"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
}{}); err != nil {
|
||||
t.Fatalf("failed to decode manifest: %v", err)
|
||||
t.Fatalf("failed to decode initialize response: %v", err)
|
||||
}
|
||||
|
||||
var firstResponse struct {
|
||||
Error string `json:"error"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := decoder.Decode(&firstResponse); err != nil {
|
||||
t.Fatalf("failed to decode first error response: %v", err)
|
||||
}
|
||||
if firstResponse.Error != "limit must be between 1 and 50" {
|
||||
if firstResponse.JSONRPC != "2.0" || firstResponse.ID != 2 {
|
||||
t.Fatalf("unexpected first error envelope: %#v", firstResponse)
|
||||
}
|
||||
if firstResponse.Error.Code != -32602 {
|
||||
t.Fatalf("expected invalid params code, got %#v", firstResponse)
|
||||
}
|
||||
if firstResponse.Error.Message != "limit must be between 1 and 50" {
|
||||
t.Fatalf("unexpected first error: %#v", firstResponse)
|
||||
}
|
||||
|
||||
var secondResponse struct {
|
||||
Error string `json:"error"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := decoder.Decode(&secondResponse); err != nil {
|
||||
t.Fatalf("failed to decode second error response: %v", err)
|
||||
}
|
||||
if secondResponse.Error != "uid is required" {
|
||||
if secondResponse.JSONRPC != "2.0" || secondResponse.ID != 3 {
|
||||
t.Fatalf("unexpected second error envelope: %#v", secondResponse)
|
||||
}
|
||||
if secondResponse.Error.Code != -32602 {
|
||||
t.Fatalf("expected invalid params code, got %#v", secondResponse)
|
||||
}
|
||||
if secondResponse.Error.Message != "uid is required" {
|
||||
t.Fatalf("unexpected second error: %#v", secondResponse)
|
||||
}
|
||||
}
|
||||
|
|
@ -412,7 +615,7 @@ func TestRunnerRunRejectsWhitespaceOnlyMailboxValues(t *testing.T) {
|
|||
Password: "secret",
|
||||
},
|
||||
}
|
||||
input := bytes.NewBufferString("{\"tool\":\"list_messages\",\"arguments\":{\"mailbox\":\" \"}}\n")
|
||||
input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_messages\",\"arguments\":{\"mailbox\":\" \"}}}\n")
|
||||
output := &bytes.Buffer{}
|
||||
runner := NewRunner(New(store, serviceStub{
|
||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
|
|
@ -435,18 +638,30 @@ func TestRunnerRunRejectsWhitespaceOnlyMailboxValues(t *testing.T) {
|
|||
|
||||
decoder := json.NewDecoder(output)
|
||||
if err := decoder.Decode(&struct {
|
||||
Tools []Tool `json:"tools"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
}{}); err != nil {
|
||||
t.Fatalf("failed to decode manifest: %v", err)
|
||||
t.Fatalf("failed to decode initialize response: %v", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Error string `json:"error"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode error response: %v", err)
|
||||
}
|
||||
if response.Error != "mailbox is required" {
|
||||
if response.JSONRPC != "2.0" || response.ID != 2 {
|
||||
t.Fatalf("unexpected error envelope: %#v", response)
|
||||
}
|
||||
if response.Error.Code != -32602 {
|
||||
t.Fatalf("expected invalid params code, got %#v", response)
|
||||
}
|
||||
if response.Error.Message != "mailbox is required" {
|
||||
t.Fatalf("unexpected error: %#v", response)
|
||||
}
|
||||
}
|
||||
|
|
@ -459,7 +674,7 @@ func TestRunnerRunAppliesDefaultLimitWhenOmitted(t *testing.T) {
|
|||
Password: "secret",
|
||||
},
|
||||
}
|
||||
input := bytes.NewBufferString("{\"tool\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\"}}\n")
|
||||
input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_messages\",\"arguments\":{\"mailbox\":\"INBOX\"}}}\n")
|
||||
output := &bytes.Buffer{}
|
||||
runner := NewRunner(New(store, serviceStub{
|
||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
|
|
@ -483,6 +698,54 @@ func TestRunnerRunAppliesDefaultLimitWhenOmitted(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunRejectsRequestsBeforeInitialize(t *testing.T) {
|
||||
store := &storeStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
}
|
||||
input := bytes.NewBufferString("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n")
|
||||
output := &bytes.Buffer{}
|
||||
runner := NewRunner(New(store, serviceStub{
|
||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||
t.Fatal("ListMailboxes should not be called")
|
||||
return nil, nil
|
||||
},
|
||||
listMessages: func(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
||||
t.Fatal("ListMessages should not be called")
|
||||
return nil, nil
|
||||
},
|
||||
getMessage: func(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
|
||||
t.Fatal("GetMessage should not be called")
|
||||
return imapclient.Message{}, nil
|
||||
},
|
||||
}), input, output, &bytes.Buffer{})
|
||||
|
||||
if err := runner.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(output).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if response.JSONRPC != "2.0" || response.ID != 1 {
|
||||
t.Fatalf("unexpected response envelope: %#v", response)
|
||||
}
|
||||
if response.Error.Code == 0 || response.Error.Message == "" {
|
||||
t.Fatalf("expected protocol error before initialization, got %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunStopsWhenContextCanceledWhileWaitingForInput(t *testing.T) {
|
||||
store := &storeStub{
|
||||
credential: secretstore.Credential{
|
||||
|
|
|
|||
60
mcp.toml
Normal file
60
mcp.toml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
binary_name = "email-mcp"
|
||||
docs_url = "https://forge.lclr.dev/AI/email-mcp"
|
||||
|
||||
[update]
|
||||
source_name = "email-mcp releases"
|
||||
driver = "gitea"
|
||||
repository = "AI/email-mcp"
|
||||
base_url = "https://forge.lclr.dev"
|
||||
asset_name_template = "{binary}-{os}-{arch}{ext}"
|
||||
checksum_asset_name = "{asset}.sha256"
|
||||
checksum_required = true
|
||||
token_env_names = ["GITEA_TOKEN"]
|
||||
|
||||
[environment]
|
||||
known = [
|
||||
"EMAIL_MCP_PROFILE",
|
||||
"EMAIL_MCP_HOST",
|
||||
"EMAIL_MCP_USERNAME",
|
||||
"EMAIL_MCP_PASSWORD",
|
||||
"BW_SESSION",
|
||||
"MCP_FRAMEWORK_BITWARDEN_CACHE",
|
||||
"MCP_FRAMEWORK_BITWARDEN_DEBUG",
|
||||
]
|
||||
|
||||
[secret_store]
|
||||
backend_policy = "bitwarden-cli"
|
||||
|
||||
[profiles]
|
||||
default = "default"
|
||||
known = ["default"]
|
||||
|
||||
[bootstrap]
|
||||
description = "Local MCP server to read an IMAP mailbox."
|
||||
|
||||
[[config.fields]]
|
||||
name = "host"
|
||||
env = "EMAIL_MCP_HOST"
|
||||
config_key = "host"
|
||||
type = "string"
|
||||
label = "IMAP host"
|
||||
required = true
|
||||
sources = ["env", "config"]
|
||||
|
||||
[[config.fields]]
|
||||
name = "username"
|
||||
env = "EMAIL_MCP_USERNAME"
|
||||
config_key = "username"
|
||||
type = "string"
|
||||
label = "Username"
|
||||
required = true
|
||||
sources = ["env", "config"]
|
||||
|
||||
[[config.fields]]
|
||||
name = "password"
|
||||
env = "EMAIL_MCP_PASSWORD"
|
||||
secret_key_template = "imap-password/{profile}"
|
||||
type = "secret"
|
||||
label = "Password"
|
||||
required = true
|
||||
sources = ["env", "secret"]
|
||||
63
mcpgen/config.go
Normal file
63
mcpgen/config.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
)
|
||||
|
||||
type ConfigFlags struct {
|
||||
values map[string]*string
|
||||
}
|
||||
|
||||
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {
|
||||
if fs == nil {
|
||||
fs = flag.CommandLine
|
||||
}
|
||||
|
||||
flags := ConfigFlags{
|
||||
values: make(map[string]*string),
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
func ConfigFlagValues(flags ConfigFlags) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for name, value := range flags.values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if trimmed := strings.TrimSpace(*value); trimmed != "" {
|
||||
values[name] = trimmed
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {
|
||||
return []fwcli.FieldSpec{
|
||||
{Name: "host", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceConfig}, FlagKey: "", EnvKey: "EMAIL_MCP_HOST", ConfigKey: "host", SecretKey: replaceProfile("", profile)},
|
||||
{Name: "username", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceConfig}, FlagKey: "", EnvKey: "EMAIL_MCP_USERNAME", ConfigKey: "username", SecretKey: replaceProfile("", profile)},
|
||||
{Name: "password", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceSecret}, FlagKey: "", EnvKey: "EMAIL_MCP_PASSWORD", ConfigKey: "", SecretKey: replaceProfile("imap-password/{profile}", profile)},
|
||||
}
|
||||
}
|
||||
|
||||
func SetupFields(existing map[string]string) []fwcli.SetupField {
|
||||
if existing == nil {
|
||||
existing = map[string]string{}
|
||||
}
|
||||
|
||||
return []fwcli.SetupField{
|
||||
{Name: "host", Label: "IMAP host", Type: fwcli.SetupFieldString, Required: true, Default: "", ExistingSecret: existing["host"]},
|
||||
{Name: "username", Label: "Username", Type: fwcli.SetupFieldString, Required: true, Default: "", ExistingSecret: existing["username"]},
|
||||
{Name: "password", Label: "Password", Type: fwcli.SetupFieldSecret, Required: true, Default: "", ExistingSecret: existing["password"]},
|
||||
}
|
||||
}
|
||||
|
||||
func replaceProfile(value, profile string) string {
|
||||
return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile))
|
||||
}
|
||||
71
mcpgen/generated_test.go
Normal file
71
mcpgen/generated_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package mcpgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestGeneratedManifestFallsBackToEmbeddedRootManifest(t *testing.T) {
|
||||
previousDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd returned error: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("Chdir temp dir returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(previousDir); err != nil {
|
||||
t.Fatalf("restore working directory: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
manifestFile, source, err := LoadManifest(".")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
if source != fwmanifest.EmbeddedSource {
|
||||
t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource)
|
||||
}
|
||||
if manifestFile.BinaryName != "email-mcp" {
|
||||
t.Fatalf("BinaryName = %q, want email-mcp", manifestFile.BinaryName)
|
||||
}
|
||||
if len(manifestFile.Config.Fields) != 3 {
|
||||
t.Fatalf("config fields = %d, want 3", len(manifestFile.Config.Fields))
|
||||
}
|
||||
if manifestFile.SecretStore.BackendPolicy != "bitwarden-cli" {
|
||||
t.Fatalf("secret store backend policy = %q, want bitwarden-cli", manifestFile.SecretStore.BackendPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedConfigHelpersExposeIMAPResolutionFields(t *testing.T) {
|
||||
specs := ResolveFieldSpecs("work")
|
||||
if len(specs) != 3 {
|
||||
t.Fatalf("ResolveFieldSpecs returned %d fields, want 3", len(specs))
|
||||
}
|
||||
|
||||
if specs[0].Name != "host" || specs[0].EnvKey != "EMAIL_MCP_HOST" || specs[0].ConfigKey != "host" {
|
||||
t.Fatalf("host spec = %+v", specs[0])
|
||||
}
|
||||
if specs[1].Name != "username" || specs[1].EnvKey != "EMAIL_MCP_USERNAME" || specs[1].ConfigKey != "username" {
|
||||
t.Fatalf("username spec = %+v", specs[1])
|
||||
}
|
||||
if specs[2].Name != "password" || specs[2].EnvKey != "EMAIL_MCP_PASSWORD" || specs[2].SecretKey != "imap-password/work" {
|
||||
t.Fatalf("password spec = %+v", specs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedManifestPrefersRootFileWhenPresent(t *testing.T) {
|
||||
manifestFile, source, err := LoadManifest(".")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadManifest returned error: %v", err)
|
||||
}
|
||||
if source == fwmanifest.EmbeddedSource {
|
||||
t.Fatalf("source = %q, want root manifest path", source)
|
||||
}
|
||||
if manifestFile.BinaryName != "email-mcp" {
|
||||
t.Fatalf("BinaryName = %q, want email-mcp", manifestFile.BinaryName)
|
||||
}
|
||||
}
|
||||
11
mcpgen/manifest.go
Normal file
11
mcpgen/manifest.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
|
||||
const embeddedManifest = "binary_name = \"email-mcp\"\ndocs_url = \"https://gitea.lclr.dev/AI/email-mcp\"\n\n[update]\nsource_name = \"email-mcp releases\"\ndriver = \"gitea\"\nrepository = \"AI/email-mcp\"\nbase_url = \"https://gitea.lclr.dev\"\nasset_name_template = \"{binary}-{os}-{arch}{ext}\"\nchecksum_asset_name = \"{asset}.sha256\"\nchecksum_required = true\ntoken_env_names = [\"GITEA_TOKEN\"]\n\n[environment]\nknown = [\n \"EMAIL_MCP_PROFILE\",\n \"EMAIL_MCP_HOST\",\n \"EMAIL_MCP_USERNAME\",\n \"EMAIL_MCP_PASSWORD\",\n \"BW_SESSION\",\n \"MCP_FRAMEWORK_BITWARDEN_CACHE\",\n \"MCP_FRAMEWORK_BITWARDEN_DEBUG\",\n]\n\n[secret_store]\nbackend_policy = \"bitwarden-cli\"\n\n[profiles]\ndefault = \"default\"\nknown = [\"default\"]\n\n[bootstrap]\ndescription = \"Local MCP server to read an IMAP mailbox.\"\n\n[[config.fields]]\nname = \"host\"\nenv = \"EMAIL_MCP_HOST\"\nconfig_key = \"host\"\ntype = \"string\"\nlabel = \"IMAP host\"\nrequired = true\nsources = [\"env\", \"config\"]\n\n[[config.fields]]\nname = \"username\"\nenv = \"EMAIL_MCP_USERNAME\"\nconfig_key = \"username\"\ntype = \"string\"\nlabel = \"Username\"\nrequired = true\nsources = [\"env\", \"config\"]\n\n[[config.fields]]\nname = \"password\"\nenv = \"EMAIL_MCP_PASSWORD\"\nsecret_key_template = \"imap-password/{profile}\"\ntype = \"secret\"\nlabel = \"Password\"\nrequired = true\nsources = [\"env\", \"secret\"]\n"
|
||||
|
||||
func LoadManifest(startDir string) (fwmanifest.File, string, error) {
|
||||
return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)
|
||||
}
|
||||
27
mcpgen/metadata.go
Normal file
27
mcpgen/metadata.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
|
||||
const BinaryName = "email-mcp"
|
||||
const DefaultDescription = "Local MCP server to read an IMAP mailbox."
|
||||
const DocsURL = "https://gitea.lclr.dev/AI/email-mcp"
|
||||
|
||||
func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {
|
||||
manifestFile, source, err := LoadManifest(startDir)
|
||||
if err != nil {
|
||||
return fwmanifest.BootstrapMetadata{}, "", err
|
||||
}
|
||||
|
||||
return manifestFile.BootstrapInfo(), source, nil
|
||||
}
|
||||
|
||||
func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {
|
||||
manifestFile, source, err := LoadManifest(startDir)
|
||||
if err != nil {
|
||||
return fwmanifest.ScaffoldMetadata{}, "", err
|
||||
}
|
||||
|
||||
return manifestFile.ScaffoldInfo(), source, nil
|
||||
}
|
||||
91
mcpgen/secretstore.go
Normal file
91
mcpgen/secretstore.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type SecretStoreOptions struct {
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
BitwardenDebug bool
|
||||
DisableBitwardenCache bool
|
||||
Shell string
|
||||
ExecutableResolver fwsecretstore.ExecutableResolver
|
||||
}
|
||||
|
||||
func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {
|
||||
return fwsecretstore.OpenFromManifest(secretStoreOpenOptions(options))
|
||||
}
|
||||
|
||||
func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {
|
||||
return fwsecretstore.DescribeRuntime(secretStoreDescribeOptions(options))
|
||||
}
|
||||
|
||||
func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {
|
||||
return fwsecretstore.PreflightFromManifest(secretStoreDescribeOptions(options))
|
||||
}
|
||||
|
||||
func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromManifestOptions {
|
||||
return fwsecretstore.OpenFromManifestOptions{
|
||||
ServiceName: secretStoreServiceName(options),
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
DisableBitwardenCache: options.DisableBitwardenCache,
|
||||
Shell: options.Shell,
|
||||
ManifestLoader: LoadManifest,
|
||||
ExecutableResolver: options.ExecutableResolver,
|
||||
}
|
||||
}
|
||||
|
||||
func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.DescribeRuntimeOptions {
|
||||
return fwsecretstore.DescribeRuntimeOptions{
|
||||
ServiceName: secretStoreServiceName(options),
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
BitwardenDebug: options.BitwardenDebug,
|
||||
DisableBitwardenCache: options.DisableBitwardenCache,
|
||||
Shell: options.Shell,
|
||||
ManifestLoader: LoadManifest,
|
||||
ExecutableResolver: options.ExecutableResolver,
|
||||
}
|
||||
}
|
||||
|
||||
func secretStoreServiceName(options SecretStoreOptions) string {
|
||||
serviceName := strings.TrimSpace(options.ServiceName)
|
||||
if serviceName != "" {
|
||||
return serviceName
|
||||
}
|
||||
|
||||
startDir := "."
|
||||
executableResolver := options.ExecutableResolver
|
||||
if executableResolver == nil {
|
||||
executableResolver = os.Executable
|
||||
}
|
||||
if executablePath, err := executableResolver(); err == nil {
|
||||
if dir := strings.TrimSpace(filepath.Dir(strings.TrimSpace(executablePath))); dir != "" {
|
||||
startDir = dir
|
||||
}
|
||||
}
|
||||
|
||||
if manifestFile, _, err := LoadManifest(startDir); err == nil {
|
||||
if binaryName := strings.TrimSpace(manifestFile.BinaryName); binaryName != "" {
|
||||
return binaryName
|
||||
}
|
||||
}
|
||||
|
||||
return BinaryName
|
||||
}
|
||||
59
mcpgen/update.go
Normal file
59
mcpgen/update.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Code generated by mcp-framework generate. DO NOT EDIT.
|
||||
|
||||
package mcpgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
fwupdate "forge.lclr.dev/AI/mcp-framework/update"
|
||||
)
|
||||
|
||||
func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {
|
||||
return UpdateOptionsFrom(".", version, stdout)
|
||||
}
|
||||
|
||||
func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {
|
||||
manifestFile, _, err := LoadManifest(startDir)
|
||||
if err != nil {
|
||||
return fwupdate.Options{}, err
|
||||
}
|
||||
|
||||
binaryName := strings.TrimSpace(manifestFile.BinaryName)
|
||||
if binaryName == "" {
|
||||
binaryName = BinaryName
|
||||
}
|
||||
|
||||
return fwupdate.Options{
|
||||
CurrentVersion: version,
|
||||
Stdout: stdout,
|
||||
BinaryName: binaryName,
|
||||
ReleaseSource: manifestFile.Update.ReleaseSource(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {
|
||||
return RunUpdateFrom(ctx, args, ".", version, stdout)
|
||||
}
|
||||
|
||||
func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error {
|
||||
fs := flag.NewFlagSet("update", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("update does not accept positional arguments: %s", strings.Join(fs.Args(), ", "))
|
||||
}
|
||||
|
||||
options, err := UpdateOptionsFrom(startDir, version, stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fwupdate.Run(ctx, options)
|
||||
}
|
||||
Loading…
Reference in a new issue