suite-timesheet-mcp 1.0.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/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "suite-timesheet-mcp",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Servidor MCP para o Suite Timesheet Importer: um assistente (Claude, Copilot…) lê e propõe horas; a extensão escreve depois de confirmação humana.",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "author": "Willian Saez",
8
+ "homepage": "https://github.com/williansaez/suite-timesheet-mcp#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/williansaez/suite-timesheet-mcp.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/williansaez/suite-timesheet-mcp/issues"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "timesheet",
20
+ "suite",
21
+ "claude",
22
+ "copilot"
23
+ ],
24
+ "bin": {
25
+ "suite-timesheet-serve": "serve.js",
26
+ "suite-timesheet-mcp": "mcp.js"
27
+ },
28
+ "files": [
29
+ "mcp.js",
30
+ "serve.js",
31
+ "lib/",
32
+ "CONTRACT.md",
33
+ "SECURITY.md"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test",
37
+ "serve": "node serve.js",
38
+ "mcp": "node mcp.js",
39
+ "prepublishOnly": "node --test"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.30.0"
46
+ }
47
+ }
package/serve.js ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ // Cola HTTP: só escuta em 127.0.0.1. Nunca fala com o Suite.
3
+
4
+ import http from 'node:http';
5
+ import { readFileSync, realpathSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { criarFila } from './lib/fila.js';
8
+ import { criarContexto } from './lib/contexto.js';
9
+ import { criarRotas } from './lib/rotas.js';
10
+
11
+ // Lida uma vez à boca do processo (não a cada pedido): é só a versão deste pacote, para
12
+ // /health a extensão poder ver se está a falar com um "serve" mais velho ou mais novo.
13
+ function lerVersao() {
14
+ try {
15
+ return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version ?? 'desconhecida';
16
+ } catch {
17
+ return 'desconhecida'; // package.json em falta ou corrompido não pode impedir o serve de arrancar
18
+ }
19
+ }
20
+ const VERSAO = lerVersao();
21
+
22
+ export const PORTA_POR_DEFEITO = 18765;
23
+ const LIMITE_CORPO_BYTES = 1024 * 1024; // 1 MB
24
+ const HOST_PERMITIDO = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
25
+
26
+ // Só a extensão (chrome-extension://…) pode chamar este serviço; uma página qualquer que
27
+ // tentasse falar com 127.0.0.1 a partir do browser manda Origin, então "sem Origin" (curl, a
28
+ // própria extensão sem CORS quando aplicável, ferramentas locais) continua a passar.
29
+ const origemPermitida = (origem) => !origem || origem.startsWith('chrome-extension://');
30
+ // DNS rebinding: um domínio público a resolver para 127.0.0.1 ainda manda Host com o nome dele,
31
+ // não com "127.0.0.1" — rejeitar tudo o que não seja isto fecha essa porta.
32
+ const hostPermitido = (host) => HOST_PERMITIDO.test(host ?? '');
33
+
34
+ // Lê o corpo por eventos (não por `for await`): assim um erro de rede (aborted/ECONNRESET)
35
+ // ou um corpo grande de mais rejeita a promise em vez de escapar como unhandled rejection.
36
+ function lerCorpo(req, limite) {
37
+ return new Promise((resolve, reject) => {
38
+ const partes = [];
39
+ let total = 0;
40
+ let terminado = false;
41
+ const falhar = (e) => {
42
+ if (terminado) return;
43
+ terminado = true;
44
+ reject(e);
45
+ };
46
+ req.on('data', (parte) => {
47
+ if (terminado) return;
48
+ total += parte.length;
49
+ if (total > limite) {
50
+ falhar(Object.assign(new Error('O corpo do pedido excede o limite.'), { code: 'ERR_CORPO_GRANDE' }));
51
+ return;
52
+ }
53
+ partes.push(parte);
54
+ });
55
+ req.on('end', () => {
56
+ if (terminado) return;
57
+ terminado = true;
58
+ resolve(Buffer.concat(partes).toString('utf8'));
59
+ });
60
+ req.on('error', falhar);
61
+ // 'aborted' dispara quando o cliente fecha a ligação a meio do corpo.
62
+ req.on('aborted', () => falhar(Object.assign(new Error('A ligação foi abaixo a meio do corpo.'), { code: 'ERR_ABORTADO' })));
63
+ });
64
+ }
65
+
66
+ export function arrancarServe({
67
+ porta = PORTA_POR_DEFEITO,
68
+ host = '127.0.0.1',
69
+ log = (linha) => console.error(linha),
70
+ despachar: despacharInjetado, // só para testes: substitui o router real (ver test/serve.test.js)
71
+ } = {}) {
72
+ const fila = criarFila();
73
+ const contexto = criarContexto();
74
+ const despachar = despacharInjetado ?? criarRotas({ fila, contexto, log, versao: VERSAO });
75
+
76
+ const server = http.createServer(async (req, res) => {
77
+ const erroJson = (status, code, message) => {
78
+ escrita = true;
79
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
80
+ res.end(JSON.stringify({ erro: { code, message } }));
81
+ };
82
+
83
+ // Um comando pode ser atribuído a este long-poll (fila.proximo) e o socket morrer antes de
84
+ // conseguirmos escrever a resposta. 'close' num res ainda não escrito é esse caso: chama o
85
+ // cancelar() que a rota /bridge/next registou, para o comando voltar para a fila em vez de
86
+ // se perder (ver lib/fila.js). Depois de a resposta ser escrita, 'close' é só o fim normal
87
+ // da ligação e não faz nada.
88
+ let escrita = false;
89
+ let cancelarEspera = null;
90
+ res.on('close', () => { if (!escrita) cancelarEspera?.(); });
91
+
92
+ let url;
93
+ try {
94
+ url = new URL(req.url, `http://${host}`);
95
+ } catch {
96
+ // Linha de pedido malformada (ex.: URL com IPv6 sem fechar "]"): new URL() rejeita e,
97
+ // fora de um try/catch, isso vira unhandled rejection e derruba este serve.
98
+ erroJson(400, 'ERR_COMANDO', 'O pedido tem um URL inválido.');
99
+ return;
100
+ }
101
+
102
+ if (!hostPermitido(req.headers.host)) {
103
+ erroJson(421, 'ERR_HOST', 'Este serviço só aceita pedidos para 127.0.0.1 ou localhost.');
104
+ return;
105
+ }
106
+ if (!origemPermitida(req.headers.origin)) {
107
+ erroJson(403, 'ERR_ORIGEM', 'Só a extensão pode chamar este serviço.');
108
+ return;
109
+ }
110
+
111
+ let corpo = null;
112
+ if (req.method === 'POST') {
113
+ const tipoCorpo = req.headers['content-type'] ?? '';
114
+ if (!tipoCorpo.toLowerCase().startsWith('application/json')) {
115
+ erroJson(415, 'ERR_CONTENT_TYPE', 'O corpo do pedido tem de ser application/json.');
116
+ return;
117
+ }
118
+ let texto;
119
+ try {
120
+ texto = await lerCorpo(req, LIMITE_CORPO_BYTES);
121
+ } catch (e) {
122
+ if (e.code === 'ERR_CORPO_GRANDE') {
123
+ // Connection: close obriga o Node a fechar o socket depois de a resposta sair. Só com
124
+ // req.destroy() o Node 20 mantinha o socket vivo para keep-alive e o cliente ficava
125
+ // pendurado até ao keepAliveTimeout.
126
+ res.setHeader('Connection', 'close');
127
+ erroJson(413, 'ERR_CORPO_GRANDE', 'O corpo do pedido excede 1 MB.');
128
+ req.destroy();
129
+ }
130
+ // ERR_ABORTADO (ou outro erro de rede): o cliente já desligou, não há a quem responder.
131
+ return;
132
+ }
133
+ try {
134
+ corpo = texto ? JSON.parse(texto) : null;
135
+ } catch {
136
+ erroJson(400, 'ERR_COMANDO', 'O corpo não é JSON válido.');
137
+ return;
138
+ }
139
+ }
140
+ let r;
141
+ try {
142
+ r = await despachar({
143
+ metodo: req.method,
144
+ caminho: url.pathname,
145
+ query: Object.fromEntries(url.searchParams),
146
+ corpo,
147
+ aoFechar: (cancelar) => { cancelarEspera = cancelar; },
148
+ });
149
+ } catch (e) {
150
+ // Um throw inesperado no router não pode virar unhandled rejection e derrubar
151
+ // este servidor local de longa duração.
152
+ log(`erro interno em ${req.method} ${url.pathname}: ${e.stack ?? e.message}`);
153
+ erroJson(500, 'ERR_INTERNO', e.message);
154
+ return;
155
+ }
156
+ escrita = true;
157
+ if (r.status === 204) { res.writeHead(204); res.end(); return; }
158
+ const texto = typeof r.corpo === 'string';
159
+ res.writeHead(r.status, { 'content-type': texto ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8' });
160
+ res.end(texto ? r.corpo : JSON.stringify(r.corpo));
161
+ });
162
+ server.keepAliveTimeout = 65_000; // acima do wait máximo do long-poll
163
+ server.headersTimeout = 70_000;
164
+
165
+ return new Promise((resolve, reject) => {
166
+ server.once('error', reject);
167
+ server.listen(porta, host, () => {
168
+ resolve({
169
+ base: `http://${host}:${server.address().port}`,
170
+ close: () => new Promise((done) => server.close(done)),
171
+ });
172
+ });
173
+ });
174
+ }
175
+
176
+ const ehMain = process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
177
+ if (ehMain) {
178
+ const porta = Number(process.argv[2] ?? PORTA_POR_DEFEITO);
179
+ arrancarServe({ porta })
180
+ .then(({ base }) => console.error(`suite-timesheet-serve em ${base} (Ctrl+C para parar)`))
181
+ .catch((e) => { console.error(`Não consegui escutar na porta ${porta}: ${e.message}`); process.exit(1); });
182
+ }