Add Codex and Grok Build integrations (#575)
* Add Codex and Grok Build integrations * Document Python FUSE integrations
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mirage": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@struktoai/mirage-cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Generated
+12
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user