@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/dist/abracadabra-mcp.cjs +197 -1
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +197 -1
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/converters/markdownToYjs.ts +14 -1
- package/src/converters/yjsToMarkdown.ts +7 -0
- package/src/index.ts +2 -0
- package/src/tools/svg.ts +83 -0
- package/src/utils/sanitizeSvg.ts +71 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/tools/svg.ts
ADDED
|
@@ -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
|
+
}
|