Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e58731a20 | |||
| 56dd1f89bd |
+12
-6
@@ -49,12 +49,13 @@ services:
|
|||||||
- internal
|
- internal
|
||||||
- backend # réseau Traefik
|
- backend # réseau Traefik
|
||||||
|
|
||||||
# Serveur MCP distant (Phase 5) — accessible publiquement sur un
|
# Serveur MCP (Phase 5) — même image/code que le serveur utilisé en
|
||||||
# sous-domaine dédié, SANS le middleware ipwhitelist-all : contrairement au
|
# développement local (mcp-server/server.js, npm run dev), déployé ici
|
||||||
# reste de l'app, ce service est volontairement ouvert à des utilisateurs
|
# accessible publiquement sur un sous-domaine dédié, SANS le middleware
|
||||||
# distants qui ne peuvent pas déployer le serveur MCP local. La clé API
|
# ipwhitelist-all : contrairement au reste de l'app, ce service est
|
||||||
# (en-tête X-API-Key, propre à chaque utilisateur) est donc la SEULE
|
# volontairement ouvert à des utilisateurs distants qui ne peuvent pas
|
||||||
# barrière d'accès — voir mcp-server/http-server.js.
|
# 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:
|
crowdlending-mcp:
|
||||||
build:
|
build:
|
||||||
context: ./mcp-server
|
context: ./mcp-server
|
||||||
@@ -66,6 +67,11 @@ services:
|
|||||||
PORT: 4100
|
PORT: 4100
|
||||||
CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1
|
CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1
|
||||||
MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net,localhost
|
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:
|
depends_on:
|
||||||
crowdlending-backend:
|
crowdlending-backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -1091,27 +1091,30 @@ function ApiKeysSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Serveur MCP ──────────────────────────────────────────────
|
/* ── Serveur MCP ──────────────────────────────────────────────
|
||||||
Guide pas-à-pas pour connecter Claude Desktop au serveur MCP local
|
Guide pas-à-pas pour connecter Claude Desktop au serveur MCP
|
||||||
(mcp-server/ à la racine du projet). Le JSON de config est généré
|
(mcp-server/ à la racine du projet, un seul modèle url+headers en HTTP,
|
||||||
côté client à partir des champs ci-dessous ; la clé API saisie ici
|
que ce soit en développement local ou déployé à distance en prod — voir
|
||||||
reste uniquement en mémoire du navigateur, elle n'est jamais envoyée
|
mcp-server/README.md). Le JSON de config est généré côté client à partir
|
||||||
au backend — seulement utilisée pour composer l'aperçu à copier. ── */
|
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 :
|
/** Devine une URL de serveur MCP raisonnable selon l'environnement courant :
|
||||||
* en dev (Vite sur :5173), le process Node du serveur MCP ne passe pas
|
* en dev (Vite sur :5173 ou localhost), le serveur MCP tourne en local sur
|
||||||
* par le proxy Vite, il faut donc viser directement le port du backend (4000).
|
* son port par défaut (npm run dev, :4100). En prod, il s'agit du
|
||||||
* En prod (nginx sert front + /api sur la même origine), l'origine courante convient. */
|
* sous-domaine dédié mcp.<domaine de l'app>, déjà déployé en continu. */
|
||||||
function guessMcpApiUrl() {
|
function guessMcpUrl() {
|
||||||
if (typeof window === 'undefined') return 'http://localhost:4000/api/v1';
|
if (typeof window === 'undefined') return 'http://localhost:4100/mcp';
|
||||||
const { hostname, port, origin } = window.location;
|
const { hostname, port } = window.location;
|
||||||
if (port === '5173') return `http://${hostname}:4000/api/v1`;
|
if (port === '5173' || hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||||
return `${origin}/api/v1`;
|
return 'http://localhost:4100/mcp';
|
||||||
|
}
|
||||||
|
return `https://mcp.${hostname}/mcp`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Détecte automatiquement l'environnement ('dev' ou 'prod') à partir de
|
/** 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" →
|
* l'URL du serveur MCP : localhost/IP locale → dev, tout le reste → prod.
|
||||||
* dev, tout le reste → prod. Best-effort — reste modifiable manuellement
|
* Best-effort — reste modifiable manuellement pour les cas particuliers. */
|
||||||
* pour les cas particuliers (domaine de test qui ne contient pas "dev"...). */
|
|
||||||
function detectLabelFromUrl(url) {
|
function detectLabelFromUrl(url) {
|
||||||
if (!url) return 'prod';
|
if (!url) return 'prod';
|
||||||
let hostname;
|
let hostname;
|
||||||
@@ -1146,27 +1149,28 @@ function CopyBlock({ text }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function McpServerSection({ goToApiKeys }) {
|
function McpServerSection({ goToApiKeys }) {
|
||||||
const [mcpPath, setMcpPath] = useState('C:\\dev\\crowdlending-app\\mcp-server\\index.js');
|
const [mcpUrl, setMcpUrl] = useState(guessMcpUrl());
|
||||||
const [apiUrl, setApiUrl] = useState(guessMcpApiUrl());
|
|
||||||
const [apiKey, setApiKey] = useState('');
|
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 label = manualLabel ?? detectedLabel;
|
||||||
|
const isLocal = label === 'dev';
|
||||||
|
|
||||||
const serverKey = `crowdlending-${label}`;
|
const serverKey = `crowdlending-${label}`;
|
||||||
const env = {
|
// claude_desktop_config.json n'a pas de champ url/headers natif : on passe
|
||||||
CROWDLENDING_API_KEY: apiKey || '<VOTRE_CLE_API>',
|
// par mcp-remote (https://github.com/geelen/mcp-remote), un pont stdio↔HTTP
|
||||||
CROWDLENDING_API_URL: apiUrl,
|
// que Claude Desktop lance comme n'importe quel serveur "command". La clé
|
||||||
CROWDLENDING_LABEL: label,
|
// 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({
|
const configJson = JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
[serverKey]: {
|
[serverKey]: {
|
||||||
command: 'node',
|
command: 'npx',
|
||||||
args: [mcpPath],
|
args: ['-y', 'mcp-remote', mcpUrl, '--header', `X-API-Key:\${${envVarName}}`],
|
||||||
env,
|
env: { [envVarName]: apiKey || '<VOTRE_CLE_API>' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, null, 2);
|
}, null, 2);
|
||||||
@@ -1176,8 +1180,8 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
<h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3>
|
<h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3>
|
||||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
<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.
|
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
|
Un seul modèle de connexion, en développement local comme en production : une URL de serveur MCP
|
||||||
(<code>npm install</code>) — voir <code>mcp-server/README.md</code> pour le détail.
|
et votre clé API personnelle — voir <code>mcp-server/README.md</code> pour le détail.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>1. Créer une clé API dédiée</h4>
|
<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>
|
<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 style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}>
|
||||||
<div>
|
<div>
|
||||||
<label>Chemin vers mcp-server/index.js</label>
|
<label>URL du serveur MCP</label>
|
||||||
<input value={mcpPath} onChange={e => setMcpPath(e.target.value)} />
|
<input value={mcpUrl} onChange={e => setMcpUrl(e.target.value)} />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>URL de l'API</label>
|
|
||||||
<input value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Clé API</label>
|
<label>Clé API</label>
|
||||||
@@ -1218,7 +1218,7 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
{manualLabel === null
|
{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</>}
|
: <>forcé manuellement</>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1236,12 +1236,26 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isLocal ? (
|
||||||
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
<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
|
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
|
prod accessibles simultanément, répétez ces étapes une deuxième fois avec l'autre URL (et une clé
|
||||||
vers l'autre environnement (et une clé API distincte) — l'étiquette et la clé de config
|
API distincte) — la clé de config (<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement,
|
||||||
(<code>{serverKey}</code> ci-dessous) s'ajustent automatiquement. Elle apparaît dans le titre et
|
Claude Desktop ne confondra jamais les deux portefeuilles.
|
||||||
la description de chaque outil, pour que l'agent ne confonde jamais les deux portefeuilles.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4>
|
<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>
|
<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)' }}>
|
<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
|
Dans la liste des outils MCP de Claude Desktop, les outils <code>crowdlending_*</code> doivent
|
||||||
apparaître. Testez avec une question du type « Quel est mon encours de crowdlending actuellement ? ».
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ node_modules
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
*.log
|
*.log
|
||||||
README.md
|
README.md
|
||||||
index.js
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ ENV NODE_ENV=production
|
|||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
COPY tools.js http-server.js ./
|
COPY tools.js server.js ./
|
||||||
|
|
||||||
EXPOSE 4100
|
EXPOSE 4100
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
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 -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
|
# crowdlending-mcp-server
|
||||||
|
|
||||||
Serveur MCP pour le portefeuille de crowdlending. Il expose en lecture seule
|
Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille de
|
||||||
les données d'un investisseur (investissements, remboursements,
|
crowdlending. Il expose en lecture seule les données d'un investisseur
|
||||||
dépôts/retraits, dashboard) à un client MCP — Claude Desktop, Claude Code,
|
(investissements, remboursements, dépôts/retraits, dashboard) à un client
|
||||||
ou tout autre client compatible — en s'appuyant sur l'API publique `/api/v1`
|
MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en
|
||||||
du backend.
|
s'appuyant sur l'API publique `/api/v1` du backend.
|
||||||
|
|
||||||
Il ne fait aucune écriture : toutes les modifications restent à faire dans
|
Il ne fait aucune écriture : toutes les modifications restent à faire dans
|
||||||
l'app web.
|
l'app web.
|
||||||
|
|
||||||
Deux modes, deux fichiers :
|
**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
|
||||||
- **Local (`index.js`, stdio)** — lancé comme process enfant par Claude
|
production (service Docker `crowdlending-mcp`, derrière Traefik sur
|
||||||
Desktop sur votre propre machine. Nécessite Node.js et ce dépôt cloné en
|
`mcp.crowdlending.croguennec.net`). Pas de distinction de code entre les
|
||||||
local. Inclut l'outil `crowdlending_fetch_url` (lecture de page web).
|
deux — seule la configuration change (quelle API cibler, quels outils
|
||||||
- **Distant (`http-server.js`, HTTP)** — un service déployé une fois (par
|
activer).
|
||||||
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.
|
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
@@ -31,184 +23,149 @@ modes.
|
|||||||
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre
|
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre
|
||||||
instance en production)
|
instance en production)
|
||||||
|
|
||||||
## Installation
|
## Développement local
|
||||||
|
|
||||||
|
Comme pour `backend/` et `frontend/` :
|
||||||
|
|
||||||
```
|
```
|
||||||
cd mcp-server
|
cd mcp-server
|
||||||
npm install
|
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 :
|
Connectez ensuite Claude Desktop : Réglages → Développeur → Serveurs MCP
|
||||||
|
locaux → **Modifier la config**, puis ajoutez dans `mcpServers` :
|
||||||
| 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.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"crowdlending-dev": {
|
"crowdlending-dev": {
|
||||||
"command": "node",
|
"command": "npx",
|
||||||
"args": ["C:\\dev\\crowdlending-app\\mcp-server\\index.js"],
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mcp-remote",
|
||||||
|
"http://localhost:4100/mcp",
|
||||||
|
"--header",
|
||||||
|
"X-API-Key:${CROWDLENDING_API_KEY}"
|
||||||
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"CROWDLENDING_API_KEY": "clk_live_...",
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Utilisez deux clés API différentes (une par instance, générées séparément
|
**Important :** `claude_desktop_config.json` n'accepte que des entrées
|
||||||
sur chaque backend) : ça permet de révoquer l'une sans affecter l'autre, et
|
`command`/`args` — il n'existe pas de champ `url`/`headers` natif dans ce
|
||||||
c'est cohérent avec le principe d'une clé par usage.
|
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
|
Notez l'absence d'espace autour du `:` dans `--header` : Claude Desktop
|
||||||
dans son titre (ex. « Synthèse du portefeuille [PROD] ») et sa description
|
(Windows) a un bug connu qui tronque les arguments contenant un espace — on
|
||||||
se termine par la source exacte interrogée — de quoi lever toute ambiguïté
|
passe donc la valeur réelle (avec l'espace éventuel) via une variable
|
||||||
si vous demandez « mon encours en prod » vs « mon encours en dev ».
|
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
|
## Configuration
|
||||||
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.
|
|
||||||
|
|
||||||
**1. Créer votre clé API** — dans l'app, Mon compte → Clés API → Nouvelle
|
Variables d'environnement, toutes optionnelles sauf pour un déploiement
|
||||||
clé (elle ne sera affichée qu'une seule fois, copiez-la).
|
distant réel (en local, les valeurs par défaut conviennent) :
|
||||||
|
|
||||||
**2. Ajouter le serveur dans Claude Desktop** — Réglages → Développeur →
|
| Variable | Défaut | Description |
|
||||||
Serveurs MCP locaux → Modifier la config, puis ajoutez :
|
|---|---|---|
|
||||||
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"crowdlending-distant": {
|
"crowdlending-dev": {
|
||||||
"url": "https://mcp.crowdlending.croguennec.net/mcp",
|
"command": "npx",
|
||||||
"headers": {
|
"args": ["-y", "mcp-remote", "http://localhost:4100/mcp", "--header", "X-API-Key:${DEV_API_KEY}"],
|
||||||
"X-API-Key": "clk_live_..."
|
"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
|
Utilisez deux clés API différentes (une par instance) : ça permet de
|
||||||
apparaître (sans `crowdlending_fetch_url`, réservé au serveur local).
|
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
|
Le service `crowdlending-mcp` du `docker-compose.yml` à la racine construit
|
||||||
utilisateur tiers non maîtrisé augmenterait le risque SSRF sans bénéfice
|
et lance ce même `server.js`, exposé via Traefik sur
|
||||||
réel pour ce cas d'usage. Cet outil reste réservé au serveur local.
|
`mcp.crowdlending.croguennec.net` (certificat TLS automatique). Contrairement
|
||||||
- **Authentification par requête, pas par process** : le serveur local a une
|
au reste de l'app, ce service n'a **pas** le middleware `ipwhitelist-all` :
|
||||||
clé API fixée une fois pour toutes via une variable d'environnement — un
|
il est volontairement accessible depuis internet, pour des utilisateurs
|
||||||
process = un utilisateur. Le serveur distant sert plusieurs utilisateurs en
|
distants qui ne peuvent pas faire tourner le serveur en local — la clé API
|
||||||
parallèle : chaque session est créée à partir de la clé API envoyée dans
|
est donc la seule barrière d'accès. Traitez-la comme un mot de passe, et
|
||||||
l'en-tête `X-API-Key` de la requête qui l'initialise, puis liée à cette
|
révoquez-la immédiatement en cas de doute (Mon compte → Clés API).
|
||||||
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.
|
|
||||||
|
|
||||||
### Déploiement (administrateur de l'instance)
|
Un enregistrement DNS pour ce sous-domaine, pointant vers la même IP que
|
||||||
|
|
||||||
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
|
|
||||||
`crowdlending.croguennec.net`, est nécessaire avant le premier déploiement.
|
`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`
|
`MCP_ENABLE_FETCH_URL` reste à `false` en production (voir docker-compose.yml)
|
||||||
(validation de l'en-tête `Host`, protection anti DNS-rebinding — doit
|
: cet outil lit une URL arbitraire fournie par l'appelant, acceptable pour
|
||||||
correspondre exactement au(x) nom(s) de domaine exposé(s)).
|
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
|
## Tester sans Claude Desktop
|
||||||
|
|
||||||
Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet
|
Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet
|
||||||
de lister et appeler les outils depuis une interface web, sans configurer de
|
de lister et appeler les outils depuis une interface web :
|
||||||
client :
|
|
||||||
|
|
||||||
```
|
```
|
||||||
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
|
## Outils exposés
|
||||||
|
|
||||||
Tous en lecture seule (`readOnlyHint: true`) :
|
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_get_investissement` | Détail d'un investissement + ses remboursements |
|
||||||
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
|
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
|
||||||
| `crowdlending_list_depots_retraits` | Historique des mouvements de cash |
|
| `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`)
|
## 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 +
|
Cet outil récupère une page web et en extrait le contenu lisible (titre +
|
||||||
texte principal) via [Readability](https://github.com/mozilla/readability),
|
texte principal) via [Readability](https://github.com/mozilla/readability),
|
||||||
la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux
|
la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux
|
||||||
@@ -249,10 +210,21 @@ Garde-fous :
|
|||||||
|
|
||||||
## Dépannage
|
## Dépannage
|
||||||
|
|
||||||
- **`ERREUR : CROWDLENDING_API_KEY est requise`** — la variable d'env n'est
|
- **`En-tête X-API-Key manquant`** — vérifiez l'argument `--header` et la
|
||||||
pas transmise. Vérifiez la section `env` de votre config MCP.
|
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é API invalide ou révoquée`** — régénérez une clé dans Mon compte →
|
||||||
Clés API et mettez à jour la config.
|
Clés API et mettez à jour la config.
|
||||||
- **`Impossible de joindre l'API`** — le backend n'est pas démarré, ou
|
- **`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
|
`CROWDLENDING_API_URL` pointe au mauvais endroit (vérifiez le port et le
|
||||||
suffixe `/api/v1`).
|
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",
|
"jsdom": "^29.1.1",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
|
||||||
"crowdlending-mcp-server": "index.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "crowdlending-mcp-server",
|
"name": "crowdlending-mcp-server",
|
||||||
"version": "0.2.0",
|
"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",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "server.js",
|
||||||
"bin": {
|
|
||||||
"crowdlending-mcp-server": "./index.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node server.js",
|
||||||
"start:http": "node http-server.js",
|
"dev": "node --watch server.js",
|
||||||
"inspect": "npx @modelcontextprotocol/inspector node index.js"
|
"inspect": "npx @modelcontextprotocol/inspector"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -1,33 +1,26 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* Point d'entrée unique, que ce soit en développement local (`npm run dev`,
|
||||||
* lecture (voir tools.js), mais accessible via une URL publique plutôt que
|
* comme le backend et le frontend) ou déployé à distance en production
|
||||||
* comme process enfant sur la machine de l'utilisateur. Pensé pour les
|
* (mcp.crowdlending.croguennec.net, service `crowdlending-mcp` du
|
||||||
* utilisateurs distants qui ne peuvent/veulent pas installer Node.js et
|
* docker-compose). Un seul modèle de transport, un seul fichier à
|
||||||
* cloner ce dépôt en local — ils n'ont qu'à ajouter une URL + leur clé API
|
* maintenir : les outils eux-mêmes vivent dans tools.js.
|
||||||
* personnelle dans la config de leur client MCP (Claude Desktop, etc.).
|
|
||||||
*
|
*
|
||||||
* DIFFÉRENCE STRUCTURELLE IMPORTANTE avec index.js : le serveur stdio est
|
* AUTHENTIFICATION : aucune clé API fixe côté serveur, contrairement à
|
||||||
* lancé une fois par utilisateur, avec UNE clé API fixée par variable
|
* l'ancien serveur stdio. Un même process peut servir plusieurs
|
||||||
* d'environnement pour toute la durée du process. Ici, un seul process sert
|
* utilisateurs/sessions en parallèle (typiquement un seul en dev local,
|
||||||
* potentiellement PLUSIEURS utilisateurs distants en parallèle — il n'y a
|
* potentiellement plusieurs en déploiement distant) — chaque session MCP est
|
||||||
* donc AUCUNE clé API fixe côté serveur. Chaque session MCP est initialisée
|
* initialisée à partir de la clé API fournie dans l'en-tête `X-API-Key` de
|
||||||
* à partir de la clé API fournie dans l'en-tête `X-API-Key` de la requête
|
* la requête HTTP qui l'a créée, puis cette clé est fermée dans le closure
|
||||||
* HTTP qui l'a créée ; cette clé est ensuite fermée dans le "closure" des
|
* des outils de CETTE session uniquement (voir createSession). Deux sessions
|
||||||
* outils de CETTE session uniquement (voir createSession ci-dessous). Deux
|
* ne partagent jamais d'état ni de données.
|
||||||
* utilisateurs distants ne partagent jamais d'état ni de données.
|
|
||||||
*
|
*
|
||||||
* `crowdlending_fetch_url` n'est PAS exposé ici (voir tools.js pour le
|
* Connexion depuis Claude Desktop (dev comme prod) :
|
||||||
* détail) : réservé au serveur local, pour limiter le risque SSRF envers
|
* { "url": "http://localhost:4100/mcp", "headers": { "X-API-Key": "..." } }
|
||||||
* des utilisateurs tiers non maîtrisés.
|
* (remplacer l'URL par https://mcp.crowdlending.croguennec.net/mcp pour la
|
||||||
*
|
* prod). Voir README.md pour le détail.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
@@ -36,28 +29,49 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|||||||
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
||||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import rateLimit from 'express-rate-limit';
|
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 PORT = Number(process.env.PORT || 4100);
|
||||||
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://crowdlending-backend:4000/api/v1').replace(/\/$/, '');
|
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost: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).
|
// Étiquette facultative pour distinguer plusieurs instances connectées en
|
||||||
// `localhost` est inclus par défaut pour que le HEALTHCHECK Docker (qui
|
// même temps à Claude Desktop (ex. dev local + prod distante). Affiche
|
||||||
// interroge http://localhost:4100/health depuis l'intérieur du container)
|
// "[DEV]"/"[PROD]" dans le titre de chaque outil et la source exacte en fin
|
||||||
// ne soit pas lui-même bloqué par ce middleware — sans risque côté externe,
|
// de description — comme l'ancien CROWDLENDING_LABEL du serveur stdio.
|
||||||
// puisque Traefik ne route déjà vers ce service que les requêtes portant le
|
const LABEL = (process.env.MCP_LABEL || '').trim();
|
||||||
// Host public configuré sur son router (une requête Host: localhost envoyée
|
|
||||||
// depuis l'extérieur n'atteint jamais ce container).
|
// 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')
|
const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net,localhost')
|
||||||
.split(',').map((h) => h.trim()).filter(Boolean);
|
.split(',').map((h) => h.trim()).filter(Boolean);
|
||||||
|
|
||||||
// Durée d'inactivité au-delà de laquelle une session orpheline est fermée
|
// 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).
|
// (client parti sans DELETE explicite — évite une fuite mémoire lente).
|
||||||
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
|
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
|
/* ── 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) {
|
function makeApiGet(apiKey) {
|
||||||
return async function apiGet(path, params) {
|
return async function apiGet(path, params) {
|
||||||
const url = new URL(API_BASE + path);
|
const url = new URL(API_BASE + path);
|
||||||
@@ -104,7 +118,7 @@ setInterval(() => {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [sessionId, s] of sessions.entries()) {
|
for (const [sessionId, s] of sessions.entries()) {
|
||||||
if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) {
|
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();
|
s.transport.close();
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
@@ -113,11 +127,9 @@ setInterval(() => {
|
|||||||
|
|
||||||
/** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */
|
/** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */
|
||||||
async function createSession(apiKey) {
|
async function createSession(apiKey) {
|
||||||
const server = new McpServer({ name: 'crowdlending-mcp-server-remote', version: '0.1.0' });
|
const server = new McpServer({ name: 'crowdlending-mcp' + (LABEL ? `-${LABEL}` : ''), version: '0.2.0' });
|
||||||
registerDataTools(server, {
|
registerDataTools(server, { apiGet: makeApiGet(apiKey), withLabel, withSource });
|
||||||
apiGet: makeApiGet(apiKey),
|
if (ENABLE_FETCH_URL) registerFetchUrlTool(server);
|
||||||
withSource: (description) => `${description}\n\nSource de données : serveur MCP distant (mcp.crowdlending.croguennec.net)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => randomUUID(),
|
sessionIdGenerator: () => randomUUID(),
|
||||||
@@ -137,8 +149,7 @@ async function createSession(apiKey) {
|
|||||||
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS });
|
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS });
|
||||||
|
|
||||||
// Limite basique anti-abus : une clé compromise ou un client buggé ne doit
|
// 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
|
// pas pouvoir marteler l'API backend sans frein.
|
||||||
// clé API n'est lue qu'après ce middleware).
|
|
||||||
app.use(rateLimit({
|
app.use(rateLimit({
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
limit: 60,
|
limit: 60,
|
||||||
@@ -186,7 +197,7 @@ app.post('/mcp', async (req, res) => {
|
|||||||
const transport = await createSession(apiKey);
|
const transport = await createSession(apiKey);
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
} catch (e) {
|
} 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 });
|
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.delete('/mcp', handleExistingSession);
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
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,
|
* Outils MCP — partagés par le serveur unique (server.js, HTTP), que ce soit
|
||||||
* index.js) et le serveur distant (HTTP, http-server.js).
|
* 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 :
|
* 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
|
* 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
|
* clé API et une base URL précises) et enregistre les outils sur le
|
||||||
* sur le `McpServer` fourni. Ainsi, le comportement des outils ne peut pas
|
* `McpServer` fourni. Ainsi, le comportement des outils ne peut pas diverger
|
||||||
* diverger entre les deux serveurs — un seul endroit à maintenir.
|
* selon l'environnement — un seul endroit à maintenir.
|
||||||
*
|
*
|
||||||
* `crowdlending_fetch_url` n'est PAS ici : il reste réservé au serveur local
|
* `registerFetchUrlTool` est séparée de `registerDataTools` et enregistrée
|
||||||
* (voir index.js) — l'exposer à des utilisateurs distants tiers via le
|
* de façon CONDITIONNELLE par server.js (variable d'env
|
||||||
* serveur HTTP augmenterait le risque SSRF sans bénéfice pour ce cas d'usage.
|
* `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 { z } from 'zod';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { Readability } from '@mozilla/readability';
|
||||||
|
|
||||||
const READ_ONLY_ANNOTATIONS = {
|
const READ_ONLY_ANNOTATIONS = {
|
||||||
readOnlyHint: true,
|
readOnlyHint: true,
|
||||||
@@ -170,13 +177,148 @@ function toolResult(data) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire.
|
/** 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é). */
|
|
||||||
export function toolError(e) {
|
export function toolError(e) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Erreur : ${e.message}` }],
|
content: [{ type: 'text', text: `Erreur : ${e.message}` }],
|
||||||
isError: true,
|
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