@creately/rdm-mcp 0.1.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.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@creately/rdm-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for RDM — parse, validate, render, and manipulate diagrams via AI agents",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "bin": {
8
+ "rdm-mcp": "src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "start": "bun run src/index.ts",
12
+ "dev": "bun run --watch src/index.ts"
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.12.1",
16
+ "@creately/rdm-core": "workspace:*"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/creately/rdm",
22
+ "directory": "packages/rdm-mcp"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * RDM MCP Server
3
+ *
4
+ * Provides 8 tools for agents to interact with RDM diagrams:
5
+ * rdm_read — Full .rdm text (rich context)
6
+ * rdm_summary — Lightweight: node count, types, relationships
7
+ * rdm_write — Write/update .rdm file
8
+ * rdm_render — Get SVG of current diagram
9
+ * rdm_validate — Check for errors with line numbers
10
+ * rdm_schema — Domain schema: valid fields, relationships, examples
11
+ * rdm_from_csv — Generate .rdm from structured data file
12
+ * rdm_init — Analyze project, generate conventions
13
+ *
14
+ * Supports both stdio and HTTP transport.
15
+ *
16
+ * Usage:
17
+ * bun run packages/rdm-mcp/src/index.ts # stdio mode
18
+ * bun run packages/rdm-mcp/src/index.ts --http 3100 # HTTP mode
19
+ */
20
+
21
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
22
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
23
+ import {
24
+ CallToolRequestSchema,
25
+ ListToolsRequestSchema,
26
+ } from '@modelcontextprotocol/sdk/types.js';
27
+
28
+ import { rdmReadTool, executeRdmRead } from './tools/rdm_read.js';
29
+ import { rdmSummaryTool, executeRdmSummary } from './tools/rdm_summary.js';
30
+ import { rdmWriteTool, executeRdmWrite } from './tools/rdm_write.js';
31
+ import { rdmRenderTool, executeRdmRender } from './tools/rdm_render.js';
32
+ import { rdmValidateTool, executeRdmValidate } from './tools/rdm_validate.js';
33
+ import { rdmSchemaTool, executeRdmSchema } from './tools/rdm_schema.js';
34
+ import { rdmFromCsvTool, executeRdmFromCsv } from './tools/rdm_from_csv.js';
35
+ import { rdmInitTool, executeRdmInit } from './tools/rdm_init.js';
36
+
37
+ const ALL_TOOLS = [
38
+ rdmReadTool,
39
+ rdmSummaryTool,
40
+ rdmWriteTool,
41
+ rdmRenderTool,
42
+ rdmValidateTool,
43
+ rdmSchemaTool,
44
+ rdmFromCsvTool,
45
+ rdmInitTool,
46
+ ];
47
+
48
+ const TOOL_EXECUTORS: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
49
+ rdm_read: executeRdmRead,
50
+ rdm_summary: executeRdmSummary,
51
+ rdm_write: executeRdmWrite,
52
+ rdm_render: executeRdmRender,
53
+ rdm_validate: executeRdmValidate,
54
+ rdm_schema: executeRdmSchema,
55
+ rdm_from_csv: executeRdmFromCsv,
56
+ rdm_init: executeRdmInit,
57
+ };
58
+
59
+ async function main() {
60
+ const server = new Server(
61
+ { name: 'rdm-mcp', version: '0.1.0' },
62
+ { capabilities: { tools: {} } }
63
+ );
64
+
65
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
66
+ tools: ALL_TOOLS,
67
+ }));
68
+
69
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
70
+ const { name, arguments: args } = request.params;
71
+ const executor = TOOL_EXECUTORS[name];
72
+
73
+ if (!executor) {
74
+ return {
75
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
76
+ isError: true,
77
+ };
78
+ }
79
+
80
+ try {
81
+ const result = await executor(args ?? {});
82
+ return {
83
+ content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }],
84
+ };
85
+ } catch (error) {
86
+ return {
87
+ content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
88
+ isError: true,
89
+ };
90
+ }
91
+ });
92
+
93
+ const transport = new StdioServerTransport();
94
+ await server.connect(transport);
95
+ }
96
+
97
+ main().catch(console.error);
@@ -0,0 +1,316 @@
1
+ /**
2
+ * rdm_from_csv — Generate .rdm from CSV data
3
+ */
4
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
5
+ import { resolve, dirname, basename } from 'path';
6
+
7
+ export const rdmFromCsvTool = {
8
+ name: 'rdm_from_csv',
9
+ description:
10
+ 'Generate an .rdm file from a CSV data file. Automatically maps columns ' +
11
+ 'to RDM fields based on the target domain (orgchart, genogram, process).',
12
+ inputSchema: {
13
+ type: 'object' as const,
14
+ properties: {
15
+ csvPath: {
16
+ type: 'string',
17
+ description: 'Path to the CSV file',
18
+ },
19
+ domain: {
20
+ type: 'string',
21
+ enum: ['orgchart', 'genogram', 'process'],
22
+ description: 'Target diagram domain',
23
+ },
24
+ outputPath: {
25
+ type: 'string',
26
+ description: 'Output .rdm file path (defaults to same directory as CSV)',
27
+ },
28
+ columnMapping: {
29
+ type: 'object',
30
+ description:
31
+ 'Optional manual column mapping: { csvColumn: rdmField }. ' +
32
+ 'If not provided, columns are auto-mapped by name.',
33
+ },
34
+ },
35
+ required: ['csvPath', 'domain'],
36
+ },
37
+ };
38
+
39
+ export async function executeRdmFromCsv(args: Record<string, unknown>): Promise<unknown> {
40
+ const csvPath = resolve(String(args.csvPath ?? ''));
41
+ const domain = String(args.domain ?? 'orgchart');
42
+ const columnMapping = (args.columnMapping as Record<string, string>) ?? {};
43
+
44
+ if (!existsSync(csvPath)) {
45
+ return { error: `CSV file not found: ${csvPath}` };
46
+ }
47
+
48
+ const csvContent = readFileSync(csvPath, 'utf-8');
49
+ const rows = parseSimpleCsv(csvContent);
50
+
51
+ if (rows.length < 2) {
52
+ return { error: 'CSV must have at least a header row and one data row' };
53
+ }
54
+
55
+ const headers = rows[0];
56
+ const data = rows.slice(1);
57
+
58
+ // Auto-map columns if no explicit mapping
59
+ const mapping = Object.keys(columnMapping).length > 0
60
+ ? columnMapping
61
+ : autoMapColumns(headers, domain);
62
+
63
+ let rdmText: string;
64
+
65
+ switch (domain) {
66
+ case 'orgchart':
67
+ rdmText = generateOrgchartRdm(headers, data, mapping, csvPath);
68
+ break;
69
+ case 'genogram':
70
+ rdmText = generateGenogramRdm(headers, data, mapping, csvPath);
71
+ break;
72
+ case 'process':
73
+ rdmText = generateProcessRdm(headers, data, mapping, csvPath);
74
+ break;
75
+ default:
76
+ return { error: `Unsupported domain: ${domain}` };
77
+ }
78
+
79
+ const outputPath = args.outputPath
80
+ ? resolve(String(args.outputPath))
81
+ : csvPath.replace(/\.csv$/i, '.rdm');
82
+
83
+ const dir = dirname(outputPath);
84
+ if (!existsSync(dir)) {
85
+ mkdirSync(dir, { recursive: true });
86
+ }
87
+
88
+ writeFileSync(outputPath, rdmText, 'utf-8');
89
+
90
+ return {
91
+ outputPath,
92
+ domain,
93
+ rowCount: data.length,
94
+ columnMapping: mapping,
95
+ written: true,
96
+ };
97
+ }
98
+
99
+ function parseSimpleCsv(content: string): string[][] {
100
+ return content
101
+ .trim()
102
+ .split('\n')
103
+ .map((line) =>
104
+ line.split(',').map((cell) => cell.trim().replace(/^["']|["']$/g, ''))
105
+ );
106
+ }
107
+
108
+ function autoMapColumns(headers: string[], domain: string): Record<string, string> {
109
+ const mapping: Record<string, string> = {};
110
+ const normalized = headers.map((h) => h.toLowerCase().trim());
111
+
112
+ if (domain === 'orgchart') {
113
+ const nameCol = normalized.findIndex((h) => ['name', 'employee', 'person', 'full_name', 'fullname'].includes(h));
114
+ const titleCol = normalized.findIndex((h) => ['title', 'role', 'job_title', 'position'].includes(h));
115
+ const deptCol = normalized.findIndex((h) => ['department', 'dept', 'division', 'team'].includes(h));
116
+ const managerCol = normalized.findIndex((h) => ['manager', 'reports_to', 'reportsto', 'supervisor', 'boss'].includes(h));
117
+ const levelCol = normalized.findIndex((h) => ['level', 'rank', 'grade'].includes(h));
118
+
119
+ if (nameCol >= 0) mapping[headers[nameCol]] = 'name';
120
+ if (titleCol >= 0) mapping[headers[titleCol]] = 'title';
121
+ if (deptCol >= 0) mapping[headers[deptCol]] = 'department';
122
+ if (managerCol >= 0) mapping[headers[managerCol]] = 'reportsTo';
123
+ if (levelCol >= 0) mapping[headers[levelCol]] = 'level';
124
+ } else if (domain === 'genogram') {
125
+ const nameCol = normalized.findIndex((h) => ['name', 'person', 'full_name'].includes(h));
126
+ const sexCol = normalized.findIndex((h) => ['sex', 'gender'].includes(h));
127
+ const bornCol = normalized.findIndex((h) => ['born', 'birth', 'birthday', 'dob', 'birth_date'].includes(h));
128
+ const statusCol = normalized.findIndex((h) => ['status', 'alive'].includes(h));
129
+
130
+ if (nameCol >= 0) mapping[headers[nameCol]] = 'name';
131
+ if (sexCol >= 0) mapping[headers[sexCol]] = 'sex';
132
+ if (bornCol >= 0) mapping[headers[bornCol]] = 'born';
133
+ if (statusCol >= 0) mapping[headers[statusCol]] = 'status';
134
+ } else if (domain === 'process') {
135
+ const nameCol = normalized.findIndex((h) => ['step', 'task', 'name', 'activity'].includes(h));
136
+ const assigneeCol = normalized.findIndex((h) => ['assignee', 'owner', 'responsible', 'role'].includes(h));
137
+ const durationCol = normalized.findIndex((h) => ['duration', 'days', 'time'].includes(h));
138
+
139
+ if (nameCol >= 0) mapping[headers[nameCol]] = 'name';
140
+ if (assigneeCol >= 0) mapping[headers[assigneeCol]] = 'assignee';
141
+ if (durationCol >= 0) mapping[headers[durationCol]] = 'duration';
142
+ }
143
+
144
+ return mapping;
145
+ }
146
+
147
+ function toId(name: string): string {
148
+ return name
149
+ .toLowerCase()
150
+ .replace(/[^a-z0-9]+/g, '_')
151
+ .replace(/^_|_$/g, '')
152
+ .slice(0, 30);
153
+ }
154
+
155
+ function generateOrgchartRdm(
156
+ headers: string[],
157
+ data: string[][],
158
+ mapping: Record<string, string>,
159
+ csvPath: string
160
+ ): string {
161
+ const reverseMap: Record<string, number> = {};
162
+ for (const [col, field] of Object.entries(mapping)) {
163
+ const idx = headers.indexOf(col);
164
+ if (idx >= 0) reverseMap[field] = idx;
165
+ }
166
+
167
+ const title = basename(csvPath, '.csv').replace(/[-_]/g, ' ');
168
+ const lines: string[] = [
169
+ '---',
170
+ 'type: orgchart',
171
+ `title: "${title}"`,
172
+ '---',
173
+ '',
174
+ `org ${title.replace(/[^a-zA-Z0-9]/g, '')} {`,
175
+ ];
176
+
177
+ // Build name→id map for reportsTo references
178
+ const nameToId: Record<string, string> = {};
179
+ for (const row of data) {
180
+ const name = reverseMap.name !== undefined ? row[reverseMap.name] : row[0];
181
+ if (name) nameToId[name] = toId(name);
182
+ }
183
+
184
+ for (const row of data) {
185
+ const name = reverseMap.name !== undefined ? row[reverseMap.name] : row[0];
186
+ if (!name) continue;
187
+
188
+ const id = toId(name);
189
+ const props: string[] = [];
190
+
191
+ if (reverseMap.title !== undefined && row[reverseMap.title]) {
192
+ props.push(` title: "${row[reverseMap.title]}"`);
193
+ }
194
+ if (reverseMap.department !== undefined && row[reverseMap.department]) {
195
+ props.push(` department: "${row[reverseMap.department]}"`);
196
+ }
197
+ if (reverseMap.level !== undefined && row[reverseMap.level]) {
198
+ props.push(` level: ${row[reverseMap.level]}`);
199
+ }
200
+ if (reverseMap.reportsTo !== undefined && row[reverseMap.reportsTo]) {
201
+ const managerId = nameToId[row[reverseMap.reportsTo]] || toId(row[reverseMap.reportsTo]);
202
+ props.push(` reportsTo: ${managerId}`);
203
+ }
204
+
205
+ lines.push(` position ${id} "${name}" {`);
206
+ lines.push(...props);
207
+ lines.push(' }');
208
+ }
209
+
210
+ lines.push('}');
211
+ return lines.join('\n') + '\n';
212
+ }
213
+
214
+ function generateGenogramRdm(
215
+ headers: string[],
216
+ data: string[][],
217
+ mapping: Record<string, string>,
218
+ csvPath: string
219
+ ): string {
220
+ const reverseMap: Record<string, number> = {};
221
+ for (const [col, field] of Object.entries(mapping)) {
222
+ const idx = headers.indexOf(col);
223
+ if (idx >= 0) reverseMap[field] = idx;
224
+ }
225
+
226
+ const title = basename(csvPath, '.csv').replace(/[-_]/g, ' ');
227
+ const lines: string[] = [
228
+ '---',
229
+ 'type: genogram',
230
+ `title: "${title}"`,
231
+ '---',
232
+ '',
233
+ `genogram ${title.replace(/[^a-zA-Z0-9]/g, '')} {`,
234
+ ' generation 1 {',
235
+ ];
236
+
237
+ for (const row of data) {
238
+ const name = reverseMap.name !== undefined ? row[reverseMap.name] : row[0];
239
+ if (!name) continue;
240
+
241
+ const id = toId(name);
242
+ const props: string[] = [];
243
+
244
+ if (reverseMap.sex !== undefined && row[reverseMap.sex]) {
245
+ props.push(` sex: ${row[reverseMap.sex].toLowerCase()}`);
246
+ }
247
+ if (reverseMap.born !== undefined && row[reverseMap.born]) {
248
+ props.push(` born: ${row[reverseMap.born]}`);
249
+ }
250
+ if (reverseMap.status !== undefined && row[reverseMap.status]) {
251
+ props.push(` status: ${row[reverseMap.status].toLowerCase()}`);
252
+ }
253
+
254
+ lines.push(` person ${id} "${name}" {`);
255
+ lines.push(...props.map((p) => ' ' + p));
256
+ lines.push(' }');
257
+ }
258
+
259
+ lines.push(' }');
260
+ lines.push('}');
261
+ return lines.join('\n') + '\n';
262
+ }
263
+
264
+ function generateProcessRdm(
265
+ headers: string[],
266
+ data: string[][],
267
+ mapping: Record<string, string>,
268
+ csvPath: string
269
+ ): string {
270
+ const reverseMap: Record<string, number> = {};
271
+ for (const [col, field] of Object.entries(mapping)) {
272
+ const idx = headers.indexOf(col);
273
+ if (idx >= 0) reverseMap[field] = idx;
274
+ }
275
+
276
+ const title = basename(csvPath, '.csv').replace(/[-_]/g, ' ');
277
+ const lines: string[] = [
278
+ '---',
279
+ 'type: process',
280
+ `title: "${title}"`,
281
+ '---',
282
+ '',
283
+ `process ${title.replace(/[^a-zA-Z0-9]/g, '')} {`,
284
+ ];
285
+
286
+ const taskIds: string[] = [];
287
+
288
+ for (const row of data) {
289
+ const name = reverseMap.name !== undefined ? row[reverseMap.name] : row[0];
290
+ if (!name) continue;
291
+
292
+ const id = toId(name);
293
+ taskIds.push(id);
294
+ const props: string[] = [' type: task'];
295
+
296
+ if (reverseMap.assignee !== undefined && row[reverseMap.assignee]) {
297
+ props.push(` assignee: "${row[reverseMap.assignee]}"`);
298
+ }
299
+ if (reverseMap.duration !== undefined && row[reverseMap.duration]) {
300
+ props.push(` duration: ${row[reverseMap.duration]}`);
301
+ }
302
+
303
+ lines.push(` ${id} "${name}" {`);
304
+ lines.push(...props);
305
+ lines.push(' }');
306
+ }
307
+
308
+ // Add flow connections
309
+ if (taskIds.length > 1) {
310
+ lines.push('');
311
+ lines.push(' ' + taskIds.join(' -> '));
312
+ }
313
+
314
+ lines.push('}');
315
+ return lines.join('\n') + '\n';
316
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * rdm_init — Analyze project and generate RDM conventions
3
+ */
4
+ import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
5
+ import { resolve, join, extname, relative } from 'path';
6
+
7
+ export const rdmInitTool = {
8
+ name: 'rdm_init',
9
+ description:
10
+ 'Analyze the current project directory to discover existing .rdm files, ' +
11
+ 'CSV data sources, and configuration. Generates project-level RDM conventions ' +
12
+ 'suitable for appending to CLAUDE.md.',
13
+ inputSchema: {
14
+ type: 'object' as const,
15
+ properties: {
16
+ projectDir: {
17
+ type: 'string',
18
+ description: 'Project root directory to analyze (defaults to cwd)',
19
+ },
20
+ },
21
+ },
22
+ };
23
+
24
+ export async function executeRdmInit(args: Record<string, unknown>): Promise<unknown> {
25
+ const projectDir = resolve(String(args.projectDir ?? process.cwd()));
26
+
27
+ if (!existsSync(projectDir)) {
28
+ return { error: `Directory not found: ${projectDir}` };
29
+ }
30
+
31
+ // Scan for .rdm files
32
+ const rdmFiles = findFiles(projectDir, '.rdm', 5);
33
+
34
+ // Scan for CSV files
35
+ const csvFiles = findFiles(projectDir, '.csv', 3);
36
+
37
+ // Analyze existing .rdm files
38
+ const rdmAnalysis = rdmFiles.map((f) => {
39
+ const content = readFileSync(f, 'utf-8');
40
+ const typeMatch = content.match(/type:\s*(\w+)/);
41
+ const titleMatch = content.match(/title:\s*"([^"]+)"/);
42
+ return {
43
+ path: relative(projectDir, f),
44
+ type: typeMatch?.[1] ?? 'unknown',
45
+ title: titleMatch?.[1] ?? 'untitled',
46
+ lines: content.split('\n').length,
47
+ };
48
+ });
49
+
50
+ // Detect dominant diagram type
51
+ const typeCounts: Record<string, number> = {};
52
+ for (const r of rdmAnalysis) {
53
+ typeCounts[r.type] = (typeCounts[r.type] || 0) + 1;
54
+ }
55
+ const dominantType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'orgchart';
56
+
57
+ // Generate conventions markdown
58
+ const conventions = generateConventions(rdmAnalysis, csvFiles.map((f) => relative(projectDir, f)), dominantType);
59
+
60
+ return {
61
+ projectDir,
62
+ rdmFileCount: rdmFiles.length,
63
+ csvFileCount: csvFiles.length,
64
+ rdmFiles: rdmAnalysis,
65
+ csvFiles: csvFiles.map((f) => relative(projectDir, f)),
66
+ dominantDiagramType: dominantType,
67
+ typeCounts,
68
+ conventions,
69
+ };
70
+ }
71
+
72
+ function findFiles(dir: string, ext: string, maxDepth: number, depth = 0): string[] {
73
+ if (depth > maxDepth) return [];
74
+
75
+ const results: string[] = [];
76
+
77
+ try {
78
+ const entries = readdirSync(dir);
79
+
80
+ for (const entry of entries) {
81
+ if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist') continue;
82
+
83
+ const fullPath = join(dir, entry);
84
+ try {
85
+ const stat = statSync(fullPath);
86
+ if (stat.isDirectory()) {
87
+ results.push(...findFiles(fullPath, ext, maxDepth, depth + 1));
88
+ } else if (extname(entry) === ext) {
89
+ results.push(fullPath);
90
+ }
91
+ } catch {
92
+ // Permission error, skip
93
+ }
94
+ }
95
+ } catch {
96
+ // Directory not readable
97
+ }
98
+
99
+ return results;
100
+ }
101
+
102
+ function generateConventions(
103
+ rdmFiles: Array<{ path: string; type: string; title: string }>,
104
+ csvFiles: string[],
105
+ dominantType: string
106
+ ): string {
107
+ const lines: string[] = [
108
+ '## RDM Conventions',
109
+ '',
110
+ `Primary diagram type: \`${dominantType}\``,
111
+ '',
112
+ ];
113
+
114
+ if (rdmFiles.length > 0) {
115
+ lines.push('### Existing Diagrams');
116
+ for (const f of rdmFiles.slice(0, 10)) {
117
+ lines.push(`- \`${f.path}\` — ${f.type}: ${f.title}`);
118
+ }
119
+ if (rdmFiles.length > 10) {
120
+ lines.push(`- ... and ${rdmFiles.length - 10} more`);
121
+ }
122
+ lines.push('');
123
+ }
124
+
125
+ if (csvFiles.length > 0) {
126
+ lines.push('### Data Sources');
127
+ for (const f of csvFiles.slice(0, 5)) {
128
+ lines.push(`- \`${f}\` — potential diagram data source`);
129
+ }
130
+ lines.push('');
131
+ }
132
+
133
+ lines.push('### Naming Rules');
134
+ lines.push('- File names: kebab-case with .rdm extension');
135
+ lines.push('- Node IDs: snake_case (e.g., `vp_eng`, `dad`)');
136
+ lines.push('- Display names: Quoted strings with proper names');
137
+ lines.push('');
138
+
139
+ lines.push('### Workflow');
140
+ lines.push('1. Generate .rdm file with `/rdm` skill');
141
+ lines.push('2. View with `bun run rdm-canvas <file.rdm>`');
142
+ lines.push('3. Edit in browser or text editor — changes sync bidirectionally');
143
+ lines.push('');
144
+
145
+ return lines.join('\n');
146
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * rdm_read — Read full .rdm file content
3
+ */
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+
7
+ export const rdmReadTool = {
8
+ name: 'rdm_read',
9
+ description:
10
+ 'Read the full content of an .rdm file. Returns the complete RDM markup ' +
11
+ 'including frontmatter, structure, layout, and style blocks. ' +
12
+ 'Use rdm_summary for a lightweight overview instead.',
13
+ inputSchema: {
14
+ type: 'object' as const,
15
+ properties: {
16
+ path: {
17
+ type: 'string',
18
+ description: 'Path to the .rdm file to read',
19
+ },
20
+ },
21
+ required: ['path'],
22
+ },
23
+ };
24
+
25
+ export async function executeRdmRead(args: Record<string, unknown>): Promise<unknown> {
26
+ const filePath = resolve(String(args.path ?? ''));
27
+
28
+ if (!existsSync(filePath)) {
29
+ return { error: `File not found: ${filePath}` };
30
+ }
31
+
32
+ const content = readFileSync(filePath, 'utf-8');
33
+ const lines = content.split('\n').length;
34
+
35
+ return {
36
+ path: filePath,
37
+ content,
38
+ lines,
39
+ sizeBytes: Buffer.byteLength(content, 'utf-8'),
40
+ };
41
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * rdm_render — Render an .rdm file to SVG
3
+ */
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { RdmService } from '@creately/rdm-core';
7
+
8
+ const rdmService = new RdmService();
9
+
10
+ export const rdmRenderTool = {
11
+ name: 'rdm_render',
12
+ description:
13
+ 'Render an .rdm file to SVG. Returns the SVG markup for visual validation. ' +
14
+ 'Use this to check what a diagram looks like.',
15
+ inputSchema: {
16
+ type: 'object' as const,
17
+ properties: {
18
+ path: {
19
+ type: 'string',
20
+ description: 'Path to the .rdm file to render',
21
+ },
22
+ rdmText: {
23
+ type: 'string',
24
+ description: 'RDM text to render directly (alternative to path)',
25
+ },
26
+ },
27
+ },
28
+ };
29
+
30
+ export async function executeRdmRender(args: Record<string, unknown>): Promise<unknown> {
31
+ let rdmText: string;
32
+
33
+ if (args.rdmText) {
34
+ rdmText = String(args.rdmText);
35
+ } else if (args.path) {
36
+ const filePath = resolve(String(args.path));
37
+ if (!existsSync(filePath)) {
38
+ return { error: `File not found: ${filePath}` };
39
+ }
40
+ rdmText = readFileSync(filePath, 'utf-8');
41
+ } else {
42
+ return { error: 'Either path or rdmText must be provided' };
43
+ }
44
+
45
+ const result = rdmService.render(rdmText);
46
+
47
+ if (!result.success) {
48
+ return {
49
+ error: 'Render failed',
50
+ errors: result.errors,
51
+ };
52
+ }
53
+
54
+ return {
55
+ svg: result.svg,
56
+ svgLength: result.svg?.length ?? 0,
57
+ };
58
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * rdm_schema — Domain schema: valid fields, relationships, examples
3
+ *
4
+ * Returns structured schema information for a given RDM domain type.
5
+ * Agents use this to generate correct RDM without guessing field names.
6
+ */
7
+
8
+ interface FieldDef {
9
+ name: string;
10
+ type: string;
11
+ values?: string[];
12
+ description?: string;
13
+ required?: boolean;
14
+ }
15
+
16
+ interface DomainSchema {
17
+ domain: string;
18
+ description: string;
19
+ nodeTypes: Record<string, { fields: FieldDef[] }>;
20
+ relationships: Array<{ syntax: string; description: string }>;
21
+ structure: string;
22
+ example: string;
23
+ }
24
+
25
+ const SCHEMAS: Record<string, DomainSchema> = {
26
+ genogram: {
27
+ domain: 'genogram',
28
+ description: 'Family relationship diagrams for clinical, genealogical, or social contexts.',
29
+ nodeTypes: {
30
+ person: {
31
+ fields: [
32
+ { name: 'sex', type: 'enum', values: ['male', 'female'], required: true },
33
+ { name: 'born', type: 'date', description: 'Birth date (YYYY or YYYY-MM-DD)' },
34
+ { name: 'died', type: 'date', description: 'Death date' },
35
+ { name: 'status', type: 'enum', values: ['living', 'deceased'] },
36
+ { name: 'health', type: 'array', description: 'List of health conditions' },
37
+ { name: 'occupation', type: 'string' },
38
+ { name: 'education', type: 'string' },
39
+ { name: 'causeOfDeath', type: 'string' },
40
+ { name: 'adopted', type: 'boolean' },
41
+ { name: 'twin', type: 'enum', values: ['identical', 'fraternal'] },
42
+ ],
43
+ },
44
+ },
45
+ relationships: [
46
+ { syntax: 'married person1 -- person2 { since: YYYY }', description: 'Marriage' },
47
+ { syntax: 'divorced person1 -- person2 { since: YYYY }', description: 'Divorce' },
48
+ { syntax: 'child parent1, parent2 -> child_id', description: 'Parent-child (two parents)' },
49
+ { syntax: 'child parent1 -> child_id', description: 'Parent-child (single parent)' },
50
+ { syntax: 'twins monozygotic parent1, parent2 -> twin1, twin2', description: 'Identical twins' },
51
+ { syntax: 'twins dizygotic parent1, parent2 -> twin1, twin2', description: 'Fraternal twins' },
52
+ { syntax: 'close person1 -- person2', description: 'Emotional: close relationship' },
53
+ { syntax: 'conflict person1 -- person2', description: 'Emotional: conflict' },
54
+ { syntax: 'cutoff person1 -- person2', description: 'Emotional: cutoff' },
55
+ ],
56
+ structure: 'genogram Name {\n generation N {\n person id "Name" { sex: male, born: 1980 }\n married p1 -- p2 { since: 2005 }\n child p1, p2 -> child_id\n }\n}',
57
+ example: '---\ntype: genogram\ntitle: "Sample Family"\n---\n\ngenogram Sample {\n generation 1 {\n person gf "John" { sex: male, born: 1940 }\n person gm "Mary" { sex: female, born: 1942 }\n married gf -- gm { since: 1962 }\n }\n generation 2 {\n person dad "Bob" { sex: male, born: 1965 }\n child gf, gm -> dad\n }\n}',
58
+ },
59
+ orgchart: {
60
+ domain: 'orgchart',
61
+ description: 'Organizational hierarchy diagrams with positions and reporting relationships.',
62
+ nodeTypes: {
63
+ position: {
64
+ fields: [
65
+ { name: 'title', type: 'string', description: 'Job title', required: true },
66
+ { name: 'department', type: 'string' },
67
+ { name: 'level', type: 'number', description: 'Hierarchy level (1 = top)' },
68
+ { name: 'reportsTo', type: 'reference', description: 'ID of reporting-to position' },
69
+ { name: 'location', type: 'string' },
70
+ { name: 'status', type: 'enum', values: ['active', 'vacant', 'planned'] },
71
+ ],
72
+ },
73
+ },
74
+ relationships: [
75
+ { syntax: 'reportsTo: parent_id', description: 'Reporting relationship (in position props)' },
76
+ { syntax: 'team name under parent_id { ... }', description: 'Team grouping' },
77
+ ],
78
+ structure: 'org Name {\n position id "Display Name" {\n title: "...", department: "...", level: N\n reportsTo: parent_id\n }\n team name under parent_id { ... }\n}',
79
+ example: '---\ntype: orgchart\ntitle: "Engineering"\n---\n\norg Engineering {\n position ceo "Alice" { title: "CEO", level: 1 }\n position vp "Bob" { title: "VP Eng", level: 2, reportsTo: ceo }\n}',
80
+ },
81
+ process: {
82
+ domain: 'process',
83
+ description: 'Business process and workflow diagrams with tasks and flows.',
84
+ nodeTypes: {
85
+ task: {
86
+ fields: [
87
+ { name: 'type', type: 'enum', values: ['task', 'start', 'end', 'decision', 'gateway'], required: true },
88
+ { name: 'assignee', type: 'string', description: 'Role or person responsible' },
89
+ { name: 'duration', type: 'number', description: 'Duration in days' },
90
+ { name: 'sla', type: 'number', description: 'SLA deadline in days' },
91
+ { name: 'priority', type: 'enum', values: ['low', 'medium', 'high', 'critical'] },
92
+ ],
93
+ },
94
+ },
95
+ relationships: [
96
+ { syntax: 'task1 -> task2', description: 'Sequential flow' },
97
+ { syntax: 'task1 -> task2 -> task3', description: 'Chained flow' },
98
+ ],
99
+ structure: 'process Name {\n task_id "Display Name" { type: task, assignee: "Role" }\n task1 -> task2 -> task3\n}',
100
+ example: '---\ntype: process\ntitle: "Approval"\n---\n\nprocess Approval {\n submit "Submit" { type: task, assignee: "Requester" }\n review "Review" { type: task, assignee: "Manager", sla: 2 }\n submit -> review\n}',
101
+ },
102
+ };
103
+
104
+ export const rdmSchemaTool = {
105
+ name: 'rdm_schema',
106
+ description:
107
+ 'Get the RDM schema for a diagram domain (genogram, orgchart, process). ' +
108
+ 'Returns valid node types, fields, relationships, and syntax examples. ' +
109
+ 'Use this before generating RDM to ensure correct syntax.',
110
+ inputSchema: {
111
+ type: 'object' as const,
112
+ properties: {
113
+ domain: {
114
+ type: 'string',
115
+ enum: ['genogram', 'orgchart', 'process'],
116
+ description: 'The diagram domain to get schema for',
117
+ },
118
+ },
119
+ required: ['domain'],
120
+ },
121
+ };
122
+
123
+ export async function executeRdmSchema(args: Record<string, unknown>): Promise<unknown> {
124
+ const domain = String(args.domain ?? '').toLowerCase();
125
+ const schema = SCHEMAS[domain];
126
+
127
+ if (!schema) {
128
+ return {
129
+ error: `Unknown domain "${domain}". Valid: ${Object.keys(SCHEMAS).join(', ')}`,
130
+ availableDomains: Object.keys(SCHEMAS),
131
+ };
132
+ }
133
+
134
+ return schema;
135
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * rdm_summary — Lightweight summary of an .rdm file (~200 tokens)
3
+ */
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { RdmService } from '@creately/rdm-core';
7
+
8
+ const rdmService = new RdmService();
9
+
10
+ export const rdmSummaryTool = {
11
+ name: 'rdm_summary',
12
+ description:
13
+ 'Get a lightweight summary of an .rdm file (~200 tokens). ' +
14
+ 'Returns node count, edge count, diagram type, and top-level structure. ' +
15
+ 'Use this first to understand what a diagram contains before reading the full content.',
16
+ inputSchema: {
17
+ type: 'object' as const,
18
+ properties: {
19
+ path: {
20
+ type: 'string',
21
+ description: 'Path to the .rdm file',
22
+ },
23
+ },
24
+ required: ['path'],
25
+ },
26
+ };
27
+
28
+ export async function executeRdmSummary(args: Record<string, unknown>): Promise<unknown> {
29
+ const filePath = resolve(String(args.path ?? ''));
30
+
31
+ if (!existsSync(filePath)) {
32
+ return { error: `File not found: ${filePath}` };
33
+ }
34
+
35
+ const content = readFileSync(filePath, 'utf-8');
36
+ const result = rdmService.parseAndValidate(content);
37
+
38
+ if (!result.success || !result.document) {
39
+ return {
40
+ path: filePath,
41
+ valid: false,
42
+ errorCount: result.errors.length,
43
+ errors: result.errors.slice(0, 3),
44
+ };
45
+ }
46
+
47
+ const doc = result.document;
48
+ const nodeCount = countType(doc.structure.declarations, 'node');
49
+ const edgeCount = countType(doc.structure.declarations, 'edge');
50
+ const groupCount = countType(doc.structure.declarations, 'group');
51
+ const genCount = countType(doc.structure.declarations, 'generation');
52
+
53
+ // Collect top-level IDs
54
+ const topIds: string[] = [];
55
+ for (const decl of doc.structure.declarations) {
56
+ if ('id' in decl && decl.id) topIds.push(decl.id as string);
57
+ }
58
+
59
+ return {
60
+ path: filePath,
61
+ valid: true,
62
+ type: doc.meta.type,
63
+ title: doc.meta.title,
64
+ nodeCount,
65
+ edgeCount,
66
+ groupCount,
67
+ generationCount: genCount,
68
+ warningCount: result.warnings.length,
69
+ topLevelIds: topIds.slice(0, 20),
70
+ hasLayout: (doc.layout?.length ?? 0) > 0,
71
+ hasStyle: (doc.style?.length ?? 0) > 0,
72
+ };
73
+ }
74
+
75
+ function countType(declarations: Array<{ type: string; declarations?: any[] }>, type: string): number {
76
+ let count = 0;
77
+ for (const d of declarations) {
78
+ if (d.type === type) count++;
79
+ if (d.declarations) count += countType(d.declarations, type);
80
+ }
81
+ return count;
82
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * rdm_validate — Validate RDM text and return diagnostics
3
+ */
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { RdmService } from '@creately/rdm-core';
7
+
8
+ const rdmService = new RdmService();
9
+
10
+ export const rdmValidateTool = {
11
+ name: 'rdm_validate',
12
+ description:
13
+ 'Validate an .rdm file or RDM text. Returns parse errors and warnings ' +
14
+ 'with line numbers. Use this to check correctness before applying changes.',
15
+ inputSchema: {
16
+ type: 'object' as const,
17
+ properties: {
18
+ path: {
19
+ type: 'string',
20
+ description: 'Path to the .rdm file to validate',
21
+ },
22
+ rdmText: {
23
+ type: 'string',
24
+ description: 'RDM text to validate directly (alternative to path)',
25
+ },
26
+ },
27
+ },
28
+ };
29
+
30
+ export async function executeRdmValidate(args: Record<string, unknown>): Promise<unknown> {
31
+ let rdmText: string;
32
+
33
+ if (args.rdmText) {
34
+ rdmText = String(args.rdmText);
35
+ } else if (args.path) {
36
+ const filePath = resolve(String(args.path));
37
+ if (!existsSync(filePath)) {
38
+ return { error: `File not found: ${filePath}` };
39
+ }
40
+ rdmText = readFileSync(filePath, 'utf-8');
41
+ } else {
42
+ return { error: 'Either path or rdmText must be provided' };
43
+ }
44
+
45
+ const result = rdmService.parseAndValidate(rdmText);
46
+
47
+ return {
48
+ valid: result.success,
49
+ errors: result.errors.map((e) => ({
50
+ code: e.code,
51
+ message: e.message,
52
+ line: e.line,
53
+ column: e.column,
54
+ })),
55
+ warnings: result.warnings.map((w) => ({
56
+ code: w.code,
57
+ message: w.message,
58
+ })),
59
+ };
60
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * rdm_write — Write or update an .rdm file
3
+ */
4
+ import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
5
+ import { resolve, dirname } from 'path';
6
+ import { RdmService } from '@creately/rdm-core';
7
+
8
+ const rdmService = new RdmService();
9
+
10
+ export const rdmWriteTool = {
11
+ name: 'rdm_write',
12
+ description:
13
+ 'Write or update an .rdm file. Can write a full document or merge a fragment ' +
14
+ 'into an existing file. Validates the content before writing.',
15
+ inputSchema: {
16
+ type: 'object' as const,
17
+ properties: {
18
+ path: {
19
+ type: 'string',
20
+ description: 'Path to write the .rdm file',
21
+ },
22
+ content: {
23
+ type: 'string',
24
+ description: 'Full RDM document text (with frontmatter) to write',
25
+ },
26
+ fragment: {
27
+ type: 'string',
28
+ description:
29
+ 'RDM fragment to merge into an existing file. ' +
30
+ 'If both content and fragment are provided, content takes priority.',
31
+ },
32
+ },
33
+ required: ['path'],
34
+ },
35
+ };
36
+
37
+ export async function executeRdmWrite(args: Record<string, unknown>): Promise<unknown> {
38
+ const filePath = resolve(String(args.path ?? ''));
39
+ const content = args.content as string | undefined;
40
+ const fragment = args.fragment as string | undefined;
41
+
42
+ if (!content && !fragment) {
43
+ return { error: 'Either content or fragment must be provided' };
44
+ }
45
+
46
+ let rdmText: string;
47
+
48
+ if (content) {
49
+ rdmText = content;
50
+ } else {
51
+ // Merge fragment into existing file
52
+ if (!existsSync(filePath)) {
53
+ return { error: `File not found for fragment merge: ${filePath}. Use content param for new files.` };
54
+ }
55
+
56
+ const existing = readFileSync(filePath, 'utf-8');
57
+ const parsed = rdmService.parseAndValidate(existing);
58
+ if (!parsed.success || !parsed.document) {
59
+ return { error: `Existing file has parse errors: ${parsed.errors.map((e) => e.message).join('; ')}` };
60
+ }
61
+
62
+ // Simple merge: insert fragment before the closing brace of the structure block
63
+ const lastBrace = existing.lastIndexOf('}');
64
+ if (lastBrace === -1) {
65
+ return { error: 'Cannot find structure block closing brace in existing file' };
66
+ }
67
+
68
+ rdmText = existing.slice(0, lastBrace) + '\n ' + fragment!.trim() + '\n' + existing.slice(lastBrace);
69
+ }
70
+
71
+ // Validate before writing
72
+ const validation = rdmService.parseAndValidate(rdmText);
73
+ if (!validation.success) {
74
+ return {
75
+ error: 'Validation failed — not written',
76
+ errors: validation.errors,
77
+ };
78
+ }
79
+
80
+ // Ensure directory exists
81
+ const dir = dirname(filePath);
82
+ if (!existsSync(dir)) {
83
+ mkdirSync(dir, { recursive: true });
84
+ }
85
+
86
+ writeFileSync(filePath, rdmText, 'utf-8');
87
+
88
+ const nodeCount = countType(validation.document!.structure.declarations, 'node');
89
+ const edgeCount = countType(validation.document!.structure.declarations, 'edge');
90
+
91
+ return {
92
+ path: filePath,
93
+ written: true,
94
+ nodeCount,
95
+ edgeCount,
96
+ sizeBytes: Buffer.byteLength(rdmText, 'utf-8'),
97
+ };
98
+ }
99
+
100
+ function countType(declarations: Array<{ type: string; declarations?: any[] }>, type: string): number {
101
+ let count = 0;
102
+ for (const d of declarations) {
103
+ if (d.type === type) count++;
104
+ if (d.declarations) count += countType(d.declarations, type);
105
+ }
106
+ return count;
107
+ }