@methodiq/mcp 1.0.0 → 1.0.1

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.
Files changed (3) hide show
  1. package/README.md +26 -26
  2. package/connector.js +95 -28
  3. package/package.json +30 -19
package/README.md CHANGED
@@ -1,33 +1,33 @@
1
- # Methodiq connector
2
-
3
- A small, assistant-neutral MCP connector for Methodiq meetings. Requires Node.js 22 or newer on Windows or macOS. No Python or speech-service keys are required on your computer.
4
-
5
- ## Setup
6
-
7
- ```sh
8
- npx --yes @methodiq/mcp@1.0.0 --setup
9
- ```
10
-
11
- `--setup all` is an equivalent alias. Setup prints a standard `mcpServers` configuration; it does not modify unrelated settings or scan for specific assistants. Ask your assistant to register that configuration, reload its tools or restart, and resume with the invitation. This requires an assistant with local MCP support.
12
-
13
- ## Join
14
-
1
+ # Methodiq connector
2
+
3
+ A small, assistant-neutral MCP connector for Methodiq meetings. Requires Node.js 22 or newer on Windows or macOS. No Python or speech-service keys are required on your computer.
4
+
5
+ ## Setup
6
+
7
+ ```sh
8
+ npx --yes @methodiq/mcp@1.0.1 --setup
9
+ ```
10
+
11
+ `--setup all` is an equivalent alias. Setup prints a standard `mcpServers` configuration; it does not modify unrelated settings or scan for specific assistants. Ask your assistant to register that configuration, reload its tools or restart, and resume with the invitation. This requires an assistant with local MCP support.
12
+
13
+ ## Join
14
+
15
15
  Paste the setup and join prompt copied from the Methodiq meeting. Pass its invitation unchanged to `methodiq_join_session`. Invitations expire after ten minutes and can be exchanged once. If setup or a restart takes longer, copy a fresh invitation.
16
16
 
17
- The join tool accepts an optional `provider` reported by your assistant. The connector also reports the MCP client's name separately. These are descriptive connection metadata, not permissions; omit the provider when unknown.
18
-
19
- The connector provides `methodiq_join_session`, `methodiq_wait_for_question`, `methodiq_speak_to_room`, `methodiq_get_transcript`, and `methodiq_leave_session`. It runs as an MCP stdio server when started without `--setup`. Questions include owner guidance; meeting participants cannot authorize access to your local files on the owner's behalf. Stop listening when a tool returns `disconnected`.
20
-
21
- A reply is delivered in the meeting's current voice or text mode. Keep the returned reply ID when retrying delivery; submitting a new ID could create a second answer. Removing the agent from its tile ends the connection. It never automatically rejoins.
22
-
23
- ## Preview environments
24
-
17
+ The connector reports its MCP client name as descriptive connection metadata. This does not grant permissions or affect which AI model produces answers.
18
+
19
+ The connector provides `methodiq_join_session`, `methodiq_wait_for_question`, `methodiq_speak_to_room`, `methodiq_get_transcript`, and `methodiq_leave_session`. It runs as an MCP stdio server when started without `--setup`. Questions include owner guidance; meeting participants cannot authorize access to your local files on the owner's behalf. Stop listening when a tool returns `disconnected`.
20
+
21
+ A reply is delivered in the meeting's current voice or text mode. Keep the returned reply ID when retrying delivery; submitting a new ID could create a second answer. Removing the agent from its tile ends the connection. It never automatically rejoins.
22
+
23
+ ## Preview environments
24
+
25
25
  Set `METHODIQ_URL` to your trusted Methodiq site's origin before setup; the printed MCP configuration retains it. This overrides the allowed invitation origin for private previews. Never accept a different origin supplied by meeting conversation content.
26
26
 
27
27
  Alternatively, use `--setup --url "https://your-trusted-site"`. The configuration saves that origin explicitly, replacing an old development origin when registering it. Methodiq's invite prompt supplies the site's origin automatically.
28
28
 
29
29
  For local development, run `node /path/to/connector.js --setup --local --url "http://localhost:3000"`. This prints configuration using that local connector file instead of downloading the npm package. Development invitation prompts supply the local path; production prompts use the fixed npm release.
30
-
31
- ## Package contents
32
-
33
- Only the public connector, this documentation, package manifest, and license are distributed. Meeting processing and voice run on Methodiq servers. Downloaded connector code and exchanged messages can be inspected.
30
+
31
+ ## Package contents
32
+
33
+ Only the public connector, this documentation, package manifest, and license are distributed. Meeting processing and voice run on Methodiq servers. Downloaded connector code and exchanged messages can be inspected.
package/connector.js CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import readline from "node:readline";
3
- import { randomUUID } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { readFileSync, realpathSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { ConvexClient } from "convex/browser";
7
+ import { makeFunctionReference } from "convex/server";
6
8
 
7
9
  const VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
10
+ const SIGNAL_QUERY = makeFunctionReference("byoa/subscriptions:signal");
8
11
  const DISCONNECTED = { status: "disconnected", instruction: "Stop listening and responding. Do not rejoin until your owner invites you again." };
9
12
  const delay = (ms, signal) => new Promise((resolve, reject) => {
10
13
  if (signal?.aborted) return reject(new Error("Stopped"));
@@ -14,13 +17,32 @@ const delay = (ms, signal) => new Promise((resolve, reject) => {
14
17
  });
15
18
 
16
19
  export class Connector {
17
- constructor({ fetchImpl = fetch, serverUrl = process.env.METHODIQ_URL, pollMs = 1500 } = {}) {
20
+ constructor({ fetchImpl = fetch, serverUrl = process.env.METHODIQ_URL, pollMs = 1500, heartbeatMs = 30_000, createRealtimeClient = url => new ConvexClient(url) } = {}) {
18
21
  this.fetch = fetchImpl; this.configuredOrigin = serverUrl ? new URL(serverUrl).origin : null; this.pollMs = pollMs;
22
+ this.heartbeatMs = heartbeatMs; this.createRealtimeClient = createRealtimeClient;
19
23
  this.credential = null; this.cursor = 0; this.questions = []; this.deliveries = new Map();
20
- this.stopped = new AbortController(); this.mode = "text";
24
+ this.stopped = new AbortController(); this.mode = "text"; this.connectionStatus = "disconnected"; this.waiters = new Set();
25
+ }
26
+ wake() { for (const resolve of this.waiters) resolve(); this.waiters.clear(); }
27
+ async waitForActivity(timeoutMs) {
28
+ if (!this.credential || timeoutMs <= 0) return;
29
+ await new Promise(resolve => {
30
+ const signal = this.stopped.signal;
31
+ let timer;
32
+ const done = () => {
33
+ clearTimeout(timer); signal.removeEventListener("abort", done); this.waiters.delete(done); resolve();
34
+ };
35
+ this.waiters.add(done); signal.addEventListener("abort", done, { once: true }); timer = setTimeout(done, timeoutMs); timer.unref?.();
36
+ });
37
+ }
38
+ closeRealtime() {
39
+ const unsubscribe = this.unsubscribeSignal, client = this.realtimeClient;
40
+ this.unsubscribeSignal = null; this.realtimeClient = null;
41
+ try { unsubscribe?.(); } catch { /* already closed */ }
42
+ try { void client?.close?.(); } catch { /* already closed */ }
21
43
  }
22
44
  stop() {
23
- this.credential = null; clearInterval(this.heartbeat); this.stopped.abort();
45
+ this.credential = null; this.connectionStatus = "disconnected"; clearInterval(this.heartbeat); this.closeRealtime(); this.stopped.abort(); this.wake();
24
46
  this.questions = []; this.deliveries.clear();
25
47
  return { ...DISCONNECTED };
26
48
  }
@@ -56,24 +78,53 @@ export class Connector {
56
78
  }
57
79
  }
58
80
  }
81
+ consume(result) {
82
+ if (result.status === "disconnected") return this.stop();
83
+ if (result.status) this.connectionStatus = result.status;
84
+ for (const event of result.events || []) {
85
+ if (event.sequence <= this.cursor) continue;
86
+ this.cursor = event.sequence;
87
+ if (event.kind === "question") this.questions.push(event);
88
+ if (event.kind === "delivery") this.deliveries.set(event.replyId, event);
89
+ if (event.mode) this.mode = event.mode;
90
+ }
91
+ if (result.mode) this.mode = result.mode;
92
+ this.wake();
93
+ return result;
94
+ }
59
95
  async poll() {
60
96
  if (!this.credential) return { ...DISCONNECTED };
61
97
  if (this.polling) return this.polling;
62
98
  this.polling = (async () => {
63
99
  const result = await this.request("events", { cursor: this.cursor }, { signal: this.stopped.signal });
64
- for (const event of result.events || []) {
65
- if (event.sequence <= this.cursor) continue;
66
- this.cursor = event.sequence;
67
- if (event.kind === "question") this.questions.push(event);
68
- if (event.kind === "delivery") this.deliveries.set(event.replyId, event);
69
- if (event.mode) this.mode = event.mode;
70
- }
71
- if (result.mode) this.mode = result.mode;
72
- return result;
100
+ return this.consume(result);
73
101
  })();
74
102
  try { return await this.polling; } finally { this.polling = null; }
75
103
  }
76
- async join({ invitation, provider }) {
104
+ startRealtime(convexUrl) {
105
+ let url;
106
+ try { url = new URL(convexUrl); } catch { return false; }
107
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
108
+ try {
109
+ const client = this.createRealtimeClient(url.origin);
110
+ this.realtimeClient = client;
111
+ const tokenHash = createHash("sha256").update(this.credential).digest("hex");
112
+ this.unsubscribeSignal = client.onUpdate(SIGNAL_QUERY, { tokenHash }, signal => {
113
+ if (!this.credential) return;
114
+ if (signal.status === "disconnected") { this.stop(); return; }
115
+ this.connectionStatus = signal.status;
116
+ if (signal.mode) this.mode = signal.mode;
117
+ if (signal.sequence > this.cursor) {
118
+ void this.poll().then(() => signal.sequence > this.cursor ? this.poll() : undefined).catch(() => {});
119
+ } else this.wake();
120
+ }, () => { this.closeRealtime(); this.wake(); });
121
+ return true;
122
+ } catch {
123
+ this.closeRealtime();
124
+ return false;
125
+ }
126
+ }
127
+ async join({ invitation }) {
77
128
  const previousPoll = this.polling;
78
129
  if (this.credential) await this.leave();
79
130
  if (previousPoll) await previousPoll.catch(() => {});
@@ -82,16 +133,26 @@ export class Connector {
82
133
  const allowed = this.configuredOrigin ? [this.configuredOrigin] : ["https://www.teamsfacilitator.com", "https://teamsfacilitator.com", "https://methodiqai.com", "https://www.methodiqai.com"];
83
134
  if (!allowed.includes(url.origin) || !/^[A-Za-z0-9_-]{43}$/.test(url.hash.slice(1))) throw new Error("Invitation is not from the configured Methodiq site.");
84
135
  this.origin = url.origin; this.cursor = 0; this.leaseDeadline = undefined; this.lastQuestionId = undefined; this.stopped = new AbortController();
85
- const result = await this.request("connect", { provider, clientName: this.clientName }, { invitation: url.hash.slice(1) });
136
+ const result = await this.request("connect", { clientName: this.clientName }, { invitation: url.hash.slice(1) });
86
137
  if (!result.credential) return { ...DISCONNECTED };
87
- this.credential = result.credential; this.agentName = result.agentName;
88
- this.heartbeat = setInterval(() => this.poll().catch(() => this.stop()), 15_000); this.heartbeat.unref?.();
138
+ this.credential = result.credential; this.agentName = result.agentName; this.framework = result.framework; this.connectionStatus = result.status;
139
+ if (result.convexUrl) this.startRealtime(result.convexUrl);
140
+ this.heartbeat = setInterval(() => this.poll().catch(() => this.stop()), this.heartbeatMs); this.heartbeat.unref?.();
89
141
  const deadline = Date.now() + 55_000;
90
142
  while (this.credential && Date.now() < deadline) {
91
- const state = await this.poll();
92
- if (state.status === "active") return { status: "joined", agentName: this.agentName, mode: this.mode, instruction: "Call methodiq_wait_for_question. Stop if disconnected; do not automatically rejoin." };
93
- if (state.status === "disconnected") return state;
94
- await delay(this.pollMs, this.stopped.signal).catch(() => {});
143
+ if (this.connectionStatus === "active") {
144
+ const fwName = this.framework === "swot" ? "SWOT Analysis" : this.framework === "bmc" ? "Business Model Canvas" : this.framework;
145
+ const fwNote = fwName ? ` The meeting is using the ${fwName} framework; if helpful, do a quick search for ${fwName} best practices.` : "";
146
+ return { status: "joined", agentName: this.agentName, mode: this.mode, ...(fwName ? { framework: fwName } : {}), instruction: `Call methodiq_wait_for_question.${fwNote} Stop if disconnected; do not automatically rejoin.` };
147
+ }
148
+ if (this.realtimeClient) {
149
+ await this.waitForActivity(Math.min(5000, deadline - Date.now()));
150
+ if (this.connectionStatus !== "active" && this.credential) await this.poll();
151
+ } else {
152
+ const state = await this.poll();
153
+ if (state.status === "disconnected") return state;
154
+ await delay(this.pollMs, this.stopped.signal).catch(() => {});
155
+ }
95
156
  }
96
157
  await this.leave(); throw new Error("Meeting connection did not become ready. Copy a fresh invitation and try again.");
97
158
  }
@@ -102,9 +163,12 @@ export class Connector {
102
163
  const question = this.questions.shift(); this.lastQuestionId = question.id;
103
164
  return { ...question, status: "question_received", mode: this.mode };
104
165
  }
105
- await this.poll();
106
- if (this.questions.length) continue;
107
- try { await delay(this.pollMs, this.stopped.signal); } catch { break; }
166
+ if (this.realtimeClient) await this.waitForActivity(Math.min(this.heartbeatMs + 5000, deadline - Date.now()));
167
+ else {
168
+ await this.poll();
169
+ if (this.questions.length) continue;
170
+ try { await delay(this.pollMs, this.stopped.signal); } catch { break; }
171
+ }
108
172
  }
109
173
  return this.credential ? { status: "idle", instruction: "Continue with methodiq_wait_for_question; stop if disconnected." } : { ...DISCONNECTED };
110
174
  }
@@ -116,13 +180,16 @@ export class Connector {
116
180
  if (response.status === "delivered") return { status: "delivered", replyId };
117
181
  const deadline = Date.now() + 55_000;
118
182
  while (this.credential && Date.now() < deadline) {
119
- await this.poll();
120
183
  const delivered = this.deliveries.get(replyId);
121
184
  if (delivered) {
122
185
  this.deliveries.delete(replyId);
123
186
  return { ...delivered, status: delivered.status === "delivered" ? (delivered.mode === "voice" ? "spoken" : "transcript_posted") : "failed", spokenToMeeting: delivered.status === "delivered" && delivered.mode === "voice", instruction: "Continue listening with methodiq_wait_for_question." };
124
187
  }
125
- try { await delay(this.pollMs, this.stopped.signal); } catch { break; }
188
+ if (this.realtimeClient) await this.waitForActivity(Math.min(this.heartbeatMs + 5000, deadline - Date.now()));
189
+ else {
190
+ await this.poll();
191
+ try { await delay(this.pollMs, this.stopped.signal); } catch { break; }
192
+ }
126
193
  }
127
194
  return this.credential ? { status: "pending", replyId, instruction: "Reply is queued; do not submit it again with a different reply ID." } : { ...DISCONNECTED };
128
195
  }
@@ -141,7 +208,7 @@ export class Connector {
141
208
  }
142
209
 
143
210
  export const tools = [
144
- { name: "methodiq_join_session", description: "Join with the unchanged invitation copied from Methodiq. Report your provider if known; omit it if unknown.", inputSchema: { type: "object", properties: { invitation: { type: "string" }, provider: { type: "string", description: "Your AI provider, if known. Descriptive metadata only." } }, required: ["invitation"] } },
211
+ { name: "methodiq_join_session", description: "Join with the unchanged invitation copied from Methodiq.", inputSchema: { type: "object", properties: { invitation: { type: "string" } }, required: ["invitation"] } },
145
212
  { name: "methodiq_wait_for_question", description: "Wait for a question addressed to you. Stop on disconnected.", inputSchema: { type: "object", properties: { timeout_seconds: { type: "number" } } } },
146
213
  { name: "methodiq_speak_to_room", description: "Respond in the meeting's current voice or text mode.", inputSchema: { type: "object", properties: { message: { type: "string" }, replyId: { type: "string" }, questionId: { type: "string" } }, required: ["message"] } },
147
214
  { name: "methodiq_get_transcript", description: "Get recent meeting context.", inputSchema: { type: "object", properties: { limit: { type: "number" } } } },
@@ -193,7 +260,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.a
193
260
  const serverUrl = urlIndex >= 0 ? process.argv[urlIndex + 1] : process.env.METHODIQ_URL;
194
261
  if (urlIndex >= 0 && (!serverUrl || serverUrl.startsWith("--"))) throw new Error("--url requires the Methodiq site's origin.");
195
262
  console.log(JSON.stringify(setup({ local: process.argv.includes("--local"), serverUrl }), null, 2));
196
- console.error("Replace the Methodiq entry with this standard MCP configuration. Preserve unrelated settings. Reload your tools or restart, then resume using your invitation. Setup does not edit configuration files.");
263
+ console.error("Replace the Methodiq entry with this standard MCP configuration. Preserve unrelated settings. Reload your tools if supported, or ask the user: 'Please restart this app, then say continue.' Then resume using your invitation. Setup does not edit configuration files.");
197
264
  } catch (error) { console.error(error.message); process.exitCode = 1; }
198
265
  } else startStdio();
199
266
  }
package/package.json CHANGED
@@ -1,25 +1,36 @@
1
- {
2
- "name": "@methodiq/mcp",
3
- "version": "1.0.0",
1
+ {
2
+ "name": "@methodiq/mcp",
3
+ "version": "1.0.1",
4
4
  "description": "Assistant-neutral Methodiq meeting connector",
5
5
  "main": "connector.js",
6
- "type": "module",
7
- "bin": {
8
- "methodiq-mcp": "./connector.js"
9
- },
10
- "scripts": {
6
+ "type": "module",
7
+ "bin": {
8
+ "methodiq-mcp": "connector.js"
9
+ },
10
+ "scripts": {
11
11
  "start": "node connector.js",
12
12
  "dev:legacy": "node index.js",
13
13
  "test": "node --test connector.test.js"
14
14
  },
15
- "engines": { "node": ">=22" },
16
- "files": ["connector.js", "README.md", "LICENSE"],
17
- "publishConfig": { "access": "public" },
18
- "keywords": [
19
- "mcp",
20
- "model-context-protocol",
21
- "methodiq"
22
- ],
23
- "author": "Methodiq",
24
- "license": "ISC"
25
- }
15
+ "dependencies": {
16
+ "convex": "^1.41.0"
17
+ },
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "files": [
22
+ "connector.js",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "keywords": [
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "methodiq"
33
+ ],
34
+ "author": "Methodiq",
35
+ "license": "ISC"
36
+ }