@abraca/mcp 1.0.15 → 1.0.18

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/dist/index.d.ts CHANGED
@@ -8783,8 +8783,10 @@ declare class AbracadabraMCPServer {
8783
8783
  private _handleStatelessChat;
8784
8784
  /**
8785
8785
  * Set the agent's status in root awareness with auto-clear after idle.
8786
+ * @param statusContext — scopes the status to a specific channel/context so the
8787
+ * dashboard only shows it in the relevant chat. Defaults to `_lastChatChannel`.
8786
8788
  */
8787
- setAutoStatus(status: string | null, docId?: string): void;
8789
+ setAutoStatus(status: string | null, docId?: string, statusContext?: string | null): void;
8788
8790
  /** Re-send typing indicator every 2s so dashboard keeps showing it (expires at 3s). */
8789
8791
  private _startTypingInterval;
8790
8792
  private _stopTypingInterval;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/mcp",
3
- "version": "1.0.15",
3
+ "version": "1.0.18",
4
4
  "description": "MCP server for Abracadabra — AI agent collaboration on CRDT documents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -113,6 +113,10 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
113
113
  if (subtitle) meta.subtitle = subtitle
114
114
  const url = getStr(['url'])
115
115
  if (url) meta.url = url
116
+ const email = getStr(['email'])
117
+ if (email) meta.email = email
118
+ const phone = getStr(['phone'])
119
+ if (phone) meta.phone = phone
116
120
 
117
121
  const ratingRaw = getStr(['rating'])
118
122
  if (ratingRaw !== undefined) {
@@ -120,6 +124,36 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
120
124
  if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n))
121
125
  }
122
126
 
127
+ // Datetime fields
128
+ const datetimeStart = getStr(['datetimeStart'])
129
+ if (datetimeStart) meta.datetimeStart = datetimeStart
130
+ const datetimeEnd = getStr(['datetimeEnd'])
131
+ if (datetimeEnd) meta.datetimeEnd = datetimeEnd
132
+ const allDayRaw = raw['allDay']
133
+ if (allDayRaw !== undefined) meta.allDay = allDayRaw === 'true' || allDayRaw === true
134
+
135
+ // Geo fields
136
+ const geoLatRaw = getStr(['geoLat'])
137
+ if (geoLatRaw !== undefined) { const n = Number(geoLatRaw); if (!Number.isNaN(n)) meta.geoLat = n }
138
+ const geoLngRaw = getStr(['geoLng'])
139
+ if (geoLngRaw !== undefined) { const n = Number(geoLngRaw); if (!Number.isNaN(n)) meta.geoLng = n }
140
+ const geoType = getStr(['geoType'])
141
+ if (geoType && (geoType === 'marker' || geoType === 'line' || geoType === 'measure')) {
142
+ meta.geoType = geoType
143
+ }
144
+ const geoDescription = getStr(['geoDescription'])
145
+ if (geoDescription) meta.geoDescription = geoDescription
146
+
147
+ // Numeric fields
148
+ const numberRaw = getStr(['number'])
149
+ if (numberRaw !== undefined) { const n = Number(numberRaw); if (!Number.isNaN(n)) meta.number = n }
150
+ const unit = getStr(['unit'])
151
+ if (unit) meta.unit = unit
152
+
153
+ // Note
154
+ const note = getStr(['note'])
155
+ if (note) meta.note = note
156
+
123
157
  const title = typeof raw['title'] === 'string' ? raw['title'] : undefined
124
158
 
125
159
  return { title, meta, body }
@@ -156,8 +190,10 @@ function parseInline(text: string): InlineToken[] {
156
190
  const kbdProps = parseMdcProps(`{${match[4]}}`)
157
191
  tokens.push({ text: kbdProps['value'] || '', attrs: { kbd: { value: kbdProps['value'] || '' } } })
158
192
  } else if (match[5] !== undefined) {
159
- const displayText = match[6] ?? match[5]
160
- tokens.push({ text: displayText! })
193
+ // Inline wikilink [[docId]] or [[docId|label]] → link to /doc/docId
194
+ const docId = match[5]
195
+ const displayText = match[6] ?? docId
196
+ tokens.push({ text: displayText!, attrs: { link: { href: `/doc/${docId}` } } })
161
197
  } else if (match[7] !== undefined) {
162
198
  tokens.push({ text: match[7], attrs: { strike: true } })
163
199
  } else if (match[8] !== undefined) {
@@ -211,6 +247,7 @@ type Block =
211
247
  | { type: 'field'; name: string; fieldType: string; required: boolean; innerBlocks: Block[] }
212
248
  | { type: 'fieldGroup'; fields: Block[] }
213
249
  | { type: 'image'; src: string; alt: string; width?: string; height?: string }
250
+ | { type: 'docEmbed'; docId: string }
214
251
 
215
252
  function parseTableRow(line: string): string[] {
216
253
  const parts = line.split('|')
@@ -339,6 +376,13 @@ function parseBlocks(markdown: string): Block[] {
339
376
  continue
340
377
  }
341
378
 
379
+ const docEmbedMatch = line.match(/^!\[\[([^\]|]+?)(?:\|[^\]]*?)?\]\]\s*$/)
380
+ if (docEmbedMatch) {
381
+ blocks.push({ type: 'docEmbed', docId: docEmbedMatch[1]! })
382
+ i++
383
+ continue
384
+ }
385
+
342
386
  const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)(\{[^}]*\})?\s*$/)
343
387
  if (imgMatch) {
344
388
  const alt = imgMatch[1] ?? ''
@@ -590,6 +634,7 @@ function blockElName(b: Block): string {
590
634
  case 'field': return 'field'
591
635
  case 'fieldGroup': return 'fieldGroup'
592
636
  case 'image': return 'image'
637
+ case 'docEmbed': return 'docEmbed'
593
638
  }
594
639
  }
595
640
 
@@ -783,6 +828,10 @@ function fillBlock(el: Y.XmlElement, block: Block): void {
783
828
  if (block.height) el.setAttribute('height', block.height)
784
829
  break
785
830
  }
831
+ case 'docEmbed': {
832
+ el.setAttribute('docId', block.docId)
833
+ break
834
+ }
786
835
  }
787
836
  }
788
837
 
@@ -838,6 +887,7 @@ export function populateYDocFromMarkdown(
838
887
  case 'field': return new Y.XmlElement('field')
839
888
  case 'fieldGroup': return new Y.XmlElement('fieldGroup')
840
889
  case 'image': return new Y.XmlElement('image')
890
+ case 'docEmbed': return new Y.XmlElement('docEmbed')
841
891
  }
842
892
  })
843
893
 
@@ -17,8 +17,13 @@ export interface UserMetaField {
17
17
  }
18
18
 
19
19
  export interface PageMeta extends Record<string, unknown> {
20
+ // Universal display
20
21
  color?: string
21
22
  icon?: string
23
+ subtitle?: string
24
+ note?: string
25
+
26
+ // Datetime
22
27
  datetimeStart?: string
23
28
  datetimeEnd?: string
24
29
  allDay?: boolean
@@ -26,39 +31,84 @@ export interface PageMeta extends Record<string, unknown> {
26
31
  dateEnd?: string
27
32
  timeStart?: string
28
33
  timeEnd?: string
29
- taskProgress?: number
30
- tags?: string[]
34
+
35
+ // Task/status
31
36
  checked?: boolean
32
37
  priority?: number
33
38
  status?: string
39
+ taskProgress?: number
34
40
  rating?: number
41
+ tags?: string[]
42
+ members?: { id: string; label: string }[]
43
+
44
+ // Contact/value
35
45
  url?: string
36
46
  email?: string
37
47
  phone?: string
38
48
  number?: number
39
49
  unit?: string
40
- subtitle?: string
41
- note?: string
50
+
51
+ // Cover image
42
52
  coverUploadId?: string
43
53
  coverDocId?: string
44
54
  coverMimeType?: string
55
+
56
+ // Geo/Map
45
57
  geoType?: 'marker' | 'line' | 'measure'
46
58
  geoLat?: number
47
59
  geoLng?: number
48
60
  geoDescription?: string
61
+
62
+ // Whiteboard
49
63
  wbX?: number
50
64
  wbY?: number
51
65
  wbW?: number
52
66
  wbH?: number
53
67
  wbBg?: string
68
+
69
+ // Dashboard
54
70
  deskX?: number
55
71
  deskY?: number
72
+ deskZ?: number
56
73
  deskMode?: string
74
+
75
+ // Mindmap
57
76
  mmX?: number
58
77
  mmY?: number
78
+
79
+ // Graph
59
80
  graphX?: number
60
81
  graphY?: number
61
82
  graphPinned?: boolean
83
+ showRefEdges?: boolean
84
+
85
+ // Spatial (3D)
86
+ spShape?: string
87
+ spColor?: string
88
+ spOpacity?: number
89
+ spX?: number
90
+ spY?: number
91
+ spZ?: number
92
+ spRX?: number
93
+ spRY?: number
94
+ spRZ?: number
95
+ spSX?: number
96
+ spSY?: number
97
+ spSZ?: number
98
+ spModelUploadId?: string
99
+ spModelDocId?: string
100
+
101
+ // Renderer config (set on the page doc itself, not children)
102
+ kanbanColumnWidth?: string
103
+ galleryColumns?: number
104
+ galleryAspect?: string
105
+ calendarView?: string
106
+ calendarWeekStart?: string
107
+ calendarShowWeekNumbers?: boolean
108
+ tableMode?: string
109
+ timelineZoom?: string
110
+
111
+ // Internal
62
112
  _metaFields?: UserMetaField[]
63
113
  _metaInitialized?: boolean
64
114
  }
@@ -95,6 +95,11 @@ function serializeElement(el: Y.XmlElement, indent = ''): string {
95
95
  case 'table':
96
96
  return serializeTable(el)
97
97
 
98
+ case 'docEmbed': {
99
+ const docId = el.getAttribute('docId')
100
+ return docId ? `![[${docId}]]` : ''
101
+ }
102
+
98
103
  case 'image': {
99
104
  const src = el.getAttribute('src') || ''
100
105
  const alt = el.getAttribute('alt') || ''
@@ -0,0 +1,204 @@
1
+ /**
2
+ * HookBridge — lightweight HTTP server that receives Claude Code hook events
3
+ * and translates them into Yjs awareness updates via AbracadabraMCPServer.
4
+ *
5
+ * Claude Code hooks (PreToolUse, PostToolUse, SubagentStart, SubagentStop, Stop)
6
+ * POST JSON to http://127.0.0.1:{port}/hook. The bridge maps these to awareness
7
+ * fields (status, activeToolCall) so the cou-sh dashboard shows real-time activity.
8
+ */
9
+ import * as http from 'node:http'
10
+ import * as fs from 'node:fs'
11
+ import * as os from 'node:os'
12
+ import * as path from 'node:path'
13
+ import type { AbracadabraMCPServer } from './server.ts'
14
+
15
+ /** Map Claude Code tool names to awareness-friendly names + extract a target string. */
16
+ function mapToolCall(toolName: string, toolInput: Record<string, any>): { name: string; target?: string } | null {
17
+ switch (toolName) {
18
+ case 'Bash':
19
+ return { name: 'bash', target: truncate(toolInput.command ?? toolInput.description, 60) }
20
+ case 'Read':
21
+ return { name: 'read_file', target: basename(toolInput.file_path) }
22
+ case 'Edit':
23
+ return { name: 'edit_file', target: basename(toolInput.file_path) }
24
+ case 'Write':
25
+ return { name: 'write_file', target: basename(toolInput.file_path) }
26
+ case 'Grep':
27
+ return { name: 'grep', target: truncate(toolInput.pattern, 40) }
28
+ case 'Glob':
29
+ return { name: 'glob', target: truncate(toolInput.pattern, 40) }
30
+ case 'Agent':
31
+ return { name: 'subagent', target: toolInput.description || toolInput.subagent_type || 'agent' }
32
+ case 'WebFetch':
33
+ return { name: 'web_fetch', target: hostname(toolInput.url) }
34
+ case 'WebSearch':
35
+ return { name: 'web_search', target: truncate(toolInput.query, 40) }
36
+ default:
37
+ // Unknown tool — use the name as-is, lowercased with underscores
38
+ return { name: toolName.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase() }
39
+ }
40
+ }
41
+
42
+ function truncate(str: string | undefined, max: number): string | undefined {
43
+ if (!str) return undefined
44
+ return str.length > max ? str.slice(0, max) + '...' : str
45
+ }
46
+
47
+ function basename(filePath: string | undefined): string | undefined {
48
+ if (!filePath) return undefined
49
+ return path.basename(filePath)
50
+ }
51
+
52
+ function hostname(url: string | undefined): string | undefined {
53
+ if (!url) return undefined
54
+ try {
55
+ return new URL(url).hostname
56
+ } catch {
57
+ return url.slice(0, 30)
58
+ }
59
+ }
60
+
61
+ export class HookBridge {
62
+ private httpServer: http.Server | null = null
63
+ private _port: number | null = null
64
+ private portFilePath: string
65
+
66
+ constructor(private server: AbracadabraMCPServer) {
67
+ this.portFilePath = process.env.ABRA_HOOK_PORT_FILE
68
+ || path.join(os.tmpdir(), 'abracadabra-mcp-hook.port')
69
+ }
70
+
71
+ get port(): number | null {
72
+ return this._port
73
+ }
74
+
75
+ /** Start the HTTP server on a random port and write the port file. */
76
+ async start(): Promise<number> {
77
+ return new Promise((resolve, reject) => {
78
+ const srv = http.createServer((req, res) => this.handleRequest(req, res))
79
+
80
+ srv.on('error', reject)
81
+
82
+ srv.listen(0, '127.0.0.1', () => {
83
+ const addr = srv.address()
84
+ if (!addr || typeof addr === 'string') {
85
+ reject(new Error('Failed to get server address'))
86
+ return
87
+ }
88
+
89
+ this._port = addr.port
90
+ this.httpServer = srv
91
+
92
+ // Write port file for hook discovery
93
+ try {
94
+ fs.writeFileSync(this.portFilePath, String(this._port), 'utf-8')
95
+ } catch (err: any) {
96
+ console.error(`[hook-bridge] Warning: could not write port file: ${err.message}`)
97
+ }
98
+
99
+ console.error(`[hook-bridge] Listening on 127.0.0.1:${this._port}`)
100
+ console.error(`[hook-bridge] Port file: ${this.portFilePath}`)
101
+ resolve(this._port)
102
+ })
103
+ })
104
+ }
105
+
106
+ /** Shut down the HTTP server and remove the port file. */
107
+ async destroy(): Promise<void> {
108
+ if (this.httpServer) {
109
+ await new Promise<void>((resolve) => {
110
+ this.httpServer!.close(() => resolve())
111
+ })
112
+ this.httpServer = null
113
+ }
114
+
115
+ // Remove port file
116
+ try {
117
+ fs.unlinkSync(this.portFilePath)
118
+ } catch {
119
+ // Already gone or never written
120
+ }
121
+
122
+ this._port = null
123
+ console.error('[hook-bridge] Shut down')
124
+ }
125
+
126
+ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
127
+ // Only accept POST /hook
128
+ if (req.method !== 'POST' || req.url !== '/hook') {
129
+ res.writeHead(404)
130
+ res.end()
131
+ return
132
+ }
133
+
134
+ let body = ''
135
+ req.on('data', (chunk: Buffer) => { body += chunk.toString() })
136
+ req.on('end', () => {
137
+ res.writeHead(200, { 'Content-Type': 'application/json' })
138
+ res.end('{}')
139
+
140
+ try {
141
+ const payload = JSON.parse(body)
142
+ this.routeEvent(payload)
143
+ } catch {
144
+ // Invalid JSON — ignore silently (fire-and-forget)
145
+ }
146
+ })
147
+ }
148
+
149
+ private routeEvent(payload: Record<string, any>): void {
150
+ const event = payload.hook_event_name
151
+ switch (event) {
152
+ case 'PreToolUse':
153
+ this.onPreToolUse(payload)
154
+ break
155
+ case 'PostToolUse':
156
+ this.onPostToolUse(payload)
157
+ break
158
+ case 'SubagentStart':
159
+ this.onSubagentStart(payload)
160
+ break
161
+ case 'SubagentStop':
162
+ this.onSubagentStop(payload)
163
+ break
164
+ case 'Stop':
165
+ this.onStop()
166
+ break
167
+ }
168
+ }
169
+
170
+ private onPreToolUse(payload: Record<string, any>): void {
171
+ const toolName: string = payload.tool_name ?? ''
172
+ // Skip Abracadabra MCP tools — they set awareness themselves
173
+ if (toolName.startsWith('mcp__abracadabra__')) return
174
+
175
+ const toolInput = payload.tool_input ?? {}
176
+ const mapped = mapToolCall(toolName, toolInput)
177
+ if (mapped) {
178
+ this.server.setActiveToolCall(mapped)
179
+ this.server.setAutoStatus('working')
180
+ }
181
+ }
182
+
183
+ private onPostToolUse(payload: Record<string, any>): void {
184
+ const toolName: string = payload.tool_name ?? ''
185
+ if (toolName.startsWith('mcp__abracadabra__')) return
186
+
187
+ this.server.setActiveToolCall(null)
188
+ }
189
+
190
+ private onSubagentStart(payload: Record<string, any>): void {
191
+ const agentType: string = payload.agent_type ?? 'agent'
192
+ this.server.setActiveToolCall({ name: 'subagent', target: agentType })
193
+ this.server.setAutoStatus('thinking')
194
+ }
195
+
196
+ private onSubagentStop(_payload: Record<string, any>): void {
197
+ this.server.setActiveToolCall(null)
198
+ }
199
+
200
+ private onStop(): void {
201
+ this.server.setAutoStatus(null)
202
+ this.server.setActiveToolCall(null)
203
+ }
204
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ import { registerChannelTools } from './tools/channel.ts'
21
21
  import { registerAgentGuide } from './resources/agent-guide.ts'
22
22
  import { registerTreeResource } from './resources/tree-resource.ts'
23
23
  import { registerServerInfoResource } from './resources/server-info.ts'
24
+ import { registerHookTools } from './tools/hooks.ts'
25
+ import { HookBridge } from './hook-bridge.ts'
24
26
 
25
27
  async function main() {
26
28
  const url = process.env.ABRA_URL
@@ -55,8 +57,9 @@ async function main() {
55
57
  ## Key Concepts
56
58
  - Documents form a tree. A kanban board's columns are child documents; cards are grandchildren.
57
59
  - A document's label IS its display name everywhere. Children ARE the content (not just the body text).
58
- - Page types (doc, kanban, table, calendar, timeline, outline, etc.) are views over the SAME tree — switching types preserves data.
60
+ - Page types (doc, kanban, table, calendar, timeline, checklist, outline, gallery, map, graph, dashboard, spatial, media, mindmap, etc.) are views over the SAME tree — switching types preserves data.
59
61
  - An empty markdown body does NOT mean empty content — always check the children array.
62
+ - Use ![[docId]] in content to embed another document, or [[docId|label]] for inline links.
60
63
 
61
64
  ## Finding Documents
62
65
  - list_documents only shows ONE level of children. If you don't find what you need, use find_document to search the entire tree by name, or get_document_tree to see the full hierarchy.
@@ -101,6 +104,18 @@ Read the resource at abracadabra://agent-guide for the complete guide covering p
101
104
  process.exit(1)
102
105
  }
103
106
 
107
+ // Start Claude Code hook bridge (HTTP server for activity events)
108
+ const hookBridge = new HookBridge(server)
109
+ try {
110
+ const hookPort = await hookBridge.start()
111
+ console.error(`[abracadabra-mcp] Hook bridge listening on port ${hookPort}`)
112
+ } catch (error: any) {
113
+ console.error(`[abracadabra-mcp] Hook bridge failed to start: ${error.message}`)
114
+ }
115
+
116
+ // Register hook config tool (must be after bridge starts so port is available)
117
+ registerHookTools(mcp, hookBridge)
118
+
104
119
  // Start MCP stdio transport
105
120
  const transport = new StdioServerTransport()
106
121
  await mcp.connect(transport)
@@ -112,6 +127,7 @@ Read the resource at abracadabra://agent-guide for the complete guide covering p
112
127
  // Graceful shutdown
113
128
  const shutdown = async () => {
114
129
  console.error('[abracadabra-mcp] Shutting down...')
130
+ await hookBridge.destroy()
115
131
  await server.destroy()
116
132
  process.exit(0)
117
133
  }