kassinao-mcp 1.0.4 → 1.0.5
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 +3 -3
- package/dist/apiResponse.js +38 -3
- package/dist/credentialRefresh.js +7 -0
- package/dist/credentialStore.js +14 -3
- package/dist/http.js +64 -0
- package/dist/index.js +83 -28
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ The connector runs **on your machine** and is a **thin** HTTP client: it does **
|
|
|
16
16
|
|
|
17
17
|
- **The bot admin must have enabled MCP** (`MCP_SECRET` set on the server). Without it, `/app/conectar-ia` and `/mcp` don't exist (404 / missing command).
|
|
18
18
|
- **Node.js 20+** on your machine.
|
|
19
|
-
- **The connector.** `npx -y kassinao-mcp@1.0.
|
|
19
|
+
- **The connector.** `npx -y kassinao-mcp@1.0.5` downloads and runs the pinned published release — nothing to install manually (you just need Node). Prefer running from source? `git clone` the repo, `cd mcp && npm ci --userconfig ../.npmrc.security && npm run build`; in the config, replace `"command": "npx"` / `"args": ["-y","kassinao-mcp@1.0.5"]` with `"command": "node"`, `"args": ["/absolute/path/to/repo/mcp/dist/index.js"]`.
|
|
20
20
|
|
|
21
21
|
## Setup
|
|
22
22
|
|
|
@@ -31,7 +31,7 @@ The connector runs **on your machine** and is a **thin** HTTP client: it does **
|
|
|
31
31
|
"mcpServers": {
|
|
32
32
|
"kassinao": {
|
|
33
33
|
"command": "npx",
|
|
34
|
-
"args": ["-y", "kassinao-mcp@1.0.
|
|
34
|
+
"args": ["-y", "kassinao-mcp@1.0.5"],
|
|
35
35
|
"env": {
|
|
36
36
|
"KASSINAO_URL": "https://mcp.kassinao.cloud",
|
|
37
37
|
"KASSINAO_PROFILE": "PROFILE_PRINTED_BY_THE_COMMAND"
|
|
@@ -50,7 +50,7 @@ For a self-hosted instance, open `APP_URL/app/conectar-ia`; the generated comman
|
|
|
50
50
|
On Discord, the owner runs **`/mcp new`** (shown as **`/mcp novo`** on pt-BR clients) — ephemeral reply with a single-use code valid for ~5 min. Then:
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
npx -y kassinao-mcp@1.0.
|
|
53
|
+
npx -y kassinao-mcp@1.0.5 exchange --stdin --url https://mcp.kassinao.cloud
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Paste the one-time code when prompted. Input is hidden so the code does not enter shell history or process arguments. The command stores the token locally and prints a copy-ready config containing a non-secret `KASSINAO_PROFILE` id. Use that block as printed; it selects this connection's own token file without placing the refresh token in your client config. Replace the URL with your instance's `MCP_URL` when self-hosting.
|
package/dist/apiResponse.js
CHANGED
|
@@ -1,13 +1,48 @@
|
|
|
1
|
+
const DEFAULT_MAX_JSON_BYTES = 32 * 1024 * 1024;
|
|
1
2
|
/**
|
|
2
3
|
* Parses a successful API response without ever copying an upstream response
|
|
3
|
-
* body into an MCP-visible error.
|
|
4
|
+
* body into an MCP-visible error. The stream is bounded before concatenation or
|
|
5
|
+
* JSON.parse so a hostile origin cannot turn a small token response into OOM.
|
|
4
6
|
*/
|
|
5
|
-
export async function readApiJson(response) {
|
|
7
|
+
export async function readApiJson(response, maxBytes = DEFAULT_MAX_JSON_BYTES) {
|
|
6
8
|
if (!response.ok) {
|
|
7
9
|
throw new Error(`Kassinão request failed (HTTP ${response.status}). Try again in a moment.`);
|
|
8
10
|
}
|
|
11
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
12
|
+
throw new Error('Kassinão returned an invalid JSON response.');
|
|
13
|
+
}
|
|
9
14
|
try {
|
|
10
|
-
|
|
15
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
16
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes)
|
|
17
|
+
throw new Error('oversized');
|
|
18
|
+
if (!response.body)
|
|
19
|
+
return JSON.parse(await response.text());
|
|
20
|
+
const reader = response.body.getReader();
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let total = 0;
|
|
23
|
+
try {
|
|
24
|
+
while (true) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
if (done)
|
|
27
|
+
break;
|
|
28
|
+
total += value.byteLength;
|
|
29
|
+
if (total > maxBytes) {
|
|
30
|
+
await reader.cancel();
|
|
31
|
+
throw new Error('oversized');
|
|
32
|
+
}
|
|
33
|
+
chunks.push(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
reader.releaseLock();
|
|
38
|
+
}
|
|
39
|
+
const payload = new Uint8Array(total);
|
|
40
|
+
let offset = 0;
|
|
41
|
+
for (const chunk of chunks) {
|
|
42
|
+
payload.set(chunk, offset);
|
|
43
|
+
offset += chunk.byteLength;
|
|
44
|
+
}
|
|
45
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(payload));
|
|
11
46
|
}
|
|
12
47
|
catch {
|
|
13
48
|
throw new Error('Kassinão returned an invalid JSON response.');
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { withCredentialStoreLock } from './credentialLock.js';
|
|
3
3
|
import { selectBootstrapRefreshToken } from './tokenAuth.js';
|
|
4
|
+
const MAX_ACCESS_TOKEN_CHARS = 8_192;
|
|
5
|
+
const MAX_REFRESH_TOKEN_CHARS = 4_096;
|
|
6
|
+
const MAX_EXPIRY_CHARS = 64;
|
|
4
7
|
export function parseCredentialTokenResponse(value) {
|
|
5
8
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
6
9
|
throw new Error('Kassinão returned an invalid token response.');
|
|
@@ -8,9 +11,13 @@ export function parseCredentialTokenResponse(value) {
|
|
|
8
11
|
const response = value;
|
|
9
12
|
if (typeof response.access_token !== 'string' ||
|
|
10
13
|
response.access_token.length === 0 ||
|
|
14
|
+
response.access_token.length > MAX_ACCESS_TOKEN_CHARS ||
|
|
11
15
|
typeof response.refresh_token !== 'string' ||
|
|
12
16
|
response.refresh_token.length === 0 ||
|
|
17
|
+
response.refresh_token.length > MAX_REFRESH_TOKEN_CHARS ||
|
|
13
18
|
typeof response.access_expires_at !== 'string' ||
|
|
19
|
+
response.access_expires_at.length === 0 ||
|
|
20
|
+
response.access_expires_at.length > MAX_EXPIRY_CHARS ||
|
|
14
21
|
!Number.isFinite(Date.parse(response.access_expires_at))) {
|
|
15
22
|
throw new Error('Kassinão returned an invalid token response.');
|
|
16
23
|
}
|
package/dist/credentialStore.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
const MAX_CREDENTIAL_STORE_BYTES = 64 * 1024;
|
|
5
|
+
const MAX_STORED_URL_CHARS = 2_048;
|
|
6
|
+
const MAX_STORED_REFRESH_TOKEN_CHARS = 4_096;
|
|
4
7
|
function parseCredentials(raw) {
|
|
5
8
|
try {
|
|
6
9
|
const parsed = JSON.parse(raw);
|
|
@@ -8,8 +11,10 @@ function parseCredentials(raw) {
|
|
|
8
11
|
return {};
|
|
9
12
|
const value = parsed;
|
|
10
13
|
return {
|
|
11
|
-
url: typeof value.url === 'string' ? value.url : undefined,
|
|
12
|
-
refreshToken: typeof value.refreshToken === 'string'
|
|
14
|
+
url: typeof value.url === 'string' && value.url.length <= MAX_STORED_URL_CHARS ? value.url : undefined,
|
|
15
|
+
refreshToken: typeof value.refreshToken === 'string' && value.refreshToken.length <= MAX_STORED_REFRESH_TOKEN_CHARS
|
|
16
|
+
? value.refreshToken
|
|
17
|
+
: undefined,
|
|
13
18
|
refreshAttempt: typeof value.refreshAttempt === 'string' && /^[a-f0-9]{32}$/.test(value.refreshAttempt)
|
|
14
19
|
? value.refreshAttempt
|
|
15
20
|
: undefined,
|
|
@@ -73,6 +78,8 @@ export function loadCredentialStore(directory, file) {
|
|
|
73
78
|
if (!openedStats.isFile())
|
|
74
79
|
throw unsafeStore('the token path is not a regular file.');
|
|
75
80
|
assertOwnedByCurrentUser(openedStats, 'the token file');
|
|
81
|
+
if (openedStats.size > MAX_CREDENTIAL_STORE_BYTES)
|
|
82
|
+
throw unsafeStore('the token file is too large.');
|
|
76
83
|
if (process.platform !== 'win32')
|
|
77
84
|
fs.fchmodSync(fd, 0o600);
|
|
78
85
|
return parseCredentials(fs.readFileSync(fd, 'utf8'));
|
|
@@ -84,6 +91,10 @@ export function loadCredentialStore(directory, file) {
|
|
|
84
91
|
/** Grava o cofre por rename atômico e só retorna depois de fsync no arquivo/diretório. */
|
|
85
92
|
export function saveCredentialStore(directory, file, credentials) {
|
|
86
93
|
protectDirectory(directory);
|
|
94
|
+
const payload = JSON.stringify(credentials);
|
|
95
|
+
if (Buffer.byteLength(payload, 'utf8') > MAX_CREDENTIAL_STORE_BYTES) {
|
|
96
|
+
throw unsafeStore('the token payload is too large.');
|
|
97
|
+
}
|
|
87
98
|
const temporary = `${file}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
88
99
|
const noFollow = process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW;
|
|
89
100
|
try {
|
|
@@ -91,7 +102,7 @@ export function saveCredentialStore(directory, file, credentials) {
|
|
|
91
102
|
try {
|
|
92
103
|
if (process.platform !== 'win32')
|
|
93
104
|
fs.fchmodSync(fd, 0o600);
|
|
94
|
-
fs.writeFileSync(fd,
|
|
105
|
+
fs.writeFileSync(fd, payload);
|
|
95
106
|
fs.fsyncSync(fd);
|
|
96
107
|
}
|
|
97
108
|
finally {
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export const DEFAULT_HTTP_TIMEOUT_MS = 20_000;
|
|
2
|
+
function abortReason(signal) {
|
|
3
|
+
if (signal.reason instanceof Error && signal.reason.name === 'TimeoutError') {
|
|
4
|
+
const error = new Error('Kassinão request timed out. Try again in a moment.');
|
|
5
|
+
error.name = 'TimeoutError';
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
if (signal.reason instanceof Error)
|
|
9
|
+
return signal.reason;
|
|
10
|
+
const error = new Error('Kassinão request was cancelled.');
|
|
11
|
+
error.name = 'AbortError';
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
function waitWithSignal(operation, signal) {
|
|
15
|
+
if (signal.aborted) {
|
|
16
|
+
void Promise.resolve(operation).catch(() => undefined);
|
|
17
|
+
return Promise.reject(abortReason(signal));
|
|
18
|
+
}
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const onAbort = () => reject(abortReason(signal));
|
|
21
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
22
|
+
Promise.resolve(operation).then((value) => {
|
|
23
|
+
signal.removeEventListener('abort', onAbort);
|
|
24
|
+
resolve(value);
|
|
25
|
+
}, (error) => {
|
|
26
|
+
signal.removeEventListener('abort', onAbort);
|
|
27
|
+
reject(error);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Nunca permite redirects nem espera indefinida. O consumidor precisa terminar
|
|
33
|
+
* de ler o body dentro do callback, para o mesmo deadline cobrir headers + body.
|
|
34
|
+
*/
|
|
35
|
+
export async function strictFetch(input, init, consume) {
|
|
36
|
+
const explicitSignal = init.signal ?? undefined;
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const signal = controller.signal;
|
|
39
|
+
const forwardExplicitAbort = () => controller.abort(explicitSignal?.reason);
|
|
40
|
+
let response;
|
|
41
|
+
if (explicitSignal?.aborted)
|
|
42
|
+
forwardExplicitAbort();
|
|
43
|
+
else
|
|
44
|
+
explicitSignal?.addEventListener('abort', forwardExplicitAbort, { once: true });
|
|
45
|
+
const timeout = setTimeout(() => {
|
|
46
|
+
controller.abort(new DOMException('Kassinão request timed out.', 'TimeoutError'));
|
|
47
|
+
}, DEFAULT_HTTP_TIMEOUT_MS);
|
|
48
|
+
timeout.unref();
|
|
49
|
+
try {
|
|
50
|
+
response = await waitWithSignal(fetch(input, { ...init, redirect: 'error', signal }), signal);
|
|
51
|
+
return await waitWithSignal(Promise.resolve(consume(response)), signal);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (signal.aborted)
|
|
55
|
+
throw abortReason(signal);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
explicitSignal?.removeEventListener('abort', forwardExplicitAbort);
|
|
61
|
+
if (response?.body && !response.bodyUsed)
|
|
62
|
+
void response.body.cancel().catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import path from 'node:path';
|
|
|
28
28
|
import { readApiJson } from './apiResponse.js';
|
|
29
29
|
import { parseCredentialTokenResponse, refreshCredential } from './credentialRefresh.js';
|
|
30
30
|
import { loadCredentialStore, saveCredentialStore } from './credentialStore.js';
|
|
31
|
+
import { DEFAULT_HTTP_TIMEOUT_MS, strictFetch } from './http.js';
|
|
31
32
|
import { createTokenProfileId, mayFallbackToEnvToken, normalizeKassinaoUrl, selectBootstrapRefreshToken, singleFlight, tokenStoreFileName, } from './tokenAuth.js';
|
|
32
33
|
import { createToolErrorResponse, createToolResponse, MCP_UNTRUSTED_DESCRIPTION } from './toolOutput.js';
|
|
33
34
|
function configuredUrl() {
|
|
@@ -114,18 +115,21 @@ function saveStore(s, file = STORE_FILE) {
|
|
|
114
115
|
}
|
|
115
116
|
let accessToken = '';
|
|
116
117
|
let accessExpMs = 0;
|
|
118
|
+
const MAX_TOKEN_RESPONSE_BYTES = 32 * 1024;
|
|
117
119
|
async function tryRefresh(token, attemptId) {
|
|
118
|
-
|
|
120
|
+
return strictFetch(`${URL_BASE}/api/mcp/refresh`, {
|
|
119
121
|
method: 'POST',
|
|
120
122
|
headers: { 'content-type': 'application/json' },
|
|
121
123
|
body: JSON.stringify({ refresh_token: token, attempt_id: attemptId }),
|
|
122
|
-
signal: AbortSignal.timeout(
|
|
124
|
+
signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
|
|
125
|
+
}, async (response) => {
|
|
126
|
+
if (mayFallbackToEnvToken(response.status))
|
|
127
|
+
return undefined;
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(`Could not refresh the token (HTTP ${response.status}). Try again in a moment.`);
|
|
130
|
+
}
|
|
131
|
+
return readApiJson(response, MAX_TOKEN_RESPONSE_BYTES);
|
|
123
132
|
});
|
|
124
|
-
if (mayFallbackToEnvToken(r.status))
|
|
125
|
-
return undefined;
|
|
126
|
-
if (!r.ok)
|
|
127
|
-
throw new Error(`Could not refresh the token (HTTP ${r.status}). Try again in a moment.`);
|
|
128
|
-
return readApiJson(r);
|
|
129
133
|
}
|
|
130
134
|
async function refreshTokens() {
|
|
131
135
|
if (!URL_BASE)
|
|
@@ -156,20 +160,28 @@ async function apiGet(pathname, params) {
|
|
|
156
160
|
if (v !== undefined && v !== null && v !== '')
|
|
157
161
|
url.searchParams.set(k, String(v));
|
|
158
162
|
}
|
|
163
|
+
const request = (token) => strictFetch(url, { headers: { authorization: `Bearer ${token}` } }, async (response) => {
|
|
164
|
+
if (response.status === 401)
|
|
165
|
+
return { unauthorized: true };
|
|
166
|
+
if (response.status === 503) {
|
|
167
|
+
throw new Error('Kassinão is starting or Discord is unavailable. Try again in a moment.');
|
|
168
|
+
}
|
|
169
|
+
return { unauthorized: false, data: await readApiJson(response) };
|
|
170
|
+
});
|
|
159
171
|
let token = await getAccess();
|
|
160
|
-
let
|
|
161
|
-
if (
|
|
172
|
+
let result = await request(token);
|
|
173
|
+
if (result.unauthorized) {
|
|
162
174
|
// Another tool may have refreshed while this request still used the previous
|
|
163
175
|
// access token. Reuse the new token and refresh only if the current one failed.
|
|
164
176
|
if (token === accessToken)
|
|
165
177
|
await refreshOnce();
|
|
166
178
|
token = accessToken;
|
|
167
|
-
|
|
179
|
+
result = await request(token);
|
|
168
180
|
}
|
|
169
|
-
if (
|
|
170
|
-
throw new Error('Kassinão
|
|
181
|
+
if (result.unauthorized) {
|
|
182
|
+
throw new Error('Kassinão request failed (HTTP 401). Try again in a moment.');
|
|
171
183
|
}
|
|
172
|
-
return
|
|
184
|
+
return result.data;
|
|
173
185
|
}
|
|
174
186
|
// ---------- tool definitions ----------
|
|
175
187
|
const rangeProps = {
|
|
@@ -184,7 +196,7 @@ const rangeProps = {
|
|
|
184
196
|
const TOOLS = [
|
|
185
197
|
{
|
|
186
198
|
name: 'list_meetings',
|
|
187
|
-
description: 'List recorded meetings in a time window (defaults to the last 30 days). Only meetings the user can access are returned. Each item carries transcriptStatus ("partial" = some speakers not transcribed yet), presentSilent (people in the call who never spoke) and audioDeleted (tiered retention: audio expired, text remains). Use for "what meetings happened between X and Y" / "list this week\'s calls".',
|
|
199
|
+
description: 'List recorded meetings in a time window (defaults to the last 30 days). Only meetings the user can access are returned. Each item carries transcriptStatus ("partial" = some speakers not transcribed yet), presentSilent (people in the call who never spoke) and audioDeleted (tiered retention: audio expired, text remains). Follow nextCursor until null; only then continue with nextScanCursor. Use for "what meetings happened between X and Y" / "list this week\'s calls".',
|
|
188
200
|
inputSchema: {
|
|
189
201
|
type: 'object',
|
|
190
202
|
properties: {
|
|
@@ -194,14 +206,21 @@ const TOOLS = [
|
|
|
194
206
|
participantId: { type: 'string' },
|
|
195
207
|
status: { type: 'string', enum: ['done', 'recording'] },
|
|
196
208
|
limit: { type: 'number' },
|
|
197
|
-
cursor: {
|
|
209
|
+
cursor: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: 'opaque nextCursor; continue result pagination before using nextScanCursor',
|
|
212
|
+
},
|
|
213
|
+
scanCursor: {
|
|
214
|
+
type: 'string',
|
|
215
|
+
description: 'opaque nextScanCursor; use only when nextCursor is null',
|
|
216
|
+
},
|
|
198
217
|
},
|
|
199
218
|
},
|
|
200
219
|
call: (a) => apiGet('/api/meetings', a),
|
|
201
220
|
},
|
|
202
221
|
{
|
|
203
222
|
name: 'pending_actions',
|
|
204
|
-
description: 'Aggregate action items (task + owner + deadline) across meetings, bucketed by deadline: overdue, dueSoon, later, noDeadline, unparseable. Items include transcriptStatus — minutes built from a partial transcript may be missing actions. Use for "what is pending this week" / "my open action items". assignee="me" matches the token owner.',
|
|
223
|
+
description: 'Aggregate action items (task + owner + deadline) across meetings, bucketed by deadline: overdue, dueSoon, later, noDeadline, unparseable. Items include transcriptStatus — minutes built from a partial transcript may be missing actions. Follow nextCursor until null; only then continue with nextScanCursor. Use for "what is pending this week" / "my open action items". assignee="me" matches the token owner.',
|
|
205
224
|
inputSchema: {
|
|
206
225
|
type: 'object',
|
|
207
226
|
properties: {
|
|
@@ -209,13 +228,22 @@ const TOOLS = [
|
|
|
209
228
|
assignee: { type: 'string', description: '"me" or part of the assignee name' },
|
|
210
229
|
meetingsWithin: { type: 'string', description: 'how far back to scan meetings (for example, "60d")' },
|
|
211
230
|
guildId: { type: 'string' },
|
|
231
|
+
limit: { type: 'number' },
|
|
232
|
+
cursor: {
|
|
233
|
+
type: 'string',
|
|
234
|
+
description: 'opaque nextCursor; continue actions before using nextScanCursor',
|
|
235
|
+
},
|
|
236
|
+
scanCursor: {
|
|
237
|
+
type: 'string',
|
|
238
|
+
description: 'opaque nextScanCursor; use only when nextCursor is null',
|
|
239
|
+
},
|
|
212
240
|
},
|
|
213
241
|
},
|
|
214
242
|
call: (a) => apiGet('/api/actions', a),
|
|
215
243
|
},
|
|
216
244
|
{
|
|
217
245
|
name: 'search_meetings',
|
|
218
|
-
description: 'Full-text search across transcripts, minutes and notes of accessible meetings, accent-insensitive, with deep links to the exact second. Use for "find where we discussed X".',
|
|
246
|
+
description: 'Full-text search across transcripts, minutes and notes of accessible meetings, accent-insensitive, with deep links to the exact second. Follow nextCursor until null; only then continue with nextScanCursor. Use for "find where we discussed X".',
|
|
219
247
|
inputSchema: {
|
|
220
248
|
type: 'object',
|
|
221
249
|
properties: {
|
|
@@ -225,6 +253,14 @@ const TOOLS = [
|
|
|
225
253
|
...rangeProps,
|
|
226
254
|
guildId: { type: 'string' },
|
|
227
255
|
limit: { type: 'number' },
|
|
256
|
+
cursor: {
|
|
257
|
+
type: 'string',
|
|
258
|
+
description: 'opaque nextCursor; continue matches before using nextScanCursor',
|
|
259
|
+
},
|
|
260
|
+
scanCursor: {
|
|
261
|
+
type: 'string',
|
|
262
|
+
description: 'opaque nextScanCursor; use only when nextCursor is null',
|
|
263
|
+
},
|
|
228
264
|
},
|
|
229
265
|
required: ['query'],
|
|
230
266
|
},
|
|
@@ -232,7 +268,7 @@ const TOOLS = [
|
|
|
232
268
|
},
|
|
233
269
|
{
|
|
234
270
|
name: 'who_said',
|
|
235
|
-
description: 'Find transcript segments matching a query (accent-insensitive), with speaker, timestamp, surrounding context and a deep link. transcriptStatus="partial" means some speakers are not transcribed yet — absence of a match is not proof nobody said it. Use for "when did Ana talk about budget".',
|
|
271
|
+
description: 'Find transcript segments matching a query (accent-insensitive), with speaker, timestamp, surrounding context and a deep link. transcriptStatus="partial" means some speakers are not transcribed yet — absence of a match is not proof nobody said it. Follow nextCursor until null; only then continue with nextScanCursor. Use for "when did Ana talk about budget".',
|
|
236
272
|
inputSchema: {
|
|
237
273
|
type: 'object',
|
|
238
274
|
properties: {
|
|
@@ -243,6 +279,14 @@ const TOOLS = [
|
|
|
243
279
|
...rangeProps,
|
|
244
280
|
guildId: { type: 'string' },
|
|
245
281
|
limit: { type: 'number' },
|
|
282
|
+
cursor: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
description: 'opaque nextCursor; continue transcript matches before using nextScanCursor',
|
|
285
|
+
},
|
|
286
|
+
scanCursor: {
|
|
287
|
+
type: 'string',
|
|
288
|
+
description: 'opaque nextScanCursor; use only when nextCursor is null',
|
|
289
|
+
},
|
|
246
290
|
},
|
|
247
291
|
required: ['query'],
|
|
248
292
|
},
|
|
@@ -274,16 +318,17 @@ async function runExchange(code) {
|
|
|
274
318
|
console.error('Set KASSINAO_URL first (for example, KASSINAO_URL=https://kassinao.example.com).');
|
|
275
319
|
process.exit(1);
|
|
276
320
|
}
|
|
277
|
-
const
|
|
321
|
+
const data = await strictFetch(`${URL_BASE}/api/mcp/exchange`, {
|
|
278
322
|
method: 'POST',
|
|
279
323
|
headers: { 'content-type': 'application/json' },
|
|
280
324
|
body: JSON.stringify({ code }),
|
|
325
|
+
}, async (response) => {
|
|
326
|
+
if (!response.ok) {
|
|
327
|
+
console.error(`Could not exchange the code (HTTP ${response.status}). Codes expire in about 5 minutes and can only be used once. Generate another with /mcp new.`);
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
return parseCredentialTokenResponse(await readApiJson(response, MAX_TOKEN_RESPONSE_BYTES));
|
|
281
331
|
});
|
|
282
|
-
if (!r.ok) {
|
|
283
|
-
console.error(`Could not exchange the code (HTTP ${r.status}). Codes expire in about 5 minutes and can only be used once. Generate another with /mcp new.`);
|
|
284
|
-
process.exit(1);
|
|
285
|
-
}
|
|
286
|
-
const data = parseCredentialTokenResponse(await readApiJson(r));
|
|
287
332
|
const profile = createTokenProfileId();
|
|
288
333
|
const profileStoreFile = path.join(STORE_DIR, tokenStoreFileName(URL_BASE, '', profile));
|
|
289
334
|
saveStore({ url: URL_BASE, refreshToken: data.refresh_token }, profileStoreFile);
|
|
@@ -307,10 +352,20 @@ function isExchangeCode(value) {
|
|
|
307
352
|
}
|
|
308
353
|
async function readExchangeCode() {
|
|
309
354
|
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
355
|
+
const maxBytes = 256;
|
|
356
|
+
const buffer = Buffer.allocUnsafe(maxBytes + 1);
|
|
357
|
+
const chunks = [];
|
|
358
|
+
let total = 0;
|
|
359
|
+
while (true) {
|
|
360
|
+
const read = fs.readSync(0, buffer, 0, Math.min(buffer.length, maxBytes + 1 - total), null);
|
|
361
|
+
if (read === 0)
|
|
362
|
+
break;
|
|
363
|
+
total += read;
|
|
364
|
+
if (total > maxBytes)
|
|
365
|
+
throw new Error('Invalid one-time connection code.');
|
|
366
|
+
chunks.push(Buffer.from(buffer.subarray(0, read)));
|
|
367
|
+
}
|
|
368
|
+
return Buffer.concat(chunks, total).toString('utf8').trim();
|
|
314
369
|
}
|
|
315
370
|
return new Promise((resolve, reject) => {
|
|
316
371
|
const input = process.stdin;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kassinao-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "kassinao-mcp",
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.5",
|
|
10
10
|
"bundleDependencies": ["@modelcontextprotocol/sdk"],
|
|
11
11
|
"license": "AGPL-3.0-or-later",
|
|
12
12
|
"dependencies": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kassinao-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
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
5
|
"author": "Mauro Marques",
|
|
6
6
|
"homepage": "https://mcp.kassinao.cloud",
|