@connect-xlab/mcp-server 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Economie de tokens (0.4.0).
3
+ *
4
+ * - Reponses legeres : JSON sans indentation, `fields` pour ne garder que
5
+ * certains champs, `limit` a 20 par defaut, `next_offset` quand il reste
6
+ * des elements.
7
+ * - Descriptions courtes : une phrase par tool et par parametre dans la
8
+ * liste envoyee au client ; la doc complete passe par `describe_tool`.
9
+ * - Mode compact (defaut) : le client ne voit que search_tools /
10
+ * describe_tool / execute ; les 192 tools restent appelables derriere
11
+ * `execute`. XLAB_MCP_MODE=full reexpose tous les tools.
12
+ */
13
+ const DEFAULT_LIMIT = 20;
14
+ /** Actions qui suppriment, retirent ou envoient quelque chose : accord explicite de l'utilisateur. */
15
+ const SENSITIVE_PREFIXES = ['delete_', 'ban_', 'kick_', 'reject_', 'send_', 'revoke_', 'clear_', 'demote_', 'stop_', 'unpublish_'];
16
+ const SENSITIVE_TOOLS = new Set(['update_slug', 'schedule_newsletter_edition', 'set_current_subscription_price', 'publish_podcast_episode']);
17
+ export function isSensitive(name) {
18
+ return SENSITIVE_TOOLS.has(name) || SENSITIVE_PREFIXES.some(prefix => name.startsWith(prefix));
19
+ }
20
+ /** Tools de lecture : ceux qui acceptent `fields`. */
21
+ export function isReadTool(name) {
22
+ return /^(list_|get_|search_|preview_|check_)/.test(name) || name === 'newsletter_dashboard';
23
+ }
24
+ /** Premiere phrase, coupee proprement au-dela de `max` caracteres. */
25
+ export function firstSentence(text, max) {
26
+ if (!text)
27
+ return '';
28
+ const flat = text.replace(/\s+/g, ' ').trim();
29
+ const match = flat.match(/^(.+?[.!?])(\s|$)/);
30
+ let sentence = match ? match[1] : flat;
31
+ if (sentence.length > max) {
32
+ const cut = sentence.slice(0, max - 1);
33
+ sentence = cut.slice(0, cut.lastIndexOf(' ') > max / 2 ? cut.lastIndexOf(' ') : cut.length) + '…';
34
+ }
35
+ return sentence;
36
+ }
37
+ export function shortDescription(tool) {
38
+ const summary = firstSentence(tool.description, 110);
39
+ return isSensitive(tool.name) ? `${summary} Action sensible : accord de l'utilisateur avant.` : summary;
40
+ }
41
+ /** Bornes retirees de la liste : la validation du tool (zod) les applique toujours, avec un message clair. */
42
+ const BOUND_KEYS = new Set(['minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'additionalProperties']);
43
+ function stripBounds(value) {
44
+ if (Array.isArray(value))
45
+ return value.map(stripBounds);
46
+ if (typeof value !== 'object' || value === null)
47
+ return value;
48
+ const copy = {};
49
+ for (const [key, inner] of Object.entries(value)) {
50
+ if (!BOUND_KEYS.has(key))
51
+ copy[key] = stripBounds(inner);
52
+ }
53
+ return copy;
54
+ }
55
+ /**
56
+ * Schema allege : descriptions de parametres raccourcies (celle de `lab`,
57
+ * commune a tous les tools, est dans les instructions du serveur), bornes
58
+ * retirees, limit a 20, `fields` sur les lectures.
59
+ */
60
+ export function lightSchema(tool) {
61
+ const properties = {};
62
+ for (const [key, prop] of Object.entries(tool.inputSchema.properties ?? {})) {
63
+ const copy = stripBounds(prop);
64
+ if (key === 'lab') {
65
+ delete copy.description;
66
+ }
67
+ else if (typeof copy.description === 'string') {
68
+ copy.description = firstSentence(copy.description, 50);
69
+ }
70
+ if (key === 'limit') {
71
+ copy.default = defaultLimit(prop);
72
+ }
73
+ properties[key] = copy;
74
+ }
75
+ if (isReadTool(tool.name)) {
76
+ properties.fields = { type: 'array', items: { type: 'string' } };
77
+ }
78
+ const { additionalProperties: _strict, ...schema } = tool.inputSchema;
79
+ return { ...schema, properties };
80
+ }
81
+ function defaultLimit(limitProp) {
82
+ const max = typeof limitProp?.maximum === 'number' ? limitProp.maximum : DEFAULT_LIMIT;
83
+ return Math.min(DEFAULT_LIMIT, max);
84
+ }
85
+ /**
86
+ * Lance un tool avec les reglages economes : `fields` retire des arguments,
87
+ * limit a 20 si le tool en a une et que rien n'est demande, reponse compacte.
88
+ */
89
+ export async function runTool(tool, rawArgs) {
90
+ const args = { ...rawArgs };
91
+ const fields = Array.isArray(args.fields) ? args.fields.filter((f) => typeof f === 'string') : [];
92
+ delete args.fields;
93
+ const limitProp = tool.inputSchema.properties?.limit;
94
+ if (limitProp && args.limit === undefined) {
95
+ args.limit = defaultLimit(limitProp);
96
+ }
97
+ const paged = !!limitProp && !!tool.inputSchema.properties?.offset;
98
+ let result;
99
+ try {
100
+ result = await tool.handler(args);
101
+ }
102
+ catch (err) {
103
+ // Arguments refuses par la validation du tool : message court et relisible.
104
+ const issues = err.issues;
105
+ const detail = issues?.length
106
+ ? issues.map(issue => `${issue.path.join('.') || 'arguments'} : ${issue.message}`).join(' ; ')
107
+ : err.message ?? String(err);
108
+ return { isError: true, content: [{ type: 'text', text: `[arguments] ${detail}` }] };
109
+ }
110
+ if (result.isError)
111
+ return result;
112
+ return {
113
+ ...result,
114
+ content: result.content.map(part => ({
115
+ ...part,
116
+ text: compactText(part.text, {
117
+ fields,
118
+ limit: typeof args.limit === 'number' ? args.limit : undefined,
119
+ offset: typeof args.offset === 'number' ? args.offset : 0,
120
+ paged,
121
+ }),
122
+ })),
123
+ };
124
+ }
125
+ function compactText(text, options) {
126
+ let value;
127
+ try {
128
+ value = JSON.parse(text);
129
+ }
130
+ catch {
131
+ return text;
132
+ }
133
+ // Enveloppe { status: true, data } de l'API : seul le contenu compte.
134
+ if (isObject(value) && value.status === true && 'data' in value) {
135
+ const { status: _status, ...rest } = value;
136
+ value = Object.keys(rest).length === 1 ? rest.data : rest;
137
+ }
138
+ if (options.fields.length) {
139
+ value = pickFields(value, options.fields);
140
+ }
141
+ // Page pleine : il en reste peut-etre, on donne l'offset suivant.
142
+ if (options.paged && options.limit) {
143
+ const list = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.data) ? value.data : null;
144
+ const nextOffset = options.offset + options.limit;
145
+ const total = isObject(value) && isObject(value.meta) && typeof value.meta.total === 'number' ? value.meta.total : null;
146
+ if (list && list.length >= options.limit && (total === null || nextOffset < total)) {
147
+ value = Array.isArray(value) ? { items: value, next_offset: nextOffset } : { ...value, next_offset: nextOffset };
148
+ }
149
+ }
150
+ return JSON.stringify(value);
151
+ }
152
+ function pickFields(value, fields) {
153
+ if (Array.isArray(value))
154
+ return value.map(item => pickFields(item, fields));
155
+ if (!isObject(value))
156
+ return value;
157
+ // Liste enveloppee ({ data: [...], meta }) : le filtre s'applique aux elements.
158
+ if (Array.isArray(value.data))
159
+ return { ...value, data: pickFields(value.data, fields) };
160
+ const picked = {};
161
+ for (const field of fields) {
162
+ if (field in value)
163
+ picked[field] = value[field];
164
+ }
165
+ return Object.keys(picked).length ? picked : value;
166
+ }
167
+ function isObject(value) {
168
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
169
+ }
170
+ function normalize(text) {
171
+ return text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/_/g, ' ');
172
+ }
173
+ /** Verbes francais -> mot des noms de tools (en anglais). */
174
+ const VERB_HINTS = [
175
+ [/^envo/, 'send'], [/^supprim|^efface/, 'delete'], [/^cre/, 'create'], [/^modifi|^change|^mettre/, 'update'],
176
+ [/^list|^voir|^affich/, 'list'], [/^banni/, 'ban'], [/^exclu|^vire|^retir/, 'kick'], [/^publi/, 'publish'],
177
+ [/^program|^planifi/, 'schedule'], [/^invit/, 'invitation'], [/^approuv|^valid|^accept/, 'approve'],
178
+ [/^refus|^rejet/, 'reject'], [/^cherch|^trouv/, 'search'], [/^epingl/, 'pin'], [/^stat|^chiffr/, 'stats'],
179
+ [/^revenu|^gain|^argent/, 'earnings'], [/^vente/, 'sales'], [/^membre/, 'member'], [/^cours|^formation|^classe/, 'class'],
180
+ [/^evenement/, 'event'], [/^boutique|^produit/, 'shop'], [/^tunnel/, 'funnel'], [/^lien/, 'link'],
181
+ [/^attente/, 'pending'], [/^abonne/, 'subscription'], [/^prix|^tarif/, 'price'],
182
+ ];
183
+ /** Mots trop courants pour departager les actions. */
184
+ const STOP_WORDS = new Set(['le', 'la', 'les', 'un', 'une', 'des', 'du', 'de', 'mes', 'mon', 'ma', 'ses', 'son', 'sa', 'au', 'aux', 'en', 'et', 'pour', 'sur', 'avec', 'dans', 'lab', 'qui', 'que', 'quelqu']);
185
+ /** Recherche simple par mots dans le nom et la description des tools. */
186
+ export function searchTools(tools, query, limit) {
187
+ const base = normalize(query).split(/[^a-z0-9]+/).filter(word => word.length > 1 && !STOP_WORDS.has(word));
188
+ const hints = base.flatMap(word => VERB_HINTS.filter(([pattern]) => pattern.test(word)).map(([, hint]) => hint));
189
+ const ranked = tools
190
+ .map(tool => {
191
+ const name = normalize(tool.name);
192
+ const parts = name.split(' ');
193
+ const description = normalize(tool.description);
194
+ let score = base.reduce((sum, word) => {
195
+ const stem = word.length > 4 ? word.slice(0, -1) : word; // membres ~ membre
196
+ return sum + (name.includes(stem) ? 3 : 0) + (description.includes(stem) ? 1 : 0);
197
+ }, 0);
198
+ // Mot traduit present tel quel dans le nom (« ban » oui, « unban » non) ; verbe en tete : bonus.
199
+ for (const hint of hints) {
200
+ if (parts.includes(hint) || parts.includes(hint + 's'))
201
+ score += parts[0] === hint ? 5 : 4;
202
+ else if (description.includes(hint))
203
+ score += 1;
204
+ }
205
+ return { tool, score };
206
+ })
207
+ .filter(entry => base.length === 0 || entry.score > 0)
208
+ // A egalite, l'action la plus generale (nom le plus court) d'abord : get_stats avant get_quiz_stats.
209
+ .sort((a, b) => b.score - a.score || a.tool.name.length - b.tool.name.length || a.tool.name.localeCompare(b.tool.name));
210
+ if (base.length === 0) {
211
+ // Sans mots-cles : tous les noms, sans description (catalogue).
212
+ return { tools: ranked.map(entry => entry.tool.name) };
213
+ }
214
+ return {
215
+ tools: ranked.slice(0, limit).map(entry => ({
216
+ name: entry.tool.name,
217
+ summary: firstSentence(entry.tool.description, 140),
218
+ ...(isSensitive(entry.tool.name) ? { sensitive: true } : {}),
219
+ })),
220
+ };
221
+ }
222
+ export const SERVER_INSTRUCTIONS = "Pilote un Lab ConnectXLab. Parametre `lab` : id numerique ou url du Lab, donnes par list_labs. Reponses compactes : limit 20 par defaut, `fields` (lectures) pour ne garder que certains champs, `next_offset` pour la page suivante. Avant une action sensible (suppression, bannissement, envoi), demande l'accord explicite de l'utilisateur.";
223
+ export const COMPACT_INSTRUCTIONS = " Mode compact : trouve l'action avec search_tools, lis ses parametres avec describe_tool, puis lance-la avec execute.";
224
+ /**
225
+ * Les 3 tools du mode compact, construits sur le catalogue complet. En mode
226
+ * complet, seul describe_tool est ajoute (doc detaillee a la demande).
227
+ */
228
+ export function compactTools(catalog) {
229
+ const byName = new Map(catalog.map(tool => [tool.name, tool]));
230
+ const text = (value, isError = false) => ({
231
+ ...(isError ? { isError: true } : {}),
232
+ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }],
233
+ });
234
+ const search = {
235
+ name: 'search_tools',
236
+ description: `Cherche parmi les ${catalog.length} actions ConnectXLab (membres, posts, cours, evenements, boutique, newsletter, podcast, tunnels, quiz…). Sans query : liste de tous les noms.`,
237
+ inputSchema: {
238
+ type: 'object',
239
+ properties: {
240
+ query: { type: 'string', description: 'Mots-cles, ex. "bannir membre" ou "newsletter envoyer"' },
241
+ limit: { type: 'number', minimum: 1, maximum: 30, default: 8 },
242
+ },
243
+ additionalProperties: false,
244
+ },
245
+ async handler(args) {
246
+ const limit = typeof args.limit === 'number' ? Math.min(30, Math.max(1, args.limit)) : 8;
247
+ return text(searchTools(catalog, typeof args.query === 'string' ? args.query : '', limit));
248
+ },
249
+ };
250
+ const describe = {
251
+ name: 'describe_tool',
252
+ description: "Description complete et parametres d'une action, avant de l'appeler avec execute.",
253
+ inputSchema: {
254
+ type: 'object',
255
+ properties: { name: { type: 'string', description: 'Nom exact, ex. list_members' } },
256
+ required: ['name'],
257
+ additionalProperties: false,
258
+ },
259
+ async handler(args) {
260
+ const tool = byName.get(String(args.name ?? ''));
261
+ if (!tool)
262
+ return text(`Action inconnue : ${String(args.name)}. Utilise search_tools.`, true);
263
+ return text({
264
+ name: tool.name,
265
+ description: tool.description,
266
+ ...(isSensitive(tool.name) ? { sensitive: "oui : accord explicite de l'utilisateur, puis execute avec confirm: true" } : {}),
267
+ ...(isReadTool(tool.name) ? { fields: 'accepte (via execute)' } : {}),
268
+ input_schema: tool.inputSchema,
269
+ });
270
+ },
271
+ };
272
+ const execute = {
273
+ name: 'execute',
274
+ description: "Lance une action ConnectXLab par son nom avec ses arguments. Actions sensibles : confirm: true, seulement apres accord de l'utilisateur.",
275
+ inputSchema: {
276
+ type: 'object',
277
+ properties: {
278
+ name: { type: 'string', description: "Nom exact de l'action" },
279
+ arguments: { type: 'object', description: "Arguments de l'action (voir describe_tool)" },
280
+ fields: { type: 'array', items: { type: 'string' }, description: 'Champs a garder dans la reponse' },
281
+ confirm: { type: 'boolean', description: 'true pour une action sensible approuvee' },
282
+ },
283
+ required: ['name'],
284
+ additionalProperties: false,
285
+ },
286
+ async handler(args) {
287
+ const name = String(args.name ?? '');
288
+ const tool = byName.get(name);
289
+ if (!tool)
290
+ return text(`Action inconnue : ${name}. Utilise search_tools.`, true);
291
+ if (isSensitive(name) && args.confirm !== true) {
292
+ return text(`${name} est une action sensible. Decris-la a l'utilisateur, obtiens son accord explicite, puis relance execute avec confirm: true.`, true);
293
+ }
294
+ const toolArgs = isObject(args.arguments) ? { ...args.arguments } : {};
295
+ if (Array.isArray(args.fields))
296
+ toolArgs.fields = args.fields;
297
+ return runTool(tool, toolArgs);
298
+ },
299
+ };
300
+ return [search, describe, execute];
301
+ }
package/dist/config.js CHANGED
@@ -15,4 +15,7 @@ export const config = {
15
15
  apiBaseUrl: process.env.XLAB_API_URL || 'https://v1.connectxlab.io/api/v1',
16
16
  mcpToken: requireEnv('XLAB_MCP_TOKEN'),
17
17
  timeoutMs: parseInt(process.env.XLAB_TIMEOUT_MS || '15000', 10),
18
+ // compact (defaut) : 3 tools search_tools / describe_tool / execute ;
19
+ // full : les ~190 tools exposes directement (descriptions courtes).
20
+ mode: process.env.XLAB_MCP_MODE === 'full' ? 'full' : 'compact',
18
21
  };
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@
15
15
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
16
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
17
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
18
+ import { config } from './config.js';
19
+ import { COMPACT_INSTRUCTIONS, SERVER_INSTRUCTIONS, compactTools, lightSchema, runTool, shortDescription } from './compact.js';
18
20
  import { listLabsTool } from './tools/listLabs.js';
19
21
  import { listMembersTool } from './tools/listMembers.js';
20
22
  import { listPostsTool } from './tools/listPosts.js';
@@ -414,26 +416,36 @@ const tools = [
414
416
  checkPartnerAgentLinksTool,
415
417
  getPartnerAgentStatsTool,
416
418
  ];
417
- const server = new Server({ name: 'connectxlab', version: '0.1.0' }, { capabilities: { tools: {} } });
419
+ const catalog = tools;
420
+ const [searchTool, describeTool, executeTool] = compactTools(catalog);
421
+ const compact = config.mode === 'compact';
422
+ const server = new Server({ name: 'connectxlab', version: '0.4.0' }, {
423
+ capabilities: { tools: {} },
424
+ instructions: SERVER_INSTRUCTIONS + (compact ? COMPACT_INSTRUCTIONS : ''),
425
+ });
426
+ // Mode compact : 3 tools. Mode complet : tous les tools (descriptions courtes) + describe_tool.
427
+ const exposed = compact ? [searchTool, describeTool, executeTool] : [...catalog, describeTool];
418
428
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
419
- tools: tools.map(t => ({
420
- name: t.name,
421
- description: t.description,
422
- inputSchema: t.inputSchema,
423
- })),
429
+ tools: exposed.map(t => compact || t === describeTool
430
+ ? { name: t.name, description: t.description, inputSchema: t.inputSchema }
431
+ : { name: t.name, description: shortDescription(t), inputSchema: lightSchema(t) }),
424
432
  }));
425
433
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
426
434
  const { name, arguments: args } = request.params;
427
- const tool = tools.find(t => t.name === name);
435
+ const tool = exposed.find(t => t.name === name);
428
436
  if (!tool) {
429
437
  return {
430
438
  isError: true,
431
439
  content: [{ type: 'text', text: `Tool inconnu : ${name}` }],
432
440
  };
433
441
  }
434
- return await tool.handler(args ?? {});
442
+ // Les tools du mode compact gerent eux-memes l'appel ; les autres passent par runTool.
443
+ if (tool === searchTool || tool === describeTool || tool === executeTool) {
444
+ return await tool.handler(args ?? {});
445
+ }
446
+ return await runTool(tool, args ?? {});
435
447
  });
436
448
  const transport = new StdioServerTransport();
437
449
  await server.connect(transport);
438
450
  // Log stderr pour debug (Claude Desktop l'affiche dans les logs).
439
- console.error(`[connectxlab-mcp] ready — ${tools.length} tools : ${tools.map(t => t.name).join(', ')}`);
451
+ console.error(`[connectxlab-mcp] ready — mode ${config.mode}, ${catalog.length} actions, ${exposed.length} tools exposes`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@connect-xlab/mcp-server",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Serveur MCP pour piloter ton Lab ConnectXLab depuis Claude Desktop / Cursor / n'importe quel client MCP.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",