Add Codex and Grok Build integrations (#575)

* Add Codex and Grok Build integrations

* Document Python FUSE integrations
This commit is contained in:
Zecheng Zhang
2026-07-19 05:37:52 -07:00
committed by GitHub
parent ccc1cb2b84
commit aa6c2f3b79
34 changed files with 1085 additions and 245 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "mirage",
"interface": {
"displayName": "Mirage"
},
"plugins": [
{
"name": "mirage",
"source": {
"source": "local",
"path": "./plugins/mirage"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
+2 -2
View File
@@ -60,7 +60,7 @@ await ws.execute('cat /s3/events/2026-05-06.parquet | jq .user')
- **Around 50 built-in backends:** RAM, Disk, Redis, S3 / R2 / OCI / Supabase / GCS, Gmail / GDrive / GDocs / GSheets / GSlides, GitHub / Linear / Notion / Trello, Slack / Discord / Email, MongoDB / GridFS / Postgres / LanceDB / Qdrant, SSH, and more, mounted side-by-side under a single root.
- **Portable workspaces:** clone, snapshot, and version a workspace; agent runs move between machines without restarting or reconfiguring the system.
- **Embeddable:** the Python and TypeScript SDKs run in-process inside FastAPI, Express, browser apps, or any async runtime; no separate process required.
- **Agent integrations:** OpenAI Agents SDK, Vercel AI SDK, LangChain, Pydantic AI, CAMEL, and OpenHands via the SDKs; coding agents like Claude Code and Codex via the lightweight CLI + daemon.
- **Agent integrations:** OpenAI Agents SDK, Vercel AI SDK, LangChain, Pydantic AI, CAMEL, and OpenHands via the SDKs; coding agents through native adapters, installable plugins, MCP, or FUSE.
## Architecture
@@ -159,7 +159,7 @@ Mirage plugs into agent frameworks as a sandbox or tool layer. POSIX operations
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Python | [OpenAI Agents SDK](https://docs.mirage.strukto.ai/python/agents/openai-agents), [LangChain](https://docs.mirage.strukto.ai/python/agents/langchain), [Pydantic AI](https://docs.mirage.strukto.ai/python/agents/pydantic-ai), [CAMEL](https://docs.mirage.strukto.ai/python/agents/camel), [OpenHands](https://docs.mirage.strukto.ai/python/agents/openhands), [Agno](https://docs.mirage.strukto.ai/python/agents/agno) |
| TypeScript | [Vercel AI SDK](https://docs.mirage.strukto.ai/typescript/agents/vercel), [OpenAI Agents SDK](https://docs.mirage.strukto.ai/typescript/agents/openai), [LangChain](https://docs.mirage.strukto.ai/typescript/agents/langchain), [Mastra](https://docs.mirage.strukto.ai/typescript/agents/mastra) |
| Coding agents | [Claude Code](https://docs.mirage.strukto.ai/python/agents/claude-code), [Codex](https://docs.mirage.strukto.ai/python/agents/codex), [OpenCode](https://docs.mirage.strukto.ai/typescript/agents/opencode), [Pi](https://docs.mirage.strukto.ai/typescript/agents/pi) |
| Coding agents | [Claude Code](https://docs.mirage.strukto.ai/python/agents/claude-code), [Codex](https://docs.mirage.strukto.ai/typescript/agents/codex), [Grok Build](https://docs.mirage.strukto.ai/typescript/agents/grok-build), [OpenCode](https://docs.mirage.strukto.ai/typescript/agents/opencode), [Pi](https://docs.mirage.strukto.ai/typescript/agents/pi) |
## Cache
+4 -2
View File
@@ -234,7 +234,8 @@
"python/agents/agno",
"python/agents/claude-code",
"python/agents/claude-agent-sdk",
"python/agents/codex"
"python/agents/codex",
"python/agents/grok-build"
]
},
{
@@ -404,7 +405,8 @@
"typescript/agents/opencode",
"typescript/agents/claude-code",
"typescript/agents/claude-agent-sdk",
"typescript/agents/codex"
"typescript/agents/codex",
"typescript/agents/grok-build"
]
},
{
+2 -1
View File
@@ -135,7 +135,8 @@ Mirage drops into the major agent application frameworks. Each adapter exposes a
| CAMEL-AI | [<Icon icon="python" />](/python/agents/camel) | For CAMEL `ChatAgent`. |
| OpenHands | [<Icon icon="python" />](/python/agents/openhands) | For the OpenHands agent SDK. |
| Claude Code (CLI) | [<Icon icon="python" />](/python/agents/claude-code) [<Icon icon="node-js" />](/typescript/agents/claude-code) | Mount via FUSE; run `claude` against the mountpoint. |
| Codex (CLI) | [<Icon icon="python" />](/python/agents/codex) [<Icon icon="node-js" />](/typescript/agents/codex) | Mount via FUSE; run `codex` against the mountpoint. |
| Codex (CLI and app) | [<Icon icon="python" />](/python/agents/codex) [<Icon icon="node-js" />](/typescript/agents/codex) | Python uses FUSE; TypeScript provides the plugin with Mirage tools and stale-write protection, plus FUSE. |
| Grok Build | [<Icon icon="python" />](/python/agents/grok-build) [<Icon icon="node-js" />](/typescript/agents/grok-build) | Python uses FUSE; TypeScript provides the Grok plugin with Mirage tools, plus FUSE. |
## Recommended Starting Points
+5 -5
View File
@@ -4,7 +4,7 @@ description: Run OpenAI's Codex CLI against any Mirage workspace by mounting it
icon: /images/openai-logo.svg
---
[OpenAI Codex](https://github.com/openai/codex) is OpenAI's coding-agent CLI. Like [Claude Code](/python/agents/claude-code), it operates on a real filesystem and doesn't expose a pluggable backend, so Mirage integrates by [FUSE-mounting](/python/setup/fuse) a workspace and letting you point `codex` at the mountpoint.
[OpenAI Codex](https://github.com/openai/codex) can use Mirage through either an installable [TypeScript plugin](/typescript/agents/codex) or a FUSE mount. The Python integration [FUSE-mounts](/python/setup/fuse) a workspace as a normal host directory, so Codex's built-in filesystem and shell tools operate on Mirage without a separate tool server.
## Install
@@ -26,10 +26,10 @@ with Workspace(
input("Press Enter when done...")
```
The mountpoint behaves like a regular directory. Codex's `read_file`, `write_file`, and `run_shell` tools all dispatch through Mirage's ops layer.
The mountpoint behaves like a regular directory. Codex's built-in file and shell tools all dispatch through Mirage's ops layer. For the Codex app, open the mountpoint as the project folder instead of starting `codex` from it.
## Why FUSE instead of an SDK integration?
## FUSE or plugin?
The Codex CLI's tool surface isn't pluggable, it expects host file operations. FUSE makes those host operations *be* Mirage operations.
Use Python FUSE when you want Codex's built-in filesystem tools to see Mirage as an ordinary directory. This works in the CLI and app, but requires an OS FUSE driver and does not add Mirage's agent-level stale-write check.
You lose per-tool customization and Mirage's op-record telemetry; you gain zero integration effort and compatibility with every Codex feature. Same trade-off as [Claude Code](/python/agents/claude-code).
Use the [TypeScript plugin](/typescript/agents/codex) when you want named Mirage tools, Pi-style stale-write protection, and plugin installation through Codex. The plugin uses Mirage's standard MCP adapter internally.
+35
View File
@@ -0,0 +1,35 @@
---
title: Grok Build
description: Run Grok Build against any Python Mirage workspace by mounting it as a real filesystem via FUSE.
icon: terminal
---
[Grok Build](https://x.ai/cli) can use Mirage through either an installable [TypeScript plugin](/typescript/agents/grok-build) or a FUSE mount. The Python integration [FUSE-mounts](/python/setup/fuse) a workspace as a normal host directory, so Grok's built-in filesystem and shell tools operate on Mirage without a separate tool server.
## Install
```bash
uv add 'mirage-ai[fuse]'
```
Install Grok Build separately.
## Usage
```python
from mirage import Mount, MountMode, Workspace
from mirage.resource.ram import RAMResource
with Workspace(
{"/": Mount(RAMResource(), mode=MountMode.WRITE, fuse=True)}) as ws:
print(f"cd {ws.fuse_mountpoint} && grok")
input("Press Enter when done...")
```
The mountpoint behaves like a regular directory. Start `grok` there and its built-in file and shell tools dispatch through Mirage's ops layer.
## FUSE or plugin?
Use Python FUSE when you want Grok's built-in tools to see Mirage as an ordinary directory. This requires an OS FUSE driver and does not add Mirage's agent-level stale-write check.
Use the [TypeScript plugin](/typescript/agents/grok-build) when you want named Mirage tools, Pi-style stale-write protection, and Grok plugin installation. The plugin uses Mirage's standard MCP adapter internally.
+3
View File
@@ -44,4 +44,7 @@ uv add 'mirage-ai[claude-agent-sdk]' # Claude Agent SDK
<Card title="Codex" icon="/images/openai-logo.svg" href="/python/agents/codex">
Same pattern as Claude Code: mount, then `codex`.
</Card>
<Card title="Grok Build" icon="terminal" href="/python/agents/grok-build">
Mount a workspace via FUSE and run `grok` against it.
</Card>
</CardGroup>
+69 -20
View File
@@ -1,38 +1,87 @@
---
title: OpenAI Codex
description: Run OpenAI's Codex CLI against any Mirage workspace by mounting it as a real filesystem via FUSE.
description: Install Mirage as a Codex plugin and use mounted data from the Codex CLI or app.
icon: /images/openai-logo.svg
---
[OpenAI Codex](https://github.com/openai/codex) is OpenAI's coding-agent CLI. Like [Claude Code](/typescript/agents/claude-code), it operates on a real filesystem and doesn't expose a pluggable backend, so Mirage integrates by [FUSE-mounting](/typescript/setup/fuse) a workspace and letting you point `codex` at the mountpoint.
[OpenAI Codex](https://github.com/openai/codex) can load Mirage as a plugin. The plugin packages a Mirage guidance skill and six filesystem tools: `read`, `write`, `edit`, `ls`, `grep`, and `execute_command`.
## Install
<Note>
Codex can work on repositories in any programming language. The Mirage tool
server itself requires Node.js 20 or newer.
</Note>
## Install the plugin
Add the Mirage repository as a Codex marketplace, then install the plugin:
```bash
pnpm add @struktoai/mirage-node @zkochan/fuse-native
codex plugin marketplace add strukto-ai/mirage
codex plugin add mirage@mirage
```
See [TypeScript FUSE setup](/typescript/setup/fuse) for the pnpm `onlyBuiltDependencies` allow-list and macOS macFUSE 4 symlink workaround. Then install [OpenAI Codex](https://github.com/openai/codex) separately.
For local development, replace `strukto-ai/mirage` with the path to a Mirage checkout.
## Usage
Start a new Codex task after installation so the plugin tools and skill are loaded.
## Configure a workspace
Add `.mirage/workspace.yaml` to the project where you start Codex:
```yaml
mounts:
/scratch:
resource: ram
/data:
resource: disk
config:
root: ${PWD}/data
```
Mirage searches the current directory and its parents for:
- `.mirage/workspace.yaml` or `.mirage/workspace.yml`
- `workspace.yaml` or `workspace.yml`
- `mirage.yaml` or `mirage.yml`
Set `MIRAGE_MCP_CONFIG` to an absolute path when the config lives elsewhere.
## Use the Codex UI
Open **Plugins** in the Codex app, or run `/plugins` in the Codex CLI, and confirm that Mirage is enabled. Then use the normal Codex conversation UI:
```text
List the files mounted in Mirage.
Read /data/report.csv and summarize it.
Create /scratch/notes.txt with the findings.
```
The plugin keeps Codex's existing UI, sessions, model selection, approvals, and authentication. Mirage only supplies the filesystem tools.
## Stale-write protection
The tool server records a content fingerprint when `read` returns a file. If that file changes before a later `edit`, the edit fails and tells Codex to read the file again. After the reread, Codex can reconsider the new content and retry.
This follows the [Pi integration](/typescript/agents/pi). The current fallback hashes the returned bytes; a future Mirage revision API can replace that without changing the Codex plugin.
## Run the server directly
The plugin launches this command automatically:
```bash
npx -y @struktoai/mirage-cli mcp
```
You can also embed the transport in a Node application:
```ts
import { Mount, MountMode, RAMResource, Workspace } from '@struktoai/mirage-node'
import { createMirageMcpServer } from '@struktoai/mirage-agents/mcp'
const ws = new Workspace({
'/': new Mount(new RAMResource(), { mode: MountMode.WRITE, fuse: true }),
})
await ws.fuseReady()
console.log(`cd ${ws.fuseMountpoint} && codex`)
// ... run codex in another terminal, then:
await ws.close()
const server = createMirageMcpServer(workspace)
```
The mountpoint behaves like a regular directory. Codex's `read_file`, `write_file`, and `run_shell` tools all dispatch through Mirage's ops layer.
## Why MCP instead of a Pi-style extension?
## Why FUSE instead of an SDK integration?
Pi exposes an in-process TypeScript API for registering tools. Codex plugins can package skills and tool integrations, but Codex does not expose an equivalent `registerTool` API. MCP is therefore the supported custom-tool transport, kept behind a thin adapter over Mirage's shared tool operations.
The Codex CLI's tool surface isn't pluggable, it expects host file operations. FUSE makes those host operations *be* Mirage operations.
You lose per-tool customization and Mirage's op-record telemetry; you gain zero integration effort and compatibility with every Codex feature. Same trade-off as [Claude Code](/typescript/agents/claude-code).
If you want Codex's built-in file tools to see Mirage paths directly, use a [FUSE mount](/typescript/setup/fuse) instead. FUSE has no custom tool namespace, but requires host FUSE setup and does not provide Mirage's agent-level stale-write check.
+74
View File
@@ -0,0 +1,74 @@
---
title: Grok Build
description: Install Mirage as a Grok Build plugin and use mounted data from its native TUI.
icon: terminal
---
[Grok Build](https://x.ai/cli) is xAI's coding-agent CLI; its executable is named `grok`. Mirage ships a Grok-compatible plugin with a guidance skill and six filesystem tools: `read`, `write`, `edit`, `ls`, `grep`, and `execute_command`.
<Note>
Grok Build can work on codebases in any programming language. The Mirage tool
server itself requires Node.js 20 or newer.
</Note>
## Install the plugin
Install from GitHub:
```bash
grok plugin install strukto-ai/mirage
```
For a local Mirage checkout:
```bash
grok plugin install /path/to/mirage/plugins/mirage
```
During plugin development, you can load the bundle without installing it:
```bash
grok --plugin-dir /path/to/mirage/plugins/mirage
```
## Configure a workspace
Add `.mirage/workspace.yaml` to the project where you start Grok:
```yaml
mounts:
/scratch:
resource: ram
/data:
resource: disk
config:
root: ${PWD}/data
```
Mirage searches the current directory and its parents for the workspace config. Set `MIRAGE_MCP_CONFIG` to an absolute config path when it lives elsewhere.
## Use the Grok UI
Run `grok`, then open `/plugins` to confirm Mirage is enabled. The `/mcps` tab shows its Mirage server and tools.
Use the normal Grok TUI:
```text
List the files mounted in Mirage.
Search /data for failed jobs.
Read and safely edit /data/config.json.
```
The plugin keeps Grok's TUI, sessions, models, approvals, and authentication. Mirage supplies only the virtual-filesystem tools.
## Stale-write protection
A Mirage `read` records a content fingerprint. If the file changes before `edit`, the edit fails and the plugin instructs Grok to reread before retrying. This is the same behavior as the [Pi integration](/typescript/agents/pi).
## Extension surfaces
Grok plugins may include skills, agents, hooks, MCP servers, and LSP servers. Hooks can observe or deny lifecycle events but cannot replace a tool call with a Mirage filesystem operation. MCP is therefore the stable public custom-tool surface for this integration.
The Grok source tree also contains an xAI-native Rust tool-server protocol, but it is not documented as a public plugin component. Mirage keeps its shared operations transport-independent so a stable native adapter can be added later without rewriting filesystem behavior.
Use a [FUSE mount](/typescript/setup/fuse) when you specifically want Grok's built-in file tools to operate on Mirage through a regular host path.
+4 -1
View File
@@ -38,6 +38,9 @@ For browser apps, swap `@struktoai/mirage-node` for `@struktoai/mirage-browser`.
For [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) via an in-process MCP server.
</Card>
<Card title="Codex" icon="/images/openai-logo.svg" href="/typescript/agents/codex">
Same pattern as Claude Code: mount, then `codex`.
Install the Mirage plugin and use its tools from the Codex CLI or app.
</Card>
<Card title="Grok Build" icon="terminal" href="/typescript/agents/grok-build">
Install the Mirage plugin and use its tools from Grok's native TUI.
</Card>
</CardGroup>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "mirage",
"version": "0.1.0",
"description": "Use Mirage-mounted cloud, database, and local data as a virtual filesystem.",
"author": {
"name": "Strukto.AI",
"url": "https://github.com/strukto-ai"
},
"homepage": "https://docs.mirage.strukto.ai/typescript/agents/codex",
"repository": "https://github.com/strukto-ai/mirage",
"license": "Apache-2.0",
"keywords": ["mirage", "filesystem", "mcp", "coding-agent"],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Mirage",
"shortDescription": "Use mounted data as a virtual filesystem.",
"longDescription": "Connect coding agents to cloud storage, databases, and local data through Mirage filesystem tools.",
"developerName": "Strukto.AI",
"category": "Developer Tools",
"capabilities": ["Read", "Write", "Search", "Execute"],
"websiteURL": "https://docs.mirage.strukto.ai",
"defaultPrompt": [
"List the files mounted in Mirage.",
"Find files in Mirage that match this query.",
"Read and safely edit a file mounted in Mirage."
]
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mirage": {
"command": "npx",
"args": ["-y", "@struktoai/mirage-cli", "mcp"]
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "mirage",
"version": "0.1.0",
"description": "Use Mirage-mounted cloud, database, and local data as a virtual filesystem.",
"author": {
"name": "Strukto.AI",
"url": "https://github.com/strukto-ai"
},
"homepage": "https://docs.mirage.strukto.ai/typescript/agents/grok-build",
"repository": "https://github.com/strukto-ai/mirage",
"license": "Apache-2.0",
"keywords": ["mirage", "filesystem", "mcp", "coding-agent"],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Mirage",
"shortDescription": "Use mounted data as a virtual filesystem.",
"longDescription": "Connect coding agents to cloud storage, databases, and local data through Mirage filesystem tools.",
"developerName": "Strukto.AI",
"category": "Developer Tools",
"capabilities": ["Read", "Write", "Search", "Execute"],
"websiteURL": "https://docs.mirage.strukto.ai",
"defaultPrompt": [
"List the files mounted in Mirage.",
"Find files in Mirage that match this query.",
"Read and safely edit a file mounted in Mirage."
]
}
}
@@ -0,0 +1,18 @@
---
name: mirage-filesystem
description: Work with files and directories mounted in a Mirage virtual filesystem. Use when a task mentions Mirage, mounted cloud or database data, Mirage virtual paths, or asks to inspect, search, create, or edit data exposed through the Mirage tools.
---
# Mirage Filesystem
Use the Mirage tools for virtual paths. Host filesystem tools cannot access those paths unless the user separately configured a FUSE mount.
## Workflow
1. Use the Mirage `ls`, `grep`, or `execute_command` tool to discover mounted data.
2. Use the Mirage `read` tool before modifying an existing file.
3. Use `edit` for an existing file and `write` only for a new file.
4. If an edit reports that the file changed since it was read, read the file again, reconsider the edit against the new content, and retry.
5. Use `execute_command` for pipelines and structured-file commands that need Mirage shell semantics.
Do not fall back to a host filesystem tool when a Mirage tool fails on a virtual path. Report the Mirage error or fix the Mirage configuration.
@@ -0,0 +1,4 @@
interface:
display_name: "Mirage Filesystem"
short_description: "Work with Mirage-mounted data safely"
default_prompt: "Use $mirage-filesystem to inspect and edit mounted data."
@@ -0,0 +1,6 @@
---
'@struktoai/mirage-agents': minor
'@struktoai/mirage-cli': minor
---
Add the shared Mirage MCP tool server, coding-agent plugin bundles, and the `mirage mcp` command for Codex and Grok Build.
+2 -2
View File
@@ -60,7 +60,7 @@ await ws.execute('cat /s3/events/2026-05-06.parquet | jq .user')
- **Around 50 built-in backends:** RAM, Disk, Redis, S3 / R2 / OCI / Supabase / GCS, Gmail / GDrive / GDocs / GSheets / GSlides, GitHub / Linear / Notion / Trello, Slack / Discord / Email, MongoDB / Postgres / LanceDB, SSH, and more, mounted side-by-side under a single root.
- **Portable workspaces:** clone, snapshot, and version a workspace; agent runs move between machines without restarting or reconfiguring the system.
- **Embeddable:** the Python and TypeScript SDKs run in-process inside FastAPI, Express, browser apps, or any async runtime; no separate process required.
- **Agent integrations:** OpenAI Agents SDK, Vercel AI SDK, LangChain, Pydantic AI, CAMEL, and OpenHands via the SDKs; coding agents like Claude Code and Codex via the lightweight CLI + daemon.
- **Agent integrations:** OpenAI Agents SDK, Vercel AI SDK, LangChain, Pydantic AI, CAMEL, and OpenHands via the SDKs; coding agents through native adapters, installable plugins, MCP, or FUSE.
## Architecture
@@ -159,7 +159,7 @@ Mirage plugs into agent frameworks as a sandbox or tool layer. POSIX operations
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Python | [OpenAI Agents SDK](https://docs.mirage.strukto.ai/python/agents/openai-agents), [LangChain](https://docs.mirage.strukto.ai/python/agents/langchain), [Pydantic AI](https://docs.mirage.strukto.ai/python/agents/pydantic-ai), [CAMEL](https://docs.mirage.strukto.ai/python/agents/camel), [OpenHands](https://docs.mirage.strukto.ai/python/agents/openhands), [Agno](https://docs.mirage.strukto.ai/python/agents/agno) |
| TypeScript | [Vercel AI SDK](https://docs.mirage.strukto.ai/typescript/agents/vercel), [OpenAI Agents SDK](https://docs.mirage.strukto.ai/typescript/agents/openai), [LangChain](https://docs.mirage.strukto.ai/typescript/agents/langchain), [Mastra](https://docs.mirage.strukto.ai/typescript/agents/mastra) |
| Coding agents | [Claude Code](https://docs.mirage.strukto.ai/python/agents/claude-code), [Codex](https://docs.mirage.strukto.ai/python/agents/codex), [OpenCode](https://docs.mirage.strukto.ai/typescript/agents/opencode), [Pi](https://docs.mirage.strukto.ai/typescript/agents/pi) |
| Coding agents | [Claude Code](https://docs.mirage.strukto.ai/python/agents/claude-code), [Codex](https://docs.mirage.strukto.ai/typescript/agents/codex), [Grok Build](https://docs.mirage.strukto.ai/typescript/agents/grok-build), [OpenCode](https://docs.mirage.strukto.ai/typescript/agents/opencode), [Pi](https://docs.mirage.strukto.ai/typescript/agents/pi) |
## Cache
+9 -8
View File
@@ -50,6 +50,10 @@
"types": "./dist/pi/index.d.ts",
"import": "./dist/pi/index.js"
},
"./mcp": {
"types": "./dist/mcp/index.d.ts",
"import": "./dist/mcp/index.js"
},
"./vercel": {
"types": "./dist/vercel/index.d.ts",
"import": "./dist/vercel/index.js"
@@ -76,8 +80,10 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@struktoai/mirage-core": "workspace:*",
"picomatch": "^4.0.4"
"picomatch": "^4.0.4",
"zod": "^4.0.0"
},
"peerDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.215",
@@ -86,8 +92,7 @@
"@opencode-ai/plugin": "^1.18.3",
"@openai/agents": "^0.13.5",
"ai": "^7.0.31",
"deepagents": "^1.11.1",
"zod": "^4.0.0"
"deepagents": "^1.11.1"
},
"peerDependenciesMeta": {
"@anthropic-ai/claude-agent-sdk": {
@@ -110,9 +115,6 @@
},
"deepagents": {
"optional": true
},
"zod": {
"optional": true
}
},
"devDependencies": {
@@ -132,7 +134,6 @@
"langchain": "^1.5.0",
"tsup": "^8.5.0",
"typescript": "^6.0.0",
"vitest": "^3.2.6",
"zod": "^4.0.0"
"vitest": "^3.2.6"
}
}
@@ -12,30 +12,11 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
export const EXECUTE_DESCRIPTION =
'Run a shell-style command on the Mirage virtual filesystem. ' +
'Supports cat, grep, find, head, tail, ls, wc, sort, uniq, tee, pipe, ' +
'and any other Unix command on mounted resources (S3, disk, RAM, etc.). ' +
'Also supports reading structured files: cat on .parquet/.orc/.csv returns a table.'
export const READ_DESCRIPTION =
'Read the contents of a file on the Mirage virtual filesystem. ' +
'Returns line-numbered text. ' +
"Optionally pass 'offset' (default 0) to start at a given line " +
"and 'limit' (default 2000) to cap the number of lines returned."
export const WRITE_DESCRIPTION =
'Write content to a new file on the Mirage virtual filesystem. ' +
'Fails if the file already exists; use edit to modify an existing file.'
export const EDIT_DESCRIPTION =
'Replace a string in an existing file on the Mirage virtual filesystem. ' +
'Fails if old_string is not found or appears more than once. ' +
'Pass replace_all=true (default false) to replace every occurrence.'
export const LS_DESCRIPTION =
'List files and directories at the given path on the Mirage virtual filesystem.'
export const GREP_DESCRIPTION =
'Search for a pattern in files on the Mirage virtual filesystem. ' +
'Supports regex. Searches recursively under path.'
export {
EDIT_DESCRIPTION,
EXECUTE_DESCRIPTION,
GREP_DESCRIPTION,
LS_DESCRIPTION,
READ_DESCRIPTION,
WRITE_DESCRIPTION,
} from '../tool-descriptions.ts'
@@ -12,11 +12,15 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { ExecuteResult, Workspace } from '@struktoai/mirage-core'
import { VERSION, gnuDirname } from '@struktoai/mirage-core'
import type { Workspace } from '@struktoai/mirage-core'
import { VERSION } from '@struktoai/mirage-core'
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
import { z } from 'zod'
import { decode, ioToStr } from '../io-text.ts'
import {
MirageToolOperations,
type MirageToolOperationsOptions,
type ToolResult,
} from '../tool-operations.ts'
import {
EDIT_DESCRIPTION,
EXECUTE_DESCRIPTION,
@@ -26,44 +30,8 @@ import {
WRITE_DESCRIPTION,
} from './descriptions.ts'
export interface ToolResult {
[key: string]: unknown
content: { type: 'text'; text: string }[]
isError?: boolean
}
function textResult(text: string): ToolResult {
return { content: [{ type: 'text', text }] }
}
function errorResult(text: string): ToolResult {
return { content: [{ type: 'text', text }], isError: true }
}
function ioResult(io: ExecuteResult): ToolResult {
const result = textResult(ioToStr(io))
if (io.exitCode !== 0) result.isError = true
return result
}
function shQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
async function ensureParents(ws: Workspace, path: string): Promise<void> {
const parent = gnuDirname(path)
if (parent === '/' || parent === '' || parent === '.') return
if (await ws.fs.exists(parent)) return
await ensureParents(ws, parent)
try {
await ws.fs.mkdir(parent)
} catch (err) {
if (!(await ws.fs.exists(parent))) throw err
}
}
export async function runExecute(ws: Workspace, command: string): Promise<ToolResult> {
return ioResult(await ws.execute(command))
return new MirageToolOperations(ws).execute(command)
}
export async function runRead(
@@ -72,30 +40,11 @@ export async function runRead(
offset = 0,
limit = 2000,
): Promise<ToolResult> {
let data: Uint8Array
try {
data = await ws.fs.readFile(path)
} catch (err) {
if (!(await ws.fs.exists(path))) {
return errorResult(`Error: file '${path}' not found`)
}
return errorResult(`Error: ${err instanceof Error ? err.message : String(err)}`)
}
const text = decode(data)
const raw = text.length === 0 ? [] : text.split(/(?<=\n)/)
const lines = raw.length > 0 && raw[raw.length - 1] === '' ? raw.slice(0, -1) : raw
const sliced = lines.slice(offset, offset + limit)
const numbered = sliced.map((line, i) => `${String(i + offset + 1).padStart(6)}\t${line}`)
return textResult(numbered.join(''))
return new MirageToolOperations(ws).read(path, offset, limit)
}
export async function runWrite(ws: Workspace, path: string, content: string): Promise<ToolResult> {
if (await ws.fs.exists(path)) {
return errorResult(`Error: file '${path}' already exists`)
}
await ensureParents(ws, path)
await ws.fs.writeFile(path, content)
return textResult(`Written: ${path}`)
return new MirageToolOperations(ws).write(path, content)
}
export async function runEdit(
@@ -105,54 +54,36 @@ export async function runEdit(
newString: string,
replaceAll = false,
): Promise<ToolResult> {
let content: string
try {
content = await ws.fs.readFileText(path)
} catch {
return errorResult(`Error: file '${path}' not found`)
}
const count = content.split(oldString).length - 1
if (count === 0) {
return errorResult(`Error: string not found in file: '${oldString}'`)
}
if (count > 1 && !replaceAll) {
return errorResult(`Error: string appears ${String(count)} times. Pass replace_all=true`)
}
const newContent = replaceAll
? content.split(oldString).join(newString)
: content.replace(oldString, newString)
await ws.fs.writeFile(path, newContent)
const occurrences = replaceAll ? count : 1
return textResult(`Edited: ${path} (${String(occurrences)} occurrence(s))`)
return new MirageToolOperations(ws).edit(path, oldString, newString, replaceAll)
}
export async function runLs(ws: Workspace, path: string): Promise<ToolResult> {
return ioResult(await ws.execute(`ls ${shQuote(path)}`))
return new MirageToolOperations(ws).ls(path)
}
export async function runGrep(ws: Workspace, pattern: string, path: string): Promise<ToolResult> {
const io = await ws.execute(`grep -rn ${shQuote(pattern)} ${shQuote(path)}`)
return textResult(ioToStr(io))
return new MirageToolOperations(ws).grep(pattern, path)
}
export function MirageServer(workspace: Workspace) {
export function MirageServer(workspace: Workspace, options: MirageToolOperationsOptions = {}) {
const operations = new MirageToolOperations(workspace, options)
return createSdkMcpServer({
name: 'mirage',
version: VERSION,
alwaysLoad: true,
tools: [
tool('execute_command', EXECUTE_DESCRIPTION, { command: z.string() }, (args) =>
runExecute(workspace, args.command),
operations.execute(args.command),
),
tool(
'read',
READ_DESCRIPTION,
{ path: z.string(), offset: z.number().optional(), limit: z.number().optional() },
(args) => runRead(workspace, args.path, args.offset, args.limit),
(args) => operations.read(args.path, args.offset, args.limit),
{ annotations: { readOnlyHint: true } },
),
tool('write', WRITE_DESCRIPTION, { path: z.string(), content: z.string() }, (args) =>
runWrite(workspace, args.path, args.content),
operations.write(args.path, args.content),
),
tool(
'edit',
@@ -163,16 +94,16 @@ export function MirageServer(workspace: Workspace) {
new_string: z.string(),
replace_all: z.boolean().optional(),
},
(args) => runEdit(workspace, args.path, args.old_string, args.new_string, args.replace_all),
(args) => operations.edit(args.path, args.old_string, args.new_string, args.replace_all),
),
tool('ls', LS_DESCRIPTION, { path: z.string() }, (args) => runLs(workspace, args.path), {
tool('ls', LS_DESCRIPTION, { path: z.string() }, (args) => operations.ls(args.path), {
annotations: { readOnlyHint: true },
}),
tool(
'grep',
GREP_DESCRIPTION,
{ pattern: z.string(), path: z.string() },
(args) => runGrep(workspace, args.pattern, args.path),
(args) => operations.grep(args.pattern, args.path),
{ annotations: { readOnlyHint: true } },
),
],
@@ -0,0 +1,98 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { createHash } from 'node:crypto'
import type { Workspace } from '@struktoai/mirage-core'
export class StaleMirageFileError extends Error {
readonly path: string
constructor(path: string) {
super(`File changed since it was last read: ${path}. Read the file again before modifying it.`)
this.name = 'StaleMirageFileError'
this.path = path
}
}
function fingerprint(content: Uint8Array | string): string {
return createHash('sha256').update(content).digest('base64url')
}
async function readBuffer(ws: Workspace, path: string): Promise<Buffer> {
const bytes = await ws.fs.readFile(path, { raw: true })
return Buffer.from(bytes)
}
export class FileVersionTracker {
private readonly readVersions = new Map<string, string>()
private readonly editVersions = new Map<string, string>()
constructor(
private readonly ws: Workspace,
private readonly enabled = true,
) {}
private async currentVersion(path: string): Promise<string | null> {
if (!(await this.ws.fs.exists(path))) return null
return fingerprint(await readBuffer(this.ws, path))
}
private async assertVersion(path: string, expected: string): Promise<void> {
if ((await this.currentVersion(path)) !== expected) {
throw new StaleMirageFileError(path)
}
}
private recordWrite(path: string, content: string): void {
if (!this.enabled) return
this.readVersions.set(path, fingerprint(content))
this.editVersions.delete(path)
}
async read(path: string): Promise<Buffer> {
const content = await readBuffer(this.ws, path)
if (this.enabled) this.readVersions.set(path, fingerprint(content))
return content
}
async readForEdit(path: string): Promise<Buffer> {
const content = await readBuffer(this.ws, path)
if (!this.enabled) return content
const version = fingerprint(content)
const readVersion = this.readVersions.get(path)
if (readVersion !== undefined && readVersion !== version) {
throw new StaleMirageFileError(path)
}
this.editVersions.set(path, version)
return content
}
async write(path: string, content: string): Promise<void> {
if (this.enabled) {
const readVersion = this.readVersions.get(path)
if (readVersion !== undefined) await this.assertVersion(path, readVersion)
}
await this.ws.fs.writeFile(path, content)
this.recordWrite(path, content)
}
async writeEdit(path: string, content: string): Promise<void> {
if (this.enabled) {
const editVersion = this.editVersions.get(path)
if (editVersion !== undefined) await this.assertVersion(path, editVersion)
}
await this.ws.fs.writeFile(path, content)
this.recordWrite(path, content)
}
}
@@ -0,0 +1,29 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
export { createMirageMcpServer, serveMirageMcp, type MirageMcpServerOptions } from './server.ts'
export {
MirageToolOperations,
type MirageToolOperationsOptions,
type ToolResult,
} from '../tool-operations.ts'
export { FileVersionTracker, StaleMirageFileError } from '../file-version.ts'
export {
EDIT_DESCRIPTION,
EXECUTE_DESCRIPTION,
GREP_DESCRIPTION,
LS_DESCRIPTION,
READ_DESCRIPTION,
WRITE_DESCRIPTION,
} from '../tool-descriptions.ts'
@@ -0,0 +1,87 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { MountMode, OpsRegistry, RAMResource, Workspace } from '@struktoai/mirage-node'
import { describe, expect, it } from 'vitest'
import { createMirageMcpServer } from './server.ts'
function mkWs(): Workspace {
const ram = new RAMResource()
const ops = new OpsRegistry()
for (const op of ram.ops()) ops.register(op)
return new Workspace({ '/': ram }, { mode: MountMode.WRITE, ops })
}
function firstText(content: unknown): string {
if (!Array.isArray(content)) return ''
const first: unknown = content[0]
if (first === null || typeof first !== 'object') return ''
const text: unknown = (first as Record<string, unknown>).text
return typeof text === 'string' ? text : ''
}
describe('createMirageMcpServer', () => {
it('exposes Mirage tools over the MCP protocol', async () => {
const workspace = mkWs()
const server = createMirageMcpServer(workspace)
const client = new Client({ name: 'mirage-test', version: '1.0.0' })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
await client.connect(clientTransport)
const tools = await client.listTools()
expect(tools.tools.map((tool) => tool.name).sort()).toEqual(
['edit', 'execute_command', 'grep', 'ls', 'read', 'write'].sort(),
)
const write = await client.callTool({
name: 'write',
arguments: { path: '/hello.txt', content: 'hello\n' },
})
expect(write.isError).not.toBe(true)
const read = await client.callTool({ name: 'read', arguments: { path: '/hello.txt' } })
expect(firstText(read.content)).toContain('hello')
await client.close()
await server.close()
await workspace.close()
})
it('requires a reread after an external change', async () => {
const workspace = mkWs()
await workspace.fs.writeFile('/doc.txt', 'first')
const server = createMirageMcpServer(workspace)
const client = new Client({ name: 'mirage-test', version: '1.0.0' })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
await client.connect(clientTransport)
await client.callTool({ name: 'read', arguments: { path: '/doc.txt' } })
await workspace.fs.writeFile('/doc.txt', 'external')
const stale = await client.callTool({
name: 'edit',
arguments: { path: '/doc.txt', old_string: 'external', new_string: 'changed' },
})
expect(stale.isError).toBe(true)
expect(firstText(stale.content)).toContain('changed since it was last read')
await client.callTool({ name: 'read', arguments: { path: '/doc.txt' } })
const edit = await client.callTool({
name: 'edit',
arguments: { path: '/doc.txt', old_string: 'external', new_string: 'changed' },
})
expect(edit.isError).not.toBe(true)
expect(await workspace.fs.readFileText('/doc.txt')).toBe('changed')
await client.close()
await server.close()
await workspace.close()
})
})
@@ -0,0 +1,110 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { Workspace } from '@struktoai/mirage-core'
import { VERSION } from '@struktoai/mirage-core'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import {
EDIT_DESCRIPTION,
EXECUTE_DESCRIPTION,
GREP_DESCRIPTION,
LS_DESCRIPTION,
READ_DESCRIPTION,
WRITE_DESCRIPTION,
} from '../tool-descriptions.ts'
import { MirageToolOperations, type MirageToolOperationsOptions } from '../tool-operations.ts'
export interface MirageMcpServerOptions extends MirageToolOperationsOptions {
name?: string
version?: string
}
export function createMirageMcpServer(
workspace: Workspace,
options: MirageMcpServerOptions = {},
): McpServer {
const operations = new MirageToolOperations(workspace, options)
const server = new McpServer({
name: options.name ?? 'mirage',
version: options.version ?? VERSION,
})
server.registerTool(
'execute_command',
{ description: EXECUTE_DESCRIPTION, inputSchema: { command: z.string() } },
(args) => operations.execute(args.command),
)
server.registerTool(
'read',
{
description: READ_DESCRIPTION,
inputSchema: {
path: z.string(),
offset: z.number().int().min(0).optional(),
limit: z.number().int().min(1).optional(),
},
annotations: { readOnlyHint: true },
},
(args) => operations.read(args.path, args.offset, args.limit),
)
server.registerTool(
'write',
{ description: WRITE_DESCRIPTION, inputSchema: { path: z.string(), content: z.string() } },
(args) => operations.write(args.path, args.content),
)
server.registerTool(
'edit',
{
description: EDIT_DESCRIPTION,
inputSchema: {
path: z.string(),
old_string: z.string(),
new_string: z.string(),
replace_all: z.boolean().optional(),
},
},
(args) => operations.edit(args.path, args.old_string, args.new_string, args.replace_all),
)
server.registerTool(
'ls',
{
description: LS_DESCRIPTION,
inputSchema: { path: z.string() },
annotations: { readOnlyHint: true },
},
(args) => operations.ls(args.path),
)
server.registerTool(
'grep',
{
description: GREP_DESCRIPTION,
inputSchema: { pattern: z.string(), path: z.string() },
annotations: { readOnlyHint: true },
},
(args) => operations.grep(args.pattern, args.path),
)
return server
}
export async function serveMirageMcp(
workspace: Workspace,
options: MirageMcpServerOptions = {},
): Promise<McpServer> {
const server = createMirageMcpServer(workspace, options)
await server.connect(new StdioServerTransport())
return server
}
@@ -12,7 +12,6 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { createHash } from 'node:crypto'
import { rstripSlash, type ExecuteResult, type Workspace } from '@struktoai/mirage-core'
import type {
BashOperations,
@@ -24,21 +23,14 @@ import type {
WriteOperations,
} from '@earendil-works/pi-coding-agent'
import picomatch from 'picomatch'
import { FileVersionTracker } from '../file-version.ts'
export { StaleMirageFileError } from '../file-version.ts'
export interface MirageOperationsOptions {
staleWriteProtection?: boolean
}
export class StaleMirageFileError extends Error {
readonly path: string
constructor(path: string) {
super(`File changed since it was last read: ${path}. Read the file again before modifying it.`)
this.name = 'StaleMirageFileError'
this.path = path
}
}
export interface MirageOperationsBundle {
read: ReadOperations
write: WriteOperations
@@ -49,78 +41,6 @@ export interface MirageOperationsBundle {
ls: LsOperations
}
function fingerprint(content: Uint8Array | string): string {
return createHash('sha256').update(content).digest('base64url')
}
async function readBuffer(ws: Workspace, path: string): Promise<Buffer> {
const bytes = await ws.fs.readFile(path, { raw: true })
return Buffer.from(bytes)
}
class FileVersionTracker {
private readonly readVersions = new Map<string, string>()
private readonly editVersions = new Map<string, string>()
constructor(
private readonly ws: Workspace,
private readonly enabled: boolean,
) {}
private async currentVersion(path: string): Promise<string | null> {
if (!(await this.ws.fs.exists(path))) return null
return fingerprint(await readBuffer(this.ws, path))
}
private async assertVersion(path: string, expected: string): Promise<void> {
if ((await this.currentVersion(path)) !== expected) {
throw new StaleMirageFileError(path)
}
}
private recordWrite(path: string, content: string): void {
if (!this.enabled) return
this.readVersions.set(path, fingerprint(content))
this.editVersions.delete(path)
}
async read(path: string): Promise<Buffer> {
const content = await readBuffer(this.ws, path)
if (this.enabled) this.readVersions.set(path, fingerprint(content))
return content
}
async readForEdit(path: string): Promise<Buffer> {
const content = await readBuffer(this.ws, path)
if (!this.enabled) return content
const version = fingerprint(content)
const readVersion = this.readVersions.get(path)
if (readVersion !== undefined && readVersion !== version) {
throw new StaleMirageFileError(path)
}
this.editVersions.set(path, version)
return content
}
async write(path: string, content: string): Promise<void> {
if (this.enabled) {
const readVersion = this.readVersions.get(path)
if (readVersion !== undefined) await this.assertVersion(path, readVersion)
}
await this.ws.fs.writeFile(path, content)
this.recordWrite(path, content)
}
async writeEdit(path: string, content: string): Promise<void> {
if (this.enabled) {
const editVersion = this.editVersions.get(path)
if (editVersion !== undefined) await this.assertVersion(path, editVersion)
}
await this.ws.fs.writeFile(path, content)
this.recordWrite(path, content)
}
}
async function ensureParent(ws: Workspace, dir: string): Promise<void> {
const norm = rstripSlash(dir) || '/'
if (norm === '/' || (await ws.fs.exists(norm))) return
@@ -0,0 +1,42 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
export const EXECUTE_DESCRIPTION =
'Run a shell-style command on the Mirage virtual filesystem. ' +
'Supports cat, grep, find, head, tail, ls, wc, sort, uniq, tee, pipe, ' +
'and any other Unix command on mounted resources (S3, disk, RAM, etc.). ' +
'Also supports reading structured files: cat on .parquet/.orc/.csv returns a table.'
export const READ_DESCRIPTION =
'Read the contents of a file on the Mirage virtual filesystem. ' +
'Returns line-numbered text. ' +
"Optionally pass 'offset' (default 0) to start at a given line " +
"and 'limit' (default 2000) to cap the number of lines returned."
export const WRITE_DESCRIPTION =
'Write content to a new file on the Mirage virtual filesystem. ' +
'Fails if the file already exists; use edit to modify an existing file.'
export const EDIT_DESCRIPTION =
'Replace a string in an existing file on the Mirage virtual filesystem. ' +
'Fails if the file changed since it was last read, old_string is not found, ' +
'or old_string appears more than once. ' +
'Pass replace_all=true (default false) to replace every occurrence.'
export const LS_DESCRIPTION =
'List files and directories at the given path on the Mirage virtual filesystem.'
export const GREP_DESCRIPTION =
'Search for a pattern in files on the Mirage virtual filesystem. ' +
'Supports regex. Searches recursively under path.'
@@ -0,0 +1,148 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { ExecuteResult, Workspace } from '@struktoai/mirage-core'
import { gnuDirname } from '@struktoai/mirage-core'
import { FileVersionTracker, StaleMirageFileError } from './file-version.ts'
import { decode, ioToStr } from './io-text.ts'
export interface ToolResult {
[key: string]: unknown
content: { type: 'text'; text: string }[]
isError?: boolean
}
export interface MirageToolOperationsOptions {
staleWriteProtection?: boolean
}
function textResult(text: string): ToolResult {
return { content: [{ type: 'text', text }] }
}
function errorResult(text: string): ToolResult {
return { content: [{ type: 'text', text }], isError: true }
}
function ioResult(io: ExecuteResult): ToolResult {
const result = textResult(ioToStr(io))
if (io.exitCode !== 0) result.isError = true
return result
}
function shQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
async function ensureParents(ws: Workspace, path: string): Promise<void> {
const parent = gnuDirname(path)
if (parent === '/' || parent === '' || parent === '.') return
if (await ws.fs.exists(parent)) return
await ensureParents(ws, parent)
try {
await ws.fs.mkdir(parent)
} catch (err) {
if (!(await ws.fs.exists(parent))) throw err
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export class MirageToolOperations {
private readonly versions: FileVersionTracker
constructor(
private readonly ws: Workspace,
options: MirageToolOperationsOptions = {},
) {
this.versions = new FileVersionTracker(ws, options.staleWriteProtection ?? true)
}
async execute(command: string): Promise<ToolResult> {
return ioResult(await this.ws.execute(command))
}
async read(path: string, offset = 0, limit = 2000): Promise<ToolResult> {
let data: Uint8Array
try {
data = await this.versions.read(path)
} catch (err) {
if (!(await this.ws.fs.exists(path))) {
return errorResult(`Error: file '${path}' not found`)
}
return errorResult(`Error: ${errorMessage(err)}`)
}
const text = decode(data)
const raw = text.length === 0 ? [] : text.split(/(?<=\n)/)
const lines = raw.length > 0 && raw[raw.length - 1] === '' ? raw.slice(0, -1) : raw
const sliced = lines.slice(offset, offset + limit)
const numbered = sliced.map((line, i) => `${String(i + offset + 1).padStart(6)}\t${line}`)
return textResult(numbered.join(''))
}
async write(path: string, content: string): Promise<ToolResult> {
if (await this.ws.fs.exists(path)) {
return errorResult(`Error: file '${path}' already exists`)
}
await ensureParents(this.ws, path)
await this.versions.write(path, content)
return textResult(`Written: ${path}`)
}
async edit(
path: string,
oldString: string,
newString: string,
replaceAll = false,
): Promise<ToolResult> {
let content: string
try {
content = (await this.versions.readForEdit(path)).toString('utf8')
} catch (err) {
if (err instanceof StaleMirageFileError) return errorResult(`Error: ${err.message}`)
if (!(await this.ws.fs.exists(path))) {
return errorResult(`Error: file '${path}' not found`)
}
return errorResult(`Error: ${errorMessage(err)}`)
}
const count = content.split(oldString).length - 1
if (count === 0) {
return errorResult(`Error: string not found in file: '${oldString}'`)
}
if (count > 1 && !replaceAll) {
return errorResult(`Error: string appears ${String(count)} times. Pass replace_all=true`)
}
const newContent = replaceAll
? content.split(oldString).join(newString)
: content.replace(oldString, newString)
try {
await this.versions.writeEdit(path, newContent)
} catch (err) {
return errorResult(`Error: ${errorMessage(err)}`)
}
const occurrences = replaceAll ? count : 1
return textResult(`Edited: ${path} (${String(occurrences)} occurrence(s))`)
}
async ls(path: string): Promise<ToolResult> {
return ioResult(await this.ws.execute(`ls ${shQuote(path)}`))
}
async grep(pattern: string, path: string): Promise<ToolResult> {
const io = await this.ws.execute(`grep -rn ${shQuote(pattern)} ${shQuote(path)}`)
return textResult(ioToStr(io))
}
}
@@ -20,6 +20,7 @@ export default defineConfig({
'src/openai/index.ts',
'src/langchain/index.ts',
'src/pi/index.ts',
'src/mcp/index.ts',
'src/vercel/index.ts',
'src/mastra/index.ts',
'src/opencode/index.ts',
+2
View File
@@ -45,6 +45,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@struktoai/mirage-agents": "workspace:*",
"@struktoai/mirage-node": "workspace:*",
"@struktoai/mirage-server": "workspace:*",
"commander": "^14.0.0",
"yaml": "^2.6.0"
+1 -1
View File
@@ -20,7 +20,7 @@ describe('mirage CLI program', () => {
const program = buildProgram()
const names = program.commands.map((c) => c.name())
expect(names.sort()).toEqual(
['config', 'daemon', 'execute', 'job', 'provision', 'session', 'workspace'].sort(),
['config', 'daemon', 'execute', 'job', 'mcp', 'provision', 'session', 'workspace'].sort(),
)
})
+2
View File
@@ -20,6 +20,7 @@ import { registerConfigCommands } from './config.ts'
import { registerDaemonCommands } from './daemon.ts'
import { registerExecuteCommand } from './execute.ts'
import { registerJobCommands } from './job.ts'
import { registerMcpCommand } from './mcp.ts'
import { registerProvisionCommand } from './provision.ts'
import { registerSessionCommands } from './session.ts'
import { registerWorkspaceCommands } from './workspace.ts'
@@ -51,6 +52,7 @@ export function buildProgram(): Command {
registerSessionCommands(program)
registerJobCommands(program)
registerExecuteCommand(program)
registerMcpCommand(program)
registerProvisionCommand(program)
registerDaemonCommands(program)
registerConfigCommands(program)
+70
View File
@@ -0,0 +1,70 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { buildMcpWorkspace, resolveMcpConfig } from './mcp.ts'
const tempDirs: string[] = []
function mkTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'mirage-mcp-'))
tempDirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
describe('resolveMcpConfig', () => {
it('uses an explicit config path', () => {
const dir = mkTempDir()
const path = join(dir, 'custom.yaml')
writeFileSync(path, 'mounts: {}\n')
expect(resolveMcpConfig('custom.yaml', { cwd: dir, env: {} })).toBe(path)
})
it('uses MIRAGE_MCP_CONFIG', () => {
const dir = mkTempDir()
const path = join(dir, 'env.yaml')
writeFileSync(path, 'mounts: {}\n')
expect(resolveMcpConfig(undefined, { cwd: dir, env: { MIRAGE_MCP_CONFIG: path } })).toBe(path)
})
it('finds .mirage/workspace.yaml from a child directory', () => {
const dir = mkTempDir()
const configDir = join(dir, '.mirage')
const child = join(dir, 'src', 'nested')
mkdirSync(configDir)
mkdirSync(child, { recursive: true })
const path = join(configDir, 'workspace.yaml')
writeFileSync(path, 'mounts: {}\n')
expect(resolveMcpConfig(undefined, { cwd: child, env: {} })).toBe(path)
})
})
describe('buildMcpWorkspace', () => {
it('builds a workspace from YAML', async () => {
const dir = mkTempDir()
const path = join(dir, 'workspace.yaml')
writeFileSync(path, 'mounts:\n /:\n resource: ram\n')
const workspace = await buildMcpWorkspace(path)
await workspace.fs.writeFile('/hello.txt', 'hello')
expect(await workspace.fs.readFileText('/hello.txt')).toBe('hello')
await workspace.close()
})
})
+128
View File
@@ -0,0 +1,128 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { serveMirageMcp } from '@struktoai/mirage-agents/mcp'
import type { CommandSafeguard, MountSpec } from '@struktoai/mirage-node'
import { Workspace, newWorkspaceId } from '@struktoai/mirage-node'
import { configToWorkspaceArgs, loadWorkspaceConfigFile } from '@struktoai/mirage-server'
import type { Command } from 'commander'
const CONFIG_CANDIDATES = [
'.mirage/workspace.yaml',
'.mirage/workspace.yml',
'workspace.yaml',
'workspace.yml',
'mirage.yaml',
'mirage.yml',
]
export interface McpConfigResolutionOptions {
cwd?: string
env?: Record<string, string | undefined>
}
interface McpCommandOptions {
staleWriteProtection: boolean
}
function requireConfig(path: string): string {
if (!existsSync(path)) throw new Error(`Mirage workspace config not found: ${path}`)
return path
}
export function resolveMcpConfig(
config: string | undefined,
options: McpConfigResolutionOptions = {},
): string {
const cwd = resolve(options.cwd ?? process.cwd())
const env = options.env ?? process.env
if (config !== undefined) return requireConfig(resolve(cwd, config))
if (env.MIRAGE_MCP_CONFIG !== undefined) {
return requireConfig(resolve(cwd, env.MIRAGE_MCP_CONFIG))
}
let dir: string | undefined = cwd
while (dir !== undefined) {
for (const candidate of CONFIG_CANDIDATES) {
const path = resolve(dir, candidate)
if (existsSync(path)) return path
}
const parent = dirname(dir)
dir = parent === dir ? undefined : parent
}
throw new Error(
'No Mirage workspace config found. Pass one to `mirage mcp <config>` or set MIRAGE_MCP_CONFIG.',
)
}
export async function buildMcpWorkspace(configPath: string): Promise<Workspace> {
const config = loadWorkspaceConfigFile(configPath)
const args = await configToWorkspaceArgs(config)
const resources: Record<string, MountSpec> = {}
const commandSafeguards: Record<string, Record<string, CommandSafeguard>> = {}
for (const [prefix, [resource, mode, safeguards]] of Object.entries(args.resources)) {
resources[prefix] = [resource, mode]
if (Object.keys(safeguards).length > 0) commandSafeguards[prefix] = safeguards
}
const workspace = new Workspace(resources, {
mode: args.options.mode,
consistency: args.options.consistency,
...(args.options.sessionId !== undefined ? { sessionId: args.options.sessionId } : {}),
...(args.options.agentId !== undefined ? { agentId: args.options.agentId } : {}),
workspaceId: args.options.workspaceId ?? newWorkspaceId(),
...(args.options.store !== undefined ? { store: args.options.store } : {}),
...(Object.keys(commandSafeguards).length > 0 ? { commandSafeguards } : {}),
...(args.options.cache !== undefined ? { cache: args.options.cache } : {}),
...(args.options.index !== undefined ? { index: args.options.index } : {}),
...(args.options.runtimes !== undefined ? { runtimes: args.options.runtimes } : {}),
...(args.options.route !== undefined ? { route: args.options.route } : {}),
})
try {
for (const [prefix, target] of Object.entries(args.fuseMounts)) {
const mountpoint = typeof target === 'string' ? target : undefined
await workspace.addFuseMount(prefix, mountpoint)
}
} catch (error) {
await workspace.close()
throw error
}
return workspace
}
async function runMcpServer(
config: string | undefined,
options: McpCommandOptions,
): Promise<void> {
const configPath = resolveMcpConfig(config)
const workspace = await buildMcpWorkspace(configPath)
try {
await serveMirageMcp(workspace, {
staleWriteProtection: options.staleWriteProtection,
})
} catch (error) {
await workspace.close()
throw error
}
}
export function registerMcpCommand(program: Command): void {
program
.command('mcp')
.argument('[config]', 'Mirage workspace YAML config')
.option('--no-stale-write-protection', 'allow edits after a file changed since it was read')
.description('Serve a Mirage workspace as MCP tools over stdio.')
.action(runMcpServer)
}
+12 -3
View File
@@ -271,12 +271,18 @@ importers:
packages/agents:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
'@struktoai/mirage-core':
specifier: workspace:*
version: link:../core
picomatch:
specifier: ^4.0.4
version: 4.0.4
zod:
specifier: ^4.0.0
version: 4.3.6
devDependencies:
'@anthropic-ai/claude-agent-sdk':
specifier: ^0.3.215
@@ -329,9 +335,6 @@ importers:
vitest:
specifier: ^3.2.6
version: 3.2.6(@types/debug@4.1.13)(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)
zod:
specifier: ^4.0.0
version: 4.3.6
packages/browser:
dependencies:
@@ -363,6 +366,12 @@ importers:
packages/cli:
dependencies:
'@struktoai/mirage-agents':
specifier: workspace:*
version: link:../agents
'@struktoai/mirage-node':
specifier: workspace:*
version: link:../node
'@struktoai/mirage-server':
specifier: workspace:*
version: link:../server