@intangle/mcp-server 2.6.19 → 2.6.21
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 +36 -0
- package/dist/claude-channel.js +359 -0
- package/dist/index.js +40 -1
- package/dist/tool-definitions.js +13 -12
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -51,6 +51,42 @@ Standard MCP (Claude desktop, Cursor): by adding to your `claude_desktop_config.
|
|
|
51
51
|
|
|
52
52
|
3. **Restart Claude Desktop** to load the MCP server
|
|
53
53
|
|
|
54
|
+
## Claude Code live Tan channel (opt-in preview)
|
|
55
|
+
|
|
56
|
+
The MCP server can forward new Tan-addressed messages from one explicitly bound
|
|
57
|
+
Intangle Room into an already-running Claude Code session and send replies back
|
|
58
|
+
through `intangle_channel_reply`. It does not attach to a session that was
|
|
59
|
+
started before the channel was enabled, and it does not replay older messages.
|
|
60
|
+
|
|
61
|
+
This custom channel requires Node.js 21 or later. The standard Intangle MCP
|
|
62
|
+
tools continue to support Node.js 18. Claude Code currently treats custom
|
|
63
|
+
channels as development channels; its channels feature is a research preview.
|
|
64
|
+
|
|
65
|
+
Configure the Intangle MCP server with the exact Space, conversation, and
|
|
66
|
+
participant IDs to receive Tan's addressed messages:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
claude mcp add intangle -s user \
|
|
70
|
+
--env MCP_API_KEY=<your-intangle-api-key> \
|
|
71
|
+
--env NEXT_APP_URL=https://renvoi.co/intangle \
|
|
72
|
+
--env INTANGLE_CLAUDE_CHANNEL_ENABLED=1 \
|
|
73
|
+
--env INTANGLE_CLAUDE_CHANNEL_SPACE_ID=<space-id> \
|
|
74
|
+
--env INTANGLE_CLAUDE_CHANNEL_CONVERSATION_ID=<conversation-id> \
|
|
75
|
+
--env INTANGLE_CLAUDE_CHANNEL_PARTICIPANT_ID=<agent-participant-id> \
|
|
76
|
+
-- npx -y @intangle/mcp-server
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then start a new Claude Code session with the custom channel explicitly enabled:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
claude --dangerously-load-development-channels server:intangle
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Claude Code shows a development-channel warning and may ask you to trust the
|
|
86
|
+
MCP server. Messages arrive only while that session is open. Remove the three
|
|
87
|
+
`INTANGLE_CLAUDE_CHANNEL_*` scope values or omit the enabled flag to return to
|
|
88
|
+
ordinary MCP-only behavior.
|
|
89
|
+
|
|
54
90
|
## Usage
|
|
55
91
|
|
|
56
92
|
Once configured, Claude will automatically have access to your Intangle context. You can:
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
export const CLAUDE_CHANNEL_REPLY_TOOL = "intangle_channel_reply";
|
|
2
|
+
export const CLAUDE_CHANNEL_ENABLED_ENV = "INTANGLE_CLAUDE_CHANNEL_ENABLED";
|
|
3
|
+
export const CLAUDE_CHANNEL_RUNTIME_ERROR = "Intangle Claude channels require Node.js 21 or later for the built-in WebSocket API; standard MCP tools still support Node.js 18.";
|
|
4
|
+
const CHANNEL_RECONNECT_SECONDS = 25;
|
|
5
|
+
const REPLAY_OVERLAP_MS = 5_000;
|
|
6
|
+
export function claudeChannelConfigFromEnv(env) {
|
|
7
|
+
if (env[CLAUDE_CHANNEL_ENABLED_ENV] !== "1")
|
|
8
|
+
return null;
|
|
9
|
+
const spaceId = nonblank(env.INTANGLE_CLAUDE_CHANNEL_SPACE_ID);
|
|
10
|
+
const conversationId = nonblank(env.INTANGLE_CLAUDE_CHANNEL_CONVERSATION_ID);
|
|
11
|
+
const participantId = nonblank(env.INTANGLE_CLAUDE_CHANNEL_PARTICIPANT_ID);
|
|
12
|
+
if (!spaceId || !conversationId || !participantId) {
|
|
13
|
+
throw new Error(`${CLAUDE_CHANNEL_ENABLED_ENV}=1 requires explicit Space, conversation, and participant IDs`);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
spaceId,
|
|
17
|
+
conversationId,
|
|
18
|
+
participantId,
|
|
19
|
+
participantLabel: nonblank(env.INTANGLE_CLAUDE_CHANNEL_PARTICIPANT_LABEL) || "Claude Code",
|
|
20
|
+
participantType: nonblank(env.INTANGLE_CLAUDE_CHANNEL_PARTICIPANT_TYPE) || "agent",
|
|
21
|
+
organizationId: nonblank(env.INTANGLE_CLAUDE_CHANNEL_ORGANIZATION_ID),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function claudeChannelToolDefinition() {
|
|
25
|
+
return {
|
|
26
|
+
name: CLAUDE_CHANNEL_REPLY_TOOL,
|
|
27
|
+
title: "Reply to Tan in this Intangle Room",
|
|
28
|
+
description: "Send a reply to Tan in the explicitly configured Intangle conversation. The Space and conversation are fixed by the channel configuration.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
properties: {
|
|
33
|
+
content: { type: "string", minLength: 1 },
|
|
34
|
+
},
|
|
35
|
+
required: ["content"],
|
|
36
|
+
},
|
|
37
|
+
annotations: { openWorldHint: true },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function sendClaudeChannelReply(config, content, sendReply, createTurnId) {
|
|
41
|
+
const normalized = nonblank(content);
|
|
42
|
+
if (!normalized)
|
|
43
|
+
throw new Error("content is required");
|
|
44
|
+
return sendReply({
|
|
45
|
+
space_id: config.spaceId,
|
|
46
|
+
conversation_id: config.conversationId,
|
|
47
|
+
content: normalized,
|
|
48
|
+
participant_id: config.participantId,
|
|
49
|
+
participant_type: config.participantType,
|
|
50
|
+
participant_label: config.participantLabel,
|
|
51
|
+
...(config.organizationId
|
|
52
|
+
? { organization_id: config.organizationId }
|
|
53
|
+
: {}),
|
|
54
|
+
client_turn_id: createTurnId(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export class ClaudeChannelBridge {
|
|
58
|
+
options;
|
|
59
|
+
cursor = null;
|
|
60
|
+
initialCursor = null;
|
|
61
|
+
baselineLoaded = false;
|
|
62
|
+
socket = null;
|
|
63
|
+
reconnectTimer = null;
|
|
64
|
+
stopped = false;
|
|
65
|
+
connecting = false;
|
|
66
|
+
messageQueue = Promise.resolve();
|
|
67
|
+
delivered = new Map();
|
|
68
|
+
createSocket;
|
|
69
|
+
schedule;
|
|
70
|
+
report;
|
|
71
|
+
constructor(options) {
|
|
72
|
+
this.options = options;
|
|
73
|
+
this.createSocket =
|
|
74
|
+
options.createSocket ||
|
|
75
|
+
((url) => new WebSocket(url));
|
|
76
|
+
this.schedule =
|
|
77
|
+
options.schedule || ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
78
|
+
this.report =
|
|
79
|
+
options.report ||
|
|
80
|
+
((status, detail) => {
|
|
81
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
82
|
+
console.error(`[Intangle Claude channel] ${status}${suffix}`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async start() {
|
|
86
|
+
if (!this.options.createSocket &&
|
|
87
|
+
typeof globalThis.WebSocket !== "function") {
|
|
88
|
+
throw new Error(CLAUDE_CHANNEL_RUNTIME_ERROR);
|
|
89
|
+
}
|
|
90
|
+
await this.connect();
|
|
91
|
+
}
|
|
92
|
+
stop() {
|
|
93
|
+
this.stopped = true;
|
|
94
|
+
if (this.reconnectTimer)
|
|
95
|
+
clearTimeout(this.reconnectTimer);
|
|
96
|
+
this.reconnectTimer = null;
|
|
97
|
+
this.socket?.close();
|
|
98
|
+
this.socket = null;
|
|
99
|
+
}
|
|
100
|
+
async connect() {
|
|
101
|
+
if (this.stopped || this.connecting)
|
|
102
|
+
return;
|
|
103
|
+
this.connecting = true;
|
|
104
|
+
try {
|
|
105
|
+
if (!this.baselineLoaded)
|
|
106
|
+
await this.loadBaseline();
|
|
107
|
+
if (this.stopped)
|
|
108
|
+
return;
|
|
109
|
+
const bootstrap = await this.fetchBootstrap();
|
|
110
|
+
if (this.stopped)
|
|
111
|
+
return;
|
|
112
|
+
const socketUrl = new URL(bootstrap.watch_url);
|
|
113
|
+
if (this.cursor !== null) {
|
|
114
|
+
socketUrl.searchParams.set("since", String(Math.max(0, this.cursor - REPLAY_OVERLAP_MS)));
|
|
115
|
+
}
|
|
116
|
+
const socket = this.createSocket(socketUrl.toString());
|
|
117
|
+
this.socket = socket;
|
|
118
|
+
socket.addEventListener("open", () => {
|
|
119
|
+
if (this.socket === socket) {
|
|
120
|
+
this.report("watch_connected", this.options.config.conversationId);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
socket.addEventListener("message", (event) => {
|
|
124
|
+
this.messageQueue = this.messageQueue
|
|
125
|
+
.then(() => this.handleFrame(event.data))
|
|
126
|
+
.catch((error) => {
|
|
127
|
+
this.report("channel_notification_failed", errorText(error));
|
|
128
|
+
socket.close();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
socket.addEventListener("error", (event) => {
|
|
132
|
+
this.report("watch_connection_error", errorText(event));
|
|
133
|
+
});
|
|
134
|
+
socket.addEventListener("close", () => {
|
|
135
|
+
if (this.socket === socket)
|
|
136
|
+
this.socket = null;
|
|
137
|
+
if (!this.stopped) {
|
|
138
|
+
this.report("watch_disconnected", "reconnecting");
|
|
139
|
+
this.scheduleReconnect(bootstrap.watch_keepalive_seconds);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
this.report("watch_start_failed", errorText(error));
|
|
145
|
+
this.scheduleReconnect(CHANNEL_RECONNECT_SECONDS);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
this.connecting = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async loadBaseline() {
|
|
152
|
+
const result = asRecord(await this.options.api.fetchMessages({
|
|
153
|
+
space_id: this.options.config.spaceId,
|
|
154
|
+
conversation_id: this.options.config.conversationId,
|
|
155
|
+
...(this.options.config.organizationId
|
|
156
|
+
? { organization_id: this.options.config.organizationId }
|
|
157
|
+
: {}),
|
|
158
|
+
limit: 1,
|
|
159
|
+
}));
|
|
160
|
+
if (result?.success !== true ||
|
|
161
|
+
result.conversation_id !== this.options.config.conversationId ||
|
|
162
|
+
!Array.isArray(result.messages)) {
|
|
163
|
+
throw new Error("Conversation baseline fetch failed");
|
|
164
|
+
}
|
|
165
|
+
this.cursor = result.messages.reduce((latest, value) => {
|
|
166
|
+
const message = asRecord(value);
|
|
167
|
+
const durableTimestamp = message?.durable_timestamp;
|
|
168
|
+
if (typeof durableTimestamp !== "number" ||
|
|
169
|
+
!Number.isFinite(durableTimestamp)) {
|
|
170
|
+
return latest;
|
|
171
|
+
}
|
|
172
|
+
return latest === null
|
|
173
|
+
? durableTimestamp
|
|
174
|
+
: Math.max(latest, durableTimestamp);
|
|
175
|
+
}, null);
|
|
176
|
+
this.initialCursor = this.cursor;
|
|
177
|
+
this.baselineLoaded = true;
|
|
178
|
+
}
|
|
179
|
+
async fetchBootstrap() {
|
|
180
|
+
const result = asRecord(await this.options.api.subscribe({
|
|
181
|
+
space_id: this.options.config.spaceId,
|
|
182
|
+
conversation_id: this.options.config.conversationId,
|
|
183
|
+
participant_id: this.options.config.participantId,
|
|
184
|
+
participant_type: this.options.config.participantType,
|
|
185
|
+
participant_label: this.options.config.participantLabel,
|
|
186
|
+
...(this.options.config.organizationId
|
|
187
|
+
? { organization_id: this.options.config.organizationId }
|
|
188
|
+
: {}),
|
|
189
|
+
mark_read: false,
|
|
190
|
+
include_watch: true,
|
|
191
|
+
}));
|
|
192
|
+
if (result?.success !== true ||
|
|
193
|
+
result.conversation_id !== this.options.config.conversationId ||
|
|
194
|
+
typeof result.watch_url !== "string" ||
|
|
195
|
+
typeof result.watch_expires_in_seconds !== "number" ||
|
|
196
|
+
typeof result.watch_keepalive_seconds !== "number" ||
|
|
197
|
+
typeof result.watch_max_lifetime_seconds !== "number" ||
|
|
198
|
+
!isWatchCursor(result.watch_cursor)) {
|
|
199
|
+
throw new Error("Conversation subscription omitted its watch bootstrap");
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
async handleFrame(raw) {
|
|
204
|
+
const text = typeof raw === "string" ? raw : String(raw);
|
|
205
|
+
let frame;
|
|
206
|
+
try {
|
|
207
|
+
frame = JSON.parse(text);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
this.report("watch_frame_invalid", "ignored non-JSON frame");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (frame.type !== "message" ||
|
|
214
|
+
frame.space_id !== this.options.config.spaceId ||
|
|
215
|
+
frame.conversation_id !== this.options.config.conversationId ||
|
|
216
|
+
typeof frame.message_id !== "string" ||
|
|
217
|
+
typeof frame.durable_timestamp !== "number" ||
|
|
218
|
+
frame.transient === true ||
|
|
219
|
+
frame.role !== "assistant" ||
|
|
220
|
+
frame.source !== "tan_agent_message" ||
|
|
221
|
+
frame.recipient_id !== this.options.config.participantId ||
|
|
222
|
+
typeof frame.content !== "string") {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (this.initialCursor !== null &&
|
|
226
|
+
frame.durable_timestamp <= this.initialCursor) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const messageId = frame.message_id;
|
|
230
|
+
const content = frame.content;
|
|
231
|
+
const notificationContent = await this.resolveNotificationContent({
|
|
232
|
+
message_id: messageId,
|
|
233
|
+
content,
|
|
234
|
+
content_truncated: frame.content_truncated,
|
|
235
|
+
});
|
|
236
|
+
const fingerprint = notificationContent.content;
|
|
237
|
+
const previous = this.delivered.get(frame.message_id);
|
|
238
|
+
if (previous?.cursor === frame.durable_timestamp &&
|
|
239
|
+
previous.fingerprint === fingerprint)
|
|
240
|
+
return;
|
|
241
|
+
await this.options.sink.notification({
|
|
242
|
+
method: "notifications/claude/channel",
|
|
243
|
+
params: {
|
|
244
|
+
content: notificationContent.content,
|
|
245
|
+
meta: {
|
|
246
|
+
conversation_id: this.options.config.conversationId,
|
|
247
|
+
message_id: frame.message_id,
|
|
248
|
+
sender: "Tan",
|
|
249
|
+
recipient_id: this.options.config.participantId,
|
|
250
|
+
...(notificationContent.truncated
|
|
251
|
+
? {
|
|
252
|
+
content_truncated: "true",
|
|
253
|
+
delivery: "full_message_unavailable",
|
|
254
|
+
}
|
|
255
|
+
: {}),
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
this.delivered.set(frame.message_id, {
|
|
260
|
+
cursor: frame.durable_timestamp,
|
|
261
|
+
fingerprint,
|
|
262
|
+
});
|
|
263
|
+
this.cursor =
|
|
264
|
+
this.cursor === null
|
|
265
|
+
? frame.durable_timestamp
|
|
266
|
+
: Math.max(this.cursor, frame.durable_timestamp);
|
|
267
|
+
const cutoff = Math.max(0, this.cursor - REPLAY_OVERLAP_MS);
|
|
268
|
+
for (const [messageId, projection] of this.delivered) {
|
|
269
|
+
if (projection.cursor < cutoff)
|
|
270
|
+
this.delivered.delete(messageId);
|
|
271
|
+
}
|
|
272
|
+
// MCP notifications have no consumer acknowledgement. This is transport
|
|
273
|
+
// write evidence only, never a claim that Claude processed the event.
|
|
274
|
+
this.report("notification_write_completed_unacknowledged", frame.message_id);
|
|
275
|
+
}
|
|
276
|
+
async resolveNotificationContent(frame) {
|
|
277
|
+
if (frame.content_truncated !== true) {
|
|
278
|
+
return { content: frame.content, truncated: false };
|
|
279
|
+
}
|
|
280
|
+
let result;
|
|
281
|
+
try {
|
|
282
|
+
result = asRecord(await this.options.api.fetchMessages({
|
|
283
|
+
space_id: this.options.config.spaceId,
|
|
284
|
+
conversation_id: this.options.config.conversationId,
|
|
285
|
+
...(this.options.config.organizationId
|
|
286
|
+
? { organization_id: this.options.config.organizationId }
|
|
287
|
+
: {}),
|
|
288
|
+
limit: 200,
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
this.report("message_content_fetch_failed", errorText(error));
|
|
293
|
+
return this.truncatedMessageWarning(frame.message_id);
|
|
294
|
+
}
|
|
295
|
+
const fullMessage = result?.success === true &&
|
|
296
|
+
result.conversation_id === this.options.config.conversationId &&
|
|
297
|
+
Array.isArray(result.messages)
|
|
298
|
+
? result.messages.find((message) => asRecord(message)?.id === frame.message_id)
|
|
299
|
+
: undefined;
|
|
300
|
+
const fullRecord = asRecord(fullMessage);
|
|
301
|
+
const fullContent = fullRecord ? messageText(fullRecord) : undefined;
|
|
302
|
+
if (typeof fullContent === "string" &&
|
|
303
|
+
fullContent.length > frame.content.length) {
|
|
304
|
+
return { content: fullContent, truncated: false };
|
|
305
|
+
}
|
|
306
|
+
return this.truncatedMessageWarning(frame.message_id);
|
|
307
|
+
}
|
|
308
|
+
truncatedMessageWarning(messageId) {
|
|
309
|
+
this.report("message_content_unavailable", messageId);
|
|
310
|
+
return {
|
|
311
|
+
content: `Tan's message ${messageId} exceeded the live channel limit, and its full text could not be retrieved. Do not act on partial text. Ask Tan to resend the message in a shorter form.`,
|
|
312
|
+
truncated: true,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
scheduleReconnect(seconds) {
|
|
316
|
+
if (this.stopped || this.reconnectTimer)
|
|
317
|
+
return;
|
|
318
|
+
const delaySeconds = Number.isFinite(seconds) && seconds > 0
|
|
319
|
+
? seconds
|
|
320
|
+
: CHANNEL_RECONNECT_SECONDS;
|
|
321
|
+
this.reconnectTimer = this.schedule(() => {
|
|
322
|
+
this.reconnectTimer = null;
|
|
323
|
+
void this.connect();
|
|
324
|
+
}, delaySeconds * 1_000);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function isWatchCursor(value) {
|
|
328
|
+
const cursor = asRecord(value);
|
|
329
|
+
return (cursor?.parameter === "since" &&
|
|
330
|
+
cursor.semantics === "exclusive durable replay cursor");
|
|
331
|
+
}
|
|
332
|
+
function asRecord(value) {
|
|
333
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
334
|
+
? value
|
|
335
|
+
: null;
|
|
336
|
+
}
|
|
337
|
+
function nonblank(value) {
|
|
338
|
+
return typeof value === "string" && value.trim() !== ""
|
|
339
|
+
? value.trim()
|
|
340
|
+
: undefined;
|
|
341
|
+
}
|
|
342
|
+
function messageText(message) {
|
|
343
|
+
if (typeof message.content === "string" && message.content !== "") {
|
|
344
|
+
return message.content;
|
|
345
|
+
}
|
|
346
|
+
if (!Array.isArray(message.parts))
|
|
347
|
+
return undefined;
|
|
348
|
+
const text = message.parts
|
|
349
|
+
.filter((part) => {
|
|
350
|
+
const record = asRecord(part);
|
|
351
|
+
return record?.type === "text" && typeof record.text === "string";
|
|
352
|
+
})
|
|
353
|
+
.map((part) => String(asRecord(part)?.text))
|
|
354
|
+
.join("");
|
|
355
|
+
return text || undefined;
|
|
356
|
+
}
|
|
357
|
+
function errorText(error) {
|
|
358
|
+
return error instanceof Error ? error.message : String(error);
|
|
359
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Intangle MCP Server - Exposes Intangle context functionality through MCP
|
|
4
4
|
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,6 +11,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
10
11
|
import { CallToolRequestSchema, ErrorCode, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
11
12
|
import { config } from "dotenv";
|
|
12
13
|
import fetch from "node-fetch";
|
|
14
|
+
import { CLAUDE_CHANNEL_ENABLED_ENV, CLAUDE_CHANNEL_REPLY_TOOL, CLAUDE_CHANNEL_RUNTIME_ERROR, ClaudeChannelBridge, claudeChannelConfigFromEnv, claudeChannelToolDefinition, sendClaudeChannelReply, } from "./claude-channel.js";
|
|
13
15
|
import { HIGHQ_DIRECT_TOOL_NAMES } from "./highq-tool-definitions.js";
|
|
14
16
|
const HIGHQ_DIRECT_TOOL_NAME_SET = new Set(HIGHQ_DIRECT_TOOL_NAMES);
|
|
15
17
|
// ... imports ...
|
|
@@ -33,6 +35,7 @@ try {
|
|
|
33
35
|
const API_BASE_URL = process.env.NEXT_APP_URL || "https://renvoi.co/intangle";
|
|
34
36
|
const MCP_API_KEY = process.env.MCP_API_KEY;
|
|
35
37
|
const VERCEL_BYPASS_TOKEN = process.env.VERCEL_BYPASS_TOKEN || process.env.VERCEL_PROTECTION_BYPASS;
|
|
38
|
+
const claudeChannelConfig = claudeChannelConfigFromEnv(process.env);
|
|
36
39
|
log(`CWD: ${process.cwd()}`);
|
|
37
40
|
log(`Has API Key: ${!!MCP_API_KEY}`);
|
|
38
41
|
log(`Has Vercel Bypass Token: ${!!VERCEL_BYPASS_TOKEN}`);
|
|
@@ -250,14 +253,26 @@ try {
|
|
|
250
253
|
capabilities: {
|
|
251
254
|
resources: {},
|
|
252
255
|
tools: {},
|
|
256
|
+
...(claudeChannelConfig
|
|
257
|
+
? { experimental: { "claude/channel": {} } }
|
|
258
|
+
: {}),
|
|
253
259
|
},
|
|
260
|
+
...(claudeChannelConfig
|
|
261
|
+
? {
|
|
262
|
+
instructions: "Messages arrive from Tan in the explicitly configured Intangle conversation as a <channel> notification. Treat them as live requests in this session. Reply to Tan in this same Room only by calling intangle_channel_reply with the reply content. This channel delivers new messages only; it does not replay prior conversation history.",
|
|
263
|
+
}
|
|
264
|
+
: {}),
|
|
254
265
|
});
|
|
255
266
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
256
267
|
const result = await makeRemoteRpcCall("tools/list", {});
|
|
257
268
|
if (!Array.isArray(result?.tools)) {
|
|
258
269
|
throw new McpError(ErrorCode.InternalError, "Remote tool catalog unavailable");
|
|
259
270
|
}
|
|
260
|
-
return {
|
|
271
|
+
return {
|
|
272
|
+
tools: claudeChannelConfig
|
|
273
|
+
? [...result.tools, claudeChannelToolDefinition()]
|
|
274
|
+
: result.tools,
|
|
275
|
+
};
|
|
261
276
|
});
|
|
262
277
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
263
278
|
const result = await makeRemoteRpcCall("resources/list", {});
|
|
@@ -470,6 +485,12 @@ try {
|
|
|
470
485
|
try {
|
|
471
486
|
let result;
|
|
472
487
|
switch (name) {
|
|
488
|
+
case CLAUDE_CHANNEL_REPLY_TOOL:
|
|
489
|
+
if (!claudeChannelConfig) {
|
|
490
|
+
throw new McpError(ErrorCode.InvalidRequest, `${CLAUDE_CHANNEL_ENABLED_ENV} is not enabled for this server process`);
|
|
491
|
+
}
|
|
492
|
+
result = await sendClaudeChannelReply(claudeChannelConfig, args?.content, handleMessage, randomUUID);
|
|
493
|
+
break;
|
|
473
494
|
case "search":
|
|
474
495
|
result = await handleSearchContext(args);
|
|
475
496
|
break;
|
|
@@ -599,9 +620,27 @@ try {
|
|
|
599
620
|
}
|
|
600
621
|
});
|
|
601
622
|
async function main() {
|
|
623
|
+
if (claudeChannelConfig && typeof globalThis.WebSocket !== "function") {
|
|
624
|
+
console.error(CLAUDE_CHANNEL_RUNTIME_ERROR);
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
602
627
|
const transport = new StdioServerTransport();
|
|
603
628
|
await server.connect(transport);
|
|
604
629
|
log("Server connected to transport");
|
|
630
|
+
if (claudeChannelConfig) {
|
|
631
|
+
const channel = new ClaudeChannelBridge({
|
|
632
|
+
config: claudeChannelConfig,
|
|
633
|
+
api: {
|
|
634
|
+
fetchMessages: handleFetchConversationMessages,
|
|
635
|
+
subscribe: handleSubscribeConversation,
|
|
636
|
+
sendReply: handleMessage,
|
|
637
|
+
},
|
|
638
|
+
sink: server,
|
|
639
|
+
});
|
|
640
|
+
await channel.start();
|
|
641
|
+
process.once("SIGINT", () => channel.stop());
|
|
642
|
+
process.once("SIGTERM", () => channel.stop());
|
|
643
|
+
}
|
|
605
644
|
// Client info is captured on-demand in ensureClientInfo() before first API call
|
|
606
645
|
}
|
|
607
646
|
main().catch((err) => {
|
package/dist/tool-definitions.js
CHANGED
|
@@ -122,8 +122,10 @@ const BASE_TOOLS = [
|
|
|
122
122
|
"relevance",
|
|
123
123
|
"recent",
|
|
124
124
|
"oldest",
|
|
125
|
-
"priority"
|
|
126
|
-
|
|
125
|
+
"priority",
|
|
126
|
+
"updated"
|
|
127
|
+
],
|
|
128
|
+
description: "Defaults to relevance, or recent when mode is recent. recent and oldest order by each result's stored timestamp (creation time for most Items); updated orders by updated_at, newest first, and falls back to the stored timestamp when an Item has no updated_at."
|
|
127
129
|
},
|
|
128
130
|
include: {
|
|
129
131
|
type: "string",
|
|
@@ -138,14 +140,14 @@ const BASE_TOOLS = [
|
|
|
138
140
|
limit: {
|
|
139
141
|
type: "integer",
|
|
140
142
|
minimum: 1,
|
|
141
|
-
maximum:
|
|
142
|
-
|
|
143
|
+
maximum: 500,
|
|
144
|
+
description: "Matches per page, at most 500. Set it to what the question needs, for example 3 for the three most recent. Omitted, a page holds 200 matches (25 for semantic search). A page also stops early at the token budget; has_more and next_offset continue it, and there is no cap on the total number of matches."
|
|
143
145
|
},
|
|
144
146
|
offset: {
|
|
145
147
|
type: "integer",
|
|
146
148
|
minimum: 0,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
default: 0,
|
|
150
|
+
description: "Pass the previous page's next_offset to continue."
|
|
149
151
|
}
|
|
150
152
|
},
|
|
151
153
|
required: []
|
|
@@ -184,11 +186,10 @@ const BASE_TOOLS = [
|
|
|
184
186
|
ids: {
|
|
185
187
|
type: "array",
|
|
186
188
|
minItems: 1,
|
|
187
|
-
maxItems: 500,
|
|
188
189
|
items: {
|
|
189
190
|
type: "string"
|
|
190
191
|
},
|
|
191
|
-
description: "Exact canonical or legacy Item IDs, returned in requested order."
|
|
192
|
+
description: "Exact canonical or legacy Item IDs, returned in requested order. Any number may be requested; they are read one page at a time."
|
|
192
193
|
},
|
|
193
194
|
under: {
|
|
194
195
|
type: "string",
|
|
@@ -203,14 +204,14 @@ const BASE_TOOLS = [
|
|
|
203
204
|
limit: {
|
|
204
205
|
type: "integer",
|
|
205
206
|
minimum: 1,
|
|
206
|
-
maximum:
|
|
207
|
-
default:
|
|
208
|
-
description: "
|
|
207
|
+
maximum: 500,
|
|
208
|
+
default: 200,
|
|
209
|
+
description: "Items per page, at most 500; defaults to 200. A page also stops early at the token budget. has_more and next_offset continue it; there is no cap on the total number of Items."
|
|
209
210
|
},
|
|
210
211
|
offset: {
|
|
211
212
|
type: "integer",
|
|
212
213
|
minimum: 0,
|
|
213
|
-
description: "Items to skip in stable breadth-first order.
|
|
214
|
+
description: "Items to skip in stable breadth-first order. Pass the previous page's next_offset to continue. Neither mode has an offset cap."
|
|
214
215
|
},
|
|
215
216
|
include: {
|
|
216
217
|
type: "array",
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intangle/mcp-server",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.21",
|
|
4
4
|
"description": "Model Context Protocol server for Intangle - AI context that persists across conversations",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"start": "node dist/index.js",
|
|
9
9
|
"dev": "tsx watch index.ts",
|
|
10
|
+
"test": "tsx --test tests/*.test.ts",
|
|
10
11
|
"prebuild": "node scripts/sync-tool-definitions.mjs",
|
|
11
12
|
"build": "tsc",
|
|
12
13
|
"lint": "biome check .",
|
|
@@ -55,5 +56,5 @@
|
|
|
55
56
|
"tsx": "^4.0.0",
|
|
56
57
|
"typescript": "^5.0.0"
|
|
57
58
|
},
|
|
58
|
-
"intangleSourceHash": "
|
|
59
|
+
"intangleSourceHash": "665576d569e05497c7d379091334d00c8a0295471c3c64732144e5352a2f8ed1"
|
|
59
60
|
}
|