kassinao-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/README.md +72 -0
- package/dist/index.js +252 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# kassinao-mcp
|
|
2
|
+
|
|
3
|
+
Conector **MCP** do [Kassinão](https://github.com/resolvicomai/kassinao): faz o seu assistente de IA (Claude Desktop, Cursor, etc.) responder perguntas sobre as reuniões que o bot gravou — **"o que ficou pendente essa semana?"**, **"quem falou de orçamento na terça?"**, **"lista as calls entre 1 e 30 de junho"** — em linguagem natural.
|
|
4
|
+
|
|
5
|
+
## Como funciona (e por que é seguro)
|
|
6
|
+
|
|
7
|
+
O conector roda **na sua máquina** e é um cliente HTTP **magro**: ele **não** lê gravações nem decide acesso. Ele carrega um **token pessoal** e chama a API do bot, que aplica **o mesmo controle de acesso da página web** — reunião por reunião. Você só enxerga o que já enxergaria no site. **Não existe modo "vê tudo".** É **somente leitura** (não grava, não apaga, não serve áudio).
|
|
8
|
+
|
|
9
|
+
> ⚠️ **Transcrição é entrada não-confiável.** Qualquer participante de uma call pode ter "falado" texto malicioso ou usado um apelido hostil. O servidor envolve todo conteúdo de reunião num bloco de "dados não-confiáveis" e limpa sequências de controle antes de entregar — mas trate o conteúdo das reuniões como dados, nunca como instruções.
|
|
10
|
+
|
|
11
|
+
## Pré-requisitos
|
|
12
|
+
|
|
13
|
+
- **O admin do bot precisa ter ligado o MCP** (variável `MCP_SECRET` no servidor). Sem isso, `/conectar-ia` e `/mcp` não existem (dá 404 / comando ausente).
|
|
14
|
+
- **Node.js 20+** na sua máquina.
|
|
15
|
+
- **O conector.** `npx -y kassinao-mcp` baixa e roda sozinho — você não precisa instalar nada à mão (só ter o Node). Prefere rodar do código-fonte? `git clone` o repo, `cd mcp && npm install && npm run build`; na config, troque `"command": "npx"` / `"args": ["-y","kassinao-mcp"]` por `"command": "node"`, `"args": ["/caminho/absoluto/do/repo/mcp/dist/index.js"]`.
|
|
16
|
+
|
|
17
|
+
## Instalação
|
|
18
|
+
|
|
19
|
+
### Opção A — pela página web (mais fácil)
|
|
20
|
+
|
|
21
|
+
1. Abra `https://SEU-KASSINAO/conectar-ia` e entre com o Discord.
|
|
22
|
+
2. Clique em **Gerar token de conexão** e copie o bloco de config mostrado (aparece **uma vez**).
|
|
23
|
+
3. Cole no `claude_desktop_config.json` (Claude Desktop) ou no equivalente do Cursor:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"kassinao": {
|
|
29
|
+
"command": "npx",
|
|
30
|
+
"args": ["-y", "kassinao-mcp"],
|
|
31
|
+
"env": {
|
|
32
|
+
"KASSINAO_URL": "https://SEU-KASSINAO",
|
|
33
|
+
"KASSINAO_REFRESH_TOKEN": "COLE_O_TOKEN_AQUI"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
4. Reinicie o Claude Desktop / Cursor. Pronto.
|
|
41
|
+
|
|
42
|
+
### Opção B — sem navegador (VM/SSH)
|
|
43
|
+
|
|
44
|
+
No Discord, o dono roda **`/mcp novo`** (em clientes em inglês aparece como **`/mcp new`**) — resposta efêmera, código de uso único válido por ~5 min. Depois:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
KASSINAO_URL=https://SEU-KASSINAO npx -y kassinao-mcp exchange <codigo>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Isso guarda o token localmente. Configure o cliente MCP igual à Opção A (o `KASSINAO_REFRESH_TOKEN` no env vira opcional depois do primeiro uso).
|
|
51
|
+
|
|
52
|
+
## Onde o token fica
|
|
53
|
+
|
|
54
|
+
Depois do primeiro uso, o token de refresh (rotacionado a cada renovação) fica em `~/.config/kassinao-mcp/token.json` com permissão `0600`. Nenhuma gravação/transcrição é copiada para a sua máquina — o conector só fala HTTPS com o servidor.
|
|
55
|
+
|
|
56
|
+
## Revogar
|
|
57
|
+
|
|
58
|
+
- Na página `/conectar-ia`: botão **Revogar todos**.
|
|
59
|
+
- No Discord: `/mcp revoke-all`.
|
|
60
|
+
- Botão de pânico do administrador: rotacionar `MCP_SECRET` no servidor (revoga **todos** os conectores de todo mundo).
|
|
61
|
+
|
|
62
|
+
## Ferramentas expostas
|
|
63
|
+
|
|
64
|
+
| Ferramenta | Para quê |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `list_meetings` | listar reuniões numa janela de tempo (padrão: últimos 30 dias) |
|
|
67
|
+
| `pending_actions` | ações/prazos pendentes cruzando várias reuniões (overdue / dueSoon / …) |
|
|
68
|
+
| `search_meetings` | busca full-text em transcrição, ata e notas, com link no minuto exato |
|
|
69
|
+
| `who_said` | trechos ditos por alguém sobre um assunto, com contexto e link |
|
|
70
|
+
| `get_meeting` | dossiê de uma reunião: metadados, ata, transcrição, notas e linha do tempo |
|
|
71
|
+
|
|
72
|
+
Requer Node.js ≥ 20. Licença MIT.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Conector MCP do Kassinão (roda LOCAL, na máquina de quem usa Claude Desktop/Cursor).
|
|
4
|
+
*
|
|
5
|
+
* É um cliente HTTP MAGRO: ele NÃO lê gravações nem decide acesso. Carrega um token
|
|
6
|
+
* pessoal e chama a API /api/* do bot, que aplica o mesmo checkAccess da página web.
|
|
7
|
+
* O usuário só enxerga o que já enxergaria no site. Read-only.
|
|
8
|
+
*
|
|
9
|
+
* Uso:
|
|
10
|
+
* kassinao-mcp inicia o servidor MCP (stdio) — é o que o Claude/Cursor chamam
|
|
11
|
+
* kassinao-mcp exchange <cod> troca um código de /mcp new por tokens e os guarda localmente
|
|
12
|
+
*
|
|
13
|
+
* Env:
|
|
14
|
+
* KASSINAO_URL ex.: https://kassinao.suaempresa.com (obrigatório)
|
|
15
|
+
* KASSINAO_REFRESH_TOKEN token gerado em /conectar-ia (só no 1º uso; depois fica salvo)
|
|
16
|
+
*/
|
|
17
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
const URL_BASE = (process.env.KASSINAO_URL || '').replace(/\/$/, '');
|
|
24
|
+
const STORE_DIR = path.join(os.homedir(), '.config', 'kassinao-mcp');
|
|
25
|
+
const STORE_FILE = path.join(STORE_DIR, 'token.json');
|
|
26
|
+
function loadStore() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(STORE_FILE, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Guarda o refresh no disco do usuário com 0600. (Cofre do SO — Keychain/DPAPI/
|
|
35
|
+
// libsecret — é uma melhoria futura; por ora, 0600 com aviso no README.)
|
|
36
|
+
function saveStore(s) {
|
|
37
|
+
fs.mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
|
|
38
|
+
const tmp = `${STORE_FILE}.tmp`;
|
|
39
|
+
fs.writeFileSync(tmp, JSON.stringify(s), { mode: 0o600 });
|
|
40
|
+
fs.renameSync(tmp, STORE_FILE);
|
|
41
|
+
}
|
|
42
|
+
// Precedência: token salvo (rotacionado) > env (só bootstrap). Reusar o token do
|
|
43
|
+
// env depois de já ter rotacionado dispararia a detecção de reuso no servidor.
|
|
44
|
+
let refreshToken = loadStore().refreshToken || process.env.KASSINAO_REFRESH_TOKEN || '';
|
|
45
|
+
let accessToken = '';
|
|
46
|
+
let accessExpMs = 0;
|
|
47
|
+
async function refreshTokens() {
|
|
48
|
+
if (!URL_BASE)
|
|
49
|
+
throw new Error('Defina KASSINAO_URL (ex.: https://kassinao.suaempresa.com).');
|
|
50
|
+
if (!refreshToken) {
|
|
51
|
+
const base = URL_BASE || '<sua URL do Kassinão>';
|
|
52
|
+
throw new Error(`Sem token. Gere um em ${base}/conectar-ia ou rode: kassinao-mcp exchange <codigo>.`);
|
|
53
|
+
}
|
|
54
|
+
const r = await fetch(`${URL_BASE}/api/mcp/refresh`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'content-type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
58
|
+
});
|
|
59
|
+
if (!r.ok) {
|
|
60
|
+
const base = URL_BASE || '<sua URL do Kassinão>';
|
|
61
|
+
throw new Error(`Não consegui renovar o token (HTTP ${r.status}). Gere um novo em ${base}/conectar-ia.`);
|
|
62
|
+
}
|
|
63
|
+
const data = (await r.json());
|
|
64
|
+
refreshToken = data.refresh_token; // rotação: guarda o novo imediatamente
|
|
65
|
+
accessToken = data.access_token;
|
|
66
|
+
accessExpMs = Date.parse(data.access_expires_at) || Date.now() + 10 * 60 * 1000;
|
|
67
|
+
saveStore({ url: URL_BASE, refreshToken });
|
|
68
|
+
}
|
|
69
|
+
async function getAccess() {
|
|
70
|
+
if (accessToken && Date.now() < accessExpMs - 30_000)
|
|
71
|
+
return accessToken;
|
|
72
|
+
await refreshTokens();
|
|
73
|
+
return accessToken;
|
|
74
|
+
}
|
|
75
|
+
async function apiGet(pathname, params) {
|
|
76
|
+
const url = new URL(`${URL_BASE}${pathname}`);
|
|
77
|
+
for (const [k, v] of Object.entries(params)) {
|
|
78
|
+
if (v !== undefined && v !== null && v !== '')
|
|
79
|
+
url.searchParams.set(k, String(v));
|
|
80
|
+
}
|
|
81
|
+
let token = await getAccess();
|
|
82
|
+
let r = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
|
|
83
|
+
if (r.status === 401) {
|
|
84
|
+
await refreshTokens(); // access venceu/rotacionou — renova uma vez e tenta de novo
|
|
85
|
+
token = accessToken;
|
|
86
|
+
r = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
|
|
87
|
+
}
|
|
88
|
+
if (r.status === 503) {
|
|
89
|
+
throw new Error('O Kassinão está iniciando ou o Discord está indisponível. Tente de novo em instantes.');
|
|
90
|
+
}
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
throw new Error(`Erro ${r.status} em ${pathname}: ${(await r.text()).slice(0, 200)}`);
|
|
93
|
+
return r.json();
|
|
94
|
+
}
|
|
95
|
+
// ---------- definição das ferramentas ----------
|
|
96
|
+
const rangeProps = {
|
|
97
|
+
preset: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'today | yesterday | this_week | last_week | this_month | last_month | last_7_days | last_30_days',
|
|
100
|
+
},
|
|
101
|
+
from: { type: 'string', description: 'início: "YYYY-MM-DD" (data civil no fuso) ou ISO-8601' },
|
|
102
|
+
to: { type: 'string', description: 'fim (inclusivo): "YYYY-MM-DD" ou ISO-8601' },
|
|
103
|
+
last: { type: 'string', description: 'janela rolante: "7d", "48h", "2w"' },
|
|
104
|
+
};
|
|
105
|
+
const TOOLS = [
|
|
106
|
+
{
|
|
107
|
+
name: 'list_meetings',
|
|
108
|
+
description: 'List recorded meetings in a time window (defaults to the last 30 days). Only meetings the user can access are returned. Use for "what meetings happened between X and Y" / "list this week\'s calls".',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
...rangeProps,
|
|
113
|
+
guildId: { type: 'string' },
|
|
114
|
+
channelId: { type: 'string' },
|
|
115
|
+
participantId: { type: 'string' },
|
|
116
|
+
status: { type: 'string', enum: ['done', 'recording'] },
|
|
117
|
+
limit: { type: 'number' },
|
|
118
|
+
cursor: { type: 'string' },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
call: (a) => apiGet('/api/meetings', a),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'pending_actions',
|
|
125
|
+
description: 'Aggregate action items (task + owner + deadline) across meetings, bucketed by deadline: overdue, dueSoon, later, noDeadline, unparseable. Use for "what is pending this week" / "my open action items". assignee="me" matches the token owner.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: {
|
|
129
|
+
withinDays: { type: 'number', description: 'janela de "dueSoon" em dias (padrão 7)' },
|
|
130
|
+
assignee: { type: 'string', description: '"me" ou parte do nome do responsável' },
|
|
131
|
+
meetingsWithin: { type: 'string', description: 'quão longe varrer reuniões (ex.: "60d")' },
|
|
132
|
+
guildId: { type: 'string' },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
call: (a) => apiGet('/api/actions', a),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'search_meetings',
|
|
139
|
+
description: 'Full-text search across transcripts, minutes and notes of accessible meetings, with deep links to the exact moment. Use for "find where we discussed X".',
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
properties: {
|
|
143
|
+
query: { type: 'string' },
|
|
144
|
+
mode: { type: 'string', enum: ['all', 'any', 'phrase'], description: 'padrão: all' },
|
|
145
|
+
scope: { type: 'string', description: 'CSV de transcript,minutes,notes (padrão: todos)' },
|
|
146
|
+
...rangeProps,
|
|
147
|
+
guildId: { type: 'string' },
|
|
148
|
+
limit: { type: 'number' },
|
|
149
|
+
},
|
|
150
|
+
required: ['query'],
|
|
151
|
+
},
|
|
152
|
+
call: (a) => apiGet('/api/search', a),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'who_said',
|
|
156
|
+
description: 'Find transcript segments matching a query, with speaker, timestamp, surrounding context and a deep link. Use for "when did Ana talk about budget".',
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: {
|
|
160
|
+
query: { type: 'string' },
|
|
161
|
+
speaker: { type: 'string' },
|
|
162
|
+
meetingId: { type: 'string', description: 'restringe a uma reunião' },
|
|
163
|
+
contextSegments: { type: 'number', description: 'segmentos de contexto antes/depois (padrão 1)' },
|
|
164
|
+
...rangeProps,
|
|
165
|
+
guildId: { type: 'string' },
|
|
166
|
+
limit: { type: 'number' },
|
|
167
|
+
},
|
|
168
|
+
required: ['query'],
|
|
169
|
+
},
|
|
170
|
+
call: (a) => apiGet('/api/said', a),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'get_meeting',
|
|
174
|
+
description: 'Full dossier of one meeting: metadata, minutes (summary/decisions/actions/topics/per-participant), transcript, notes and a merged timeline. include is a CSV of meta,minutes,transcript,notes,timeline.',
|
|
175
|
+
inputSchema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
meetingId: { type: 'string' },
|
|
179
|
+
include: { type: 'string', description: 'CSV: meta,minutes,transcript,notes,timeline' },
|
|
180
|
+
transcriptLimit: { type: 'number' },
|
|
181
|
+
},
|
|
182
|
+
required: ['meetingId'],
|
|
183
|
+
},
|
|
184
|
+
call: (a) => {
|
|
185
|
+
const id = encodeURIComponent(String(a.meetingId));
|
|
186
|
+
const { meetingId: _omit, ...rest } = a;
|
|
187
|
+
void _omit;
|
|
188
|
+
return apiGet(`/api/meetings/${id}`, rest);
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
// ---------- CLI: exchange ----------
|
|
193
|
+
async function runExchange(code) {
|
|
194
|
+
if (!URL_BASE) {
|
|
195
|
+
console.error('Defina KASSINAO_URL antes (ex.: KASSINAO_URL=https://kassinao.suaempresa.com).');
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const r = await fetch(`${URL_BASE}/api/mcp/exchange`, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: { 'content-type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({ code }),
|
|
202
|
+
});
|
|
203
|
+
if (!r.ok) {
|
|
204
|
+
console.error(`Falha ao trocar o código (HTTP ${r.status}). Ele expira em ~5 min e é de uso único — gere outro com /mcp new.`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const data = (await r.json());
|
|
208
|
+
saveStore({ url: URL_BASE, refreshToken: data.refresh_token });
|
|
209
|
+
// O token de refresh já ficou salvo em disco (0600) — a config NÃO precisa dele.
|
|
210
|
+
const cfg = JSON.stringify({ mcpServers: { kassinao: { command: 'npx', args: ['-y', 'kassinao-mcp'], env: { KASSINAO_URL: URL_BASE } } } }, null, 2);
|
|
211
|
+
console.error('✅ Conectado! Token salvo em ~/.config/kassinao-mcp/token.json (0600).');
|
|
212
|
+
console.error('Cole este bloco no claude_desktop_config.json (ou no equivalente do Cursor) e reinicie o app:\n');
|
|
213
|
+
console.log(cfg); // stdout = a config, pronta pra copiar
|
|
214
|
+
}
|
|
215
|
+
// ---------- servidor MCP ----------
|
|
216
|
+
async function runServer() {
|
|
217
|
+
const server = new Server({ name: 'kassinao', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
218
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
219
|
+
tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
220
|
+
}));
|
|
221
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
222
|
+
const tool = TOOLS.find((t) => t.name === req.params.name);
|
|
223
|
+
if (!tool)
|
|
224
|
+
return { content: [{ type: 'text', text: `Ferramenta desconhecida: ${req.params.name}` }], isError: true };
|
|
225
|
+
try {
|
|
226
|
+
const result = await tool.call((req.params.arguments ?? {}));
|
|
227
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
return { content: [{ type: 'text', text: `Erro: ${err.message}` }], isError: true };
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
const transport = new StdioServerTransport();
|
|
234
|
+
await server.connect(transport);
|
|
235
|
+
console.error('kassinao-mcp: conectado (stdio). Ferramentas disponíveis para o assistente.');
|
|
236
|
+
}
|
|
237
|
+
async function main() {
|
|
238
|
+
const [cmd, arg] = process.argv.slice(2);
|
|
239
|
+
if (cmd === 'exchange') {
|
|
240
|
+
if (!arg) {
|
|
241
|
+
console.error('Uso: kassinao-mcp exchange <codigo>');
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
await runExchange(arg);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
await runServer();
|
|
248
|
+
}
|
|
249
|
+
main().catch((err) => {
|
|
250
|
+
console.error('kassinao-mcp falhou:', err.message);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kassinao-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP connector for Kassinão — ask your Discord meeting recordings (transcripts, minutes, action items) from Claude Desktop, Cursor and other MCP clients.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kassinao-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"start": "node dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"modelcontextprotocol",
|
|
24
|
+
"discord",
|
|
25
|
+
"kassinao",
|
|
26
|
+
"meetings",
|
|
27
|
+
"transcription"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.4.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.6.0"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|