Compare commits

...

48 commits
v1.0 ... main

Author SHA1 Message Date
0de2a2b351 chore: migrate forge URLs from gitea.lclr.dev to forge.lclr.dev
All checks were successful
Release / release (push) Successful in 26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:17:26 +02:00
72fb48f59e chore: upgrade framework to v1.13.0
All checks were successful
Release / release (push) Successful in 26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:02:38 +02:00
23fff88ccb refactor(doctor): drop redundant password check
All checks were successful
Release / release (push) Successful in 27s
Le check "password" ouvrait le store et lisait le secret indépendamment
du ConnectivityCheck, provoquant deux appels bw --version + bw status.
La connectivité échoue déjà si le mot de passe est inaccessible, donc
le check était redondant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:51:48 +02:00
235727106d chore: upgrade framework to v1.12.0, wire login, drop manifest glue
- Bump mcp-framework v1.10.0 → v1.12.0
- Wire Login hook via BitwardenLoginHandler (backend_policy = bitwarden-cli)
- Remove ManifestDir/ManifestValidator from config test: le manifest est
  un artefact de build, pas une contrainte runtime (per framework design)
- Drop doctorManifestDir(), validateManifestUpdate() and dead imports
- Update tests accordingly (5 checks instead of 6, remove manifest test)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:39:39 +02:00
1e0bdfc42d feat(install): unlock Bitwarden before setup
Calls `email-mcp login` before `email-mcp setup` so the secret store
is ready to persist the IMAP password.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:33:43 +02:00
51dda82cd0 chore: update release base URL to forge.lclr.dev 2026-05-11 12:21:55 +02:00
fbaea341c7 chore: upgrade framework to v1.10.0, drop login glue
All checks were successful
Release / release (push) Successful in 36s
login is now handled by default in the framework bootstrap when
Hooks.Login is nil.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:16:20 +02:00
ebd3a64522 feat: wire login hook for Bitwarden unlock
The framework v1.9.0 added a Login command/hook to authenticate and
unlock Bitwarden. App.runBootstrap was not registering it, causing
"command not configured: login".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:36:12 +02:00
801d7fbb95 fix: preserve bitwarden backend errors 2026-05-11 11:29:25 +02:00
fe9e70b61a fix: use bitwarden secret backend 2026-05-11 11:24:56 +02:00
be2b7e631b feat: upgrade mcp framework generation 2026-05-11 11:16:37 +02:00
be33b467a6 migrate gitea > forgejo 2026-05-11 11:06:48 +02:00
a87db60345 feat: upgrade framework to v1.4.2 2026-04-15 15:19:42 +02:00
9b90332b7c build: upgrade framework to v1.4.1 2026-04-15 11:31:25 +02:00
9c6f8b70d4 chore(deps): upgrade mcp-framework to v1.4.0-rc3 2026-04-15 11:05:19 +02:00
b6276776fd feat(install): chain MCP configuration after setup 2026-04-15 10:59:23 +02:00
678b966425 fix(install): guard tmp dir trap against unset variable 2026-04-15 10:47:34 +02:00
226d636ca2 fix(install): place claude --env after server name 2026-04-15 10:45:28 +02:00
a19b35cbea feat(install): add arrow-key menu navigation 2026-04-15 10:43:00 +02:00
21808ddce0 fix(install): keep release-based install in TUI wizard 2026-04-15 10:38:58 +02:00
ec3774138e feat(install): add TUI wizard with Claude and Codex apply flows 2026-04-15 10:35:41 +02:00
647a03a10c feat: upgrade framework to v1.4.0-rc1 with installer script 2026-04-15 10:19:42 +02:00
7315f2658c fix(cli): rely on framework doctor alias 2026-04-15 09:57:43 +02:00
46c94a1a12 chore(deps): bump mcp-framework to v1.3.2 2026-04-15 09:41:02 +02:00
ba42be8d77 refactor(cli): upgrade mcp-framework to v1.3.1 and remove glue code 2026-04-14 17:09:46 +02:00
thibaud-leclere
fcee5a0a36 refactor(cli): reduce framework command glue paths 2026-04-14 16:17:22 +02:00
thibaud-leclere
8c88084181 feat: align cli behaviors with mcp-framework rc3 2026-04-14 15:54:17 +02:00
thibaud-leclere
b32b6c8a55 build: upgrade framework to 1.3.0-rc3 2026-04-14 15:47:52 +02:00
thibaud-leclere
3ba3475753 feat(update): adopt framework rc2 release driver and checksum flow 2026-04-14 15:13:47 +02:00
thibaud-leclere
9b0bd7e175 build: updgrade framework to v1.3.0-rc2 2026-04-14 15:10:25 +02:00
thibaud-leclere
7998e049cb feat(cli): adopt framework v1.3 manifest metadata and config delete 2026-04-14 12:48:36 +02:00
thibaud-leclere
88818641e4 build: upgrade framework to v1.3.0-rc1 2026-04-14 12:38:27 +02:00
thibaud-leclere
781a5985ab feat: align CLI with mcp-framework v1.2.1 config commands 2026-04-14 11:21:01 +02:00
thibaud-leclere
8a448be942 chore: bump mcp-framework to v1.2.1 2026-04-14 11:15:29 +02:00
thibaud-leclere
ddecb1eb7f build: bump framework version to 1.2.0 2026-04-14 10:25:11 +02:00
thibaud-leclere
1da92a8240 feat(cli): adopt rc4 declarative setup engine 2026-04-14 10:13:39 +02:00
thibaud-leclere
8c79db73d7 feat: adopt rc3 credential resolution flow 2026-04-14 09:04:40 +02:00
thibaud-leclere
d1fd485fb2 feat: adopt mcp-framework rc2 bootstrap commands 2026-04-14 08:42:56 +02:00
thibaud-leclere
2aa5e92b50 feat: add doctor command 2026-04-13 22:33:37 +02:00
thibaud-leclere
460868f4de chore: remove replace 2026-04-13 20:16:42 +02:00
thibaud-leclere
15ea1e11ab feat: adopt mcp framework cli flow 2026-04-13 18:01:28 +02:00
thibaud-leclere
1dbb9e15d9 fix: broken workflow 2026-04-10 16:37:18 +02:00
thibaud-leclere
ffb28dbc52 fix: add required inputSchema to list_mailboxes tool
The MCP spec requires inputSchema on every tool. With the omitempty
JSON tag, the nil field was omitted entirely, causing clients to
reject the tools/list response and report 0 tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:15:36 +02:00
thibaud-leclere
f10b797640 fix: advertise MCP tools capability explicitly 2026-04-10 16:01:59 +02:00
thibaud-leclere
92fc30cb2d fix: tolerate unknown fields in MCP protocol messages and defer credential loading
Claude Code sends extra fields (e.g. "title") in initialize params that
caused the server to reject the request due to DisallowUnknownFields.
Use lenient JSON decoding for protocol messages while keeping strict
validation for tool arguments. Also defer KWallet credential loading
from server startup to tool invocation time, and negotiate protocol
versions per MCP spec instead of rejecting unknown ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:46:53 +02:00
thibaud-leclere
5f9e0e3a5a feat: add commit list to release notes 2026-04-10 15:44:04 +02:00
thibaud-leclere
1472d7a107 fix: support Claude Code MCP protocol version 2026-04-10 15:38:58 +02:00
thibaud-leclere
5dbc073e5c fix: implement MCP JSON-RPC protocol 2026-04-10 15:24:37 +02:00
27 changed files with 4177 additions and 902 deletions

View 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

View file

@ -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

View file

@ -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
View file

@ -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 sappuie 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/`)
- lauto-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 laccè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 laide 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 dune 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. lhôte IMAP
2. le nom dutilisateur
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 lenvironnement. `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 na été configuré pour le profil résolu, le serveur renvoie lerreur :
```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 nest 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 lupdate, 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 lexé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
```

View file

@ -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
View file

@ -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
View file

@ -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
View 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 "$@"

View file

@ -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

View file

@ -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)
}
}

View file

@ -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
View 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)
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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, &params); 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, &params); 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,
},
})
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}