Compare commits
2 Commits
5a0a1c03ac
...
6e58731a20
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e58731a20 | |||
| 56dd1f89bd |
+12
-6
@@ -49,12 +49,13 @@ services:
|
||||
- internal
|
||||
- backend # réseau Traefik
|
||||
|
||||
# Serveur MCP distant (Phase 5) — accessible publiquement sur un
|
||||
# sous-domaine dédié, SANS le middleware ipwhitelist-all : contrairement au
|
||||
# reste de l'app, ce service est volontairement ouvert à des utilisateurs
|
||||
# distants qui ne peuvent pas déployer le serveur MCP local. La clé API
|
||||
# (en-tête X-API-Key, propre à chaque utilisateur) est donc la SEULE
|
||||
# barrière d'accès — voir mcp-server/http-server.js.
|
||||
# Serveur MCP (Phase 5) — même image/code que le serveur utilisé en
|
||||
# développement local (mcp-server/server.js, npm run dev), déployé ici
|
||||
# accessible publiquement sur un sous-domaine dédié, SANS le middleware
|
||||
# ipwhitelist-all : contrairement au reste de l'app, ce service est
|
||||
# volontairement ouvert à des utilisateurs distants qui ne peuvent pas
|
||||
# faire tourner le serveur en local. La clé API (en-tête X-API-Key, propre
|
||||
# à chaque utilisateur) est donc la SEULE barrière d'accès.
|
||||
crowdlending-mcp:
|
||||
build:
|
||||
context: ./mcp-server
|
||||
@@ -66,6 +67,11 @@ services:
|
||||
PORT: 4100
|
||||
CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1
|
||||
MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net,localhost
|
||||
MCP_LABEL: prod
|
||||
# Explicitement désactivé : ce serveur sert des utilisateurs distants
|
||||
# non maîtrisés, l'outil de lecture d'URL arbitraire (SSRF) reste
|
||||
# réservé au développement local. Ne pas passer à true ici.
|
||||
MCP_ENABLE_FETCH_URL: "false"
|
||||
depends_on:
|
||||
crowdlending-backend:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1091,27 +1091,30 @@ function ApiKeysSection() {
|
||||
}
|
||||
|
||||
/* ── Serveur MCP ──────────────────────────────────────────────
|
||||
Guide pas-à-pas pour connecter Claude Desktop au serveur MCP local
|
||||
(mcp-server/ à la racine du projet). Le JSON de config est généré
|
||||
côté client à partir des champs ci-dessous ; la clé API saisie ici
|
||||
reste uniquement en mémoire du navigateur, elle n'est jamais envoyée
|
||||
au backend — seulement utilisée pour composer l'aperçu à copier. ── */
|
||||
Guide pas-à-pas pour connecter Claude Desktop au serveur MCP
|
||||
(mcp-server/ à la racine du projet, un seul modèle url+headers en HTTP,
|
||||
que ce soit en développement local ou déployé à distance en prod — voir
|
||||
mcp-server/README.md). Le JSON de config est généré côté client à partir
|
||||
des champs ci-dessous ; la clé API saisie ici reste uniquement en mémoire
|
||||
du navigateur, elle n'est jamais envoyée au backend — seulement utilisée
|
||||
pour composer l'aperçu à copier. ── */
|
||||
|
||||
/** Devine une URL d'API raisonnable selon l'environnement courant :
|
||||
* en dev (Vite sur :5173), le process Node du serveur MCP ne passe pas
|
||||
* par le proxy Vite, il faut donc viser directement le port du backend (4000).
|
||||
* En prod (nginx sert front + /api sur la même origine), l'origine courante convient. */
|
||||
function guessMcpApiUrl() {
|
||||
if (typeof window === 'undefined') return 'http://localhost:4000/api/v1';
|
||||
const { hostname, port, origin } = window.location;
|
||||
if (port === '5173') return `http://${hostname}:4000/api/v1`;
|
||||
return `${origin}/api/v1`;
|
||||
/** Devine une URL de serveur MCP raisonnable selon l'environnement courant :
|
||||
* en dev (Vite sur :5173 ou localhost), le serveur MCP tourne en local sur
|
||||
* son port par défaut (npm run dev, :4100). En prod, il s'agit du
|
||||
* sous-domaine dédié mcp.<domaine de l'app>, déjà déployé en continu. */
|
||||
function guessMcpUrl() {
|
||||
if (typeof window === 'undefined') return 'http://localhost:4100/mcp';
|
||||
const { hostname, port } = window.location;
|
||||
if (port === '5173' || hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return 'http://localhost:4100/mcp';
|
||||
}
|
||||
return `https://mcp.${hostname}/mcp`;
|
||||
}
|
||||
|
||||
/** Détecte automatiquement l'environnement ('dev' ou 'prod') à partir de
|
||||
* l'URL de l'API : localhost/IP locale ou nom d'hôte contenant "dev" →
|
||||
* dev, tout le reste → prod. Best-effort — reste modifiable manuellement
|
||||
* pour les cas particuliers (domaine de test qui ne contient pas "dev"...). */
|
||||
* l'URL du serveur MCP : localhost/IP locale → dev, tout le reste → prod.
|
||||
* Best-effort — reste modifiable manuellement pour les cas particuliers. */
|
||||
function detectLabelFromUrl(url) {
|
||||
if (!url) return 'prod';
|
||||
let hostname;
|
||||
@@ -1146,27 +1149,28 @@ function CopyBlock({ text }) {
|
||||
}
|
||||
|
||||
function McpServerSection({ goToApiKeys }) {
|
||||
const [mcpPath, setMcpPath] = useState('C:\\dev\\crowdlending-app\\mcp-server\\index.js');
|
||||
const [apiUrl, setApiUrl] = useState(guessMcpApiUrl());
|
||||
const [mcpUrl, setMcpUrl] = useState(guessMcpUrl());
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis apiUrl, sinon override manuel
|
||||
const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis mcpUrl, sinon override manuel
|
||||
|
||||
const detectedLabel = detectLabelFromUrl(apiUrl);
|
||||
const detectedLabel = detectLabelFromUrl(mcpUrl);
|
||||
const label = manualLabel ?? detectedLabel;
|
||||
const isLocal = label === 'dev';
|
||||
|
||||
const serverKey = `crowdlending-${label}`;
|
||||
const env = {
|
||||
CROWDLENDING_API_KEY: apiKey || '<VOTRE_CLE_API>',
|
||||
CROWDLENDING_API_URL: apiUrl,
|
||||
CROWDLENDING_LABEL: label,
|
||||
};
|
||||
// claude_desktop_config.json n'a pas de champ url/headers natif : on passe
|
||||
// par mcp-remote (https://github.com/geelen/mcp-remote), un pont stdio↔HTTP
|
||||
// que Claude Desktop lance comme n'importe quel serveur "command". La clé
|
||||
// API passe en variable d'environnement plutôt que directement dans args
|
||||
// (bug connu de Claude Desktop Windows qui tronque les valeurs à espaces).
|
||||
const envVarName = `${label.toUpperCase()}_API_KEY`;
|
||||
|
||||
const configJson = JSON.stringify({
|
||||
mcpServers: {
|
||||
[serverKey]: {
|
||||
command: 'node',
|
||||
args: [mcpPath],
|
||||
env,
|
||||
command: 'npx',
|
||||
args: ['-y', 'mcp-remote', mcpUrl, '--header', `X-API-Key:\${${envVarName}}`],
|
||||
env: { [envVarName]: apiKey || '<VOTRE_CLE_API>' },
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
@@ -1176,8 +1180,8 @@ function McpServerSection({ goToApiKeys }) {
|
||||
<h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
Permet à Claude Desktop (ou tout client MCP) de consulter votre portefeuille en lecture seule.
|
||||
Le serveur (dossier <code>mcp-server/</code> du projet) doit être installé sur cette machine
|
||||
(<code>npm install</code>) — voir <code>mcp-server/README.md</code> pour le détail.
|
||||
Un seul modèle de connexion, en développement local comme en production : une URL de serveur MCP
|
||||
et votre clé API personnelle — voir <code>mcp-server/README.md</code> pour le détail.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>1. Créer une clé API dédiée</h4>
|
||||
@@ -1194,12 +1198,8 @@ function McpServerSection({ goToApiKeys }) {
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>2. Renseigner les paramètres</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}>
|
||||
<div>
|
||||
<label>Chemin vers mcp-server/index.js</label>
|
||||
<input value={mcpPath} onChange={e => setMcpPath(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>URL de l'API</label>
|
||||
<input value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
|
||||
<label>URL du serveur MCP</label>
|
||||
<input value={mcpUrl} onChange={e => setMcpUrl(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Clé API</label>
|
||||
@@ -1218,7 +1218,7 @@ function McpServerSection({ goToApiKeys }) {
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
{manualLabel === null
|
||||
? <>déduit de l'URL de l'API ci-dessus (localhost / « dev » → dev, sinon prod)</>
|
||||
? <>déduit de l'URL ci-dessus (localhost → dev, sinon prod)</>
|
||||
: <>forcé manuellement</>}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1236,12 +1236,26 @@ function McpServerSection({ goToApiKeys }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
|
||||
{isLocal ? (
|
||||
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
En développement, ce serveur doit tourner sur votre machine pour que l'URL ci-dessus réponde :
|
||||
<code style={{ display: 'block', margin: '6px 0', padding: '6px 8px', borderRadius: 6, background: 'var(--surface-2, #f9fafb)' }}>
|
||||
cd mcp-server && npm install && npm run dev
|
||||
</code>
|
||||
comme pour le backend et le frontend. Laissez ce terminal ouvert tant que vous utilisez Claude Desktop.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
En production, le serveur tourne déjà en continu (service <code>crowdlending-mcp</code>) — aucune
|
||||
action nécessaire au-delà de renseigner l'URL et votre clé API.
|
||||
</p>
|
||||
)}
|
||||
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir dev et
|
||||
prod accessibles simultanément, répétez ces étapes une deuxième fois avec une URL d'API pointant
|
||||
vers l'autre environnement (et une clé API distincte) — l'étiquette et la clé de config
|
||||
(<code>{serverKey}</code> ci-dessous) s'ajustent automatiquement. Elle apparaît dans le titre et
|
||||
la description de chaque outil, pour que l'agent ne confonde jamais les deux portefeuilles.
|
||||
prod accessibles simultanément, répétez ces étapes une deuxième fois avec l'autre URL (et une clé
|
||||
API distincte) — la clé de config (<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement,
|
||||
Claude Desktop ne confondra jamais les deux portefeuilles.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4>
|
||||
@@ -1268,8 +1282,10 @@ function McpServerSection({ goToApiKeys }) {
|
||||
|
||||
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4>
|
||||
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||
Dans la liste des outils MCP de Claude Desktop, les 7 outils <code>crowdlending_*</code> doivent
|
||||
apparaître. Testez avec une question du type « Quel est mon encours de crowdlending actuellement ? ».
|
||||
Dans la liste des outils MCP de Claude Desktop, les outils <code>crowdlending_*</code> doivent
|
||||
apparaître (6 en production, 7 en développement local si <code>crowdlending_fetch_url</code> est
|
||||
activé — voir <code>mcp-server/README.md</code>). Testez avec une question du type « Quel est mon
|
||||
encours de crowdlending actuellement ? ».
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,4 +2,3 @@ node_modules
|
||||
npm-debug.log
|
||||
*.log
|
||||
README.md
|
||||
index.js
|
||||
|
||||
@@ -5,11 +5,11 @@ ENV NODE_ENV=production
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY tools.js http-server.js ./
|
||||
COPY tools.js server.js ./
|
||||
|
||||
EXPOSE 4100
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||
CMD node -e "fetch('http://localhost:4100/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
CMD ["node", "http-server.js"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
+127
-155
@@ -1,28 +1,20 @@
|
||||
# crowdlending-mcp-server
|
||||
|
||||
Serveur MCP pour le portefeuille de crowdlending. Il expose en lecture seule
|
||||
les données d'un investisseur (investissements, remboursements,
|
||||
dépôts/retraits, dashboard) à un client MCP — Claude Desktop, Claude Code,
|
||||
ou tout autre client compatible — en s'appuyant sur l'API publique `/api/v1`
|
||||
du backend.
|
||||
Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille de
|
||||
crowdlending. Il expose en lecture seule les données d'un investisseur
|
||||
(investissements, remboursements, dépôts/retraits, dashboard) à un client
|
||||
MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en
|
||||
s'appuyant sur l'API publique `/api/v1` du backend.
|
||||
|
||||
Il ne fait aucune écriture : toutes les modifications restent à faire dans
|
||||
l'app web.
|
||||
|
||||
Deux modes, deux fichiers :
|
||||
|
||||
- **Local (`index.js`, stdio)** — lancé comme process enfant par Claude
|
||||
Desktop sur votre propre machine. Nécessite Node.js et ce dépôt cloné en
|
||||
local. Inclut l'outil `crowdlending_fetch_url` (lecture de page web).
|
||||
- **Distant (`http-server.js`, HTTP)** — un service déployé une fois (par
|
||||
l'administrateur de l'instance) sur `mcp.crowdlending.croguennec.net`,
|
||||
accessible à n'importe quel utilisateur distant sans rien installer :
|
||||
juste une URL + sa clé API personnelle à coller dans son client MCP.
|
||||
N'inclut PAS `crowdlending_fetch_url` (voir plus bas).
|
||||
|
||||
Les deux partagent le même code pour les 6 outils de lecture de données
|
||||
(`tools.js`) — leur comportement ne peut donc pas diverger entre les deux
|
||||
modes.
|
||||
**Un seul serveur, un seul fichier (`server.js`)**, utilisé aussi bien en
|
||||
développement local (`npm run dev`, comme le backend et le frontend) qu'en
|
||||
production (service Docker `crowdlending-mcp`, derrière Traefik sur
|
||||
`mcp.crowdlending.croguennec.net`). Pas de distinction de code entre les
|
||||
deux — seule la configuration change (quelle API cibler, quels outils
|
||||
activer).
|
||||
|
||||
## Prérequis
|
||||
|
||||
@@ -31,184 +23,149 @@ modes.
|
||||
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre
|
||||
instance en production)
|
||||
|
||||
## Installation
|
||||
## Développement local
|
||||
|
||||
Comme pour `backend/` et `frontend/` :
|
||||
|
||||
```
|
||||
cd mcp-server
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Configuration
|
||||
`npm run dev` (comme `backend`) relance automatiquement le serveur à chaque
|
||||
modification de fichier (`node --watch`). Par défaut, il écoute sur
|
||||
`http://localhost:4100` et cible l'API locale (`http://localhost:4000/api/v1`).
|
||||
|
||||
Trois variables d'environnement :
|
||||
|
||||
| Variable | Obligatoire | Défaut | Exemple |
|
||||
|---|---|---|---|
|
||||
| `CROWDLENDING_API_KEY` | oui | — | `clk_live_...` |
|
||||
| `CROWDLENDING_API_URL` | non | `http://localhost:4000/api/v1` | `https://mon-domaine.fr/api/v1` |
|
||||
| `CROWDLENDING_LABEL` | non | — | `dev`, `prod` |
|
||||
|
||||
`CROWDLENDING_LABEL` sert uniquement à distinguer plusieurs instances
|
||||
connectées en même temps (voir [Faire tourner dev et prod en même
|
||||
temps](#faire-tourner-dev-et-prod-en-même-temps)) : il apparaît dans le nom
|
||||
du serveur, dans le titre de chaque outil (`[DEV]` / `[PROD]`) et dans la
|
||||
description (avec l'URL API ciblée), pour que l'agent — et vous — sachiez
|
||||
toujours quel environnement est interrogé.
|
||||
|
||||
## Utiliser avec Claude Desktop
|
||||
|
||||
Localisez le fichier de config Claude Desktop — le chemin diffère selon la
|
||||
provenance de l'installation Windows :
|
||||
|
||||
- **Installeur classique** (téléchargé depuis claude.ai) :
|
||||
`%APPDATA%\Claude\claude_desktop_config.json`
|
||||
- **Microsoft Store** : Windows redirige `%APPDATA%` vers un dossier virtualisé
|
||||
propre à l'app —
|
||||
`%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\claude_desktop_config.json`
|
||||
(le `<id>` est un identifiant généré, propre à votre installation).
|
||||
|
||||
Le plus fiable dans les deux cas : Réglages → Développeur → Serveurs MCP
|
||||
locaux → **Modifier la config**, qui ouvre directement le bon fichier quelle
|
||||
que soit la provenance de l'installation.
|
||||
|
||||
Ajoutez une entrée dans `mcpServers` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"crowdlending": {
|
||||
"command": "node",
|
||||
"args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"],
|
||||
"env": {
|
||||
"CROWDLENDING_API_KEY": "clk_live_...",
|
||||
"CROWDLENDING_API_URL": "http://localhost:4000/api/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Redémarrez Claude Desktop. L'icône 🔌 (ou le menu des outils MCP) doit
|
||||
afficher les 7 outils `crowdlending_*` ci-dessous.
|
||||
|
||||
Pour pointer vers votre instance de production plutôt que le backend local,
|
||||
changez uniquement `CROWDLENDING_API_URL` (et utilisez une clé API générée
|
||||
sur cette instance).
|
||||
|
||||
## Faire tourner dev et prod en même temps
|
||||
|
||||
Claude Desktop peut se connecter à plusieurs serveurs MCP simultanément : il
|
||||
suffit de déclarer deux entrées avec des clés distinctes dans `mcpServers`.
|
||||
Chaque outil est alors automatiquement rattaché à son serveur d'origine —
|
||||
pas de collision technique possible entre les deux, même si les noms
|
||||
d'outils (`crowdlending_get_dashboard`, etc.) sont identiques des deux côtés.
|
||||
Connectez ensuite Claude Desktop : Réglages → Développeur → Serveurs MCP
|
||||
locaux → **Modifier la config**, puis ajoutez dans `mcpServers` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"crowdlending-dev": {
|
||||
"command": "node",
|
||||
"args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"],
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"http://localhost:4100/mcp",
|
||||
"--header",
|
||||
"X-API-Key:${CROWDLENDING_API_KEY}"
|
||||
],
|
||||
"env": {
|
||||
"CROWDLENDING_API_KEY": "clk_live_...",
|
||||
"CROWDLENDING_API_URL": "http://localhost:4000/api/v1",
|
||||
"CROWDLENDING_LABEL": "dev"
|
||||
}
|
||||
},
|
||||
"crowdlending-prod": {
|
||||
"command": "node",
|
||||
"args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"],
|
||||
"env": {
|
||||
"CROWDLENDING_API_KEY": "clk_live_...",
|
||||
"CROWDLENDING_API_URL": "https://mon-domaine.fr/api/v1",
|
||||
"CROWDLENDING_LABEL": "prod"
|
||||
"CROWDLENDING_API_KEY": "clk_live_..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Utilisez deux clés API différentes (une par instance, générées séparément
|
||||
sur chaque backend) : ça permet de révoquer l'une sans affecter l'autre, et
|
||||
c'est cohérent avec le principe d'une clé par usage.
|
||||
**Important :** `claude_desktop_config.json` n'accepte que des entrées
|
||||
`command`/`args` — il n'existe pas de champ `url`/`headers` natif dans ce
|
||||
fichier (contrairement à d'autres clients MCP). Pour un serveur HTTP comme
|
||||
celui-ci, on passe donc par
|
||||
[`mcp-remote`](https://github.com/geelen/mcp-remote), un petit pont
|
||||
stdio↔HTTP officiel : Claude Desktop lance `npx mcp-remote` comme d'habitude,
|
||||
qui se charge de parler HTTP à notre serveur en coulisses, en-tête
|
||||
`X-API-Key` inclus. Aucune installation manuelle requise, `npx` le télécharge
|
||||
à la volée.
|
||||
|
||||
Avec `CROWDLENDING_LABEL` renseigné, chaque outil affiche son environnement
|
||||
dans son titre (ex. « Synthèse du portefeuille [PROD] ») et sa description
|
||||
se termine par la source exacte interrogée — de quoi lever toute ambiguïté
|
||||
si vous demandez « mon encours en prod » vs « mon encours en dev ».
|
||||
Notez l'absence d'espace autour du `:` dans `--header` : Claude Desktop
|
||||
(Windows) a un bug connu qui tronque les arguments contenant un espace — on
|
||||
passe donc la valeur réelle (avec l'espace éventuel) via une variable
|
||||
d'environnement dans `env` plutôt que directement dans `args`.
|
||||
|
||||
## Serveur distant — pour les utilisateurs sans installation locale
|
||||
Redémarrez Claude Desktop. Les outils `crowdlending_*` doivent apparaître
|
||||
(voir la liste plus bas — `crowdlending_fetch_url` en plus si activé, voir
|
||||
Configuration).
|
||||
|
||||
Si vous n'êtes pas sur la machine qui héberge ce dépôt (pas de Node.js, pas
|
||||
envie de cloner le repo), vous pouvez vous connecter directement au serveur
|
||||
MCP distant déployé sur `https://mcp.crowdlending.croguennec.net` — aucune
|
||||
installation nécessaire, juste votre clé API personnelle.
|
||||
## Configuration
|
||||
|
||||
**1. Créer votre clé API** — dans l'app, Mon compte → Clés API → Nouvelle
|
||||
clé (elle ne sera affichée qu'une seule fois, copiez-la).
|
||||
Variables d'environnement, toutes optionnelles sauf pour un déploiement
|
||||
distant réel (en local, les valeurs par défaut conviennent) :
|
||||
|
||||
**2. Ajouter le serveur dans Claude Desktop** — Réglages → Développeur →
|
||||
Serveurs MCP locaux → Modifier la config, puis ajoutez :
|
||||
| Variable | Défaut | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `4100` | Port d'écoute HTTP |
|
||||
| `CROWDLENDING_API_URL` | `http://localhost:4000/api/v1` | API v1 ciblée |
|
||||
| `MCP_LABEL` | — | Étiquette d'environnement (`dev`, `prod`...) — voir plus bas |
|
||||
| `MCP_ENABLE_FETCH_URL` | `false` | Active l'outil `crowdlending_fetch_url` (voir plus bas) |
|
||||
| `MCP_ALLOWED_HOSTS` | `mcp.crowdlending.croguennec.net,localhost` | En-têtes `Host` acceptés (protection anti DNS-rebinding) |
|
||||
|
||||
Aucune clé API fixe n'est configurée côté serveur, contrairement à une
|
||||
ancienne version qui utilisait le transport stdio : chaque session MCP lit
|
||||
sa propre clé dans l'en-tête `X-API-Key` de la requête qui l'initialise
|
||||
(transmis via `mcp-remote --header`, voir ci-dessus). Un même process peut
|
||||
donc servir plusieurs utilisateurs/sessions en parallèle sans jamais mélanger
|
||||
leurs données — voir le déploiement distant plus bas.
|
||||
|
||||
## Faire tourner dev et prod en même temps
|
||||
|
||||
Claude Desktop peut se connecter à plusieurs serveurs MCP simultanément : il
|
||||
suffit de déclarer deux entrées dans `mcpServers`, une par URL. Chaque outil
|
||||
est automatiquement rattaché à son serveur d'origine — pas de collision
|
||||
possible, même si les noms d'outils sont identiques des deux côtés.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"crowdlending-distant": {
|
||||
"url": "https://mcp.crowdlending.croguennec.net/mcp",
|
||||
"headers": {
|
||||
"X-API-Key": "clk_live_..."
|
||||
}
|
||||
"crowdlending-dev": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "http://localhost:4100/mcp", "--header", "X-API-Key:${DEV_API_KEY}"],
|
||||
"env": { "DEV_API_KEY": "clk_live_..." }
|
||||
},
|
||||
"crowdlending-prod": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "https://mcp.crowdlending.croguennec.net/mcp", "--header", "X-API-Key:${PROD_API_KEY}"],
|
||||
"env": { "PROD_API_KEY": "clk_live_..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Redémarrez Claude Desktop.** Les 6 outils `crowdlending_*` doivent
|
||||
apparaître (sans `crowdlending_fetch_url`, réservé au serveur local).
|
||||
Utilisez deux clés API différentes (une par instance) : ça permet de
|
||||
révoquer l'une sans affecter l'autre. Réglez `MCP_LABEL=dev` (côté serveur
|
||||
local, dans votre `.env` ou variable d'environnement au lancement) pour que
|
||||
le titre de chaque outil affiche `[DEV]` et lève toute ambiguïté — le
|
||||
serveur distant est déjà configuré avec `MCP_LABEL` correspondant en
|
||||
production.
|
||||
|
||||
### Différences avec le serveur local
|
||||
## Déploiement en production (Docker)
|
||||
|
||||
- **Pas de `crowdlending_fetch_url`** : lire une page web arbitraire pour un
|
||||
utilisateur tiers non maîtrisé augmenterait le risque SSRF sans bénéfice
|
||||
réel pour ce cas d'usage. Cet outil reste réservé au serveur local.
|
||||
- **Authentification par requête, pas par process** : le serveur local a une
|
||||
clé API fixée une fois pour toutes via une variable d'environnement — un
|
||||
process = un utilisateur. Le serveur distant sert plusieurs utilisateurs en
|
||||
parallèle : chaque session est créée à partir de la clé API envoyée dans
|
||||
l'en-tête `X-API-Key` de la requête qui l'initialise, puis liée à cette
|
||||
session uniquement. Deux utilisateurs ne partagent jamais de données.
|
||||
- **Exposé publiquement, sans la liste blanche d'IP** qui protège le reste de
|
||||
l'app (`ipwhitelist-all`) : la clé API est la seule barrière d'accès.
|
||||
Traitez-la comme un mot de passe — ne la partagez pas, révoquez-la
|
||||
immédiatement en cas de doute (Mon compte → Clés API).
|
||||
- **Limite de débit** : 60 requêtes/minute par adresse IP, au-delà l'API
|
||||
répond `429`.
|
||||
- **Sessions** : une session inactive plus de 30 minutes est fermée côté
|
||||
serveur (mémoire uniquement, aucune persistance) ; votre client MCP en
|
||||
recréera une automatiquement à la prochaine requête.
|
||||
Le service `crowdlending-mcp` du `docker-compose.yml` à la racine construit
|
||||
et lance ce même `server.js`, exposé via Traefik sur
|
||||
`mcp.crowdlending.croguennec.net` (certificat TLS automatique). Contrairement
|
||||
au reste de l'app, ce service n'a **pas** le middleware `ipwhitelist-all` :
|
||||
il est volontairement accessible depuis internet, pour des utilisateurs
|
||||
distants qui ne peuvent pas faire tourner le serveur en local — la clé API
|
||||
est donc la seule barrière d'accès. Traitez-la comme un mot de passe, et
|
||||
révoquez-la immédiatement en cas de doute (Mon compte → Clés API).
|
||||
|
||||
### Déploiement (administrateur de l'instance)
|
||||
|
||||
Le service `crowdlending-mcp` du `docker-compose.yml` construit et lance
|
||||
`http-server.js`, exposé via Traefik sur `mcp.crowdlending.croguennec.net`
|
||||
(certificat TLS automatique, même resolver que le reste de l'app). Un
|
||||
enregistrement DNS pour ce sous-domaine, pointant vers la même IP que
|
||||
Un enregistrement DNS pour ce sous-domaine, pointant vers la même IP que
|
||||
`crowdlending.croguennec.net`, est nécessaire avant le premier déploiement.
|
||||
Variables d'environnement du service : `CROWDLENDING_API_URL` (URL interne
|
||||
du backend sur le réseau Docker, déjà configurée) et `MCP_ALLOWED_HOSTS`
|
||||
(validation de l'en-tête `Host`, protection anti DNS-rebinding — doit
|
||||
correspondre exactement au(x) nom(s) de domaine exposé(s)).
|
||||
|
||||
`MCP_ENABLE_FETCH_URL` reste à `false` en production (voir docker-compose.yml)
|
||||
: cet outil lit une URL arbitraire fournie par l'appelant, acceptable pour
|
||||
un usage perso où vous seul détenez la clé, mais risqué (SSRF) face à des
|
||||
utilisateurs distants non maîtrisés.
|
||||
|
||||
Autres protections : limite de 60 requêtes/minute par IP (au-delà, `429`),
|
||||
et fermeture automatique des sessions inactives depuis plus de 30 minutes
|
||||
(mémoire uniquement, aucune persistance).
|
||||
|
||||
## Tester sans Claude Desktop
|
||||
|
||||
Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet
|
||||
de lister et appeler les outils depuis une interface web, sans configurer de
|
||||
client :
|
||||
de lister et appeler les outils depuis une interface web :
|
||||
|
||||
```
|
||||
CROWDLENDING_API_KEY=clk_live_... npm run inspect
|
||||
npm run inspect
|
||||
```
|
||||
|
||||
Dans l'interface, choisissez le transport **Streamable HTTP**, entrez
|
||||
l'URL (`http://localhost:4100/mcp` en dev) et ajoutez l'en-tête
|
||||
`X-API-Key` avec votre clé.
|
||||
|
||||
## Outils exposés
|
||||
|
||||
Tous en lecture seule (`readOnlyHint: true`) :
|
||||
@@ -221,10 +178,14 @@ Tous en lecture seule (`readOnlyHint: true`) :
|
||||
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
|
||||
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
|
||||
| `crowdlending_list_depots_retraits` | Historique des mouvements de cash |
|
||||
| `crowdlending_fetch_url` | *(serveur local uniquement)* Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous |
|
||||
| `crowdlending_fetch_url` | *(actif seulement si `MCP_ENABLE_FETCH_URL=true`)* Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous |
|
||||
|
||||
## Lire une annonce de projet (`crowdlending_fetch_url`)
|
||||
|
||||
Désactivé par défaut — à activer avec `MCP_ENABLE_FETCH_URL=true`,
|
||||
typiquement en développement local uniquement (voir Configuration et
|
||||
Sécurité plus haut).
|
||||
|
||||
Cet outil récupère une page web et en extrait le contenu lisible (titre +
|
||||
texte principal) via [Readability](https://github.com/mozilla/readability),
|
||||
la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux
|
||||
@@ -249,10 +210,21 @@ Garde-fous :
|
||||
|
||||
## Dépannage
|
||||
|
||||
- **`ERREUR : CROWDLENDING_API_KEY est requise`** — la variable d'env n'est
|
||||
pas transmise. Vérifiez la section `env` de votre config MCP.
|
||||
- **`En-tête X-API-Key manquant`** — vérifiez l'argument `--header` et la
|
||||
variable d'environnement correspondante dans votre config Claude Desktop.
|
||||
- **« Certains serveurs MCP n'ont pas pu être chargés » / entrée ignorée** —
|
||||
votre config utilise un bloc `url`/`headers` directement, non supporté par
|
||||
`claude_desktop_config.json` (voir Développement local plus haut) : il faut
|
||||
passer par `command: "npx"` + `mcp-remote`.
|
||||
- **`Clé API invalide ou révoquée`** — régénérez une clé dans Mon compte →
|
||||
Clés API et mettez à jour la config.
|
||||
- **`Impossible de joindre l'API`** — le backend n'est pas démarré, ou
|
||||
`CROWDLENDING_API_URL` pointe au mauvais endroit (vérifiez le port et le
|
||||
suffixe `/api/v1`).
|
||||
- **`Invalid Host` (403)** — l'en-tête `Host` de la requête ne correspond à
|
||||
aucune valeur de `MCP_ALLOWED_HOSTS`. En local, vérifiez que vous appelez
|
||||
bien `localhost:4100` (pas `127.0.0.1:4100`, absent de la liste par
|
||||
défaut — ajoutez-le à `MCP_ALLOWED_HOSTS` si besoin).
|
||||
- **Session `404` après une longue pause** — la session a expiré après 30
|
||||
minutes d'inactivité, votre client MCP doit s'y reconnecter (généralement
|
||||
automatique).
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Serveur MCP local (stdio) — Crowdlending Tracker
|
||||
*
|
||||
* Expose en lecture seule le portefeuille de crowdlending (investissements,
|
||||
* remboursements, dépôts/retraits, dashboard) à un client MCP (Claude Desktop,
|
||||
* Claude Code...) via l'API publique /api/v1 du backend.
|
||||
*
|
||||
* Authentification : clé API générée dans l'app (Mon compte → Clés API),
|
||||
* transmise ici via la variable d'environnement CROWDLENDING_API_KEY.
|
||||
* La clé est scopée à un seul investisseur — ce serveur ne voit donc que
|
||||
* les données de cet investisseur, jamais l'ensemble du portefeuille famille.
|
||||
*
|
||||
* IMPORTANT : ce process communique avec le client MCP via stdout (protocole
|
||||
* JSON-RPC). Ne jamais utiliser console.log ici — uniquement console.error
|
||||
* pour les logs de diagnostic (redirigés vers stderr, invisibles du protocole).
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import { registerDataTools, toolError } from './tools.js';
|
||||
|
||||
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, '');
|
||||
const API_KEY = process.env.CROWDLENDING_API_KEY;
|
||||
|
||||
// Étiquette facultative pour distinguer plusieurs instances connectées en
|
||||
// même temps à Claude Desktop (ex. une pour le backend de dev, une pour la
|
||||
// prod). Sans elle, on peut toujours faire tourner les deux : Claude Desktop
|
||||
// namespace déjà chaque outil par le nom du serveur (clé du bloc mcpServers
|
||||
// dans claude_desktop_config.json), donc pas de collision technique. Le
|
||||
// LABEL sert surtout à ce que l'agent (et vous) voyiez clairement, dans le
|
||||
// titre et la description de chaque outil, quel environnement est visé.
|
||||
const LABEL = (process.env.CROWDLENDING_LABEL || '').trim();
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error('ERREUR : la variable d\'environnement CROWDLENDING_API_KEY est requise.');
|
||||
console.error('Générez une clé dans l\'app : Mon compte → Clés API.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ── Client API partagé ───────────────────────────────────────────────── */
|
||||
async function apiGet(path, params) {
|
||||
const url = new URL(API_BASE + path);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
headers: { 'X-API-Key': API_KEY, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Impossible de joindre l'API (${API_BASE}) : ${e.message}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = (body && body.error) || res.statusText || 'Requête échouée';
|
||||
if (res.status === 401) throw new Error(`Clé API invalide ou révoquée (${msg}). Générez-en une nouvelle dans Mon compte → Clés API.`);
|
||||
if (res.status === 404) throw new Error(`Ressource introuvable : ${msg}`);
|
||||
throw new Error(`Erreur API (${res.status}) : ${msg}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/* ── Serveur MCP ──────────────────────────────────────────────────────── */
|
||||
const server = new McpServer({
|
||||
name: 'crowdlending-mcp-server' + (LABEL ? `-${LABEL}` : ''),
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
const READ_ONLY_ANNOTATIONS = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
};
|
||||
|
||||
/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */
|
||||
const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title;
|
||||
|
||||
/** Ajoute la source (URL API + libellé) en fin de description, pour que
|
||||
* l'agent distingue sans ambiguïté deux instances connectées en parallèle. */
|
||||
const withSource = (description) =>
|
||||
`${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`;
|
||||
|
||||
// Les 6 outils de lecture de données (investisseur, dashboard, investissements,
|
||||
// remboursements, dépôts/retraits) sont définis dans tools.js, partagés avec
|
||||
// le serveur HTTP distant — voir ce fichier pour leur documentation.
|
||||
registerDataTools(server, { apiGet, withLabel, withSource });
|
||||
|
||||
/* ── Outil fetch_url (Phase 3) ────────────────────────────────────────────
|
||||
Récupère une page web (annonce de projet sur une plateforme, par ex.) et en
|
||||
extrait le contenu lisible (titre + texte, sans menus/scripts/pubs) via
|
||||
Readability — la même librairie que le mode lecture de Firefox. L'outil ne
|
||||
fait AUCUNE extraction métier (pas de tentative de deviner taux/montant/
|
||||
échéance côté serveur) : il fournit le texte propre, et c'est à l'agent
|
||||
d'en extraire les informations pertinentes dans la conversation, puis de
|
||||
les proposer à l'utilisateur pour confirmation. Cohérent avec le reste du
|
||||
serveur : aucune écriture, la création d'un investissement reste un geste
|
||||
manuel dans l'app. ────────────────────────────────────────────────────── */
|
||||
|
||||
const CHARACTER_LIMIT = 8000; // évite de saturer le contexte de l'agent sur une page très longue
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
const FETCH_USER_AGENT = 'Mozilla/5.0 (compatible; CrowdlendingMcpServer/0.1; +local-tool)';
|
||||
|
||||
/** Garde-fou basique contre le SSRF : un outil qui prend une URL arbitraire
|
||||
* en entrée ne doit pas pouvoir taper sur le réseau local de l'utilisateur
|
||||
* (y compris son propre backend). Vérif sur le nom d'hôte littéral — ne
|
||||
* résout pas le DNS, donc pas une protection anti-rebinding DNS complète,
|
||||
* mais bloque les cas évidents (localhost, IP privées écrites en clair). */
|
||||
function isBlockedHost(hostname) {
|
||||
const h = hostname.toLowerCase();
|
||||
if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return true;
|
||||
const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4) {
|
||||
const [a, b] = ipv4.slice(1).map(Number);
|
||||
if (a === 127 || a === 10 || a === 0) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
server.registerTool(
|
||||
'crowdlending_fetch_url',
|
||||
{
|
||||
title: 'Lire une page web',
|
||||
description:
|
||||
"Récupère une page web (ex. annonce d'un projet sur une plateforme de " +
|
||||
"crowdlending) et en extrait le contenu lisible (titre + texte principal, " +
|
||||
"débarrassé du menu/CSS/pubs). Ne fait AUCUNE écriture et ne crée rien " +
|
||||
"dans l'app — l'agent doit extraire lui-même les informations utiles du " +
|
||||
"texte retourné (taux, montant, durée, émetteur...) et les proposer à " +
|
||||
"l'utilisateur pour confirmation avant toute saisie manuelle dans l'app " +
|
||||
"(l'API n'a pas de capacité d'écriture). Le texte est tronqué à " +
|
||||
`${CHARACTER_LIMIT} caractères sur les pages très longues.`,
|
||||
inputSchema: {
|
||||
url: z.string().url().describe("URL de la page à lire (http/https uniquement)"),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
async ({ url }) => {
|
||||
try {
|
||||
let parsed;
|
||||
try { parsed = new URL(url); }
|
||||
catch { throw new Error(`URL invalide : ${url}`); }
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new Error(`Protocole non autorisé (${parsed.protocol}) — http/https uniquement.`);
|
||||
}
|
||||
if (isBlockedHost(parsed.hostname)) {
|
||||
throw new Error(`Hôte non autorisé (${parsed.hostname}) — cet outil ne peut pas cibler le réseau local.`);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(parsed, {
|
||||
headers: {
|
||||
'User-Agent': FETCH_USER_AGENT,
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9',
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
// Node/undici masque souvent la vraie cause derrière un message générique
|
||||
// "fetch failed" — la cause réelle (DNS, TLS, connexion refusée...) est
|
||||
// dans e.cause, à remonter explicitement pour un diagnostic utile.
|
||||
const cause = e.cause ? ` (${e.cause.code || e.cause.message || e.cause})` : '';
|
||||
throw new Error(`Impossible de récupérer la page : ${e.message}${cause}`);
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`La page a répondu avec le statut ${res.status} ${res.statusText}`.trim());
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (!contentType.includes('html')) {
|
||||
throw new Error(`Contenu non HTML (${contentType || 'type inconnu'}) — cet outil ne lit que des pages web.`);
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
const dom = new JSDOM(html, { url: parsed.toString() });
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
|
||||
const title = article?.title || dom.window.document.title || null;
|
||||
let text = (article?.textContent || dom.window.document.body?.textContent || '')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
const fullLength = text.length;
|
||||
let truncated = false;
|
||||
if (text.length > CHARACTER_LIMIT) {
|
||||
text = text.slice(0, CHARACTER_LIMIT);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
if (!text) throw new Error("Aucun contenu lisible n'a pu être extrait de cette page.");
|
||||
|
||||
const output = {
|
||||
url: parsed.toString(),
|
||||
title,
|
||||
site_name: article?.siteName || null,
|
||||
excerpt: article?.excerpt || null,
|
||||
text,
|
||||
length: fullLength,
|
||||
truncated,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
|
||||
structuredContent: output,
|
||||
};
|
||||
} catch (e) { return toolError(e); }
|
||||
},
|
||||
);
|
||||
|
||||
/* ── Démarrage ────────────────────────────────────────────────────────── */
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error(`[crowdlending-mcp-server${LABEL ? `-${LABEL}` : ''}] connecté — API cible : ${API_BASE}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[crowdlending-mcp-server] erreur fatale :', e);
|
||||
process.exit(1);
|
||||
});
|
||||
Generated
-3
@@ -15,9 +15,6 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"bin": {
|
||||
"crowdlending-mcp-server": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
{
|
||||
"name": "crowdlending-mcp-server",
|
||||
"version": "0.2.0",
|
||||
"description": "Serveur MCP pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...). Deux modes : local (stdio, index.js) et distant (HTTP, http-server.js).",
|
||||
"description": "Serveur MCP (HTTP) pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...). Même serveur en développement local et en production distante.",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"bin": {
|
||||
"crowdlending-mcp-server": "./index.js"
|
||||
},
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"start:http": "node http-server.js",
|
||||
"inspect": "npx @modelcontextprotocol/inspector node index.js"
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"inspect": "npx @modelcontextprotocol/inspector"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Serveur MCP distant (HTTP, Streamable HTTP transport) — Crowdlending Tracker
|
||||
* Serveur MCP (HTTP, Streamable HTTP transport) — Crowdlending Tracker
|
||||
*
|
||||
* Variante "réseau" du serveur local stdio (index.js) : mêmes outils de
|
||||
* lecture (voir tools.js), mais accessible via une URL publique plutôt que
|
||||
* comme process enfant sur la machine de l'utilisateur. Pensé pour les
|
||||
* utilisateurs distants qui ne peuvent/veulent pas installer Node.js et
|
||||
* cloner ce dépôt en local — ils n'ont qu'à ajouter une URL + leur clé API
|
||||
* personnelle dans la config de leur client MCP (Claude Desktop, etc.).
|
||||
* Point d'entrée unique, que ce soit en développement local (`npm run dev`,
|
||||
* comme le backend et le frontend) ou déployé à distance en production
|
||||
* (mcp.crowdlending.croguennec.net, service `crowdlending-mcp` du
|
||||
* docker-compose). Un seul modèle de transport, un seul fichier à
|
||||
* maintenir : les outils eux-mêmes vivent dans tools.js.
|
||||
*
|
||||
* DIFFÉRENCE STRUCTURELLE IMPORTANTE avec index.js : le serveur stdio est
|
||||
* lancé une fois par utilisateur, avec UNE clé API fixée par variable
|
||||
* d'environnement pour toute la durée du process. Ici, un seul process sert
|
||||
* potentiellement PLUSIEURS utilisateurs distants en parallèle — il n'y a
|
||||
* donc AUCUNE clé API fixe côté serveur. Chaque session MCP est initialisée
|
||||
* à partir de la clé API fournie dans l'en-tête `X-API-Key` de la requête
|
||||
* HTTP qui l'a créée ; cette clé est ensuite fermée dans le "closure" des
|
||||
* outils de CETTE session uniquement (voir createSession ci-dessous). Deux
|
||||
* utilisateurs distants ne partagent jamais d'état ni de données.
|
||||
* AUTHENTIFICATION : aucune clé API fixe côté serveur, contrairement à
|
||||
* l'ancien serveur stdio. Un même process peut servir plusieurs
|
||||
* utilisateurs/sessions en parallèle (typiquement un seul en dev local,
|
||||
* potentiellement plusieurs en déploiement distant) — chaque session MCP est
|
||||
* initialisée à partir de la clé API fournie dans l'en-tête `X-API-Key` de
|
||||
* la requête HTTP qui l'a créée, puis cette clé est fermée dans le closure
|
||||
* des outils de CETTE session uniquement (voir createSession). Deux sessions
|
||||
* ne partagent jamais d'état ni de données.
|
||||
*
|
||||
* `crowdlending_fetch_url` n'est PAS exposé ici (voir tools.js pour le
|
||||
* détail) : réservé au serveur local, pour limiter le risque SSRF envers
|
||||
* des utilisateurs tiers non maîtrisés.
|
||||
*
|
||||
* Sécurité réseau : ce service est prévu pour être exposé directement sur
|
||||
* internet (sous-domaine dédié, sans la liste blanche d'IP qui protège le
|
||||
* reste de l'app) — la clé API est donc la SEULE barrière. Voir le
|
||||
* middleware `requireApiKeyHeader` et la validation du Host (protection
|
||||
* anti DNS-rebinding) plus bas.
|
||||
* Connexion depuis Claude Desktop (dev comme prod) :
|
||||
* { "url": "http://localhost:4100/mcp", "headers": { "X-API-Key": "..." } }
|
||||
* (remplacer l'URL par https://mcp.crowdlending.croguennec.net/mcp pour la
|
||||
* prod). Voir README.md pour le détail.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -36,28 +29,49 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { registerDataTools, toolError } from './tools.js';
|
||||
import { registerDataTools, registerFetchUrlTool } from './tools.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 4100);
|
||||
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://crowdlending-backend:4000/api/v1').replace(/\/$/, '');
|
||||
// Nom(s) d'hôte public(s) attendus dans l'en-tête Host — protection anti
|
||||
// DNS-rebinding. Séparés par des virgules si plusieurs (ex. dev + prod).
|
||||
// `localhost` est inclus par défaut pour que le HEALTHCHECK Docker (qui
|
||||
// interroge http://localhost:4100/health depuis l'intérieur du container)
|
||||
// ne soit pas lui-même bloqué par ce middleware — sans risque côté externe,
|
||||
// puisque Traefik ne route déjà vers ce service que les requêtes portant le
|
||||
// Host public configuré sur son router (une requête Host: localhost envoyée
|
||||
// depuis l'extérieur n'atteint jamais ce container).
|
||||
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, '');
|
||||
|
||||
// Étiquette facultative pour distinguer plusieurs instances connectées en
|
||||
// même temps à Claude Desktop (ex. dev local + prod distante). Affiche
|
||||
// "[DEV]"/"[PROD]" dans le titre de chaque outil et la source exacte en fin
|
||||
// de description — comme l'ancien CROWDLENDING_LABEL du serveur stdio.
|
||||
const LABEL = (process.env.MCP_LABEL || '').trim();
|
||||
|
||||
// Désactivé par défaut : cet outil lit une URL arbitraire fournie par
|
||||
// l'appelant, sûr pour un usage perso (vous seul avez la clé API et
|
||||
// n'atteignez que ce process) mais risqué si le serveur est exposé à des
|
||||
// tiers non maîtrisés (SSRF). À activer explicitement pour le développement
|
||||
// local — jamais sur le déploiement distant public (voir docker-compose.yml).
|
||||
const ENABLE_FETCH_URL = ['1', 'true', 'yes'].includes((process.env.MCP_ENABLE_FETCH_URL || '').toLowerCase());
|
||||
|
||||
// Nom(s) d'hôte attendus dans l'en-tête Host — protection anti DNS-rebinding.
|
||||
// `localhost` est inclus par défaut : nécessaire en développement local
|
||||
// (connexion directe à localhost:4100) et pour que le HEALTHCHECK Docker
|
||||
// (qui interroge http://localhost:4100/health depuis l'intérieur du
|
||||
// container) ne soit pas lui-même bloqué. Sans risque côté externe en
|
||||
// production : Traefik ne route vers ce service que les requêtes portant le
|
||||
// Host public configuré sur son router.
|
||||
const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net,localhost')
|
||||
.split(',').map((h) => h.trim()).filter(Boolean);
|
||||
|
||||
// Durée d'inactivité au-delà de laquelle une session orpheline est fermée
|
||||
// (client parti sans DELETE explicite — évite une fuite mémoire lente).
|
||||
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
console.error(`[crowdlending-mcp-remote] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}`);
|
||||
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}, fetch_url : ${ENABLE_FETCH_URL ? 'activé' : 'désactivé'}`);
|
||||
|
||||
/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */
|
||||
const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title;
|
||||
|
||||
/** Ajoute la source (URL API + libellé) en fin de description. */
|
||||
const withSource = (description) =>
|
||||
`${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`;
|
||||
|
||||
/* ── Client API : une closure par session, liée à LA clé API de cette
|
||||
session (jamais un module-level constant, contrairement à index.js). ── */
|
||||
session (jamais un module-level constant). ── */
|
||||
function makeApiGet(apiKey) {
|
||||
return async function apiGet(path, params) {
|
||||
const url = new URL(API_BASE + path);
|
||||
@@ -104,7 +118,7 @@ setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, s] of sessions.entries()) {
|
||||
if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) {
|
||||
console.error(`[crowdlending-mcp-remote] session ${sessionId} inactive depuis plus de 30 min, fermeture.`);
|
||||
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] session ${sessionId} inactive depuis plus de 30 min, fermeture.`);
|
||||
s.transport.close();
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
@@ -113,11 +127,9 @@ setInterval(() => {
|
||||
|
||||
/** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */
|
||||
async function createSession(apiKey) {
|
||||
const server = new McpServer({ name: 'crowdlending-mcp-server-remote', version: '0.1.0' });
|
||||
registerDataTools(server, {
|
||||
apiGet: makeApiGet(apiKey),
|
||||
withSource: (description) => `${description}\n\nSource de données : serveur MCP distant (mcp.crowdlending.croguennec.net)`,
|
||||
});
|
||||
const server = new McpServer({ name: 'crowdlending-mcp' + (LABEL ? `-${LABEL}` : ''), version: '0.2.0' });
|
||||
registerDataTools(server, { apiGet: makeApiGet(apiKey), withLabel, withSource });
|
||||
if (ENABLE_FETCH_URL) registerFetchUrlTool(server);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
@@ -137,8 +149,7 @@ async function createSession(apiKey) {
|
||||
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS });
|
||||
|
||||
// Limite basique anti-abus : une clé compromise ou un client buggé ne doit
|
||||
// pas pouvoir marteler l'API backend sans frein. Comptabilisé par IP (la
|
||||
// clé API n'est lue qu'après ce middleware).
|
||||
// pas pouvoir marteler l'API backend sans frein.
|
||||
app.use(rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 60,
|
||||
@@ -186,7 +197,7 @@ app.post('/mcp', async (req, res) => {
|
||||
const transport = await createSession(apiKey);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (e) {
|
||||
console.error('[crowdlending-mcp-remote] erreur /mcp POST :', e);
|
||||
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] erreur /mcp POST :`, e);
|
||||
if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
|
||||
}
|
||||
});
|
||||
@@ -209,5 +220,5 @@ app.get('/mcp', handleExistingSession);
|
||||
app.delete('/mcp', handleExistingSession);
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.error(`[crowdlending-mcp-remote] à l'écoute sur le port ${PORT}`);
|
||||
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] à l'écoute sur le port ${PORT}`);
|
||||
});
|
||||
+154
-12
@@ -1,19 +1,26 @@
|
||||
/**
|
||||
* Outils MCP de lecture de données — partagés entre le serveur local (stdio,
|
||||
* index.js) et le serveur distant (HTTP, http-server.js).
|
||||
* Outils MCP — partagés par le serveur unique (server.js, HTTP), que ce soit
|
||||
* en développement local (npm run dev, localhost:4100) ou déployé à distance
|
||||
* (mcp.crowdlending.croguennec.net).
|
||||
*
|
||||
* Ce module ne contient AUCUNE logique de transport ni d'authentification :
|
||||
* il reçoit un `apiGet(path, params)` déjà prêt à l'emploi (déjà lié à une
|
||||
* clé API et une base URL précises) et enregistre les 6 outils de lecture
|
||||
* sur le `McpServer` fourni. Ainsi, le comportement des outils ne peut pas
|
||||
* diverger entre les deux serveurs — un seul endroit à maintenir.
|
||||
* clé API et une base URL précises) et enregistre les outils sur le
|
||||
* `McpServer` fourni. Ainsi, le comportement des outils ne peut pas diverger
|
||||
* selon l'environnement — un seul endroit à maintenir.
|
||||
*
|
||||
* `crowdlending_fetch_url` n'est PAS ici : il reste réservé au serveur local
|
||||
* (voir index.js) — l'exposer à des utilisateurs distants tiers via le
|
||||
* serveur HTTP augmenterait le risque SSRF sans bénéfice pour ce cas d'usage.
|
||||
* `registerFetchUrlTool` est séparée de `registerDataTools` et enregistrée
|
||||
* de façon CONDITIONNELLE par server.js (variable d'env
|
||||
* `MCP_ENABLE_FETCH_URL`) : cet outil lit une URL arbitraire fournie par
|
||||
* l'appelant, ce qui est sûr pour un usage perso (vous seul pouvez y
|
||||
* accéder) mais présente un risque SSRF si exposé à des utilisateurs
|
||||
* distants non maîtrisés — désactivé par défaut, à activer explicitement
|
||||
* pour le développement local.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
|
||||
const READ_ONLY_ANNOTATIONS = {
|
||||
readOnlyHint: true,
|
||||
@@ -170,13 +177,148 @@ function toolResult(data) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire.
|
||||
* Exporté pour être réutilisé tel quel par le serveur HTTP distant (mêmes
|
||||
* messages d'erreur que le serveur local, pour ne pas dérouter l'agent
|
||||
* selon le transport utilisé). */
|
||||
/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. */
|
||||
export function toolError(e) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Erreur : ${e.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Outil fetch_url (Phase 3) — enregistrement conditionnel ─────────────
|
||||
Récupère une page web (annonce de projet sur une plateforme, par ex.) et en
|
||||
extrait le contenu lisible (titre + texte, sans menus/scripts/pubs) via
|
||||
Readability — la même librairie que le mode lecture de Firefox. L'outil ne
|
||||
fait AUCUNE extraction métier (pas de tentative de deviner taux/montant/
|
||||
échéance côté serveur) : il fournit le texte propre, et c'est à l'agent
|
||||
d'en extraire les informations pertinentes dans la conversation, puis de
|
||||
les proposer à l'utilisateur pour confirmation. Cohérent avec le reste du
|
||||
serveur : aucune écriture, la création d'un investissement reste un geste
|
||||
manuel dans l'app. ────────────────────────────────────────────────────── */
|
||||
|
||||
const CHARACTER_LIMIT = 8000; // évite de saturer le contexte de l'agent sur une page très longue
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
const FETCH_USER_AGENT = 'Mozilla/5.0 (compatible; CrowdlendingMcpServer/0.2; +local-tool)';
|
||||
|
||||
/** Garde-fou basique contre le SSRF : un outil qui prend une URL arbitraire
|
||||
* en entrée ne doit pas pouvoir taper sur le réseau local de l'utilisateur
|
||||
* (y compris son propre backend). Vérif sur le nom d'hôte littéral — ne
|
||||
* résout pas le DNS, donc pas une protection anti-rebinding DNS complète,
|
||||
* mais bloque les cas évidents (localhost, IP privées écrites en clair). */
|
||||
function isBlockedHost(hostname) {
|
||||
const h = hostname.toLowerCase();
|
||||
if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return true;
|
||||
const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4) {
|
||||
const [a, b] = ipv4.slice(1).map(Number);
|
||||
if (a === 127 || a === 10 || a === 0) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Enregistre `crowdlending_fetch_url` sur `server`. À n'appeler que si
|
||||
* `MCP_ENABLE_FETCH_URL` est activé côté server.js — voir note en tête de
|
||||
* fichier. */
|
||||
export function registerFetchUrlTool(server) {
|
||||
server.registerTool(
|
||||
'crowdlending_fetch_url',
|
||||
{
|
||||
title: 'Lire une page web',
|
||||
description:
|
||||
"Récupère une page web (ex. annonce d'un projet sur une plateforme de " +
|
||||
"crowdlending) et en extrait le contenu lisible (titre + texte principal, " +
|
||||
"débarrassé du menu/CSS/pubs). Ne fait AUCUNE écriture et ne crée rien " +
|
||||
"dans l'app — l'agent doit extraire lui-même les informations utiles du " +
|
||||
"texte retourné (taux, montant, durée, émetteur...) et les proposer à " +
|
||||
"l'utilisateur pour confirmation avant toute saisie manuelle dans l'app " +
|
||||
"(l'API n'a pas de capacité d'écriture). Le texte est tronqué à " +
|
||||
`${CHARACTER_LIMIT} caractères sur les pages très longues.`,
|
||||
inputSchema: {
|
||||
url: z.string().url().describe("URL de la page à lire (http/https uniquement)"),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
async ({ url }) => {
|
||||
try {
|
||||
let parsed;
|
||||
try { parsed = new URL(url); }
|
||||
catch { throw new Error(`URL invalide : ${url}`); }
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new Error(`Protocole non autorisé (${parsed.protocol}) — http/https uniquement.`);
|
||||
}
|
||||
if (isBlockedHost(parsed.hostname)) {
|
||||
throw new Error(`Hôte non autorisé (${parsed.hostname}) — cet outil ne peut pas cibler le réseau local.`);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(parsed, {
|
||||
headers: {
|
||||
'User-Agent': FETCH_USER_AGENT,
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9',
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
// Node/undici masque souvent la vraie cause derrière un message générique
|
||||
// "fetch failed" — la cause réelle (DNS, TLS, connexion refusée...) est
|
||||
// dans e.cause, à remonter explicitement pour un diagnostic utile.
|
||||
const cause = e.cause ? ` (${e.cause.code || e.cause.message || e.cause})` : '';
|
||||
throw new Error(`Impossible de récupérer la page : ${e.message}${cause}`);
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`La page a répondu avec le statut ${res.status} ${res.statusText}`.trim());
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (!contentType.includes('html')) {
|
||||
throw new Error(`Contenu non HTML (${contentType || 'type inconnu'}) — cet outil ne lit que des pages web.`);
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
const dom = new JSDOM(html, { url: parsed.toString() });
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
|
||||
const title = article?.title || dom.window.document.title || null;
|
||||
let text = (article?.textContent || dom.window.document.body?.textContent || '')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
const fullLength = text.length;
|
||||
let truncated = false;
|
||||
if (text.length > CHARACTER_LIMIT) {
|
||||
text = text.slice(0, CHARACTER_LIMIT);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
if (!text) throw new Error("Aucun contenu lisible n'a pu être extrait de cette page.");
|
||||
|
||||
const output = {
|
||||
url: parsed.toString(),
|
||||
title,
|
||||
site_name: article?.siteName || null,
|
||||
excerpt: article?.excerpt || null,
|
||||
text,
|
||||
length: fullLength,
|
||||
truncated,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
|
||||
structuredContent: output,
|
||||
};
|
||||
} catch (e) { return toolError(e); }
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user