@intangle/mcp-server 2.6.18 → 2.6.20
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 +18 -5
- 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
|
@@ -159,11 +159,20 @@ const BASE_TOOLS = [
|
|
|
159
159
|
{
|
|
160
160
|
name: "get_items",
|
|
161
161
|
title: "Get Items",
|
|
162
|
-
description: "Read complete Items by exact IDs or browse below one Space/Item using authoritative parent_id hierarchy. Every Item is a Room; Spaces remain typed scopes. Legacy IDs are accepted with alias provenance preserved. Use include=comments for canonical Item-owned comments and body revision. Use include=assignees for typed User/Agent principals.",
|
|
162
|
+
description: "For whole-Space organisation, use mode=structure. It returns complete authorised structural aggregates plus compact paged Item details; aggregates cover the full scope. Omit ids and depth, and omit under to inspect the whole authorised Space. Use coverage.snapshot, a structural SHA-256 checksum over authoritative inventory, to compare pages; it does not make page hydration transactional. Read complete Items by exact IDs or browse below one Space/Item using authoritative parent_id hierarchy. Every Item is a Room; Spaces remain typed scopes. Legacy IDs are accepted with alias provenance preserved. Use include=comments for canonical Item-owned comments and body revision. Use include=assignees for typed User/Agent principals.",
|
|
163
163
|
inputSchema: {
|
|
164
164
|
type: "object",
|
|
165
165
|
additionalProperties: false,
|
|
166
166
|
properties: {
|
|
167
|
+
mode: {
|
|
168
|
+
type: "string",
|
|
169
|
+
enum: [
|
|
170
|
+
"items",
|
|
171
|
+
"structure"
|
|
172
|
+
],
|
|
173
|
+
default: "items",
|
|
174
|
+
description: "items (default) returns bounded Item reads. structure returns complete authorised structural aggregates plus compact paged Item details; omit ids and depth. Omit under to inspect the whole authorised Space."
|
|
175
|
+
},
|
|
167
176
|
space_id: {
|
|
168
177
|
type: "string",
|
|
169
178
|
description: "REQUIRED: Exact Space containing the Items."
|
|
@@ -188,17 +197,20 @@ const BASE_TOOLS = [
|
|
|
188
197
|
depth: {
|
|
189
198
|
type: "integer",
|
|
190
199
|
minimum: 0,
|
|
191
|
-
maximum: 20
|
|
200
|
+
maximum: 20,
|
|
201
|
+
description: "Maximum hierarchy depth. Defaults to 0 for current Space roots and 1 for explicit selectors. Omit in structure mode, which inspects all depths."
|
|
192
202
|
},
|
|
193
203
|
limit: {
|
|
194
204
|
type: "integer",
|
|
195
205
|
minimum: 1,
|
|
196
|
-
maximum: 200
|
|
206
|
+
maximum: 200,
|
|
207
|
+
default: 50,
|
|
208
|
+
description: "Maximum Items in this page."
|
|
197
209
|
},
|
|
198
210
|
offset: {
|
|
199
211
|
type: "integer",
|
|
200
212
|
minimum: 0,
|
|
201
|
-
|
|
213
|
+
description: "Items to skip in stable breadth-first order. items mode supports up to 5000; structure has no offset cap."
|
|
202
214
|
},
|
|
203
215
|
include: {
|
|
204
216
|
type: "array",
|
|
@@ -216,7 +228,8 @@ const BASE_TOOLS = [
|
|
|
216
228
|
"metadata",
|
|
217
229
|
"all"
|
|
218
230
|
]
|
|
219
|
-
}
|
|
231
|
+
},
|
|
232
|
+
description: "Optional payload fields for the returned page. structure defaults to []; items defaults to content, attributes, assignee, artifact, and metadata. Explicit include hydrates only selected page details, never aggregate content coverage."
|
|
220
233
|
}
|
|
221
234
|
},
|
|
222
235
|
required: [
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intangle/mcp-server",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.20",
|
|
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": "8cbc22fac71d3139119b38680db7337458c57e1a36d23033969618c3b8458dcb"
|
|
59
60
|
}
|