Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,26 @@ codex mcp add radical-folder -- node /absolute/path/to/radical.tools/apps/mcp/di
codex mcp list
```

Launch Studio with `npm run dev:web` from the repository root, open it in Chromium, and open the **same OS folder** via the Models dialog. The browser asks for File System Access permission. The MCP server cannot access a browser-only OPFS folder. Ask Codex to use `get_model_summary` first, then `search_model` or the node/relation tools.
Claude Code takes the same command (`claude mcp add radical-folder -- node … --folder …`). For Claude Desktop, add it under `mcpServers` in `~/Library/Application Support/Claude/claude_desktop_config.json`, with an absolute path to `node`, because the app does not see your shell's `PATH`.

Launch Studio with `npm run dev:web` from the repository root, open it in Chromium, and open the **same OS folder** via the Models dialog. The browser asks for File System Access permission. The MCP server cannot access a browser-only OPFS folder. Ask the client to use `get_model_summary` first, then `search_model` or the node/relation tools.

## Tool and file behavior

The server binds to one absolute folder path at launch, then reads it afresh for every tool call. It provides `get_model_summary`, `search_model`, and `add_`, `update_`, and `delete_` tools for nodes and relations. These use Studio's shared schemas and model rules. New nodes get a deterministic position; existing positions are not laid out again. `add_node` returns the persisted real ID, and a `tempId` can be used in later calls on the same MCP connection. Newly added nodes appear in Studio's **All elements** view; the server does not add them to named views. The server refreshes its model data per call, but if the metamodel's tool schemas change, restart the server.
The server binds to one absolute folder path at launch, then reads it afresh for every tool call. Its tools are the shared AI tool catalogue from `@radical/common/ai/tools`, the one Studio's AI chat and Radical Forge use, minus the canvas-only `set_active_view` and `focus_node` and the destructive `reset_diagram`. It provides:

- `get_model_summary` (counts, views, sequences, presentations and the metamodel rules) and `search_model` (a small query language, including `LIST SEQUENCES` and `GET SEQUENCE`);
- `add_`, `update_` and `delete_` tools for nodes and relations, and `move_node` to change a node's parent (it keeps the node's canvas position and enforces the metamodel's `allowedParents`);
- `create_view`, `update_view` (name, kind, linked sequence, hidden relations), `set_view_nodes` and `delete_view`;
- `create_sequence`, `update_sequence` and `delete_sequence`: ordered relation flows that a `dynamic` view plays;
- `create_presentation`, `update_presentation` and `delete_presentation`: slides over views, which Studio frames to fit;
- `upsert_node_type`, `delete_node_type`, `upsert_relation_type` and `delete_relation_type`. A built-in metamodel is copied to a `…-custom` id on the first edit, because Studio replaces built-in metamodels with their preset on load. After a metamodel change (from these tools or from Studio) the server re-advertises the node and relation tool schemas;
- `smart_layout`, for All elements or one static or dynamic view (`viewId`), which then gets its own saved positions.

These use Studio's shared schemas and model rules. Besides counts, `get_model_summary` returns the metamodel context message the other tool descriptions refer to: each type's property keys and enum options, allowed parents, cardinality and relation pairs. The server's MCP instructions tell clients to call it first. New nodes are placed inside their parent below its header (root nodes to the right of the existing ones), and every parent a change adds to, moves or removes from is refitted to its children. `smart_layout` runs Studio's Smart Layout over the whole model (the **All elements** arrangement) and saves the result; views with their own saved positions keep them. Writes also update `defaultPositions`, which Studio restores when you switch back to **All elements**. `add_node` returns the persisted real ID, and a `tempId` can be used in later calls on the same MCP connection. Newly added nodes appear in Studio's **All elements** view until a view lists them. The server refreshes its model data per call.

Writes preserve unrelated files and the existing `radical.md` text. A tool reports success only after the changed model files are written. If a file changed between the server's read and write, it returns a conflict without writing. Studio's external-change reload no longer queues an autosave of the loaded data.

For now, keep Studio in **viewer use while Codex writes**. Folder writes span several files, and Studio and MCP do not share a cross-process transaction lock. Editing the same folder simultaneously from both can still race; the stamp check only catches changes visible before a write begins. Single `.radical` JSON files, view editing, browser commands, and remote HTTP MCP are not supported by this server.
For now, keep Studio in **viewer use while the MCP client writes**. Folder writes span several files, and Studio and MCP do not share a cross-process transaction lock. Editing the same folder simultaneously from both can still race; the stamp check only catches changes visible before a write begins. Single `.radical` JSON files, snapshots (milestones), browser commands, and remote HTTP MCP are not supported by this server.

Run `npm run test -w @radical/mcp` to build and exercise a real stdio connection and temporary disk folder.
4 changes: 3 additions & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"dependencies": {
"@modelcontextprotocol/server": "2.2.0",
"@radical/common": "*",
"@radical/node-files": "*"
"@radical/layout": "*",
"@radical/node-files": "*",
"elkjs": "^0.9.3"
},
"devDependencies": {
"@modelcontextprotocol/client": "2.2.0",
Expand Down
115 changes: 115 additions & 0 deletions apps/mcp/src/catalogueTools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { Client } from '@modelcontextprotocol/client'
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
import { builtInGovernanceMetamodel } from '@radical/common/metamodel'
import { serializeToMdFolder, deserializeFromMdFolder } from '@radical/common/formats/mdFolder'
import { MdFolderSession } from '@radical/common/formats/mdFolderSync'
import { createModelFacade } from '@radical/common/ai/modelFacade'
import { diskFolderStorage } from '@radical/node-files/diskFolderStorage'

const folders: string[] = []
afterEach(async () => { await Promise.all(folders.splice(0).map((folder) => rm(folder, { recursive: true, force: true }))) })

async function connect(): Promise<{ client: Client; folder: string; read: () => Promise<ReturnType<typeof deserializeFromMdFolder>['data']> }> {
const folder = await mkdtemp(join(tmpdir(), 'radical-mcp-doc-'))
folders.push(folder)
const files = serializeToMdFolder({ nodes: [], relations: [], metamodel: builtInGovernanceMetamodel() }, 'MCP docs')
for (const [path, content] of Object.entries(files)) {
await mkdir(dirname(join(folder, path)), { recursive: true })
await writeFile(join(folder, path), content)
}
const client = new Client({ name: 'radical-test', version: '1.0.0' })
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [join(import.meta.dirname, '../dist/index.js'), '--folder', folder],
}))
const read = async () => deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data
return { client, folder, read }
}

async function call(client: Client, name: string, args: Record<string, unknown>): Promise<string> {
const result = await client.callTool({ name, arguments: args })
const text = JSON.stringify(result.content)
expect(result.isError, `${name}: ${text}`).not.toBe(true)
return text
}

describe('MCP document tools', () => {
it('lays out one view into its own positions and builds a presentation over it', async () => {
const { client, read } = await connect()
try {
await call(client, 'add_node', { tempId: 'user', type: 'person', label: 'Buyer' })
await call(client, 'add_node', { tempId: 'shop', type: 'system', label: 'Shop' })
await call(client, 'add_node', { tempId: 'bank', type: 'system', label: 'Bank' })
await call(client, 'add_relation', { sourceId: 'user', targetId: 'shop', relationType: 'interacts', label: 'Orders' })
await call(client, 'add_relation', { sourceId: 'shop', targetId: 'bank', relationType: 'interacts', label: 'Charges' })
await call(client, 'create_view', { tempId: 'ctx', name: 'Context', nodeIds: ['user', 'shop', 'bank'] })
const before = await read()
const view = before.views![0]

const laidOut = await call(client, 'smart_layout', { viewId: view.id })
expect(laidOut).toContain('Smart Layout (Context)')
const after = await read()
// The model's own (All elements) positions are untouched; the view got its own.
expect(after.nodes.map((n) => [n.x, n.y])).toEqual(before.nodes.map((n) => [n.x, n.y]))
const positions = after.views![0].positions
expect(Object.keys(positions).sort()).toEqual(after.nodes.map((n) => n.id).sort())

await call(client, 'create_presentation', { name: 'Pitch', slides: [{ name: 'Overview' }, { name: 'Context', viewId: view.id }] })
const presented = await read()
const pitch = presented.presentations!.find((p) => p.name === 'Pitch')!
expect(pitch.slides).toEqual([
expect.objectContaining({ name: 'Overview', viewId: null, viewport: { x: 0, y: 0, zoom: 0 } }),
expect.objectContaining({ name: 'Context', viewId: view.id }),
])
await call(client, 'update_presentation', { id: pitch.id, slides: [{ id: pitch.slides[1].id, name: 'Context only', viewId: view.id }] })
expect((await read()).presentations!.find((p) => p.id === pitch.id)!.slides).toEqual([expect.objectContaining({ id: pitch.slides[1].id, name: 'Context only' })])

const summary = await call(client, 'get_model_summary', {})
expect(summary).toContain('Pitch')
const bad = await client.callTool({ name: 'smart_layout', arguments: { viewId: 'nope' } })
expect(bad.isError).toBe(true)
} finally {
await client.close()
}
}, 60_000)

it('copies a built-in metamodel on the first edit, persists it and refreshes tool schemas', async () => {
const { client, read } = await connect()
try {
const enumOf = async () => {
const tools = await client.listTools()
const addNode = tools.tools.find((t) => t.name === 'add_node')!
return (addNode.inputSchema as unknown as { properties: { type: { enum: string[] } } }).properties.type.enum
}
expect(await enumOf()).not.toContain('risk')
const added = await call(client, 'upsert_node_type', {
id: 'risk', label: 'Risk', baseType: 'adr', tableTab: true, allowedParents: ['system'], allowedAtRoot: true,
properties: [{ key: 'severity', label: 'Severity', type: 'enum', options: ['low', 'high'] }],
})
expect(added).toContain('copied to the custom metamodel')
expect(await enumOf()).toContain('risk')

await call(client, 'upsert_relation_type', { id: 'mitigates', label: 'Mitigates', allowedPairs: [{ from: 'adr', to: 'risk' }] })
await call(client, 'add_node', { tempId: 'r', type: 'risk', label: 'Data loss', properties: { severity: 'high' } })
await call(client, 'add_node', { tempId: 'a', type: 'adr', label: 'Backups' })
await call(client, 'add_relation', { sourceId: 'a', targetId: 'r', relationType: 'mitigates' })

const data = await read()
expect(data.metamodel).toMatchObject({ id: 'c4-ddd-governance-custom' })
// Reloading keeps the custom metamodel (a built-in id would be swapped for the preset).
expect(createModelFacade(data).getMetamodel!()!.nodeTypes.risk).toMatchObject({ label: 'Risk', tableTab: true })
expect(data.nodes.find((n) => n.label === 'Data loss')).toMatchObject({ type: 'risk', severity: 'high' })

const inUse = await client.callTool({ name: 'delete_node_type', arguments: { id: 'risk' } })
expect(inUse.isError).toBe(true)
const builtin = await client.callTool({ name: 'delete_relation_type', arguments: { id: 'interacts' } })
expect(builtin.isError).toBe(true)
} finally {
await client.close()
}
}, 60_000)
})
70 changes: 70 additions & 0 deletions apps/mcp/src/folderModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ describe('folder-backed MCP model', () => {
const tools = await client.listTools()
expect(tools.tools.map((tool) => tool.name)).toContain('add_node')
expect(tools.tools.map((tool) => tool.name)).not.toContain('reset_diagram')
expect(client.getInstructions()).toContain('get_model_summary')
const summary = JSON.stringify((await client.callTool({ name: 'get_model_summary', arguments: {} })).content)
expect(summary).toContain('ears_type')
expect(summary).toContain('allowedPairs')
const result = await client.callTool({ name: 'add_node', arguments: { tempId: 'new-system', type: 'system', label: 'New System' } })
expect(result.isError).not.toBe(true)
const message = JSON.stringify(result.content)
Expand Down Expand Up @@ -67,11 +71,77 @@ describe('folder-backed MCP model', () => {
expect(update.isError, JSON.stringify(update.content)).not.toBe(true)
const updated = deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data
expect(updated.nodes.find((node) => node.id === id)?.label).toBe('Renamed System')

const domain = await client.callTool({ name: 'add_node', arguments: { tempId: 'shop', type: 'domain', label: 'Shop' } })
expect(domain.isError, JSON.stringify(domain.content)).not.toBe(true)
const moved = await client.callTool({ name: 'move_node', arguments: { id: 'new-system', parentId: 'shop' } })
expect(moved.isError, JSON.stringify(moved.content)).not.toBe(true)
const view = await client.callTool({ name: 'create_view', arguments: { tempId: 'ctx', name: 'Context', nodeIds: ['actor', 'new-system'] } })
expect(view.isError, JSON.stringify(view.content)).not.toBe(true)
expect(JSON.stringify(view.content)).toContain('views.json')
const final = deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data
const domainId = final.nodes.find((node) => node.label === 'Shop')?.id
expect(final.nodes.find((node) => node.id === id)?.parentId).toBe(domainId)
expect(final.views).toEqual([expect.objectContaining({ name: 'Context', nodeIds: expect.arrayContaining([id]) })])
} finally {
await client.close()
}
})

it('places children inside their parent, keeps defaultPositions in step and runs Smart Layout', async () => {
const folder = await fixture()
const files = await new MdFolderSession(diskFolderStorage(folder)).readAll()
// Studio writes defaultPositions; give the fixture an (empty) map like a saved model.
const layout = JSON.parse(files['_layout.json'] ?? '{}')
await writeFile(join(folder, '_layout.json'), JSON.stringify({ ...layout, defaultPositions: {} }))
const client = new Client({ name: 'radical-test', version: '1.0.0' })
const transport = new StdioClientTransport({
command: process.execPath,
args: [join(import.meta.dirname, '../dist/index.js'), '--folder', folder],
})
const read = async () => deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data
try {
await client.connect(transport)
for (const args of [
{ tempId: 'shop', type: 'domain', label: 'Shop' },
{ tempId: 'orders', type: 'system', label: 'Orders', parentId: 'shop' },
{ tempId: 'billing', type: 'system', label: 'Billing', parentId: 'shop' },
{ tempId: 'crm', type: 'system', label: 'CRM' },
]) {
const added = await client.callTool({ name: 'add_node', arguments: args })
expect(added.isError, JSON.stringify(added.content)).not.toBe(true)
}
let data = await read()
const byLabel = (label: string) => data.nodes.find((node) => node.label === label)!
const [shop, orders, billing] = [byLabel('Shop'), byLabel('Orders'), byLabel('Billing')]
expect(orders).toMatchObject({ x: 30, y: 120 })
expect(billing.x).toBeGreaterThan(orders.x + orders.width)
// The parent grew to wrap both children.
expect(shop.width).toBeGreaterThanOrEqual(billing.x + billing.width + 30)
expect(shop.height).toBeGreaterThanOrEqual(orders.y + orders.height + 30)

const moved = await client.callTool({ name: 'move_node', arguments: { id: 'crm', parentId: 'shop' } })
expect(moved.isError, JSON.stringify(moved.content)).not.toBe(true)
data = await read()
const crm = byLabel('CRM')
expect(crm.parentId).toBe(shop.id)
expect(data.defaultPositions?.[crm.id]).toEqual({ x: crm.x, y: crm.y, width: crm.width, height: crm.height })

const user = await client.callTool({ name: 'add_node', arguments: { tempId: 'user', type: 'person', label: 'Buyer' } })
expect(user.isError).not.toBe(true)
await client.callTool({ name: 'add_relation', arguments: { sourceId: 'user', targetId: 'orders', relationType: 'interacts' } })
const laidOut = await client.callTool({ name: 'smart_layout', arguments: {} })
expect(laidOut.isError, JSON.stringify(laidOut.content)).not.toBe(true)
expect(JSON.stringify(laidOut.content)).toMatch(/Smart Layout/)
data = await read()
for (const node of data.nodes) {
expect(data.defaultPositions?.[node.id]).toEqual({ x: node.x, y: node.y, width: node.width, height: node.height })
}
} finally {
await client.close()
}
}, 60_000)

it('rejects a changed folder before writing', async () => {
const folder = await fixture()
const session = new MdFolderSession(diskFolderStorage(folder))
Expand Down
Loading
Loading