@bussolabs/closeyourit-cli 0.25.1 → 0.27.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.
- package/README.md +91 -4
- package/dist/base.d.ts +3 -18
- package/dist/base.js +16 -66
- package/dist/commands/kb/context.d.ts +12 -0
- package/dist/commands/kb/context.js +93 -0
- package/dist/commands/kb/doctor.d.ts +21 -0
- package/dist/commands/kb/doctor.js +107 -0
- package/dist/commands/kb/search.d.ts +1 -0
- package/dist/commands/kb/search.js +7 -0
- package/dist/commands/mcp.d.ts +10 -0
- package/dist/commands/mcp.js +33 -0
- package/dist/lib/api.d.ts +16 -0
- package/dist/lib/api.js +21 -1
- package/dist/lib/git.d.ts +17 -0
- package/dist/lib/git.js +56 -0
- package/dist/lib/knowledge-context.d.ts +27 -0
- package/dist/lib/knowledge-context.js +68 -0
- package/dist/lib/knowledge-doctor.d.ts +127 -0
- package/dist/lib/knowledge-doctor.js +310 -0
- package/dist/lib/knowledge.d.ts +30 -0
- package/dist/lib/knowledge.js +64 -0
- package/dist/lib/lookup.d.ts +30 -0
- package/dist/lib/lookup.js +82 -0
- package/dist/lib/mcp/protocol.d.ts +51 -0
- package/dist/lib/mcp/protocol.js +67 -0
- package/dist/lib/mcp/server.d.ts +41 -0
- package/dist/lib/mcp/server.js +221 -0
- package/dist/lib/mcp/tools.d.ts +43 -0
- package/dist/lib/mcp/tools.js +340 -0
- package/oclif.manifest.json +4276 -4081
- package/opencli.json +154 -2
- package/package.json +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'node:stream';
|
|
2
|
+
import { type CliApi } from '../api';
|
|
3
|
+
import { type JsonRpcRequest, type JsonRpcResponse } from './protocol';
|
|
4
|
+
import { type McpToolContext } from './tools';
|
|
5
|
+
/**
|
|
6
|
+
* Il server Model Context Protocol della conoscenza CloseYourIt (CYCL-56).
|
|
7
|
+
*
|
|
8
|
+
* Parla la revisione con handshake (`initialize`), quella che i client sanno parlare oggi. Non
|
|
9
|
+
* espone `server/discover`: un client che sa fare entrambe le ere prova quella nuova, riceve un
|
|
10
|
+
* errore che non riconosce come moderno e ricade sull'handshake — che è esattamente la strada che
|
|
11
|
+
* qui funziona.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SERVER_NAME = "closeyourit";
|
|
14
|
+
/**
|
|
15
|
+
* Le revisioni del protocollo che questo server serve, dalla più recente. Tutte trattano allo
|
|
16
|
+
* stesso modo un server di soli strumenti: fra loro cambiano cose che qui non esistono
|
|
17
|
+
* (autorizzazione, elicitation, risorse), quindi la versione richiesta si può confermare com'è.
|
|
18
|
+
*/
|
|
19
|
+
export declare const PROTOCOL_VERSIONS: readonly ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
20
|
+
export declare const DEFAULT_PROTOCOL_VERSION: "2025-11-25";
|
|
21
|
+
/** Lo stato di una connessione: quello che serve agli strumenti più chi si è presentato. */
|
|
22
|
+
export interface McpSession extends McpToolContext {
|
|
23
|
+
version: string;
|
|
24
|
+
/** Interrompe le chiamate rimaste in volo quando la finestra di chiusura è scaduta. */
|
|
25
|
+
abort: AbortController;
|
|
26
|
+
shutdownGraceMs: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function createSession(opts: {
|
|
29
|
+
api: CliApi;
|
|
30
|
+
version: string;
|
|
31
|
+
cwd?: string;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
shutdownGraceMs?: number;
|
|
34
|
+
}): McpSession;
|
|
35
|
+
export declare function handleRequest(request: JsonRpcRequest, session: McpSession): Promise<JsonRpcResponse | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* Serve il protocollo su due flussi: un messaggio JSON per riga in ingresso, un messaggio JSON per
|
|
38
|
+
* riga in uscita e niente altro. La promessa si chiude quando il flusso di ingresso finisce — o
|
|
39
|
+
* quando uno dei due si rompe, che è il modo in cui un client se ne va.
|
|
40
|
+
*/
|
|
41
|
+
export declare function serveStdio(input: Readable, output: Writable, session: McpSession): Promise<void>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_PROTOCOL_VERSION = exports.PROTOCOL_VERSIONS = exports.SERVER_NAME = void 0;
|
|
4
|
+
exports.createSession = createSession;
|
|
5
|
+
exports.handleRequest = handleRequest;
|
|
6
|
+
exports.serveStdio = serveStdio;
|
|
7
|
+
const api_1 = require("../api");
|
|
8
|
+
const protocol_1 = require("./protocol");
|
|
9
|
+
const tools_1 = require("./tools");
|
|
10
|
+
/**
|
|
11
|
+
* Il server Model Context Protocol della conoscenza CloseYourIt (CYCL-56).
|
|
12
|
+
*
|
|
13
|
+
* Parla la revisione con handshake (`initialize`), quella che i client sanno parlare oggi. Non
|
|
14
|
+
* espone `server/discover`: un client che sa fare entrambe le ere prova quella nuova, riceve un
|
|
15
|
+
* errore che non riconosce come moderno e ricade sull'handshake — che è esattamente la strada che
|
|
16
|
+
* qui funziona.
|
|
17
|
+
*/
|
|
18
|
+
exports.SERVER_NAME = 'closeyourit';
|
|
19
|
+
const SERVER_TITLE = 'CloseYourIt knowledge';
|
|
20
|
+
/**
|
|
21
|
+
* Le revisioni del protocollo che questo server serve, dalla più recente. Tutte trattano allo
|
|
22
|
+
* stesso modo un server di soli strumenti: fra loro cambiano cose che qui non esistono
|
|
23
|
+
* (autorizzazione, elicitation, risorse), quindi la versione richiesta si può confermare com'è.
|
|
24
|
+
*/
|
|
25
|
+
exports.PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
26
|
+
exports.DEFAULT_PROTOCOL_VERSION = exports.PROTOCOL_VERSIONS[0];
|
|
27
|
+
/** Quanto si aspetta `git` per riconoscere il progetto della cartella, quando non lo si dice. */
|
|
28
|
+
const DEFAULT_GIT_TIMEOUT_MS = 5000;
|
|
29
|
+
/**
|
|
30
|
+
* Quanto si concede alle chiamate ancora in volo quando il canale si chiude, prima di interromperle.
|
|
31
|
+
*
|
|
32
|
+
* Chiudere lo standard input È il modo in cui un client MCP chiede al server di uscire, e subito
|
|
33
|
+
* dopo passa ai segnali. Una risposta che arriva entro questa finestra fa ancora in tempo a uscire;
|
|
34
|
+
* oltre, chi l'aspettava non c'è più — e restare in attesa terrebbe vivo il sottoprocesso finché
|
|
35
|
+
* la richiesta non torna, cioè potenzialmente fino al segnale che lo uccide.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_SHUTDOWN_GRACE_MS = 2000;
|
|
38
|
+
/** Quello che il client legge prima di usare gli strumenti: a cosa serve questa conoscenza. */
|
|
39
|
+
const INSTRUCTIONS = [
|
|
40
|
+
'The CloseYourIt knowledge base of this organization: decisions taken, guides and notes that the code and the git history do not say.',
|
|
41
|
+
'Search it (kb_search) or ask it (kb_ask) before answering how something is done here; kb_context lists what to know about the project of the current directory.',
|
|
42
|
+
'New knowledge goes in with kb_propose: it always waits for a person to accept it, so nothing written from here is published on its own.',
|
|
43
|
+
].join(' ');
|
|
44
|
+
function createSession(opts) {
|
|
45
|
+
const abort = new AbortController();
|
|
46
|
+
// Sul client, non su ogni chiamata: così l'interruzione arriva anche alle ricerche per nome che
|
|
47
|
+
// uno strumento si porta dietro, senza che debbano saperne niente.
|
|
48
|
+
opts.api.signal = abort.signal;
|
|
49
|
+
return {
|
|
50
|
+
api: opts.api,
|
|
51
|
+
version: opts.version,
|
|
52
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
53
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
|
|
54
|
+
shutdownGraceMs: opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS,
|
|
55
|
+
abort,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function isRecord(value) {
|
|
59
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Il testo che il modello legge quando uno strumento non è andato a buon fine. Un rifiuto del
|
|
63
|
+
* server porta il suo codice, come lo porta il terminale: è quello che distingue «non hai accesso»
|
|
64
|
+
* da «non sei collegato» senza doverlo indovinare.
|
|
65
|
+
*/
|
|
66
|
+
function toolErrorText(error) {
|
|
67
|
+
if (error instanceof api_1.ApiRequestError)
|
|
68
|
+
return `${error.code}: ${error.message}`;
|
|
69
|
+
return String(error.message);
|
|
70
|
+
}
|
|
71
|
+
function initialize(params, session) {
|
|
72
|
+
const clientInfo = params.clientInfo;
|
|
73
|
+
const clientName = typeof clientInfo?.name === 'string' ? clientInfo.name.trim() : '';
|
|
74
|
+
if (clientName !== '')
|
|
75
|
+
session.clientName = clientName;
|
|
76
|
+
const requested = params.protocolVersion;
|
|
77
|
+
const known = typeof requested === 'string' && exports.PROTOCOL_VERSIONS.includes(requested);
|
|
78
|
+
return {
|
|
79
|
+
protocolVersion: known ? requested : exports.DEFAULT_PROTOCOL_VERSION,
|
|
80
|
+
capabilities: { tools: {} },
|
|
81
|
+
serverInfo: { name: exports.SERVER_NAME, title: SERVER_TITLE, version: session.version },
|
|
82
|
+
instructions: INSTRUCTIONS,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function callTool(id, params, session) {
|
|
86
|
+
const name = typeof params.name === 'string' ? params.name : '';
|
|
87
|
+
const tool = (0, tools_1.findTool)(name);
|
|
88
|
+
// Uno strumento che non esiste è un errore di protocollo: non c'è nessuna chiamata di cui
|
|
89
|
+
// raccontare l'esito, e il client deve accorgersene come si accorge di un metodo sbagliato.
|
|
90
|
+
if (!tool) {
|
|
91
|
+
return (0, protocol_1.rpcFailure)(id, protocol_1.RPC_ERROR.invalidParams, `Unknown tool: ${name}. Call tools/list for the tools this server offers.`);
|
|
92
|
+
}
|
|
93
|
+
const args = params.arguments;
|
|
94
|
+
try {
|
|
95
|
+
const result = await tool.run(isRecord(args) ? args : {}, session);
|
|
96
|
+
return (0, protocol_1.rpcResult)(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], isError: false });
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Un argomento sbagliato o un rifiuto del server sono l'ESITO dello strumento, non un guasto del
|
|
100
|
+
// protocollo: così il motivo arriva al modello, che può correggersi da sé invece di fermarsi.
|
|
101
|
+
return (0, protocol_1.rpcResult)(id, { content: [{ type: 'text', text: toolErrorText(error) }], isError: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function handleRequest(request, session) {
|
|
105
|
+
const { id, method, params } = request;
|
|
106
|
+
// Una notifica non ha a chi rispondere: qualunque cosa chieda, l'unica replica corretta è nessuna.
|
|
107
|
+
if (id === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
switch (method) {
|
|
110
|
+
case 'initialize': {
|
|
111
|
+
return (0, protocol_1.rpcResult)(id, initialize(params, session));
|
|
112
|
+
}
|
|
113
|
+
case 'ping': {
|
|
114
|
+
return (0, protocol_1.rpcResult)(id, {});
|
|
115
|
+
}
|
|
116
|
+
case 'tools/list': {
|
|
117
|
+
return (0, protocol_1.rpcResult)(id, { tools: (0, tools_1.toolDefinitions)() });
|
|
118
|
+
}
|
|
119
|
+
case 'tools/call': {
|
|
120
|
+
return callTool(id, params, session);
|
|
121
|
+
}
|
|
122
|
+
default: {
|
|
123
|
+
return (0, protocol_1.rpcFailure)(id, protocol_1.RPC_ERROR.methodNotFound, `Unknown method: ${method}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Serve il protocollo su due flussi: un messaggio JSON per riga in ingresso, un messaggio JSON per
|
|
129
|
+
* riga in uscita e niente altro. La promessa si chiude quando il flusso di ingresso finisce — o
|
|
130
|
+
* quando uno dei due si rompe, che è il modo in cui un client se ne va.
|
|
131
|
+
*/
|
|
132
|
+
function serveStdio(input, output, session) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const pending = new Set();
|
|
135
|
+
let buffer = '';
|
|
136
|
+
let closed = false;
|
|
137
|
+
// Una sola write per messaggio, a capo compreso: due write potrebbero intrecciarsi con quelle
|
|
138
|
+
// di una richiesta servita in parallelo e spezzare una riga a metà.
|
|
139
|
+
//
|
|
140
|
+
// Se scrivere fallisce non c'è un canale su cui dirlo, e provarci sarebbe peggio: l'errore
|
|
141
|
+
// uscirebbe da qui, risalirebbe fino al gestore che risponde ai guasti — che scrive anche lui —
|
|
142
|
+
// e finirebbe come rifiuto senza padrone, cioè un processo morto invece di un server che chiude.
|
|
143
|
+
const send = (response) => {
|
|
144
|
+
try {
|
|
145
|
+
output.write(`${JSON.stringify(response)}\n`);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
void finish();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const drain = async () => {
|
|
152
|
+
while (pending.size > 0) {
|
|
153
|
+
// eslint-disable-next-line no-await-in-loop
|
|
154
|
+
await Promise.all([...pending]);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
// Il timer non deve tenere vivo il processo per conto suo: se non resta altro da aspettare,
|
|
158
|
+
// uscire subito è la cosa giusta, e il ritardo serve solo finché c'è una chiamata in volo.
|
|
159
|
+
const grace = () => new Promise((done) => {
|
|
160
|
+
setTimeout(done, session.shutdownGraceMs).unref();
|
|
161
|
+
});
|
|
162
|
+
const finish = async () => {
|
|
163
|
+
if (closed)
|
|
164
|
+
return;
|
|
165
|
+
closed = true;
|
|
166
|
+
// Chi ha chiuso il canale non aspetta più nessuna risposta: le chiamate in volo hanno la loro
|
|
167
|
+
// finestra per finire e poi vengono interrotte. Senza interromperle davvero, la connessione
|
|
168
|
+
// aperta terrebbe vivo l'event loop — e con esso un sottoprocesso che il client ha già
|
|
169
|
+
// considerato chiuso — a prescindere da quando questa promessa si risolve.
|
|
170
|
+
await Promise.race([drain(), grace()]);
|
|
171
|
+
session.abort.abort();
|
|
172
|
+
await drain();
|
|
173
|
+
resolve();
|
|
174
|
+
};
|
|
175
|
+
const serve = (line) => {
|
|
176
|
+
const parsed = (0, protocol_1.parseLine)(line);
|
|
177
|
+
if (parsed.kind === 'ignore')
|
|
178
|
+
return;
|
|
179
|
+
if (parsed.kind === 'response') {
|
|
180
|
+
send(parsed.response);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const { request } = parsed;
|
|
184
|
+
const task = handleRequest(request, session)
|
|
185
|
+
.then((response) => {
|
|
186
|
+
if (response)
|
|
187
|
+
send(response);
|
|
188
|
+
})
|
|
189
|
+
.catch((error) => {
|
|
190
|
+
// Un guasto qui è nostro, non del client: si dichiara sul canale e il server resta in piedi.
|
|
191
|
+
if (request.id !== undefined)
|
|
192
|
+
send((0, protocol_1.rpcFailure)(request.id, protocol_1.RPC_ERROR.internal, toolErrorText(error)));
|
|
193
|
+
})
|
|
194
|
+
.finally(() => {
|
|
195
|
+
pending.delete(task);
|
|
196
|
+
});
|
|
197
|
+
pending.add(task);
|
|
198
|
+
};
|
|
199
|
+
input.setEncoding('utf8');
|
|
200
|
+
input.on('data', (chunk) => {
|
|
201
|
+
buffer += chunk;
|
|
202
|
+
let cut = buffer.indexOf('\n');
|
|
203
|
+
while (cut !== -1) {
|
|
204
|
+
serve(buffer.slice(0, cut));
|
|
205
|
+
buffer = buffer.slice(cut + 1);
|
|
206
|
+
cut = buffer.indexOf('\n');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
input.on('end', () => {
|
|
210
|
+
// L'ultima riga può arrivare senza a capo: chi chiude subito dopo averla scritta non deve
|
|
211
|
+
// perdere la richiesta che ha appena fatto.
|
|
212
|
+
serve(buffer);
|
|
213
|
+
buffer = '';
|
|
214
|
+
void finish();
|
|
215
|
+
});
|
|
216
|
+
// Un flusso che si rompe non è un guasto da raccontare: il client se n'è andato (stdin chiuso,
|
|
217
|
+
// uscita interrotta) e al server non resta che chiudere pulito.
|
|
218
|
+
input.on('error', () => void finish());
|
|
219
|
+
output.on('error', () => void finish());
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CliApi } from '../api';
|
|
2
|
+
/**
|
|
3
|
+
* Gli strumenti che il server MCP offre sulla conoscenza (CYCL-56).
|
|
4
|
+
*
|
|
5
|
+
* Ognuno è la chiamata che già fa il comando corrispondente, sullo stesso client HTTP e con lo
|
|
6
|
+
* stesso token: i limiti di chi si collega restano quelli che ha da riga di comando, senza che
|
|
7
|
+
* questo strato debba conoscerli. Cinque leggono; il sesto, `kb_propose`, è l'unico che scrive e
|
|
8
|
+
* manda la pagina in revisione SEMPRE — non c'è argomento che possa pubblicarla.
|
|
9
|
+
*/
|
|
10
|
+
/** Un argomento sbagliato: il modello che l'ha scritto può correggersi e riprovare. */
|
|
11
|
+
export declare class McpToolInputError extends Error {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
/** Quello che uno strumento sa del mondo attorno alla chiamata. */
|
|
15
|
+
export interface McpToolContext {
|
|
16
|
+
api: CliApi;
|
|
17
|
+
/** Cartella da cui il server è stato lanciato: è lì che `kb_context` cerca il remote git. */
|
|
18
|
+
cwd: string;
|
|
19
|
+
/** Quanto aspettare `git` prima di rinunciare a riconoscere il progetto. */
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
/** Nome del client dichiarato nell'handshake, usato per attribuire una proposta di pagina. */
|
|
22
|
+
clientName?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Suggerimenti che il client mostra prima di invocare: dicono chi legge soltanto e chi scrive. */
|
|
25
|
+
export interface McpToolAnnotations {
|
|
26
|
+
title: string;
|
|
27
|
+
readOnlyHint: boolean;
|
|
28
|
+
destructiveHint: boolean;
|
|
29
|
+
idempotentHint: boolean;
|
|
30
|
+
openWorldHint: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface McpTool {
|
|
33
|
+
name: string;
|
|
34
|
+
title: string;
|
|
35
|
+
description: string;
|
|
36
|
+
inputSchema: Record<string, unknown>;
|
|
37
|
+
annotations: McpToolAnnotations;
|
|
38
|
+
run(args: Record<string, unknown>, ctx: McpToolContext): Promise<unknown>;
|
|
39
|
+
}
|
|
40
|
+
export declare const MCP_TOOLS: McpTool[];
|
|
41
|
+
export declare function findTool(name: string): McpTool | undefined;
|
|
42
|
+
/** Il catalogo come lo vuole `tools/list`: tutto tranne il codice che esegue la chiamata. */
|
|
43
|
+
export declare function toolDefinitions(): Array<Omit<McpTool, 'run'>>;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MCP_TOOLS = exports.McpToolInputError = void 0;
|
|
4
|
+
exports.findTool = findTool;
|
|
5
|
+
exports.toolDefinitions = toolDefinitions;
|
|
6
|
+
const error_codes_1 = require("../../errors/error-codes");
|
|
7
|
+
const knowledge_1 = require("../knowledge");
|
|
8
|
+
const knowledge_context_1 = require("../knowledge-context");
|
|
9
|
+
const limits_1 = require("../limits");
|
|
10
|
+
const lookup_1 = require("../lookup");
|
|
11
|
+
/**
|
|
12
|
+
* Gli strumenti che il server MCP offre sulla conoscenza (CYCL-56).
|
|
13
|
+
*
|
|
14
|
+
* Ognuno è la chiamata che già fa il comando corrispondente, sullo stesso client HTTP e con lo
|
|
15
|
+
* stesso token: i limiti di chi si collega restano quelli che ha da riga di comando, senza che
|
|
16
|
+
* questo strato debba conoscerli. Cinque leggono; il sesto, `kb_propose`, è l'unico che scrive e
|
|
17
|
+
* manda la pagina in revisione SEMPRE — non c'è argomento che possa pubblicarla.
|
|
18
|
+
*/
|
|
19
|
+
/** Un argomento sbagliato: il modello che l'ha scritto può correggersi e riprovare. */
|
|
20
|
+
class McpToolInputError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'McpToolInputError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.McpToolInputError = McpToolInputError;
|
|
27
|
+
/** Attribuzione di una proposta quando il client non si è presentato e nessuno l'ha dichiarata. */
|
|
28
|
+
const DEFAULT_AUTHOR_ORIGIN = 'mcp';
|
|
29
|
+
function optionalString(args, key) {
|
|
30
|
+
const raw = args[key];
|
|
31
|
+
if (raw === undefined || raw === null)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (typeof raw !== 'string')
|
|
34
|
+
throw new McpToolInputError(`"${key}" must be a string.`);
|
|
35
|
+
const trimmed = raw.trim();
|
|
36
|
+
return trimmed === '' ? undefined : trimmed;
|
|
37
|
+
}
|
|
38
|
+
function requiredString(args, key) {
|
|
39
|
+
const value = optionalString(args, key);
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
throw new McpToolInputError(`"${key}" is required and cannot be empty.`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Un interruttore è acceso solo se vale davvero `true`. Nessun'altra forma conta come sì: uno
|
|
46
|
+
* strumento che leggesse `"false"` come acceso farebbe l'opposto di quello che gli è stato chiesto.
|
|
47
|
+
*/
|
|
48
|
+
function flag(args, key) {
|
|
49
|
+
return args[key] === true ? true : undefined;
|
|
50
|
+
}
|
|
51
|
+
function optionalInteger(args, key, min) {
|
|
52
|
+
const raw = args[key];
|
|
53
|
+
if (raw === undefined || raw === null)
|
|
54
|
+
return undefined;
|
|
55
|
+
const value = typeof raw === 'string' ? Number(raw) : raw;
|
|
56
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
57
|
+
throw new McpToolInputError(`"${key}" must be a whole number.`);
|
|
58
|
+
}
|
|
59
|
+
if (value < min)
|
|
60
|
+
throw new McpToolInputError(`"${key}" must be at least ${min}.`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
/** Un campo ripetibile, accettato sia come singolo valore sia come lista (i client fanno entrambi). */
|
|
64
|
+
function stringList(args, key) {
|
|
65
|
+
const raw = args[key];
|
|
66
|
+
if (raw === undefined || raw === null)
|
|
67
|
+
return [];
|
|
68
|
+
return (0, knowledge_1.cleanList)((Array.isArray(raw) ? raw : [raw]).map((value) => String(value)));
|
|
69
|
+
}
|
|
70
|
+
/** La busta del server così com'è: gli stessi dati che `cyi … --json` stampa sul terminale. */
|
|
71
|
+
function payload(envelope) {
|
|
72
|
+
return envelope.meta === undefined ? { data: envelope.data } : { data: envelope.data, meta: envelope.meta };
|
|
73
|
+
}
|
|
74
|
+
const PROJECT_PROPERTY = {
|
|
75
|
+
type: 'string',
|
|
76
|
+
description: 'Project key (e.g. ACME) or UUID. Only projects your access already includes.',
|
|
77
|
+
};
|
|
78
|
+
const QUESTION_PROPERTY = {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'Keep only the related pages relevant to this question.',
|
|
81
|
+
};
|
|
82
|
+
exports.MCP_TOOLS = [
|
|
83
|
+
{
|
|
84
|
+
name: 'kb_search',
|
|
85
|
+
title: 'Search knowledge pages',
|
|
86
|
+
description: 'Search the CloseYourIt knowledge base (semantic when available, title match otherwise) and return the matching pages. Only pages your access already includes are ever returned.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
query: { type: 'string', description: 'What to look for, in plain words.' },
|
|
91
|
+
project: PROJECT_PROPERTY,
|
|
92
|
+
kind: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
items: { type: 'string', enum: [...knowledge_1.KNOWLEDGE_KINDS] },
|
|
95
|
+
description: 'Keep only these page kinds.',
|
|
96
|
+
},
|
|
97
|
+
status: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
enum: [...knowledge_1.KNOWLEDGE_STATUSES],
|
|
100
|
+
description: 'Review state to search in. Omitted, only accepted pages; "all" also finds proposals waiting for review — use it before proposing a page, to see a duplicate that is not accepted yet.',
|
|
101
|
+
},
|
|
102
|
+
page: { type: 'integer', minimum: 1, description: 'Result page (default 1).' },
|
|
103
|
+
per: { type: 'integer', minimum: 1, description: 'Page size.' },
|
|
104
|
+
with_related: { type: 'boolean', description: 'Also return the pages each result links to.' },
|
|
105
|
+
},
|
|
106
|
+
required: ['query'],
|
|
107
|
+
},
|
|
108
|
+
annotations: {
|
|
109
|
+
title: 'Search knowledge pages',
|
|
110
|
+
readOnlyHint: true,
|
|
111
|
+
destructiveHint: false,
|
|
112
|
+
idempotentHint: true,
|
|
113
|
+
openWorldHint: true,
|
|
114
|
+
},
|
|
115
|
+
async run(args, { api }) {
|
|
116
|
+
const query = (0, knowledge_1.buildPagesQuery)({
|
|
117
|
+
kinds: (0, knowledge_1.normalizeKinds)(stringList(args, 'kind')),
|
|
118
|
+
page: optionalInteger(args, 'page', 1) ?? 1,
|
|
119
|
+
per: optionalInteger(args, 'per', 1),
|
|
120
|
+
project: optionalString(args, 'project'),
|
|
121
|
+
q: requiredString(args, 'query'),
|
|
122
|
+
related: flag(args, 'with_related'),
|
|
123
|
+
status: optionalString(args, 'status'),
|
|
124
|
+
});
|
|
125
|
+
return payload(await api.get(`/cli/v1/knowledge/pages?${query}`));
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'kb_ask',
|
|
130
|
+
title: 'Ask the knowledge base',
|
|
131
|
+
description: 'Ask a natural-language question; the CloseYourIt AI answers from the knowledge pages you can see and cites the ones it used. Takes a few seconds.',
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
properties: { question: { type: 'string', description: 'The question, in plain words.' } },
|
|
135
|
+
required: ['question'],
|
|
136
|
+
},
|
|
137
|
+
annotations: {
|
|
138
|
+
title: 'Ask the knowledge base',
|
|
139
|
+
readOnlyHint: true,
|
|
140
|
+
destructiveHint: false,
|
|
141
|
+
idempotentHint: false,
|
|
142
|
+
openWorldHint: true,
|
|
143
|
+
},
|
|
144
|
+
async run(args, { api }) {
|
|
145
|
+
return payload(await api.post('/cli/v1/knowledge/ask', { question: requiredString(args, 'question') }));
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'kb_show',
|
|
150
|
+
title: 'Read a knowledge page',
|
|
151
|
+
description: 'Read one knowledge page in full: metadata, markdown body and technical section, optionally with the pages to read next.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
id: { type: 'string', description: 'Knowledge page id, as returned by kb_search or kb_context.' },
|
|
156
|
+
related: { type: 'boolean', description: 'Also list the pages to read next.' },
|
|
157
|
+
question: QUESTION_PROPERTY,
|
|
158
|
+
},
|
|
159
|
+
required: ['id'],
|
|
160
|
+
},
|
|
161
|
+
annotations: {
|
|
162
|
+
title: 'Read a knowledge page',
|
|
163
|
+
readOnlyHint: true,
|
|
164
|
+
destructiveHint: false,
|
|
165
|
+
idempotentHint: true,
|
|
166
|
+
openWorldHint: true,
|
|
167
|
+
},
|
|
168
|
+
async run(args, { api }) {
|
|
169
|
+
const id = requiredString(args, 'id');
|
|
170
|
+
const query = new URLSearchParams();
|
|
171
|
+
const question = optionalString(args, 'question');
|
|
172
|
+
if (question)
|
|
173
|
+
query.set('question', question);
|
|
174
|
+
else if (flag(args, 'related'))
|
|
175
|
+
query.set('related', '1');
|
|
176
|
+
const suffix = query.toString() === '' ? '' : `?${query.toString()}`;
|
|
177
|
+
return payload(await api.get(`/cli/v1/knowledge/pages/${encodeURIComponent(id)}${suffix}`));
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'kb_related',
|
|
182
|
+
title: 'Pages to read next',
|
|
183
|
+
description: 'List the pages to read next: the ones this page links to (and that link to it), plus close matches by meaning.',
|
|
184
|
+
inputSchema: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
id: { type: 'string', description: 'Knowledge page id.' },
|
|
188
|
+
question: QUESTION_PROPERTY,
|
|
189
|
+
links_only: { type: 'boolean', description: 'Only pages linked with [[wiki links]], no close matches.' },
|
|
190
|
+
},
|
|
191
|
+
required: ['id'],
|
|
192
|
+
},
|
|
193
|
+
annotations: {
|
|
194
|
+
title: 'Pages to read next',
|
|
195
|
+
readOnlyHint: true,
|
|
196
|
+
destructiveHint: false,
|
|
197
|
+
idempotentHint: true,
|
|
198
|
+
openWorldHint: true,
|
|
199
|
+
},
|
|
200
|
+
async run(args, { api }) {
|
|
201
|
+
const id = requiredString(args, 'id');
|
|
202
|
+
const query = new URLSearchParams();
|
|
203
|
+
const question = optionalString(args, 'question');
|
|
204
|
+
if (question)
|
|
205
|
+
query.set('question', question);
|
|
206
|
+
if (flag(args, 'links_only'))
|
|
207
|
+
query.set('links_only', '1');
|
|
208
|
+
const suffix = query.toString() === '' ? '' : `?${query.toString()}`;
|
|
209
|
+
return payload(await api.get(`/cli/v1/knowledge/pages/${encodeURIComponent(id)}/related${suffix}`));
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'kb_context',
|
|
214
|
+
title: 'Knowledge context of this project',
|
|
215
|
+
description: 'The short list of pages whoever starts working on this project should already know. Without a project it is recognised from the git remote of the directory the server was started in; outside a project the list is simply empty.',
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
properties: {
|
|
219
|
+
project: PROJECT_PROPERTY,
|
|
220
|
+
limit: { type: 'integer', minimum: 1, description: 'How many pages at most (default 10).' },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
annotations: {
|
|
224
|
+
title: 'Knowledge context of this project',
|
|
225
|
+
readOnlyHint: true,
|
|
226
|
+
destructiveHint: false,
|
|
227
|
+
idempotentHint: true,
|
|
228
|
+
openWorldHint: true,
|
|
229
|
+
},
|
|
230
|
+
async run(args, { api, cwd, timeoutMs }) {
|
|
231
|
+
const limit = optionalInteger(args, 'limit', 1) ?? 10;
|
|
232
|
+
const project = optionalString(args, 'project') ?? (await (0, knowledge_context_1.projectFromGitRemote)(api, cwd, timeoutMs));
|
|
233
|
+
// Fuori da un progetto non c'è niente da sapere: uno stato normale, non un guasto (CYCL-55).
|
|
234
|
+
if (project === undefined)
|
|
235
|
+
return { data: { project: null, pages: [] } };
|
|
236
|
+
const answer = await (0, knowledge_context_1.fetchKnowledgeContext)(api, project, limit);
|
|
237
|
+
// Il tetto vale sulla risposta, non solo sulla richiesta: nulla garantisce che la rotta del
|
|
238
|
+
// contesto onori `per`, e chi ha chiesto cinque pagine non deve riceverne dieci.
|
|
239
|
+
return payload({ ...answer, data: (0, knowledge_1.capContextPages)(answer.data, limit) });
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: 'kb_propose',
|
|
244
|
+
title: 'Propose a knowledge page',
|
|
245
|
+
description: 'Propose a new knowledge page. It ALWAYS enters the review queue and waits for a person to accept it: it stays out of search, answers and related panels until then, and nothing this tool receives can publish it directly.',
|
|
246
|
+
inputSchema: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
properties: {
|
|
249
|
+
title: { type: 'string', description: `Page title (max ${limits_1.LENGTH_LIMITS.title} characters).` },
|
|
250
|
+
body: { type: 'string', description: `Page body in markdown (max ${limits_1.LENGTH_LIMITS.pageBody} characters).` },
|
|
251
|
+
project: {
|
|
252
|
+
type: 'array',
|
|
253
|
+
items: { type: 'string' },
|
|
254
|
+
description: 'Project keys or UUIDs the page belongs to. At least one project or group is required.',
|
|
255
|
+
},
|
|
256
|
+
group: {
|
|
257
|
+
type: 'array',
|
|
258
|
+
items: { type: 'string' },
|
|
259
|
+
description: 'Group names or UUIDs the page belongs to.',
|
|
260
|
+
},
|
|
261
|
+
tag: { type: 'array', items: { type: 'string' }, description: 'Tags for the page.' },
|
|
262
|
+
kind: { type: 'string', enum: [...knowledge_1.KNOWLEDGE_KINDS], description: 'Page kind (default note).' },
|
|
263
|
+
tech_spec: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
description: `Technical section, kept separate from the body (max ${limits_1.LENGTH_LIMITS.techSpec} characters).`,
|
|
266
|
+
},
|
|
267
|
+
review_note: {
|
|
268
|
+
type: 'string',
|
|
269
|
+
description: `One line for the reviewer on why the page is worth keeping (max ${limits_1.LENGTH_LIMITS.reviewNote} characters).`,
|
|
270
|
+
},
|
|
271
|
+
author_origin: {
|
|
272
|
+
type: 'string',
|
|
273
|
+
description: 'Name of the assistant or skill that wrote the text; defaults to the connected client.',
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
required: ['title', 'body'],
|
|
277
|
+
},
|
|
278
|
+
annotations: {
|
|
279
|
+
title: 'Propose a knowledge page',
|
|
280
|
+
readOnlyHint: false,
|
|
281
|
+
// Nulla viene sovrascritto o pubblicato: la proposta si aggiunge alla coda di revisione.
|
|
282
|
+
destructiveHint: false,
|
|
283
|
+
idempotentHint: false,
|
|
284
|
+
openWorldHint: true,
|
|
285
|
+
},
|
|
286
|
+
async run(args, { api, clientName }) {
|
|
287
|
+
const title = requiredString(args, 'title');
|
|
288
|
+
const body = requiredString(args, 'body');
|
|
289
|
+
const techSpec = optionalString(args, 'tech_spec');
|
|
290
|
+
const reviewNote = optionalString(args, 'review_note');
|
|
291
|
+
const tooLong = (0, limits_1.tooLongMessage)([
|
|
292
|
+
{ flag: 'title', value: title, max: limits_1.LENGTH_LIMITS.title },
|
|
293
|
+
{ flag: 'body', value: body, max: limits_1.LENGTH_LIMITS.pageBody },
|
|
294
|
+
{ flag: 'tech_spec', value: techSpec, max: limits_1.LENGTH_LIMITS.techSpec },
|
|
295
|
+
{ flag: 'review_note', value: reviewNote, max: limits_1.LENGTH_LIMITS.reviewNote },
|
|
296
|
+
]);
|
|
297
|
+
if (tooLong)
|
|
298
|
+
throw new McpToolInputError(tooLong);
|
|
299
|
+
const [primary, ...extraProjects] = stringList(args, 'project');
|
|
300
|
+
const groupRefs = stringList(args, 'group');
|
|
301
|
+
// Una pagina senza scope il server la creerebbe a livello di organizzazione, cioè visibile
|
|
302
|
+
// soltanto a chi ha accesso pieno — invisibile a chi l'ha appena proposta.
|
|
303
|
+
if (primary === undefined && groupRefs.length === 0) {
|
|
304
|
+
throw new McpToolInputError('Name at least one "project" or "group": a knowledge page has to belong somewhere.');
|
|
305
|
+
}
|
|
306
|
+
const projectIds = await Promise.all(extraProjects.map((ref) => (0, lookup_1.resolveProjectId)(api, ref)));
|
|
307
|
+
const groupIds = await Promise.all(groupRefs.map((ref) => (0, lookup_1.resolveNamedId)(api, '/cli/v1/groups', ref, error_codes_1.ErrorCodes.Group.notFound, 'Group')));
|
|
308
|
+
const tags = stringList(args, 'tag');
|
|
309
|
+
const res = await api.post('/cli/v1/knowledge/pages', {
|
|
310
|
+
...(primary !== undefined && { project: primary }),
|
|
311
|
+
...(projectIds.length > 0 && { project_ids: projectIds }),
|
|
312
|
+
...(groupIds.length > 0 && { group_ids: groupIds }),
|
|
313
|
+
...(tags.length > 0 && { tags }),
|
|
314
|
+
title,
|
|
315
|
+
kind: optionalString(args, 'kind') ?? 'note',
|
|
316
|
+
body,
|
|
317
|
+
tech_spec: techSpec,
|
|
318
|
+
// Scritto qui e mai letto dagli argomenti: è la garanzia che la revisione umana non si
|
|
319
|
+
// possa saltare da questo lato, qualunque cosa chieda chi invoca lo strumento.
|
|
320
|
+
in_review: true,
|
|
321
|
+
review_note: reviewNote,
|
|
322
|
+
author_origin: optionalString(args, 'author_origin') ?? clientName ?? DEFAULT_AUTHOR_ORIGIN,
|
|
323
|
+
});
|
|
324
|
+
return payload(res);
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
function findTool(name) {
|
|
329
|
+
return exports.MCP_TOOLS.find((tool) => tool.name === name);
|
|
330
|
+
}
|
|
331
|
+
/** Il catalogo come lo vuole `tools/list`: tutto tranne il codice che esegue la chiamata. */
|
|
332
|
+
function toolDefinitions() {
|
|
333
|
+
return exports.MCP_TOOLS.map(({ name, title, description, inputSchema, annotations }) => ({
|
|
334
|
+
name,
|
|
335
|
+
title,
|
|
336
|
+
description,
|
|
337
|
+
inputSchema,
|
|
338
|
+
annotations,
|
|
339
|
+
}));
|
|
340
|
+
}
|