@abraca/mcp 1.0.21 → 1.0.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/mcp",
3
- "version": "1.0.21",
3
+ "version": "1.0.25",
4
4
  "description": "MCP server for Abracadabra — AI agent collaboration on CRDT documents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -248,6 +248,7 @@ type Block =
248
248
  | { type: 'fieldGroup'; fields: Block[] }
249
249
  | { type: 'image'; src: string; alt: string; width?: string; height?: string }
250
250
  | { type: 'docEmbed'; docId: string }
251
+ | { type: 'svgEmbed'; svg: string; title: string }
251
252
 
252
253
  function parseTableRow(line: string): string[] {
253
254
  const parts = line.split('|')
@@ -359,7 +360,12 @@ function parseBlocks(markdown: string): Block[] {
359
360
  i++
360
361
  }
361
362
  i++
362
- blocks.push({ type: 'codeBlock', lang, code: codeLines.join('\n') })
363
+ if (lang === 'svg' || lang.startsWith('svg ')) {
364
+ const svgTitle = lang === 'svg' ? '' : lang.slice(4).trim()
365
+ blocks.push({ type: 'svgEmbed', svg: codeLines.join('\n'), title: svgTitle })
366
+ } else {
367
+ blocks.push({ type: 'codeBlock', lang, code: codeLines.join('\n') })
368
+ }
363
369
  continue
364
370
  }
365
371
 
@@ -635,6 +641,7 @@ function blockElName(b: Block): string {
635
641
  case 'fieldGroup': return 'fieldGroup'
636
642
  case 'image': return 'image'
637
643
  case 'docEmbed': return 'docEmbed'
644
+ case 'svgEmbed': return 'svgEmbed'
638
645
  }
639
646
  }
640
647
 
@@ -832,6 +839,11 @@ function fillBlock(el: Y.XmlElement, block: Block): void {
832
839
  el.setAttribute('docId', block.docId)
833
840
  break
834
841
  }
842
+ case 'svgEmbed': {
843
+ el.setAttribute('svg', block.svg)
844
+ if (block.title) el.setAttribute('title', block.title)
845
+ break
846
+ }
835
847
  }
836
848
  }
837
849
 
@@ -888,6 +900,7 @@ export function populateYDocFromMarkdown(
888
900
  case 'fieldGroup': return new Y.XmlElement('fieldGroup')
889
901
  case 'image': return new Y.XmlElement('image')
890
902
  case 'docEmbed': return new Y.XmlElement('docEmbed')
903
+ case 'svgEmbed': return new Y.XmlElement('svgEmbed')
891
904
  }
892
905
  })
893
906
 
@@ -100,6 +100,13 @@ function serializeElement(el: Y.XmlElement, indent = ''): string {
100
100
  return docId ? `![[${docId}]]` : ''
101
101
  }
102
102
 
103
+ case 'svgEmbed': {
104
+ const svg = el.getAttribute('svg') || ''
105
+ const svgTitle = el.getAttribute('title') || ''
106
+ if (!svg) return ''
107
+ return `\`\`\`svg${svgTitle ? ` ${svgTitle}` : ''}\n${svg}\n\`\`\``
108
+ }
109
+
103
110
  case 'image': {
104
111
  const src = el.getAttribute('src') || ''
105
112
  const alt = el.getAttribute('alt') || ''
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import { registerMetaTools } from './tools/meta.ts'
18
18
  import { registerFileTools } from './tools/files.ts'
19
19
  import { registerAwarenessTools } from './tools/awareness.ts'
20
20
  import { registerChannelTools } from './tools/channel.ts'
21
+ import { registerSvgTools } from './tools/svg.ts'
21
22
  import { registerAgentGuide } from './resources/agent-guide.ts'
22
23
  import { registerTreeResource } from './resources/tree-resource.ts'
23
24
  import { registerServerInfoResource } from './resources/server-info.ts'
@@ -91,6 +92,7 @@ Read the resource at abracadabra://agent-guide for the complete guide covering p
91
92
  registerFileTools(mcp, server)
92
93
  registerAwarenessTools(mcp, server)
93
94
  registerChannelTools(mcp, server)
95
+ registerSvgTools(mcp, server)
94
96
 
95
97
  // Register resources
96
98
  registerAgentGuide(mcp)
@@ -0,0 +1,83 @@
1
+ /**
2
+ * SVG tools — insert SVG diagrams/images into documents via Y.js.
3
+ */
4
+ import * as Y from 'yjs'
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
6
+ import { z } from 'zod'
7
+ import type { AbracadabraMCPServer } from '../server.ts'
8
+ import { sanitizeSvg } from '../utils/sanitizeSvg.ts'
9
+
10
+ /**
11
+ * Find the insertion index after documentHeader and documentMeta elements.
12
+ */
13
+ function findContentStart(fragment: Y.XmlFragment): number {
14
+ let idx = 0
15
+ for (let i = 0; i < fragment.length; i++) {
16
+ const child = fragment.get(i)
17
+ if (child instanceof Y.XmlElement) {
18
+ const name = child.nodeName
19
+ if (name === 'documentHeader' || name === 'documentMeta') {
20
+ idx = i + 1
21
+ } else {
22
+ break
23
+ }
24
+ }
25
+ }
26
+ return idx
27
+ }
28
+
29
+ export function registerSvgTools(mcp: McpServer, server: AbracadabraMCPServer) {
30
+ mcp.tool(
31
+ 'write_svg',
32
+ 'Insert an SVG diagram or image into a document. Creates an svgEmbed block node containing the SVG markup. Use this for diagrams, charts, flowcharts, illustrations, icons, or any visual content expressed as SVG. The SVG is sanitized server-side before writing.',
33
+ {
34
+ docId: z.string().describe('Document ID to write SVG into.'),
35
+ svg: z.string().describe('Raw SVG markup string (the full <svg>...</svg> element). Will be sanitized.'),
36
+ title: z.string().optional().describe('Optional title/caption displayed above the SVG.'),
37
+ position: z.enum(['append', 'prepend']).optional().describe('Where to insert the SVG block. "append" adds at the end (default), "prepend" adds at the start of content.'),
38
+ },
39
+ async ({ docId, svg, title, position }) => {
40
+ try {
41
+ server.setAutoStatus('writing', docId)
42
+ server.setActiveToolCall({ name: 'write_svg', target: docId })
43
+
44
+ const cleanSvg = sanitizeSvg(svg)
45
+ if (!cleanSvg) {
46
+ server.setActiveToolCall(null)
47
+ return {
48
+ content: [{ type: 'text', text: 'Error: SVG markup was empty or entirely stripped by sanitizer.' }],
49
+ isError: true,
50
+ }
51
+ }
52
+
53
+ const provider = await server.getChildProvider(docId)
54
+ const doc = provider.document
55
+ const fragment = doc.getXmlFragment('default')
56
+
57
+ doc.transact(() => {
58
+ const el = new Y.XmlElement('svgEmbed')
59
+ el.setAttribute('svg', cleanSvg)
60
+ if (title) el.setAttribute('title', title)
61
+
62
+ const insertPos = position === 'prepend'
63
+ ? findContentStart(fragment)
64
+ : fragment.length
65
+ fragment.insert(insertPos, [el])
66
+ })
67
+
68
+ server.setFocusedDoc(docId)
69
+ server.setActiveToolCall(null)
70
+
71
+ return {
72
+ content: [{ type: 'text', text: `SVG inserted into document ${docId}${title ? ` ("${title}")` : ''}` }],
73
+ }
74
+ } catch (error: any) {
75
+ server.setActiveToolCall(null)
76
+ return {
77
+ content: [{ type: 'text', text: `Error writing SVG: ${error.message}` }],
78
+ isError: true,
79
+ }
80
+ }
81
+ }
82
+ )
83
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Lightweight SVG sanitizer for server-side use (no DOM dependency).
3
+ * Strips dangerous elements and attributes from SVG markup using allowlists.
4
+ */
5
+
6
+ const ALLOWED_ELEMENTS = new Set([
7
+ 'svg', 'g', 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline',
8
+ 'polygon', 'text', 'tspan', 'textPath', 'defs', 'use', 'symbol',
9
+ 'clipPath', 'mask', 'pattern', 'linearGradient', 'radialGradient', 'stop',
10
+ 'marker', 'image', 'title', 'desc', 'metadata',
11
+ 'animate', 'animateTransform', 'animateMotion', 'set',
12
+ 'filter', 'feGaussianBlur', 'feOffset', 'feMerge', 'feMergeNode',
13
+ 'feFlood', 'feComposite', 'feBlend', 'feColorMatrix', 'feComponentTransfer',
14
+ 'feFuncR', 'feFuncG', 'feFuncB', 'feFuncA', 'feConvolveMatrix',
15
+ 'feDiffuseLighting', 'feDisplacementMap', 'feDropShadow', 'feImage',
16
+ 'feMorphology', 'fePointLight', 'feSpecularLighting', 'feSpotLight',
17
+ 'feTile', 'feTurbulence',
18
+ ])
19
+
20
+ const FORBIDDEN_ELEMENTS = new Set([
21
+ 'script', 'foreignObject', 'iframe', 'object', 'embed', 'applet',
22
+ ])
23
+
24
+ /** Matches event handler attributes: on* */
25
+ const EVENT_HANDLER_RE = /\bon\w+\s*=/gi
26
+
27
+ /** Matches javascript: URLs in href/xlink:href */
28
+ const JS_URL_RE = /(href\s*=\s*["'])\s*javascript:/gi
29
+
30
+ /** Matches a full element tag (opening or self-closing) */
31
+ const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)((?:\s[^>]*)?)>/g
32
+
33
+ /** Matches complete forbidden element blocks including content */
34
+ function stripForbiddenBlocks(svg: string): string {
35
+ for (const tag of FORBIDDEN_ELEMENTS) {
36
+ // Strip both <tag>...</tag> and self-closing <tag ... />
37
+ const blockRe = new RegExp(`<${tag}[\\s>][\\s\\S]*?</${tag}>`, 'gi')
38
+ svg = svg.replace(blockRe, '')
39
+ const selfCloseRe = new RegExp(`<${tag}[\\s/][^>]*/?>`, 'gi')
40
+ svg = svg.replace(selfCloseRe, '')
41
+ }
42
+ return svg
43
+ }
44
+
45
+ /**
46
+ * Sanitize SVG markup by removing dangerous elements and attributes.
47
+ * Uses an allowlist approach — only known-safe SVG elements are kept.
48
+ */
49
+ export function sanitizeSvg(svg: string): string {
50
+ if (!svg) return ''
51
+
52
+ // 1. Strip forbidden element blocks (script, foreignObject, etc.)
53
+ let clean = stripForbiddenBlocks(svg)
54
+
55
+ // 2. Strip event handler attributes
56
+ clean = clean.replace(EVENT_HANDLER_RE, '')
57
+
58
+ // 3. Strip javascript: URLs
59
+ clean = clean.replace(JS_URL_RE, '$1')
60
+
61
+ // 4. Remove any elements not in the allowlist
62
+ clean = clean.replace(TAG_RE, (match, tagName: string) => {
63
+ const lower = tagName.toLowerCase()
64
+ if (ALLOWED_ELEMENTS.has(lower) || ALLOWED_ELEMENTS.has(tagName)) {
65
+ return match
66
+ }
67
+ return ''
68
+ })
69
+
70
+ return clean.trim()
71
+ }