biaws-mcp 0.3.0 → 0.5.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 +37 -3
- package/package.json +1 -1
- package/src/domains/issues/service.js +28 -0
- package/src/httpClient.js +59 -9
- package/src/index.js +109 -7
- package/src/logger.js +97 -0
- package/src/mcpServer.js +55 -6
- package/src/requestContext.js +4 -0
- package/src/tools.js +54 -0
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -66,13 +66,13 @@ O pacote público expõe o executável `biaws-mcp`. Clientes configurados pelo C
|
|
|
66
66
|
usam uma versão fixada por meio do cache local do npm:
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
npx --yes biaws-mcp@0.
|
|
69
|
+
npx --yes biaws-mcp@0.5.0
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
Também é possível instalá-lo explicitamente:
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
npm install --global biaws-mcp@0.
|
|
75
|
+
npm install --global biaws-mcp@0.5.0
|
|
76
76
|
biaws-mcp
|
|
77
77
|
```
|
|
78
78
|
|
|
@@ -91,7 +91,7 @@ npm start
|
|
|
91
91
|
Em um cliente MCP, configure o comando:
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
|
-
npx --yes biaws-mcp@0.
|
|
94
|
+
npx --yes biaws-mcp@0.5.0
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
O fluxo recomendado é gerar a configuração completa com:
|
|
@@ -105,6 +105,36 @@ node /caminho/para/biaws/biaws-cli/src/index.js \
|
|
|
105
105
|
O processo de publicação e rollback está em
|
|
106
106
|
[`docs/releasing.md`](docs/releasing.md).
|
|
107
107
|
|
|
108
|
+
## Diagnóstico do transporte
|
|
109
|
+
|
|
110
|
+
A partir da versão 0.5.0, o processo escreve um evento JSON por linha em
|
|
111
|
+
`stderr`. O `stdout` permanece exclusivo para frames JSON-RPC do MCP. Cada
|
|
112
|
+
execução recebe um `executionId` e cada chamada de tool recebe um `requestId`,
|
|
113
|
+
permitindo correlacionar ciclo de vida, duração, tentativas HTTP, cancelamento e
|
|
114
|
+
falhas de processo sem registrar argumentos, respostas ou credenciais.
|
|
115
|
+
|
|
116
|
+
Os eventos principais são:
|
|
117
|
+
|
|
118
|
+
- `mcp_server_started`, `mcp_shutdown_requested` e `mcp_server_stopped`;
|
|
119
|
+
- `mcp_tool_call_started`, `mcp_tool_call_completed`,
|
|
120
|
+
`mcp_tool_call_failed` e `mcp_tool_call_cancelled`;
|
|
121
|
+
- `mcp_http_attempt_completed`, `mcp_http_attempt_failed` e
|
|
122
|
+
`mcp_http_retry_scheduled`;
|
|
123
|
+
- `mcp_input_error`, `mcp_stdout_error`, `mcp_uncaught_exception` e
|
|
124
|
+
`mcp_unhandled_rejection`.
|
|
125
|
+
|
|
126
|
+
`BIAWS_MCP_LOG_LEVEL` aceita `debug`, `info`, `warn` ou `error` e usa `info` por
|
|
127
|
+
padrão. O nível `debug` também registra o início de cada tentativa HTTP. O MCP
|
|
128
|
+
seleciona somente origem, método, status, duração e dados de correlação; URLs
|
|
129
|
+
completas, query strings, headers, argumentos, payloads e valores de ambiente
|
|
130
|
+
não são emitidos.
|
|
131
|
+
|
|
132
|
+
Para investigar `Transport closed`, preserve o `stderr` do processo configurado
|
|
133
|
+
pelo cliente MCP e procure, usando o mesmo `executionId`, por
|
|
134
|
+
`mcp_stdout_error`, eventos fatais, sinais ou encerramentos sem
|
|
135
|
+
`mcp_server_stopped`. Um `mcp_tool_call_failed` com resposta MCP válida indica
|
|
136
|
+
falha funcional ou da API, não fechamento do transporte.
|
|
137
|
+
|
|
108
138
|
## Ferramentas
|
|
109
139
|
|
|
110
140
|
### Catálogo e contexto de aplicações
|
|
@@ -213,6 +243,10 @@ conhecimento.
|
|
|
213
243
|
- `issues_search`: busca issues com os mesmos filtros principais da
|
|
214
244
|
`biaws-api`, inclusive por workspace, aplicação e componente afetado.
|
|
215
245
|
- `issues_get`: obtém uma issue com comentários e metadados dos anexos.
|
|
246
|
+
- `issues_add_comment`: adiciona um comentário em Markdown a uma issue
|
|
247
|
+
existente; a autoria é atribuída pela identidade autenticada na API.
|
|
248
|
+
- `issues_update_comment`: atualiza o conteúdo e, opcionalmente, a data de um
|
|
249
|
+
comentário existente, preservando sua autoria original.
|
|
216
250
|
- `issues_get_classification_catalog`: obtém a árvore taxonômica e os grupos de tags válidos; opcionalmente inclui listas achatadas de IDs e caminhos.
|
|
217
251
|
- `issues_create_taxonomy_item`: inclui um item na raiz ou sob outro item,
|
|
218
252
|
opcionalmente configurando seu escopo por aplicações.
|
package/package.json
CHANGED
|
@@ -130,6 +130,34 @@ export async function getIssueDetails(args = {}) {
|
|
|
130
130
|
return fetchJson(`/api/issues/${encodeURIComponent(args.issueId)}`);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
export async function addIssueComment(args = {}) {
|
|
134
|
+
const issueId = String(args.issueId || "").trim();
|
|
135
|
+
const text = String(args.text || "").trim();
|
|
136
|
+
if (!issueId) throw new Error("issueId is required");
|
|
137
|
+
if (!text) throw new Error("text is required");
|
|
138
|
+
|
|
139
|
+
return sendJson(
|
|
140
|
+
`/api/issues/${encodeURIComponent(issueId)}/comments`,
|
|
141
|
+
cleanParams({ text, date: args.date }),
|
|
142
|
+
{},
|
|
143
|
+
"POST",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function updateIssueComment(args = {}) {
|
|
148
|
+
const issueId = String(args.issueId || "").trim();
|
|
149
|
+
const commentId = String(args.commentId || "").trim();
|
|
150
|
+
const text = String(args.text || "").trim();
|
|
151
|
+
if (!issueId) throw new Error("issueId is required");
|
|
152
|
+
if (!commentId) throw new Error("commentId is required");
|
|
153
|
+
if (!text) throw new Error("text is required");
|
|
154
|
+
|
|
155
|
+
return sendJson(
|
|
156
|
+
`/api/issues/${encodeURIComponent(issueId)}/comments/${encodeURIComponent(commentId)}`,
|
|
157
|
+
cleanParams({ text, date: args.date }),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
export async function getIssueClassificationCatalog(args = {}) {
|
|
134
162
|
const payload = await fetchJson(
|
|
135
163
|
"/api/issues/taxonomy",
|
package/src/httpClient.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
currentRequestContext,
|
|
3
|
+
currentRequestSignal,
|
|
4
|
+
} from "./requestContext.js";
|
|
2
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
6
|
|
|
4
7
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
@@ -145,9 +148,10 @@ function attachmentSizeError(maxBytes, actualBytes) {
|
|
|
145
148
|
return error;
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
function createRequestContext(path, params, maxRetries) {
|
|
151
|
+
function createRequestContext(path, params, maxRetries, method) {
|
|
149
152
|
const url = buildUrl(path, params);
|
|
150
153
|
const externalSignal = currentRequestSignal();
|
|
154
|
+
const mcpContext = currentRequestContext();
|
|
151
155
|
const timeoutSignal = AbortSignal.timeout(readTimeoutMs());
|
|
152
156
|
return {
|
|
153
157
|
url,
|
|
@@ -156,6 +160,10 @@ function createRequestContext(path, params, maxRetries) {
|
|
|
156
160
|
? AbortSignal.any([externalSignal, timeoutSignal])
|
|
157
161
|
: timeoutSignal,
|
|
158
162
|
maxRetries,
|
|
163
|
+
method: method || "GET",
|
|
164
|
+
logger: mcpContext?.logger,
|
|
165
|
+
requestId: mcpContext?.requestId,
|
|
166
|
+
tool: mcpContext?.tool,
|
|
159
167
|
};
|
|
160
168
|
}
|
|
161
169
|
|
|
@@ -174,8 +182,17 @@ function shouldRetryRequest(error, attempt, context) {
|
|
|
174
182
|
}
|
|
175
183
|
|
|
176
184
|
async function waitBeforeRetry(attempt, context) {
|
|
185
|
+
const backoffMs = 100 * 2 ** attempt;
|
|
186
|
+
context.logger?.warn("mcp_http_retry_scheduled", {
|
|
187
|
+
requestId: context.requestId,
|
|
188
|
+
tool: context.tool,
|
|
189
|
+
method: context.method,
|
|
190
|
+
origin: context.url.origin,
|
|
191
|
+
nextAttempt: attempt + 2,
|
|
192
|
+
backoffMs,
|
|
193
|
+
});
|
|
177
194
|
try {
|
|
178
|
-
await delay(
|
|
195
|
+
await delay(backoffMs, undefined, { signal: context.signal });
|
|
179
196
|
} catch (error) {
|
|
180
197
|
throw transportError(error, context.url, context.externalSignal);
|
|
181
198
|
}
|
|
@@ -183,11 +200,40 @@ async function waitBeforeRetry(attempt, context) {
|
|
|
183
200
|
|
|
184
201
|
async function requestWithRetries(context, operation) {
|
|
185
202
|
for (let attempt = 0; ; attempt += 1) {
|
|
203
|
+
const startedAt = Date.now();
|
|
204
|
+
context.logger?.debug("mcp_http_attempt_started", {
|
|
205
|
+
requestId: context.requestId,
|
|
206
|
+
tool: context.tool,
|
|
207
|
+
method: context.method,
|
|
208
|
+
origin: context.url.origin,
|
|
209
|
+
attempt: attempt + 1,
|
|
210
|
+
});
|
|
186
211
|
try {
|
|
187
|
-
|
|
212
|
+
const result = await operation(context);
|
|
213
|
+
context.logger?.info("mcp_http_attempt_completed", {
|
|
214
|
+
requestId: context.requestId,
|
|
215
|
+
tool: context.tool,
|
|
216
|
+
method: context.method,
|
|
217
|
+
origin: context.url.origin,
|
|
218
|
+
attempt: attempt + 1,
|
|
219
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
220
|
+
statusCode: context.responseStatus,
|
|
221
|
+
});
|
|
222
|
+
return result;
|
|
188
223
|
} catch (cause) {
|
|
189
224
|
const error = normalizeRequestError(cause, context);
|
|
190
|
-
|
|
225
|
+
const retry = shouldRetryRequest(error, attempt, context);
|
|
226
|
+
context.logger?.[retry ? "warn" : "error"]("mcp_http_attempt_failed", {
|
|
227
|
+
requestId: context.requestId,
|
|
228
|
+
tool: context.tool,
|
|
229
|
+
method: context.method,
|
|
230
|
+
origin: context.url.origin,
|
|
231
|
+
attempt: attempt + 1,
|
|
232
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
233
|
+
willRetry: retry,
|
|
234
|
+
error,
|
|
235
|
+
});
|
|
236
|
+
if (!retry) throw error;
|
|
191
237
|
await waitBeforeRetry(attempt, context);
|
|
192
238
|
}
|
|
193
239
|
}
|
|
@@ -217,14 +263,16 @@ async function requestJson(
|
|
|
217
263
|
{ method, body, params = {}, headers = {} } = {},
|
|
218
264
|
) {
|
|
219
265
|
const maxRetries = !method || method === "GET" ? readMaxRetries() : 0;
|
|
220
|
-
const context = createRequestContext(path, params, maxRetries);
|
|
221
|
-
return requestWithRetries(context, async (
|
|
266
|
+
const context = createRequestContext(path, params, maxRetries, method);
|
|
267
|
+
return requestWithRetries(context, async (requestContext) => {
|
|
268
|
+
const { url, signal } = requestContext;
|
|
222
269
|
const response = await fetch(url, {
|
|
223
270
|
...(method ? { method } : {}),
|
|
224
271
|
headers: { ...authenticationHeaders(), ...headers },
|
|
225
272
|
body,
|
|
226
273
|
signal,
|
|
227
274
|
});
|
|
275
|
+
requestContext.responseStatus = response.status;
|
|
228
276
|
const payload = await readPayload(response);
|
|
229
277
|
if (!response.ok) throw responseError(response, payload);
|
|
230
278
|
return payload;
|
|
@@ -235,12 +283,14 @@ async function requestBinary(
|
|
|
235
283
|
path,
|
|
236
284
|
{ params = {}, headers = {}, maxBytes } = {},
|
|
237
285
|
) {
|
|
238
|
-
const context = createRequestContext(path, params, readMaxRetries());
|
|
239
|
-
return requestWithRetries(context, async (
|
|
286
|
+
const context = createRequestContext(path, params, readMaxRetries(), "GET");
|
|
287
|
+
return requestWithRetries(context, async (requestContext) => {
|
|
288
|
+
const { url, signal } = requestContext;
|
|
240
289
|
const response = await fetch(url, {
|
|
241
290
|
headers: { ...authenticationHeaders(), ...headers },
|
|
242
291
|
signal,
|
|
243
292
|
});
|
|
293
|
+
requestContext.responseStatus = response.status;
|
|
244
294
|
if (!response.ok) {
|
|
245
295
|
throw responseError(response, await readPayload(response));
|
|
246
296
|
}
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
|
|
5
|
+
import { createLogger } from "./logger.js";
|
|
4
6
|
import { createMcpMessageHandler, protocolError } from "./mcpServer.js";
|
|
5
7
|
import { dispatchTool, listTools } from "./tools.js";
|
|
6
8
|
import { loadEnv } from "./loadEnv.js";
|
|
9
|
+
import { SERVER_NAME, SERVER_VERSION } from "./version.js";
|
|
7
10
|
|
|
8
11
|
const TOOL_DIR = path.resolve(
|
|
9
12
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -12,9 +15,27 @@ const TOOL_DIR = path.resolve(
|
|
|
12
15
|
|
|
13
16
|
// O workspace pertence à configuração do projeto/cliente MCP. O arquivo da
|
|
14
17
|
// instância fornece URL e credencial, mas não pode mudar esse escopo.
|
|
15
|
-
loadEnv(TOOL_DIR, {
|
|
18
|
+
const environment = loadEnv(TOOL_DIR, {
|
|
19
|
+
preserve: ["BIAWS_WORKSPACE_ID"],
|
|
20
|
+
});
|
|
21
|
+
const executionId = randomUUID();
|
|
22
|
+
const startedAt = Date.now();
|
|
23
|
+
const logger = createLogger({
|
|
24
|
+
service: SERVER_NAME,
|
|
25
|
+
version: SERVER_VERSION,
|
|
26
|
+
executionId,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// A broken stderr cannot be reported anywhere safely. Keep it from becoming a
|
|
30
|
+
// second fatal error while preserving stdout exclusively for MCP frames.
|
|
31
|
+
process.stderr.on("error", () => {});
|
|
16
32
|
|
|
17
33
|
function writeMessage(message) {
|
|
34
|
+
if (process.stdout.destroyed || process.stdout.writableEnded) {
|
|
35
|
+
const error = new Error("MCP stdout is not writable");
|
|
36
|
+
error.code = "MCP_STDOUT_CLOSED";
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
18
39
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
19
40
|
}
|
|
20
41
|
|
|
@@ -22,8 +43,20 @@ const server = createMcpMessageHandler({
|
|
|
22
43
|
dispatchTool,
|
|
23
44
|
listTools,
|
|
24
45
|
writeMessage,
|
|
46
|
+
logger,
|
|
25
47
|
});
|
|
26
48
|
let buffer = "";
|
|
49
|
+
let shuttingDown = false;
|
|
50
|
+
|
|
51
|
+
logger.info("mcp_server_started", {
|
|
52
|
+
nodeVersion: process.versions.node,
|
|
53
|
+
pid: process.pid,
|
|
54
|
+
transport: "stdio",
|
|
55
|
+
environmentFilesLoaded: environment.loaded.length,
|
|
56
|
+
workspaceConfigured: Boolean(
|
|
57
|
+
String(process.env.BIAWS_WORKSPACE_ID || "").trim(),
|
|
58
|
+
),
|
|
59
|
+
});
|
|
27
60
|
|
|
28
61
|
process.stdin.setEncoding("utf8");
|
|
29
62
|
process.stdin.on("data", (chunk) => {
|
|
@@ -35,23 +68,92 @@ process.stdin.on("data", (chunk) => {
|
|
|
35
68
|
if (!line.trim()) continue;
|
|
36
69
|
try {
|
|
37
70
|
void server.accept(JSON.parse(line));
|
|
38
|
-
} catch {
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logger.warn("mcp_input_parse_failed", {
|
|
73
|
+
bytes: Buffer.byteLength(line),
|
|
74
|
+
error,
|
|
75
|
+
});
|
|
39
76
|
writeMessage(protocolError(null, -32700, "Parse error"));
|
|
40
77
|
}
|
|
41
78
|
}
|
|
42
79
|
});
|
|
43
80
|
|
|
44
81
|
process.stdin.on("end", () => {
|
|
45
|
-
|
|
82
|
+
logger.info("mcp_input_closed", { bufferedBytes: Buffer.byteLength(buffer) });
|
|
83
|
+
void shutdown({ reason: "stdin_end" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
process.stdin.on("error", (error) => {
|
|
87
|
+
logger.error("mcp_input_error", { error });
|
|
88
|
+
void shutdown({ reason: "stdin_error", exitCode: 1, cancel: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
process.stdout.on("error", (error) => {
|
|
92
|
+
logger.error("mcp_stdout_error", { error });
|
|
93
|
+
void shutdown({ reason: "stdout_error", exitCode: 1, cancel: true });
|
|
46
94
|
});
|
|
47
95
|
|
|
48
|
-
function shutdown() {
|
|
49
|
-
|
|
96
|
+
async function shutdown({ reason, signal, exitCode = 0, cancel = false }) {
|
|
97
|
+
if (shuttingDown) return;
|
|
98
|
+
shuttingDown = true;
|
|
99
|
+
logger.info("mcp_shutdown_requested", {
|
|
100
|
+
reason,
|
|
101
|
+
signal,
|
|
102
|
+
exitCode,
|
|
103
|
+
cancelActiveRequests: cancel,
|
|
104
|
+
});
|
|
105
|
+
if (cancel) server.cancelAll();
|
|
106
|
+
|
|
107
|
+
const timeoutMs = 5_000;
|
|
108
|
+
let timedOut = false;
|
|
109
|
+
let timer;
|
|
110
|
+
await Promise.race([
|
|
111
|
+
server.waitForIdle(),
|
|
112
|
+
new Promise((resolve) => {
|
|
113
|
+
timer = setTimeout(() => {
|
|
114
|
+
timedOut = true;
|
|
115
|
+
resolve();
|
|
116
|
+
}, timeoutMs);
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
if (timedOut) {
|
|
121
|
+
logger.warn("mcp_shutdown_timed_out", { reason, timeoutMs });
|
|
122
|
+
}
|
|
123
|
+
logger.info("mcp_server_stopped", {
|
|
124
|
+
reason,
|
|
125
|
+
exitCode,
|
|
126
|
+
graceful: !timedOut,
|
|
127
|
+
uptimeMs: Math.max(0, Date.now() - startedAt),
|
|
128
|
+
});
|
|
129
|
+
process.exit(exitCode);
|
|
50
130
|
}
|
|
51
131
|
|
|
52
132
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
53
133
|
process.on(signal, () => {
|
|
54
|
-
|
|
55
|
-
|
|
134
|
+
void shutdown({
|
|
135
|
+
reason: "signal",
|
|
136
|
+
signal,
|
|
137
|
+
exitCode: 0,
|
|
138
|
+
cancel: true,
|
|
139
|
+
});
|
|
56
140
|
});
|
|
57
141
|
}
|
|
142
|
+
|
|
143
|
+
process.on("uncaughtException", (error, origin) => {
|
|
144
|
+
logger.error("mcp_uncaught_exception", { origin, error });
|
|
145
|
+
void shutdown({
|
|
146
|
+
reason: "uncaught_exception",
|
|
147
|
+
exitCode: 1,
|
|
148
|
+
cancel: true,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
process.on("unhandledRejection", (reason) => {
|
|
153
|
+
logger.error("mcp_unhandled_rejection", { error: reason });
|
|
154
|
+
void shutdown({
|
|
155
|
+
reason: "unhandled_rejection",
|
|
156
|
+
exitCode: 1,
|
|
157
|
+
cancel: true,
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
|
|
2
|
+
const SENSITIVE_KEY =
|
|
3
|
+
/authorization|cookie|password|passwd|token|secret|api.?key|connection.?string/iu;
|
|
4
|
+
|
|
5
|
+
function sanitizeText(value) {
|
|
6
|
+
return String(value)
|
|
7
|
+
.replace(/Bearer\s+[^\s,;]+/giu, "Bearer [REDACTED]")
|
|
8
|
+
.replace(/(https?:\/\/)[^/@\s]+@/giu, "$1[REDACTED]@")
|
|
9
|
+
.replace(
|
|
10
|
+
/([?&](?:token|key|secret|password)\s*=)[^&#\s]+/giu,
|
|
11
|
+
"$1[REDACTED]",
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function serializeError(error, depth = 0) {
|
|
16
|
+
if (!(error instanceof Error)) {
|
|
17
|
+
return { name: "Error", message: sanitizeText(error) };
|
|
18
|
+
}
|
|
19
|
+
const result = {
|
|
20
|
+
name: error.name,
|
|
21
|
+
message: sanitizeText(error.message),
|
|
22
|
+
...(error.code === undefined ? {} : { code: String(error.code) }),
|
|
23
|
+
...(Number.isInteger(error.statusCode)
|
|
24
|
+
? { statusCode: error.statusCode }
|
|
25
|
+
: {}),
|
|
26
|
+
...(typeof error.retryable === "boolean"
|
|
27
|
+
? { retryable: error.retryable }
|
|
28
|
+
: {}),
|
|
29
|
+
...(error.stack ? { stack: sanitizeText(error.stack) } : {}),
|
|
30
|
+
};
|
|
31
|
+
if (depth < 2 && error.cause !== undefined) {
|
|
32
|
+
result.cause = serializeError(error.cause, depth + 1);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitize(value, key = "", seen = new WeakSet()) {
|
|
38
|
+
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
|
39
|
+
if (value instanceof Error) return serializeError(value);
|
|
40
|
+
if (typeof value === "string") return sanitizeText(value);
|
|
41
|
+
if (value === null || typeof value !== "object") return value;
|
|
42
|
+
if (seen.has(value)) return "[Circular]";
|
|
43
|
+
seen.add(value);
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
return value.map((entry) => sanitize(entry, "", seen));
|
|
46
|
+
}
|
|
47
|
+
return Object.fromEntries(
|
|
48
|
+
Object.entries(value).map(([entryKey, entry]) => [
|
|
49
|
+
entryKey,
|
|
50
|
+
sanitize(entry, entryKey, seen),
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function configuredLevel(level) {
|
|
56
|
+
const normalized = String(level || "info").toLowerCase();
|
|
57
|
+
return LEVELS[normalized] === undefined ? "info" : normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createLogger({
|
|
61
|
+
service,
|
|
62
|
+
version,
|
|
63
|
+
executionId,
|
|
64
|
+
level = process.env.BIAWS_MCP_LOG_LEVEL,
|
|
65
|
+
stream = process.stderr,
|
|
66
|
+
now = () => new Date().toISOString(),
|
|
67
|
+
} = {}) {
|
|
68
|
+
const minimumLevel = LEVELS[configuredLevel(level)];
|
|
69
|
+
|
|
70
|
+
function log(entryLevel, event, fields = {}) {
|
|
71
|
+
if (LEVELS[entryLevel] < minimumLevel) return false;
|
|
72
|
+
const record = sanitize({
|
|
73
|
+
timestamp: now(),
|
|
74
|
+
level: entryLevel,
|
|
75
|
+
service,
|
|
76
|
+
version,
|
|
77
|
+
executionId,
|
|
78
|
+
event,
|
|
79
|
+
...fields,
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
stream.write(`${JSON.stringify(record)}\n`);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
// stderr is the diagnostic fallback. Logging failures must never recurse
|
|
86
|
+
// or corrupt the MCP protocol on stdout.
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
debug: (event, fields) => log("debug", event, fields),
|
|
93
|
+
info: (event, fields) => log("info", event, fields),
|
|
94
|
+
warn: (event, fields) => log("warn", event, fields),
|
|
95
|
+
error: (event, fields) => log("error", event, fields),
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/mcpServer.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { runWithRequestContext } from "./requestContext.js";
|
|
2
4
|
import { SERVER_NAME, SERVER_VERSION } from "./version.js";
|
|
3
5
|
|
|
@@ -59,6 +61,9 @@ export function createMcpMessageHandler({
|
|
|
59
61
|
dispatchTool,
|
|
60
62
|
listTools,
|
|
61
63
|
writeMessage,
|
|
64
|
+
logger,
|
|
65
|
+
createRequestId = randomUUID,
|
|
66
|
+
now = () => Date.now(),
|
|
62
67
|
}) {
|
|
63
68
|
const activeRequests = new Map();
|
|
64
69
|
const inFlight = new Set();
|
|
@@ -66,13 +71,41 @@ export function createMcpMessageHandler({
|
|
|
66
71
|
async function handleToolCall(id, params) {
|
|
67
72
|
const controller = new AbortController();
|
|
68
73
|
activeRequests.set(id, controller);
|
|
74
|
+
const requestId = createRequestId();
|
|
75
|
+
const tool = String(params.name || "");
|
|
76
|
+
const startedAt = now();
|
|
77
|
+
const logContext = {
|
|
78
|
+
requestId,
|
|
79
|
+
rpcRequestId: ["string", "number"].includes(typeof id) ? id : undefined,
|
|
80
|
+
tool,
|
|
81
|
+
};
|
|
82
|
+
logger?.info("mcp_tool_call_started", {
|
|
83
|
+
...logContext,
|
|
84
|
+
inFlight: activeRequests.size,
|
|
85
|
+
});
|
|
69
86
|
try {
|
|
70
87
|
const result = await runWithRequestContext(
|
|
71
|
-
{ signal: controller.signal },
|
|
88
|
+
{ signal: controller.signal, logger, requestId, tool },
|
|
72
89
|
() => dispatchTool(params.name, params.arguments || {}),
|
|
73
90
|
);
|
|
74
91
|
writeMessage(success(id, toolResult(result)));
|
|
92
|
+
logger?.info("mcp_tool_call_completed", {
|
|
93
|
+
...logContext,
|
|
94
|
+
durationMs: Math.max(0, now() - startedAt),
|
|
95
|
+
});
|
|
75
96
|
} catch (error) {
|
|
97
|
+
const cancelled = error?.code === "REQUEST_CANCELLED";
|
|
98
|
+
const expectedFailure =
|
|
99
|
+
cancelled ||
|
|
100
|
+
(Number.isInteger(error?.statusCode) && error.statusCode < 500);
|
|
101
|
+
logger?.[expectedFailure ? "warn" : "error"](
|
|
102
|
+
cancelled ? "mcp_tool_call_cancelled" : "mcp_tool_call_failed",
|
|
103
|
+
{
|
|
104
|
+
...logContext,
|
|
105
|
+
durationMs: Math.max(0, now() - startedAt),
|
|
106
|
+
error,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
76
109
|
writeMessage(success(id, toolErrorResult(error)));
|
|
77
110
|
} finally {
|
|
78
111
|
activeRequests.delete(id);
|
|
@@ -119,15 +152,31 @@ export function createMcpMessageHandler({
|
|
|
119
152
|
}
|
|
120
153
|
|
|
121
154
|
function accept(request) {
|
|
122
|
-
const operation = handleMessage(request).catch(() => {
|
|
155
|
+
const operation = handleMessage(request).catch((error) => {
|
|
156
|
+
logger?.error("mcp_message_handling_failed", {
|
|
157
|
+
rpcRequestId: ["string", "number"].includes(typeof request?.id)
|
|
158
|
+
? request.id
|
|
159
|
+
: undefined,
|
|
160
|
+
method: String(request?.method || ""),
|
|
161
|
+
error,
|
|
162
|
+
});
|
|
123
163
|
if (request?.id !== undefined) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
164
|
+
try {
|
|
165
|
+
writeMessage(
|
|
166
|
+
protocolError(request.id, -32603, "Internal JSON-RPC error"),
|
|
167
|
+
);
|
|
168
|
+
} catch (writeError) {
|
|
169
|
+
logger?.error("mcp_error_response_write_failed", {
|
|
170
|
+
error: writeError,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
127
173
|
}
|
|
128
174
|
});
|
|
129
175
|
inFlight.add(operation);
|
|
130
|
-
void operation.
|
|
176
|
+
void operation.then(
|
|
177
|
+
() => inFlight.delete(operation),
|
|
178
|
+
() => inFlight.delete(operation),
|
|
179
|
+
);
|
|
131
180
|
return operation;
|
|
132
181
|
}
|
|
133
182
|
|
package/src/requestContext.js
CHANGED
package/src/tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
addIssueComment,
|
|
2
3
|
classifyIssue,
|
|
3
4
|
createTaxonomyItem,
|
|
4
5
|
createIssue,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
summarizeIssuesForSupport,
|
|
11
12
|
suggestTaxonomy,
|
|
12
13
|
updateIssueState,
|
|
14
|
+
updateIssueComment,
|
|
13
15
|
updateTaxonomyItem,
|
|
14
16
|
} from "./domains/issues/service.js";
|
|
15
17
|
import {
|
|
@@ -156,6 +158,58 @@ const tools = [
|
|
|
156
158
|
},
|
|
157
159
|
handler: getIssueDetails,
|
|
158
160
|
},
|
|
161
|
+
{
|
|
162
|
+
name: "issues_add_comment",
|
|
163
|
+
description: "Adiciona um comentário em Markdown a uma issue existente.",
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: "object",
|
|
166
|
+
required: ["issueId", "text"],
|
|
167
|
+
additionalProperties: false,
|
|
168
|
+
properties: {
|
|
169
|
+
issueId: { type: "string", minLength: 1 },
|
|
170
|
+
text: {
|
|
171
|
+
type: "string",
|
|
172
|
+
minLength: 1,
|
|
173
|
+
description: "Conteúdo do comentário em Markdown.",
|
|
174
|
+
},
|
|
175
|
+
date: {
|
|
176
|
+
type: "string",
|
|
177
|
+
description:
|
|
178
|
+
"Data do comentário em formato aceito pela API; usa o momento atual quando omitida.",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
handler: addIssueComment,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "issues_update_comment",
|
|
186
|
+
description:
|
|
187
|
+
"Atualiza o conteúdo e, opcionalmente, a data de um comentário de issue.",
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: "object",
|
|
190
|
+
required: ["issueId", "commentId", "text"],
|
|
191
|
+
additionalProperties: false,
|
|
192
|
+
properties: {
|
|
193
|
+
issueId: { type: "string", minLength: 1 },
|
|
194
|
+
commentId: {
|
|
195
|
+
type: "string",
|
|
196
|
+
minLength: 1,
|
|
197
|
+
description: "ID do comentário retornado por issues_get.",
|
|
198
|
+
},
|
|
199
|
+
text: {
|
|
200
|
+
type: "string",
|
|
201
|
+
minLength: 1,
|
|
202
|
+
description: "Novo conteúdo do comentário em Markdown.",
|
|
203
|
+
},
|
|
204
|
+
date: {
|
|
205
|
+
type: "string",
|
|
206
|
+
description:
|
|
207
|
+
"Nova data do comentário; usa o momento atual quando omitida.",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
handler: updateIssueComment,
|
|
212
|
+
},
|
|
159
213
|
{
|
|
160
214
|
name: "issues_get_classification_catalog",
|
|
161
215
|
description:
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export const SERVER_NAME = "biaws-mcp";
|
|
2
|
-
export const SERVER_VERSION = "0.
|
|
2
|
+
export const SERVER_VERSION = "0.5.0";
|