@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.
- package/README.md +26 -26
- package/connector.js +95 -28
- 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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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", {
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|