@abraca/mcp 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,115 +1,155 @@
1
1
  /**
2
2
  * Channel tools — reply to channel events and send chat messages.
3
3
  */
4
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
- import { z } from 'zod'
6
- import type { AbracadabraMCPServer } from '../server.ts'
7
- import { makeEntryMap } from '@abraca/dabra'
8
- import { populateYDocFromMarkdown } from '../converters/markdownToYjs.ts'
9
4
 
10
- export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServer) {
11
- mcp.tool(
12
- 'reply',
13
- 'Create a document reply. Creates a child document under doc_id with the reply content as a rich-text document. Use this for structured, persistent responses. For conversational chat, use send_chat_message instead.',
14
- {
15
- doc_id: z.string().describe('Document ID to create the reply under.'),
16
- text: z.string().describe('Markdown content for the reply.'),
17
- task_id: z.string().optional().describe('If replying to an ai:task, the task ID to clear from awareness.'),
18
- },
19
- async ({ doc_id, text, task_id }) => {
20
- try {
21
- server.setAutoStatus('writing', doc_id)
22
- server.setActiveToolCall({ name: 'reply', target: doc_id })
5
+ import { makeEntryMap } from "@abraca/dabra";
6
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { z } from "zod";
8
+ import { populateYDocFromMarkdown } from "../converters/markdownToYjs.ts";
9
+ import type { AbracadabraMCPServer } from "../server.ts";
23
10
 
24
- const treeMap = server.getTreeMap()
25
- const rootDoc = server.rootDocument
26
- if (!treeMap || !rootDoc) {
27
- return { content: [{ type: 'text' as const, text: 'Not connected' }], isError: true }
28
- }
11
+ export function registerChannelTools(
12
+ mcp: McpServer,
13
+ server: AbracadabraMCPServer,
14
+ ) {
15
+ mcp.tool(
16
+ "reply",
17
+ "Create a document reply. Creates a child document under doc_id with the reply content as a rich-text document. Use this for structured, persistent responses. For conversational chat, use send_chat_message instead.",
18
+ {
19
+ doc_id: z.string().describe("Document ID to create the reply under."),
20
+ text: z.string().describe("Markdown content for the reply."),
21
+ task_id: z
22
+ .string()
23
+ .optional()
24
+ .describe(
25
+ "If replying to an ai:task, the task ID to clear from awareness.",
26
+ ),
27
+ },
28
+ async ({ doc_id, text, task_id }) => {
29
+ try {
30
+ server.setAutoStatus("writing", doc_id);
31
+ server.setActiveToolCall({ name: "reply", target: doc_id });
29
32
 
30
- const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
31
- const snippet = text.slice(0, 40).replace(/\n/g, ' ')
32
- const label = `AI Reply — ${timestamp}: ${snippet}`
33
- const replyId = crypto.randomUUID()
34
- const now = Date.now()
33
+ const treeMap = server.getTreeMap();
34
+ const rootDoc = server.rootDocument;
35
+ if (!treeMap || !rootDoc) {
36
+ return {
37
+ content: [{ type: "text" as const, text: "Not connected" }],
38
+ isError: true,
39
+ };
40
+ }
35
41
 
36
- rootDoc.transact(() => {
37
- treeMap.set(replyId, makeEntryMap({
38
- label,
39
- parentId: doc_id,
40
- order: now,
41
- type: 'doc',
42
- createdAt: now,
43
- updatedAt: now,
44
- }))
45
- })
42
+ const timestamp = new Date()
43
+ .toISOString()
44
+ .replace("T", " ")
45
+ .slice(0, 19);
46
+ const snippet = text.slice(0, 40).replace(/\n/g, " ");
47
+ const label = `AI Reply — ${timestamp}: ${snippet}`;
48
+ const replyId = crypto.randomUUID();
49
+ const now = Date.now();
46
50
 
47
- const replyProvider = await server.getChildProvider(replyId)
48
- const fragment = replyProvider.document.getXmlFragment('default')
49
- populateYDocFromMarkdown(fragment, text)
51
+ rootDoc.transact(() => {
52
+ treeMap.set(
53
+ replyId,
54
+ makeEntryMap({
55
+ label,
56
+ parentId: doc_id,
57
+ order: now,
58
+ type: "doc",
59
+ createdAt: now,
60
+ updatedAt: now,
61
+ }),
62
+ );
63
+ });
50
64
 
51
- if (task_id) {
52
- server.clearAiTask(task_id)
53
- }
65
+ const replyProvider = await server.getChildProvider(replyId);
66
+ const fragment = replyProvider.document.getXmlFragment("default");
67
+ populateYDocFromMarkdown(fragment, text);
54
68
 
69
+ if (task_id) {
70
+ server.clearAiTask(task_id);
71
+ }
55
72
 
56
- return {
57
- content: [{ type: 'text' as const, text: JSON.stringify({ replyDocId: replyId, parentId: doc_id }) }],
58
- }
59
- } catch (error: any) {
60
- return {
61
- content: [{ type: 'text' as const, text: `Error: ${error.message}` }],
62
- isError: true,
63
- }
64
- }
65
- }
66
- )
73
+ return {
74
+ content: [
75
+ {
76
+ type: "text" as const,
77
+ text: JSON.stringify({ replyDocId: replyId, parentId: doc_id }),
78
+ },
79
+ ],
80
+ };
81
+ } catch (error: any) {
82
+ return {
83
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
84
+ isError: true,
85
+ };
86
+ }
87
+ },
88
+ );
67
89
 
68
- mcp.tool(
69
- 'send_chat_message',
70
- 'Send a chat message visible in the dashboard chat UI. Pass channel_doc_id — the UUID of a doc with kind "channel" (group chat under a Space) or "dm" (direct message). Use list_connected_users + the doc tree to discover channels.',
71
- {
72
- channel_doc_id: z.string().describe('Channel doc UUID (kind = "channel" or "dm")'),
73
- text: z.string().describe('Message text'),
74
- },
75
- async ({ channel_doc_id, text }) => {
76
- try {
77
- const rootProvider = server.rootYProvider
78
- if (!rootProvider) {
79
- return { content: [{ type: 'text' as const, text: 'Not connected' }], isError: true }
80
- }
90
+ mcp.tool(
91
+ "send_chat_message",
92
+ 'Send a chat message visible in the dashboard chat UI. Pass channel_doc_id — the UUID of a doc with kind "channel" (group chat under a Space) or "dm" (direct message). Use list_connected_users + the doc tree to discover channels.',
93
+ {
94
+ channel_doc_id: z
95
+ .string()
96
+ .describe('Channel doc UUID (kind = "channel" or "dm")'),
97
+ text: z.string().describe("Message text"),
98
+ },
99
+ async ({ channel_doc_id, text }) => {
100
+ try {
101
+ const rootProvider = server.rootYProvider;
102
+ if (!rootProvider) {
103
+ return {
104
+ content: [{ type: "text" as const, text: "Not connected" }],
105
+ isError: true,
106
+ };
107
+ }
81
108
 
82
- // Normalize literal escape sequences. Some LLM outputs emit the
83
- // 2-char `\n` / `\t` / `\r` instead of the real control chars, which
84
- // then render literally in the chat (markdown renderer can't see a
85
- // newline where there is just "\n"). Convert them back to real chars.
86
- const normalized = text
87
- .replace(/\\r\\n/g, '\n')
88
- .replace(/\\n/g, '\n')
89
- .replace(/\\t/g, '\t')
90
- .replace(/\\r/g, '\n')
109
+ // Normalize literal escape sequences. Some LLM outputs emit the
110
+ // 2-char `\n` / `\t` / `\r` instead of the real control chars, which
111
+ // then render literally in the chat (markdown renderer can't see a
112
+ // newline where there is just "\n"). Convert them back to real chars.
113
+ const normalized = text
114
+ .replace(/\\r\\n/g, "\n")
115
+ .replace(/\\n/g, "\n")
116
+ .replace(/\\t/g, "\t")
117
+ .replace(/\\r/g, "\n");
91
118
 
92
- // Order matters: clear status + tool pill FIRST so the dashboard's
93
- // typing-indicator filter (which hides typing while an activeToolCall
94
- // exists) doesn't swallow the burst. Then emit the typing frame, then
95
- // the actual messages:send. The clear also flushes toolHistory + turnId.
96
- server.setAutoStatus(null)
97
- server.sendTypingIndicator(channel_doc_id)
119
+ // Show a brief "writing" phase so the dashboard renders typing dots
120
+ // just before the message lands (the reasoning phase showed the
121
+ // incantation; this is the distinct compose/send phase). Clear any
122
+ // tool pill, mark status 'writing' (the dashboard maps this to typing
123
+ // dots, not the phrase), emit a typing frame, and pause briefly so
124
+ // the dots are actually visible before the bubble appears.
125
+ server.setActiveToolCall(null);
126
+ server.setWritingStatus(channel_doc_id);
127
+ await new Promise((r) => setTimeout(r, 650));
98
128
 
99
- rootProvider.sendStateless(JSON.stringify({
100
- type: 'messages:send',
101
- channel_doc_id,
102
- content: normalized,
103
- mentions: [],
104
- }))
129
+ rootProvider.sendStateless(
130
+ JSON.stringify({
131
+ type: "messages:send",
132
+ channel_doc_id,
133
+ content: normalized,
134
+ mentions: [],
135
+ }),
136
+ );
105
137
 
106
- return { content: [{ type: 'text' as const, text: `Sent to ${channel_doc_id}` }] }
107
- } catch (error: any) {
108
- return {
109
- content: [{ type: 'text' as const, text: `Error: ${error.message}` }],
110
- isError: true,
111
- }
112
- }
113
- }
114
- )
138
+ // End the turn AFTER the send: clears status, tool pill, turn id and
139
+ // stops the typing heartbeat so nothing lingers after the reply.
140
+ server.setAutoStatus(null);
141
+
142
+ return {
143
+ content: [
144
+ { type: "text" as const, text: `Sent to ${channel_doc_id}` },
145
+ ],
146
+ };
147
+ } catch (error: any) {
148
+ return {
149
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
150
+ isError: true,
151
+ };
152
+ }
153
+ },
154
+ );
115
155
  }
@@ -1,42 +1,51 @@
1
1
  /**
2
2
  * Hook tools — provides Claude Code hook configuration for activity bridging.
3
3
  */
4
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
- import type { HookBridge } from '../hook-bridge.ts'
4
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import type { HookBridge } from "../hook-bridge.ts";
6
6
 
7
7
  export function registerHookTools(mcp: McpServer, hookBridge: HookBridge) {
8
- mcp.tool(
9
- 'get_hook_config',
10
- 'Returns Claude Code hook configuration JSON for bridging activity to the Abracadabra dashboard. Copy the "hooks" object into your .claude/settings.local.json to enable real-time activity indicators (tool calls, subagents, etc.) visible to all connected users.',
11
- {},
12
- async () => {
13
- const port = hookBridge.port
14
- if (!port) {
15
- return {
16
- content: [{ type: 'text', text: 'Hook bridge is not running.' }],
17
- isError: true,
18
- }
19
- }
20
-
21
- const url = `http://127.0.0.1:${port}/hook`
22
- const hookEntry = { type: 'http', url, timeout: 3 }
23
-
24
- const config = {
25
- hooks: {
26
- PreToolUse: [{ hooks: [hookEntry] }],
27
- PostToolUse: [{ hooks: [hookEntry] }],
28
- SubagentStart: [{ hooks: [hookEntry] }],
29
- SubagentStop: [{ hooks: [hookEntry] }],
30
- Stop: [{ hooks: [hookEntry] }],
31
- },
32
- }
8
+ mcp.tool(
9
+ "get_hook_config",
10
+ 'Returns Claude Code hook configuration JSON for bridging activity to the Abracadabra dashboard. Copy the "hooks" object into your .claude/settings.json (or settings.local.json) to enable real-time tool-call activity cards in the chat, visible to all connected users.',
11
+ {},
12
+ async () => {
13
+ const config = { hooks: buildHookConfig(hookBridge.portFile) };
14
+ return {
15
+ content: [
16
+ {
17
+ type: "text",
18
+ text: JSON.stringify(config, null, 2),
19
+ },
20
+ ],
21
+ };
22
+ },
23
+ );
24
+ }
33
25
 
34
- return {
35
- content: [{
36
- type: 'text',
37
- text: JSON.stringify(config, null, 2),
38
- }],
39
- }
40
- },
41
- )
26
+ /**
27
+ * Build the Claude Code `hooks` object. We use a `command` hook (not `http`)
28
+ * that reads the bridge's port file at execution time and POSTs the event
29
+ * JSON (received on stdin) to it. This survives MCP restarts that pick a new
30
+ * random port — the URL is resolved fresh each call instead of being baked in.
31
+ *
32
+ * The command is a no-op (`exit 0`) when the port file is missing or curl
33
+ * fails, so a stopped bridge never blocks a tool call.
34
+ */
35
+ export function buildHookConfig(portFile: string): Record<string, unknown> {
36
+ const cmd = `P=$(cat ${JSON.stringify(portFile)} 2>/dev/null); [ -n "$P" ] && curl -s -m 3 -X POST "http://127.0.0.1:$P/hook" --data-binary @- >/dev/null 2>&1; exit 0`;
37
+ const hookEntry = { type: "command", command: cmd, timeout: 5 };
38
+ const events = [
39
+ "UserPromptSubmit",
40
+ "PreToolUse",
41
+ "PostToolUse",
42
+ "SubagentStart",
43
+ "SubagentStop",
44
+ "Stop",
45
+ ];
46
+ const hooks: Record<string, unknown> = {};
47
+ for (const ev of events) {
48
+ hooks[ev] = [{ hooks: [hookEntry] }];
49
+ }
50
+ return hooks;
42
51
  }