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
6 changes: 3 additions & 3 deletions apps/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ with [Playwright](https://playwright.dev). It drives the production bundles
From the repo root:

```bash
npm run e2e # build Studio + Hub, run the suite
npm run e2e -w @radical/e2e # run it against the existing builds
npm run e2e # build Studio + Hub + MCP, run the suite
npm run e2e -w @radical/e2e # build MCP, run against the existing web builds
npm run e2e -w @radical/e2e -- -g "undo" # one test by name
npm run e2e:ui -w @radical/e2e # Playwright UI mode
npm run e2e:report -w @radical/e2e # open the last HTML report
Expand All @@ -28,7 +28,7 @@ The first time, install Chromium: `npx playwright install chromium`.
| `studio/layout` | Smart Layout: no overlapping siblings, children inside their parent, same result on every run |
| `studio/presentation` | Playing slides, keyboard navigation, slide deep links |
| `studio/export` | PNG and SVG export produce real files |
| `studio/folders` | Models stored as a folder of Markdown files: save as folder, reload, renames that move files, edits made outside Studio, files Studio must not touch |
| `studio/folders` | Models stored as a folder of Markdown files: save as folder, reload, renames that move files, outside edits (including MCP), files Studio must not touch |
| `studio/known-issues` | Open bugs, marked `test.fail` (see below) |
| `hub/hub` | Landing page, categories, search, concept canvas / wiki / table, `.radical` download |

Expand Down
5 changes: 5 additions & 0 deletions apps/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
"description": "End-to-end regression suite (Playwright) for the web builds of Studio and Hub.",
"type": "module",
"scripts": {
"pree2e": "npm run build -w @radical/mcp",
"e2e": "playwright test",
"pree2e:ui": "npm run build -w @radical/mcp",
"e2e:ui": "playwright test --ui",
"e2e:report": "playwright show-report",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@modelcontextprotocol/client": "2.2.0",
"@radical/common": "*",
"@radical/hub": "*",
"@radical/node-files": "*",
"@types/node": "^20.19.39",
"radical-model": "*",
"typescript": "^5.3.3"
Expand Down
2 changes: 1 addition & 1 deletion apps/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* The suite drives the production bundles (`vite preview` of out/), not the
* dev servers, so it checks what is deployed. Build them first:
*
* npm run e2e (root: builds Studio + Hub, then runs this suite)
* npm run e2e (root: builds Studio + Hub; E2E script builds MCP)
*
* Screenshot baselines are Linux-only: they are generated and compared in CI,
* inside the Playwright Docker image, where fonts and rasterisation are fixed.
Expand Down
26 changes: 22 additions & 4 deletions apps/e2e/support/folder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,19 @@ export class ModelFolder {
return this.page.evaluate(async (name) => {
const out: Record<string, string> = {}
const walk = async (dir: FileSystemDirectoryHandle, prefix: string): Promise<void> => {
for await (const [entryName, entry] of (dir as unknown as AsyncIterable<[string, FileSystemHandle]>)) {
const rel = prefix ? `${prefix}/${entryName}` : entryName
if (entry.kind === 'directory') await walk(entry as FileSystemDirectoryHandle, rel)
else out[rel] = await (await (entry as FileSystemFileHandle).getFile()).text()
try {
for await (const [entryName, entry] of (dir as unknown as AsyncIterable<[string, FileSystemHandle]>)) {
const rel = prefix ? `${prefix}/${entryName}` : entryName
try {
if (entry.kind === 'directory') await walk(entry as FileSystemDirectoryHandle, rel)
else out[rel] = await (await (entry as FileSystemFileHandle).getFile()).text()
} catch (error) {
// Folder saves can rename an entry between iteration and read.
if ((error as DOMException).name !== 'NotFoundError') throw error
}
}
} catch (error) {
if ((error as DOMException).name !== 'NotFoundError') throw error
}
}
const root = await (await navigator.storage.getDirectory()).getDirectoryHandle(name, { create: true })
Expand All @@ -78,4 +87,13 @@ export class ModelFolder {
await writable.close()
}, [this.name, path, content] as const)
}

async remove(path: string): Promise<void> {
await this.page.evaluate(async ([name, rel]) => {
let dir = await (await navigator.storage.getDirectory()).getDirectoryHandle(name)
const parts = rel.split('/')
for (const part of parts.slice(0, -1)) dir = await dir.getDirectoryHandle(part)
await dir.removeEntry(parts[parts.length - 1])
}, [this.name, path] as const)
}
}
52 changes: 48 additions & 4 deletions apps/e2e/tests/studio/folders.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { expect } from '../../support/fixtures'
import { ModelFolder, test } from '../../support/folder'
import type { Page } from '@playwright/test'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/client'
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
import { MdFolderSession } from '@radical/common/formats/mdFolderSync'
import { diskFolderStorage } from '@radical/node-files/diskFolderStorage'

// Models persisted as a folder of Markdown files (web build: File System
// Access API over an OPFS directory, see support/folder.ts). Systems and
Expand Down Expand Up @@ -109,15 +117,51 @@ test('an edit made outside the app shows up on the canvas', async ({ page, studi

// The folder is polled every couple of seconds.
await expect(studio.node('customer')).toContainText('Book Lover', { timeout: 10_000 })
// The app never wrote its own version back over the edit; its next save
// just files the element under its new name.
await expect.poll(() => folder.paths()).toContain('nodes/book-lover.md')
// Reloading an outside edit must not immediately queue an autosave.
await page.waitForTimeout(3000)
const files = await folder.files()
expect(files['nodes/book-lover.md']).toContain('label: "Book Lover"')
expect(files['nodes/customer.md']).toContain('label: "Book Lover"')
expect(Object.values(files).some((c) => c.includes('label: "Customer"'))).toBe(false)
})

test('an MCP model edit appears in the open browser canvas', async ({ page, studio }) => {
await saveAsFolder(page)
const disk = await mkdtemp(join(tmpdir(), 'radical-mcp-browser-'))
try {
// The native directory picker cannot be driven by Playwright. Mirror the
// real model bytes from its OPFS handle into a temporary OS directory,
// mutate them through the MCP model service, then deliver those bytes to
// the handle that Studio polls.
const before = await folder.files()
for (const [path, content] of Object.entries(before)) {
await mkdir(dirname(join(disk, path)), { recursive: true })
await writeFile(join(disk, path), content)
}
const client = new Client({ name: 'radical-browser-test', version: '1.0.0' })
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL('../../../mcp/dist/index.js', import.meta.url)), '--folder', disk],
})
try {
await client.connect(transport)
const change = await client.callTool({ name: 'update_node', arguments: { id: 'customer', label: 'MCP Customer' } })
expect(change.isError, JSON.stringify(change.content)).not.toBe(true)
} finally {
await client.close()
}
const after = await new MdFolderSession(diskFolderStorage(disk)).readAll()
for (const [path, content] of Object.entries(after)) {
if (content !== before[path]) await folder.write(path, content)
}
for (const path of Object.keys(before)) if (!(path in after)) await folder.remove(path)
await expect(studio.node('customer')).toContainText('MCP Customer', { timeout: 10_000 })
await page.waitForTimeout(3000)
expect((await folder.files())['nodes/mcp-customer.md']).toContain('label: "MCP Customer"')
} finally {
await rm(disk, { recursive: true, force: true })
}
})

test('files the model does not own are left alone', async ({ page, studio }) => {
await folder.write('package.json', '{"name":"shop"}')
await folder.write('nodes/README.md', '# Notes\n')
Expand Down
40 changes: 29 additions & 11 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
# @radical/mcp
# Radical folder MCP server

MCP server that lets AI agents read and edit `.radical` models. Empty for now.
This local stdio MCP server lets Codex read and edit one Radical **Markdown model folder**. Point it at a folder containing `radical.md`, `nodes/**/*.md` and the JSON sidecars. Studio's browser folder watcher reloads outside edits on its normal poll, so changes appear on the open canvas without a separate browser connection.

Planned shape:
## Build and connect

- Serve the AI tool catalogue from `@radical/common/ai/tools` as MCP tools, so
Studio's AI chat and MCP clients share one set of tools.
- Back those tools with `createModelFacade` from `@radical/common/ai/modelFacade`,
a headless `DiagramFacade` over a plain `DiagramData` loaded from a `.radical`
file. It applies the same rules as Studio's store; apps/studio's
`modelFacadeParity` test keeps the two in step.
- Use `@radical/layout` to place new elements, so models written through MCP
open in Studio already laid out.
Use Node.js 20 or newer. From the repository root:

```sh
npm install
npm run build -w @radical/mcp
node apps/mcp/dist/index.js --folder /absolute/path/to/my-model
```

The last command waits for MCP messages on stdin; it prints no banner to stdout. To connect Codex, run the following with absolute paths (or add the equivalent `command` and `args` in [Codex's MCP configuration](https://learn.chatgpt.com/docs/extend/mcp?surface=cli)):

```sh
codex mcp add radical-folder -- node /absolute/path/to/radical.tools/apps/mcp/dist/index.js --folder /absolute/path/to/my-model
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.

## 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.

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.

Run `npm run test -w @radical/mcp` to build and exercise a real stdio connection and temporary disk folder.
16 changes: 12 additions & 4 deletions apps/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@
"name": "@radical/mcp",
"version": "0.0.0",
"private": true,
"description": "MCP server for radical models (not implemented yet).",
"description": "Local MCP server for Radical Markdown model folders.",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"build": "tsup",
"start": "node dist/index.js",
"test": "npm run build && vitest run"
},
"dependencies": {
"@modelcontextprotocol/server": "2.2.0",
"@radical/common": "*",
"@radical/layout": "*"
"@radical/node-files": "*"
},
"devDependencies": {
"typescript": "^5.3.3"
"@modelcontextprotocol/client": "2.2.0",
"@types/node": "^22.20.4",
"tsup": "^8.5.1",
"typescript": "^5.3.3",
"vitest": "^4.1.11"
}
}
97 changes: 97 additions & 0 deletions apps/mcp/src/folderModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, 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 { diskFolderStorage } from '@radical/node-files/diskFolderStorage'
import { FolderModel } from './folderModel'

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

async function fixture(): Promise<string> {
const folder = await mkdtemp(join(tmpdir(), 'radical-mcp-'))
folders.push(folder)
const files = serializeToMdFolder({ nodes: [], relations: [], metamodel: builtInGovernanceMetamodel() }, 'MCP test')
for (const [path, content] of Object.entries(files)) {
await mkdir(dirname(join(folder, path)), { recursive: true })
await writeFile(join(folder, path), content)
}
await writeFile(join(folder, 'notes.txt'), 'Keep me')
await mkdir(join(folder, 'nodes'), { recursive: true })
await writeFile(join(folder, 'nodes/README.md'), '# Keep these notes\n')
return folder
}

describe('folder-backed MCP model', () => {
it('lists and calls tools over stdio, persisting a real node ID', async () => {
const folder = await fixture()
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],
})
try {
await client.connect(transport)
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')
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)
expect(message).not.toContain('nodes/README.md')
const id = /Created node ([^ .]+)\./.exec(message)?.[1]
expect(id).toBeTruthy()
const files = await new MdFolderSession(diskFolderStorage(folder)).readAll()
const saved = deserializeFromMdFolder(files).data
expect(saved.nodes).toEqual(expect.arrayContaining([expect.objectContaining({ id, label: 'New System' })]))
expect(await readFile(join(folder, 'notes.txt'), 'utf8')).toBe('Keep me')
expect(await readFile(join(folder, 'nodes/README.md'), 'utf8')).toBe('# Keep these notes\n')

const actor = await client.callTool({ name: 'add_node', arguments: { tempId: 'actor', type: 'person', label: 'User' } })
expect(actor.isError).not.toBe(true)
const relation = await client.callTool({
name: 'add_relation',
arguments: { sourceId: 'actor', targetId: 'new-system', relationType: 'interacts' },
})
expect(relation.isError, JSON.stringify(relation.content)).not.toBe(true)
expect(JSON.stringify(relation.content)).toMatch(/Relation ID: [^ .]+\./)
const related = deserializeFromMdFolder(await new MdFolderSession(diskFolderStorage(folder)).readAll()).data
expect(related.relations).toEqual(expect.arrayContaining([expect.objectContaining({ targetId: id })]))

const update = await client.callTool({ name: 'update_node', arguments: { id: 'new-system', label: 'Renamed System' } })
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')
} finally {
await client.close()
}
})

it('rejects a changed folder before writing', async () => {
const folder = await fixture()
const session = new MdFolderSession(diskFolderStorage(folder))
const files = await session.readAll()
await writeFile(join(folder, 'radical.md'), files['radical.md'] + '\nOutside edit')
const result = await session.write({ ...files, 'radical.md': files['radical.md'] + '\nMCP edit' })
expect(result).toEqual({ ok: false, conflict: ['radical.md'] })
expect(await readFile(join(folder, 'radical.md'), 'utf8')).toContain('Outside edit')
})

it('rejects malformed sidecars and symlink escapes', async () => {
const folder = await fixture()
await writeFile(join(folder, 'relations.json'), '{bad json')
await expect(FolderModel.open(folder)).rejects.toThrow('Malformed JSON in relations.json')
await rm(join(folder, 'relations.json'))
const outside = await mkdtemp(join(tmpdir(), 'radical-outside-'))
folders.push(outside)
const { symlink } = await import('node:fs/promises')
await rm(join(folder, 'nodes'), { recursive: true })
await symlink(outside, join(folder, 'nodes'))
await expect(FolderModel.open(folder)).rejects.toThrow('symlink in model path')
})
})
Loading
Loading