@quillmeetings/cli 0.1.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.
@@ -0,0 +1,326 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+
4
+ const DEFAULT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
5
+
6
+ export class McpClient {
7
+ constructor(config) {
8
+ this.config = config;
9
+ this.maxBufferBytes = Number.parseInt(config.max_buffer_bytes || String(DEFAULT_MAX_BUFFER_BYTES), 10);
10
+ this.nextId = 1;
11
+ this.pending = new Map();
12
+ this.buffer = Buffer.alloc(0);
13
+ this.textBuffer = "";
14
+ this.child = null;
15
+ }
16
+
17
+ async connect() {
18
+ if (this.config.command === "node" && this.config.args?.[0] && !existsSync(this.config.args[0])) {
19
+ throw bridgeNotFoundError(this.config);
20
+ }
21
+
22
+ try {
23
+ this.child = spawn(this.config.command, this.config.args, {
24
+ stdio: ["pipe", "pipe", "pipe"],
25
+ env: mcpEnvironment(),
26
+ });
27
+ } catch (error) {
28
+ throw normalizeSpawnError(error, this.config);
29
+ }
30
+
31
+ this.child.stdout.on("data", (chunk) => this.#onData(chunk));
32
+ this.child.stderr.on("data", (chunk) => {
33
+ if (process.env.QUILL_DEBUG) process.stderr.write(chunk);
34
+ });
35
+ this.child.on("exit", (code, signal) => {
36
+ const error = new Error(`Quill MCP server exited with ${signal || code}`);
37
+ error.code = "mcp_server_exited";
38
+ for (const { reject, timeout } of this.pending.values()) {
39
+ clearTimeout(timeout);
40
+ reject(error);
41
+ }
42
+ this.pending.clear();
43
+ });
44
+ this.child.on("error", (error) => {
45
+ const normalized = normalizeSpawnError(error, this.config);
46
+ for (const { reject, timeout } of this.pending.values()) {
47
+ clearTimeout(timeout);
48
+ reject(normalized);
49
+ }
50
+ this.pending.clear();
51
+ });
52
+
53
+ await this.request("initialize", {
54
+ protocolVersion: "2024-11-05",
55
+ capabilities: {},
56
+ clientInfo: {
57
+ name: "quill-cli",
58
+ version: "0.1.0",
59
+ },
60
+ });
61
+ this.notify("notifications/initialized", {});
62
+ }
63
+
64
+ async close() {
65
+ if (!this.child) return;
66
+ this.child.stdin.end();
67
+ this.child.kill();
68
+ }
69
+
70
+ async listTools() {
71
+ const result = await this.request("tools/list", {});
72
+ return result.tools || [];
73
+ }
74
+
75
+ async callTool(name, args = {}) {
76
+ const timeoutMs = name === "create_note"
77
+ ? Number.parseInt(this.config.mutation_timeout_ms || "120000", 10)
78
+ : undefined;
79
+ return this.request("tools/call", { name, arguments: args }, { timeoutMs });
80
+ }
81
+
82
+ request(method, params, options = {}) {
83
+ const id = this.nextId++;
84
+ const message = { jsonrpc: "2.0", id, method, params };
85
+ const promise = new Promise((resolve, reject) => {
86
+ const timeoutMs = options.timeoutMs || Number.parseInt(this.config.timeout_ms || "15000", 10);
87
+ const timeout = setTimeout(() => {
88
+ this.pending.delete(id);
89
+ const error = new Error(`Timed out waiting for MCP response to ${method}`);
90
+ error.code = "mcp_timeout";
91
+ reject(error);
92
+ }, timeoutMs);
93
+ this.pending.set(id, { resolve, reject, timeout });
94
+ });
95
+ this.#send(message);
96
+ return promise;
97
+ }
98
+
99
+ notify(method, params) {
100
+ this.#send({ jsonrpc: "2.0", method, params });
101
+ }
102
+
103
+ #send(message) {
104
+ const body = JSON.stringify(message);
105
+ if (this.config.framing === "newline") {
106
+ this.child.stdin.write(`${body}\n`);
107
+ return;
108
+ }
109
+ this.child.stdin.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
110
+ }
111
+
112
+ #onData(chunk) {
113
+ if (this.config.framing === "newline") {
114
+ this.#onLineData(chunk);
115
+ return;
116
+ }
117
+
118
+ this.buffer = Buffer.concat([this.buffer, chunk]);
119
+ if (this.#failIfBufferLimitExceeded(this.buffer.length)) return;
120
+ while (true) {
121
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
122
+ if (headerEnd === -1) return;
123
+
124
+ const header = this.buffer.slice(0, headerEnd).toString("utf8");
125
+ const match = header.match(/Content-Length:\s*(\d+)/i);
126
+ if (!match) {
127
+ this.buffer = this.buffer.slice(headerEnd + 4);
128
+ continue;
129
+ }
130
+
131
+ const length = Number.parseInt(match[1], 10);
132
+ const bodyStart = headerEnd + 4;
133
+ const bodyEnd = bodyStart + length;
134
+ if (this.buffer.length < bodyEnd) return;
135
+
136
+ const body = this.buffer.slice(bodyStart, bodyEnd).toString("utf8");
137
+ this.buffer = this.buffer.slice(bodyEnd);
138
+ this.#handleMessage(JSON.parse(body));
139
+ }
140
+ }
141
+
142
+ #onLineData(chunk) {
143
+ this.textBuffer += chunk.toString("utf8");
144
+ if (this.#failIfBufferLimitExceeded(Buffer.byteLength(this.textBuffer, "utf8"))) return;
145
+ while (true) {
146
+ const lineEnd = this.textBuffer.indexOf("\n");
147
+ if (lineEnd === -1) return;
148
+ const line = this.textBuffer.slice(0, lineEnd).trim();
149
+ this.textBuffer = this.textBuffer.slice(lineEnd + 1);
150
+ if (!line) continue;
151
+ this.#handleMessage(JSON.parse(line));
152
+ }
153
+ }
154
+
155
+ #failIfBufferLimitExceeded(size) {
156
+ if (size <= this.maxBufferBytes) return false;
157
+ const error = new Error(`MCP response buffer exceeded ${this.maxBufferBytes} bytes`);
158
+ error.code = "mcp_buffer_limit_exceeded";
159
+ for (const { reject, timeout } of this.pending.values()) {
160
+ clearTimeout(timeout);
161
+ reject(error);
162
+ }
163
+ this.pending.clear();
164
+ this.close();
165
+ return true;
166
+ }
167
+
168
+ #handleMessage(message) {
169
+ if (!Object.hasOwn(message, "id")) return;
170
+ const pending = this.pending.get(message.id);
171
+ if (!pending) return;
172
+ this.pending.delete(message.id);
173
+ clearTimeout(pending.timeout);
174
+ if (message.error) {
175
+ const error = new Error(message.error.message || "MCP request failed");
176
+ error.code = message.error.code || "mcp_error";
177
+ error.details = message.error.data;
178
+ pending.reject(error);
179
+ } else {
180
+ pending.resolve(message.result);
181
+ }
182
+ }
183
+ }
184
+
185
+ function normalizeSpawnError(error, config) {
186
+ if (error?.code === "ENOENT") {
187
+ return bridgeNotFoundError(config);
188
+ }
189
+ return error;
190
+ }
191
+
192
+ function bridgeNotFoundError(config) {
193
+ const error = new Error("Bridge not found. Run `quill doctor`.");
194
+ error.code = "mcp_bridge_not_found";
195
+ error.exitCode = 1;
196
+ error.details = {
197
+ command: config.command,
198
+ args: config.args,
199
+ };
200
+ return error;
201
+ }
202
+
203
+ function mcpEnvironment() {
204
+ const env = { ...process.env };
205
+ delete env.QUILL_CONFIG;
206
+ delete env.QUILL_AGENT_MODE;
207
+ return env;
208
+ }
209
+
210
+ export function extractToolResult(result) {
211
+ if (!result?.content) return result;
212
+ const textParts = result.content
213
+ .filter((item) => item.type === "text" && typeof item.text === "string")
214
+ .map((item) => item.text);
215
+ if (textParts.length === 0) return result;
216
+
217
+ const text = textParts.join("\n");
218
+ try {
219
+ return JSON.parse(text);
220
+ } catch {
221
+ return parseQuillToolResponse(text);
222
+ }
223
+ }
224
+
225
+ function parseQuillToolResponse(text) {
226
+ if (!text.includes("<ToolResponse>")) return { text };
227
+
228
+ const inner = text
229
+ .replace(/^<ToolResponse>\s*/s, "")
230
+ .replace(/\s*<\/ToolResponse>$/s, "")
231
+ .trim();
232
+
233
+ const meetings = parseMeetings(inner);
234
+ if (meetings.length > 0) {
235
+ const result = { count: meetings.length, meetings };
236
+ const query = inner.match(/<results[^>]*query="([^"]*)"/)?.[1];
237
+ if (query) result.query = decodeXml(query);
238
+ return result;
239
+ }
240
+
241
+ for (const spec of [
242
+ { container: "contacts", item: "contact", key: "contacts" },
243
+ { container: "events", item: "event", key: "events" },
244
+ { container: "templates", item: "template", key: "templates", childFields: ["description"] },
245
+ { container: "threads", item: "thread", key: "threads" },
246
+ { container: "notes", item: "note", key: "notes" },
247
+ ]) {
248
+ const parsed = parseElementList(inner, spec);
249
+ if (parsed) return parsed;
250
+ }
251
+
252
+ const message = inner.replace(/<[^>]+>/g, "").trim();
253
+ return message ? { message } : { text: inner };
254
+ }
255
+
256
+ function parseMeetings(text) {
257
+ const meetings = [];
258
+ const meetingPattern = /<meeting\s+([^>]*?)(?:\/>|>([\s\S]*?)<\/meeting>)/g;
259
+ let match;
260
+ while ((match = meetingPattern.exec(text)) !== null) {
261
+ const attrs = parseAttributes(match[1]);
262
+ const body = match[2] || "";
263
+ meetings.push({
264
+ id: attrs.id,
265
+ title: childText(body, "title") || attrs.title,
266
+ blurb: childText(body, "blurb") || attrs.blurb,
267
+ summary: childText(body, "summary") || attrs.summary,
268
+ date: attrs.date,
269
+ duration: attrs.duration,
270
+ participants: attrs.participants,
271
+ tags: attrs.tags,
272
+ url: attrs.url,
273
+ });
274
+ }
275
+ return meetings;
276
+ }
277
+
278
+ function childText(text, tag) {
279
+ const match = text.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
280
+ return match ? decodeXml(match[1].trim()) : undefined;
281
+ }
282
+
283
+ function parseAttributes(text) {
284
+ const attrs = {};
285
+ const attrPattern = /([A-Za-z_:][A-Za-z0-9_:.-]*)="([^"]*)"/g;
286
+ let match;
287
+ while ((match = attrPattern.exec(text)) !== null) {
288
+ attrs[match[1]] = decodeXml(match[2]);
289
+ }
290
+ return attrs;
291
+ }
292
+
293
+ function parseElementList(text, spec) {
294
+ const containerMatch = text.match(new RegExp(`<${spec.container}\\s+([^>]*)>`));
295
+ if (!containerMatch) return null;
296
+
297
+ const container = parseAttributes(containerMatch[1]);
298
+ const items = [];
299
+ const itemPattern = new RegExp(`<${spec.item}\\s+([^>]*?)(?:\\/>|>([\\s\\S]*?)<\\/${spec.item}>)`, "g");
300
+ let match;
301
+ while ((match = itemPattern.exec(text)) !== null) {
302
+ const item = parseAttributes(match[1]);
303
+ const body = match[2] || "";
304
+ for (const field of spec.childFields || []) {
305
+ const child = body.match(new RegExp(`<${field}>([\\s\\S]*?)<\\/${field}>`));
306
+ if (child) item[field] = decodeXml(child[1].trim());
307
+ }
308
+ items.push(item);
309
+ }
310
+
311
+ return {
312
+ count: Number.parseInt(container.count || String(items.length), 10),
313
+ offset: container.offset ? Number.parseInt(container.offset, 10) : undefined,
314
+ limit: container.limit ? Number.parseInt(container.limit, 10) : undefined,
315
+ [spec.key]: items,
316
+ };
317
+ }
318
+
319
+ function decodeXml(value) {
320
+ return String(value)
321
+ .replaceAll("&quot;", "\"")
322
+ .replaceAll("&apos;", "'")
323
+ .replaceAll("&lt;", "<")
324
+ .replaceAll("&gt;", ">")
325
+ .replaceAll("&amp;", "&");
326
+ }
@@ -0,0 +1,104 @@
1
+ const ROUTES = {
2
+ listMeetings: [
3
+ "meetings_list",
4
+ "list_meetings",
5
+ "meeting_list",
6
+ "get_meetings",
7
+ "search_meetings",
8
+ ],
9
+ getMeeting: [
10
+ "meeting_get",
11
+ "get_meeting",
12
+ "meeting_view",
13
+ "get_meeting_details",
14
+ ],
15
+ getTranscript: [
16
+ "transcript_get",
17
+ "get_transcript",
18
+ "meeting_transcript",
19
+ "get_meeting_transcript",
20
+ ],
21
+ getNotes: [
22
+ "get_minutes",
23
+ "notes_get",
24
+ "get_notes",
25
+ "meeting_notes",
26
+ "get_meeting_notes",
27
+ "summary_get",
28
+ "get_summary",
29
+ ],
30
+ search: [
31
+ "search",
32
+ "search_meetings",
33
+ "search_transcripts",
34
+ "meetings_search",
35
+ ],
36
+ listContacts: ["list_contacts", "contacts_list"],
37
+ getContact: ["get_contact", "contact_get"],
38
+ listThreads: ["list_threads", "threads_list"],
39
+ getThread: ["get_thread", "thread_get"],
40
+ listEvents: ["list_events", "events_list"],
41
+ getEvent: ["get_event", "event_get"],
42
+ listTemplates: ["list_templates", "templates_list"],
43
+ getTemplate: ["get_template", "template_get"],
44
+ createNote: ["create_note", "note_create", "create_meeting_note"],
45
+ };
46
+
47
+ export function findTool(tools, routeName) {
48
+ const candidates = ROUTES[routeName] || [];
49
+ const normalizedTools = tools.map((tool) => ({
50
+ ...tool,
51
+ normalizedName: normalize(tool.name),
52
+ searchable: normalize(`${tool.name} ${tool.description || ""}`),
53
+ }));
54
+
55
+ for (const candidate of candidates) {
56
+ const normalized = normalize(candidate);
57
+ const exact = normalizedTools.find((tool) => tool.normalizedName === normalized);
58
+ if (exact) return exact;
59
+ }
60
+
61
+ const words = routeName.replace(/[A-Z]/g, (match) => ` ${match.toLowerCase()}`).trim().split(/\s+/);
62
+ return normalizedTools.find((tool) => words.every((word) => tool.searchable.includes(word)));
63
+ }
64
+
65
+ export function buildArgs(tool, values) {
66
+ const schema = tool.inputSchema || {};
67
+ const properties = schema.properties || {};
68
+ const args = {};
69
+
70
+ for (const [key, value] of Object.entries(values)) {
71
+ if (value === undefined || value === null || value === "") continue;
72
+ const targetKey = pickProperty(properties, key) || key;
73
+ args[targetKey] = value;
74
+ }
75
+
76
+ return args;
77
+ }
78
+
79
+ function pickProperty(properties, key) {
80
+ if (Object.hasOwn(properties, key)) return key;
81
+ const aliases = {
82
+ id: ["meeting_id", "meetingId", "document_id", "documentId", "id"],
83
+ query: ["query", "q", "search", "text"],
84
+ limit: ["limit", "count", "max", "page_size", "pageSize"],
85
+ offset: ["offset", "skip"],
86
+ since: ["since", "start", "from", "after", "start_date", "startDate"],
87
+ until: ["until", "end", "to", "before", "end_date", "endDate"],
88
+ includeMeetings: ["include_meetings", "includeMeetings"],
89
+ meetingsLimit: ["meetings_limit", "meetingsLimit"],
90
+ kind: ["kind", "type"],
91
+ includeDisabled: ["include_disabled", "includeDisabled"],
92
+ meetingId: ["meeting_id", "meetingId", "id"],
93
+ prompt: ["prompt"],
94
+ instruction: ["instruction"],
95
+ templateId: ["template_id", "templateId"],
96
+ includePrivateNotes: ["include_private_notes", "includePrivateNotes"],
97
+ data: ["data"],
98
+ };
99
+ return aliases[key]?.find((alias) => Object.hasOwn(properties, alias));
100
+ }
101
+
102
+ function normalize(value) {
103
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
104
+ }