MCP Studio
A floating cross-platform widget that hosts MCP servers and lets you drive them from one place. Type or speak; the agent picks tools across every installed MCP.
Panel modes
The panel has two layouts. The top-right icon toggles between them:
- FAB mode (default) — compact 420×600 overlay anchored below the floating action button, always on top, doesn't appear in cmd-tab.
- Window mode — normal app window with OS decorations, in cmd-tab. Expanding preserves your current panel size and position — drag the window edges to resize, then it stays that size until you contract back. Click the contract icon (or the OS close button) to return to FAB mode.
Slash commands
| Command | What it does |
|---|---|
/help | Show all built-in commands |
/mcp-servers | List installed MCPs (use -name <id> to drill into one) |
/install <git-url-or-path> | Install a new MCP from git or a local directory |
/gallery | Browse the curated public gallery and copy an /install line for any entry |
/configure [mcp-id] | Open settings, optionally focused on an MCP |
/project [new|switch|delete|end] | Persistent conversation contexts — see Projects |
/build-mcp [new|load|switch|remove] | Open the MCP Builder — scaffold, load, or manage local MCPs you're authoring |
/tab [list|new|switch|close|rename|pop] | Multi-session tabs — each tab has its own transcript, agent memory, and project pointer. /tab pop promotes the active tab into its own OS window. |
/transparency <0–100> | Panel opacity (also +N / -N deltas) |
/clear | Clear the transcript |
/hide | Close the panel |
Tabs
Each tab is an independent agent session — its own transcript, its own conversation memory (priorMessages), its own active project, and its own per-project MCP storage. App-level settings (provider, model, installed MCPs, keychain, transparency) are shared.
Open a new tab with /tab new [name] or Cmd-T. Close with /tab close or Cmd-W (the last tab resets instead of closing). Switch with /tab switch <name-or-number>, Cmd-1..9, or Cmd-Shift-[ / Cmd-Shift-] to cycle. The tab strip auto-hides when only one tab exists.
Pop a tab into its own window with /tab pop (or /tab pop <name-or-number>). Studio opens a new OS window pinned to that tab — useful when you want a long-running agent run visible alongside other work. The popped window has standard decorations and no FAB. Closing it leaves the tab in your main panel; the tab itself never gets deleted by a pop.
Per-project MCP storage. When a tab is in a project (/project switch foo), MCP storage reads merge a per-project overlay on top of the global file. Writes from storage_action quick commands route to the overlay. This lets /workspace in tab A's project foo set a different Git workspace than tab B's project bar — Studio's Settings → MCP Servers shows both panels (global + Project overrides) when a project is active.
Files: global storage at ~/.mcp-widget/storage/<mcp_id>.json, project overlay at ~/.mcp-widget/projects/<slug>/mcp-storage/<mcp_id>.json. Overlay wins per-field; missing overlay keys fall through to global.
Caveats: agent-mediated tool calls (where the LLM picks tools without quick-command interpolation) still read the global storage view — the agent doesn't know which tab's project it's in. Quick commands honor the overlay correctly.
Persistence. Your tabs (id + name + project pointer) are saved to ~/.mcp-widget/tabs.json on every change and restored on launch. Project transcripts and agent context persist per-project as before; ephemeral tab transcripts reset on relaunch.
AI agent
Natural-language prompts (no leading /) go to the configured LLM provider. The provider receives every installed MCP's tools as a single catalog and chains across them freely. Configure in Settings → AI Agent.
Three families of provider:
- Local CLIs — no API key. Studio shells out to a binary you already have authenticated:
claude,gemini, orcodex. For each, Studio writes a temporarymcp-config.jsonwith all installed Studio MCPs and passes--mcp-config(where supported) so the CLI's own tool dispatcher sees the same catalog. - Direct APIs — Anthropic / OpenAI / Gemini. Bring your own key. The Studio agent loop is in charge and dispatches MCP tools directly. Gemini's function-calling API uses an OpenAPI 3.0 subset, so Studio normalizes every tool's
inputSchemabefore sending: strips fields Gemini doesn't recognise (additionalProperties,$schema,$id,$ref,$defs,definitions) and harmonizesenumwithtype(Gemini only acceptsenumwhentype: "string"— Studio infers the type from enum values when absent, or drops the enum if the declared type is non-string).
Quick commands are deterministic shortcuts; the agent is for ambiguous multi-step requests like "go to news.ycombinator.com, save the top 3 titles to a file in widget, commit them".
Memory across turns. Every agent call threads the prior conversation as context — whether you're in a project, in a scheduled fire, or in an ephemeral chat. Memory is bounded to the last 30 messages and truncated at a clean user-prompt boundary. /clear wipes it, /project end ends the project's, and each schedule has its own independent history.
Rich responses. Markdown, fenced code blocks, lists, tables, and inline HTML all auto-render in the transcript — both your prompts and the agent's replies. Each response sits in its own card so turns stay visually distinct. URLs autolink, inline HTML is sanitized (no <script>, no event handlers).
Voice
Two buttons in the input bar:
- voice: one-shot. Captures one phrase, sends as a prompt.
- Always on: continuous. Each detected phrase auto-sends.
Both swap their icon for a pulsing red dot while listening. Defaults to the browser's Web Speech API. macOS will prompt for microphone permission on first use.
Elicitation — the agent (or any MCP) can ask the user
Studio runs a generic elicitation channel: a single primitive any in-flight tool, agent, or quick command can use to pause and ask the user something. The panel — and any paired mobile — both show the card; first responder wins, the loser's card collapses to "answered elsewhere."
Four shapes cover every request:
| Shape | What it renders |
|---|---|
approve | Allow / Deny buttons. Defaults to "Allow" / "Deny"; the MCP can override labels. |
select_one | Radio list. Single-click submits when there are ≤ 5 options; longer lists require an explicit Submit. |
select_many | Checkbox list with optional min / max validation. |
free_text | input (or textarea when multiline: true). Enter submits; Shift+Enter newline. |
Four sources can trigger one:
- LLM provider — the agent loop adds a synthetic
studio.ask_usertool whenever the run has an elicit hook (every desktop run does). The model can call it for plan-mode confirmations, missing parameters, or destructive-action approval. If the user dismisses the card, the whole agent run stops withstoppedFor: "dismissed". - MCP server — spec-compliant
elicitation/createrequests. Useserver.elicitInput({ message, requestedSchema })from the SDK; Studio maps the JSON Schema to one of the four shapes (boolean→ approve,string + enum→ select_one,array of enum→ select_many,string→ free_text). Single-field form schemas are also accepted. Dismissed returns{ action: "cancel" }per spec; the server decides whether to abort the tool call. - Quick command — declare an
elicitblock on aquick_commandsentry to pause before the tool fires. The response is bound underargs.<key>(default key:answer) so downstream interpolation can reference it.context_requirementsentries can also opt-in withtype: "elicit"+ ashape. - Studio Core — bundled
dev.mcpwidget.coreexposesask_useras a regular MCP tool. Use it for explicit, manifest-declared prompts (/ask <question>).
Mobile parity. Every elicit broadcasts to paired mobile clients via elicit:request; mobile renders the same card. Whichever surface answers first wins (registry returns false for the second responder).
Late-join replay. If you reload the panel mid-elicit, pending requests for your tab — plus any schedule-fired ones with tabId: null — are re-fetched and re-inserted into the transcript. Closing a tab dismisses every elicit parked on it so callers don't hang.
Host bridge — Studio drives itself
Studio's Rust shell exposes a tiny loopback HTTP control surface that any installed MCP can call. Bundled Studio Core uses this to give the agent four self-control tools:
| Tool | What it does |
|---|---|
panel_show | Open the chat panel (anchored under the FAB) |
panel_hide | Hide the panel without closing the app |
panel_toggle | Flip panel visibility |
fab_move | Move the floating FAB to {x, y} — useful for getting Studio out of the way before a screenshot |
You can call these directly with /panel_show, /panel_hide, /panel_toggle, and /fab, or just ask the agent: "hide yourself, screenshot my screen, then come back and tell me what's on it."
Third-party MCPs get the same access — Studio injects MCP_WIDGET_HOST_PORT and MCP_WIDGET_HOST_TOKEN env vars at spawn. POST to http://127.0.0.1:<port>/panel/show (etc.) with Authorization: Bearer <token>.
Projects
A project keeps your transcript + agent memory together across panel close, app restart, and tab switches. Ephemeral chats reset on tab close; projects persist on disk at ~/.mcp-widget/projects/<slug>/.
| Command | What it does |
|---|---|
/project | List every project, marking the active one with * |
/project new <name> | Create + switch to a fresh project. Transcript clears, memory clears. |
/project switch <name> | Switch to an existing project; its transcript and agent memory reload. |
/project delete <slug> | Delete a project's directory entirely — transcript, agent context, schedule fires, MCP storage overlays. Slug is what's shown in parentheses in the /project list. If you delete the active project the current tab resets to ephemeral. |
/project end (or /project close) | Leave the current project. The tab becomes ephemeral again; the project itself stays on disk. |
Project context is per-tab. Tab A can be in project foo while tab B is in project bar — each tab's storage reads merge its own project overlay on top of global.
Schedules
Run prompts automatically at a future time or on a recurring interval. Each fired schedule looks identical to typing the prompt and hitting send — your configured agent handles it.
Manage from Settings → Schedules or with these slash commands:
| Command | What it does |
|---|---|
/schedule | list all schedules |
/schedule add | open the new-schedule form in Settings |
/schedule rm <id> | delete a schedule |
/schedule run <id> | fire a schedule immediately (advances next_fire_at) |
Two trigger kinds:
- Once — fires at a specific date/time, then disables itself.
- Every N minutes — fires repeatedly. If your laptop slept past several fire times, the schedule fires once on resume (within a 1-hour grace), not N backlogged copies.
The timer loop lives in the Rust core and ticks every 30 seconds. State persists at ~/.mcp-widget/schedules.json.
Own context, not the active project
Each schedule has its own dedicated context. When a schedule fires:
- The agent runs the prompt headlessly — not in the visible panel, and not inside whatever project is currently active.
- The prompt and response (or error) are appended to
~/.mcp-widget/schedules/<id>/runs.jsonl, one record per fire. - The visible panel only shows a one-liner:
⏰ <name> completed — view in Settings → Schedules.
Open the schedule's runs button in Settings to read past fires. This keeps your project transcripts clean — a "every 5 min, check if X" schedule won't bury your real conversation.
The agent remembers across fires. Each schedule's own prior runs are threaded into the next call as priorMessages, so a recurring "summarize new emails" schedule sees what it reported last time. Same goes for projects (memory persists per project) and ephemeral chats (memory lives for the panel session). Memory is capped at 30 messages and truncated at a clean user-prompt boundary so tool exchanges aren't split.
MCP Builder
/build-mcp opens a dedicated workspace inside Studio for scaffolding, editing, and (eventually) publishing your own MCPs from a prompt. The Builder keeps a library of every MCP you've ever scaffolded or loaded — health-checked on every render, so you always know which entries still resolve on disk.
| Command | What it does |
|---|---|
/build-mcp | Open the Builder on your active build (or the empty state) |
/build-mcp new [prompt] | Scaffold a new build from a natural-language prompt |
/build-mcp load <path> | Register an existing local folder (must contain widget.manifest.json) as a build |
/build-mcp switch <id> | Switch the Builder to another build in your library |
/build-mcp remove <id> | Drop a build from your library (loaded folders are never deleted from disk) |
Where things live. Scaffolded builds materialize under ~/.mcp-widget/built/<id>/; loaded builds stay wherever you keep them. The library itself is at ~/.mcp-widget/builder.json. Once a build's manifest validates, the existing local-install path takes over — a built MCP behaves like any third-party MCP from that point on.
Publish to GitHub. The Builder pushes a build to a fresh GitHub repo via the gh CLI. First publish runs gh repo create; subsequent publishes detect the existing origin remote and just git push the new commits, so iterating on a published MCP is a single click. The library entry tracks published state — a Published badge surfaces on the row, and locally-imported builds with an existing GitHub origin are recognised on import. If the build doesn't have a README.md, the publish flow auto-generates one from the manifest (name, description, install hooks, auth fields, quick commands). User-written READMEs are never overwritten.
This is a phased feature. Today: the view, the library with stale-path detection + published-state tracking, scaffold creation, load-an-existing-folder, in-panel editor, debug-log tail, and one-click publish to GitHub (create-or-push). Coming up: richer collaboration affordances around published builds.
Gallery
Two ways to browse the curated list of third-party MCPs:
- Settings → Gallery — search across name / summary / tags, with a one-click Install button on each entry. Already-installed MCPs surface as an "Installed" pill instead. Tab opens directly to the search box.
/galleryin the panel — renders the same list as a transcript card with ready-to-paste/installlines. Useful when you're already in flow and don't want to leave the panel.
The registry is a single JSON file served from this site (gallery.json) — Studio fetches it over HTTPS on demand, with a short in-memory cache so repeated views are cheap. Pull requests against RPieterse/mcp-studio-releases are welcome to add or update entries; the schema is documented in schema_version: 1 at the top of that file.
Mobile remote-control
Drive Studio from your phone over your tunnel (Tailscale, Cloudflare Tunnel, ngrok, ZeroTier — whichever you already use). The phone is a thin client: every MCP, every agent call still runs on the desktop. Studio operates no cloud infrastructure.
→ Mobile download + dedicated docs: rpieterse.github.io/mcp-studio-mobile-releases
One-time setup
- Open Settings → Mobile in the desktop app, paste your tunnel URL (e.g.
https://studio.tail-xxx.ts.net) and click Enable. - Click Generate pair code. You get a
mcpstudio://pair?t=…URI valid for 5 minutes. - Install the MCP Studio mobile app — APK + iOS TestFlight links on the mobile download page.
- In the mobile app: Scan QR (or paste the link), name the device, done.
The device token lives in your phone's Secure Enclave / Keystore via Expo SecureStore. Only its SHA-256 lives on the desktop in ~/.mcp-widget/mobile-pairings.json. Revoke any device from Settings → Mobile → Paired devices → Revoke; the revoked phone's WebSocket gets force-closed with code 4401.
What's in v1
- Live transcript streaming for the active tab
- Submit prompts (text or slash commands) from the phone
- Switch / create / close / rename tabs; switch / create / end projects
- Auto-reconnect with 1s → 30s exponential backoff; 20s heartbeat
Deferred to v2
- Push notifications on agent completion (pairing record reserves a
push_tokenslot) - Voice input on the phone
- Editing settings / installing MCPs / driving the Builder from the phone
- File attachments / image prompts
CLI
A terminal REPL for MCP Studio — same on-disk state as the desktop, same sidecar, same agent loop. Install once with npm; run alongside the desktop or instead of it.
→ CLI landing + install instructions: cli.html
npm install -g mcp-studio-cli
mcp-studio
Boots a REPL with >. The slash surface mirrors the desktop's: /help, /mcp-servers, /install, /configure, /project, /schedule, /workspace. Plain prose runs through the agent loop; agent:progress events stream inline; final responses render with marked-terminal (ANSI strips when piped).
Shared state with the desktop
Both clients read and write the same ~/.mcp-widget/ tree:
storage/<mcp_id>.json+projects/<slug>/mcp-storage/<mcp_id>.json— per-MCP state, with project overlays.projects/<slug>/{history.jsonl, agent-context.json, .name}— per-project transcript + agent memory.schedules.json+schedules/<id>/runs.jsonl— schedules and their fire history.credentials.enc— AES-256-GCM vault, OS-keychain umbrella key (serviceMCP Studio, account__mcp_studio_umbrella__).mcps/<id>/— installed MCPs.
Atomic tmp+rename writes everywhere. Last-write-wins on agent-context.json + history.jsonl if you happen to type into both clients simultaneously in the same project — usually fine, document if you want to avoid.
Scheduler leadership
Whichever client starts first acquires ~/.mcp-widget/scheduler.lock and becomes the schedule fire-er. The other observes runs.jsonl and surfaces fires inline — no double-fires. Lock TTL is 60s, refreshed every 15s; if the leader dies, the next tick takes over.
OAuth in the terminal
/configure <mcp-id> walks the manifest's auth fields. For api_key auth, secret fields land in the vault, non-secrets in credentials.local.json. For oauth2 / oauth2_pkce auth, the CLI spins up a one-shot loopback HTTP server, opens the browser to the authorize URL, captures the callback with state + PKCE validation, then exchanges the code via the sidecar's oauth_exchange. The desktop is not required — the CLI can configure an MCP from a fresh machine.
What ships in 0.1
- Multi-turn REPL with persistent slash + quick commands.
- Full
/helpoutput combining built-ins + every installed manifest'squick_commands. /project list|new|switch|delete|endseeded from disk; per-project agent memory persisted after every turn./schedule list|add|delete|runwith the timer loop active when the CLI is leader./configure <id>forapi_key,oauth2, andoauth2_pkceauth.- Host bridge that lets
studio-core-style MCPs callpanel_*/fab_*tools without crashing (they return harmless no-ops; the CLI logs(noop: …)to stderr so you can see what the agent asked for).
Deferred
- Tabs (multi-session transcripts).
- Slash + quick-command autocomplete in the readline loop.
- Per-project readline history persistence.
- Raw-mode secret input masking during
/configure. - Native Windows smoke-test (macOS + Linux work today; Windows should work but is untested).
Installing MCPs
/install https://github.com/your-org/your-mcp
/install /Users/you/code/your-mcp # local dev path
Studio clones (or symlinks if local), validates the manifest, and runs install hooks. A Cloning + installing from <source>… indicator stays in the transcript until the hooks finish (this can take a minute for an MCP that runs npm install + npm run build), then it resolves to installed <id> (<version>).
If the manifest declares auth of type api_key, oauth2, or oauth2_pkce, Studio opens Settings → MCP Servers focused on the new server so you can fill in the credentials before using it. MCPs with auth: { type: "none" } (or no auth block) are usable immediately.
To uninstall an MCP, open Settings → MCP Servers, expand the row, and click Uninstall. The bundled MCPs below (Chrome, Filesystem, Studio Core) are locked — they ship with Studio and can't be removed.
MCP Library
Third-party MCPs maintained alongside MCP Studio. Install any of these by pasting the URL into /install:
| MCP | What it does | Install URL |
|---|---|---|
| Claude Code dev.mcpwidget.claude_code |
Drive the local claude CLI as MCP tools — hands a prompt to Claude Code in a specific project directory. Was previously bundled. |
https://github.com/RPieterse/mcp-studio-claude-code-mcp |
| Git dev.mcpwidget.git |
Local git + GitHub PRs via the gh CLI. Holds the per-workspace storage that other MCPs read via ${storage.dev.mcpwidget.git.active_workspace_path}. Was previously bundled. |
https://github.com/RPieterse/mcp-studio-git-mcp |
| Google Sheets (service account) com.rohanpieterse.gsheets |
Create / read / modify Sheets via a Google service account. Files owned by the SA. Best for headless / scheduled jobs. | https://github.com/RPieterse/mcp-studio-google-sheets-mcp |
| Google Sheets (OAuth) com.rohanpieterse.gsheets_oauth |
Create / read / modify Sheets as the authenticated user. Files owned by you, in your personal Drive. Best for interactive use. | https://github.com/RPieterse/mcp-studio-google-sheets-oauth-mcp |
| Gmail (OAuth) com.mcpstudio.gmail |
List unread, search, read, draft, send, and trash emails as the authenticated Gmail user. BYO Google OAuth client (Client ID + Secret); Studio's loopback redirect handles the consent flow. | https://github.com/RPieterse/mcp-studio-gmail-mcp |
Service account vs OAuth — which to use? Service account: Sheets are owned by the service account; great for headless/scheduled jobs but the SA owns the files and shares aren't tied to a personal account. OAuth: Sheets are owned by you in your personal Drive; great for interactive use and natural sharing/visibility, but every install needs you to complete an OAuth consent flow.
Bundled MCPs
These ship with Studio and can't be uninstalled. Everything else lives in the MCP library as a regular /install target.
Chrome dev.mcpwidget.chrome
Open URLs, search, and read open tabs in Google Chrome (macOS, via AppleScript).
| Tool | Args |
|---|---|
open | url |
search | query |
current_tab | — |
list_tabs | — |
Quick commands: /open, /search, /tabs
Filesystem dev.mcpwidget.fs
Read, write, list, search, and stat files on the local filesystem. Refuses to delete / or $HOME.
| Tool | Args |
|---|---|
list_dir | path |
read_file | path, max_bytes? |
write_file | path, content, create_parents? |
create_dir | path |
delete | path, recursive? |
move | from, to |
stat | path |
search | path, query, max_results? |
Quick commands: /ls, /cat, /find
Studio Core dev.mcpwidget.core
The self-MCP — exposes Studio's own panel and FAB as tools the agent can call. Lets you say "hide the panel" or "move the floating button to the top-right" and have it happen.
| Tool | Args |
|---|---|
panel_show | — |
panel_hide | — |
panel_toggle | — |
fab_move | x, y |
Implemented via the loopback host bridge — every spawned MCP gets the bridge port + bearer token via env, but Studio Core is the only one that's expected to use them out of the box.
Building an MCP
An MCP for Studio is just a regular Model Context Protocol server with one extra file: widget.manifest.json at the repo root.
widget.manifest.json
The contract every Studio-compatible MCP targets. Here's a minimal example:
{
"manifest_version": 1,
"id": "com.example.weather",
"name": "Weather",
"version": "1.0.0",
"description": "Current conditions for a city.",
"mcp": {
"transport": "stdio",
"command": "node",
"args": ["./dist/server.js"],
"env": { "WEATHER_API_KEY": "${secrets.weather.api_key}" }
},
"install": [
{ "shell": "npm install" },
{ "shell": "npm run build" }
],
"auth": {
"type": "api_key",
"fields": [{ "key": "api_key", "label": "Weather API key", "secret": true }],
"store_as": "weather"
},
"permissions": {
"network": ["api.weather.example.com"],
"secrets": ["weather"]
},
"quick_commands": [
{ "trigger": "weather", "tool": "current", "arg_key": "city" }
]
}
Fields:
id— reverse-DNS, lowercasename,version— display + semvermcp— how to launch (stdio / sse / http). For stdio, theenvvalues may contain${secrets.<store_as>.<field>}interpolations.install— shell hooks run after cloneauth—none,api_key(form on install), oroauth2/oauth2_pkce(browser flow)permissions— network domains, filesystem paths, secret keys the MCP needsquick_commands— slash-command shortcuts (below)
Quick commands
Each quick command maps a slash trigger to a single tool call without going through the LLM.
{
"trigger": "commit",
"description": "Commit staged changes",
"tool": "commit",
"arg_key": "message",
"args": { "cwd": "${storage.active_workspace_path}" }
}
Three knobs:
arg_key— the user's input after the trigger becomes the value at this key./commit fix typo→{message: "fix typo"}.args— static defaults merged on top. String values support two kinds of interpolation:${context.now}/${context.home}— Studio-provided values.${storage.<key>}— your MCP's own stored value (see Storage below).
store_result— after the tool call succeeds, write part or all of the result back to this MCP's own storage.{ "key": "last_weather" }stores the whole tool output as a string. Add a dottedpath(e.g.{ "key": "last_temp_c", "path": "current.temp_c" }) to extract a single field from a JSON result. The stored value chains into the next command via${storage.<key>}— quick commands become composable pipelines. If the result isn't JSON or the path doesn't resolve, the existing storage value is left untouched.
Authentication
none
MCP uses your local system auth (existing CLI tokens, OS keychain, etc.). Used by the bundled Git, Chrome, Filesystem, and Claude Code MCPs.
api_key
Studio renders a form derived from auth.fields. Storage routing is per-field: any field marked "secret": true in the manifest goes to your OS keychain (macOS Keychain Access / Linux gnome-keyring / Windows Credential Manager), while non-secret fields go to local app storage. Both halves are merged at MCP spawn time and injected into env via ${secrets.<store_as>.<field>}. So a manifest like fields: [{ key: "service_account_json", secret: true }, { key: "user_email", secret: false }] puts the JSON in the keychain and the email in local storage — never the other way round.
oauth2 / oauth2_pkce
Click Authorize in browser. Studio spawns a one-shot loopback server, opens the authorize URL, captures the redirect, exchanges the code for tokens, and stores them in the OS keychain.
Auto-reauth on expired tokens. When a tool call fails with an auth-expired signal (401, invalid_token, expired_token, "token has expired", etc.), Studio detects the pattern in the MCP's error response, refreshes the access token in place using the saved refresh_token, respawns the MCP so the new token lands in env, and retries the call — once. The user sees the successful retry, never the underlying expiry. Falls through to the original error if the refresh itself fails (e.g. refresh token revoked) — at that point you need to re-authorize manually via Settings → MCP Servers → <your MCP>.
Storage
Many MCPs need a small amount of per-MCP state that isn't a secret — an active project path, a default branch, a preferred unit system. Declare a storage block in the manifest and Studio gives you:
- A typed form auto-rendered under Settings → MCP Servers → <your MCP> → Storage.
- A JSON file at
~/.mcp-widget/storage/<mcp_id>.jsonthat holds the values. ${storage.<key>}interpolation inside theenvat spawn and inside quick-commandargsat call time.
"storage": [
{
"key": "active_workspace_path",
"type": "text",
"label": "Active workspace",
"description": "Project directory git operations run in.",
"default": ""
},
{
"key": "default_branch",
"type": "enum",
"label": "Default branch",
"options": ["main", "master", "develop"],
"default": "main"
}
]
Supported type values: text, number, boolean, enum (with options), and map (a string→string dictionary, e.g. friendly_name → absolute_path). Add "secret": true on a text field to mask the input — useful for tokens you want kept in the per-MCP file rather than the OS keychain. map entries are not secret-eligible.
Add "hidden": true to keep a field out of the settings UI while still allowing interpolation. Useful for mirrored or derived values (e.g. Git's active_workspace_path is auto-set from workspaces[active_workspace_name] by the /workspace switcher — other MCPs read it cross-MCP, but you shouldn't edit it directly).
Each MCP owns its own storage. To read another MCP's value (so you can avoid duplicating concepts like a workspace path) use a dotted reference:
"args": { "cwd": "${storage.dev.mcpwidget.git.active_workspace_path}" }
The host resolves the longest matching prefix as the MCP id and the final segment as the key. The bundled ClaudeCode and Filesystem MCPs use this to inherit Git's workspace without storing their own copy.
Storage shortcuts
Quick commands can read or write a single storage key directly — no tool call involved. Declare storage_action instead of tool:
{
"trigger": "workspace",
"description": "Show or set the active workspace path",
"storage_action": { "key": "active_workspace_path" }
}
Behavior: /workspace prints the current value; /workspace /path/to/repo writes it.
Switcher shortcuts (select_from)
Pair a primitive key with a map storage key and the quick command becomes a named-entry switcher. Optional name_key mirrors the selected entry's NAME into a second storage key so both halves of the selection are explicitly stored:
"storage": [
{ "key": "workspaces", "type": "map", "label": "Workspaces" },
{ "key": "active_workspace_name", "type": "text", "label": "Active name" },
{
"key": "active_workspace_path",
"type": "text",
"label": "Active path",
"hidden": true // derived; readable cross-MCP but not editable in settings
}
],
"quick_commands": [
{
"trigger": "workspace",
"description": "List, switch, add, or remove saved workspaces",
"storage_action": {
"key": "active_workspace_path",
"name_key": "active_workspace_name",
"select_from": "workspaces"
}
}
]
The Git MCP ships with exactly this shape. Behavior of /workspace:
/workspace— list saved entries with the active one marked./workspace <name>— switch active to that entry. Writesactive_workspace_name = "<name>"ANDactive_workspace_path = "<path>"; cross-MCP reads pick up either via${storage.dev.mcpwidget.git.active_workspace_path}/${storage.dev.mcpwidget.git.active_workspace_name}./workspace <name> <path>— add or overwrite an entry and switch to it./workspace rm <name>(or/workspace -<name>) — remove an entry; if it was the active one, both the name and path mirrors are cleared.
Omit name_key for a single-write switcher (only key is mirrored) — backwards compatible with manifests that don't need the name stored separately.
Full tutorial: Notes MCP
Walk through building a working MCP from zero — wraps a fictional notes.example.com REST API, ships an api_key auth flow, exposes 5 tools and 3 quick commands with context forms, installs into Studio in under 10 minutes.
1. Scaffold the project
mkdir notes-mcp && cd notes-mcp
npm init -y
npm install @modelcontextprotocol/sdk
npm install --save-dev typescript @types/node @mcp-widget/manifest-schema
mkdir src
cat > tsconfig.json <<'EOF'
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
EOF
2. Write widget.manifest.json
The contract — at the repo root.
{
"manifest_version": 1,
"id": "com.example.notes",
"name": "Notes",
"version": "0.0.1",
"description": "Read and write notes at notes.example.com.",
"icon": "./icon.svg",
"homepage": "https://notes.example.com",
"mcp": {
"transport": "stdio",
"command": "node",
"args": ["./dist/server.js"],
"env": {
"NOTES_API_KEY": "${secrets.notes.api_key}"
}
},
"install": [
{ "shell": "npm install" },
{ "shell": "npm run build" }
],
"auth": {
"type": "api_key",
"fields": [
{ "key": "api_key", "label": "Notes API key", "secret": true }
],
"store_as": "notes"
},
"permissions": {
"network": ["notes.example.com"],
"secrets": ["notes"]
},
"quick_commands": [
{
"trigger": "note",
"description": "Create a new note",
"tool": "create_note",
"context_requirements": [
{ "key": "title", "type": "text", "label": "Title", "required": true },
{ "key": "content", "type": "text", "label": "Content", "required": true, "multiline": true },
{ "key": "tags", "type": "enum", "label": "Tags",
"options": ["work", "personal", "ideas", "todo"], "default": "ideas" }
]
},
{
"trigger": "notes",
"description": "List recent notes",
"tool": "list_notes"
},
{
"trigger": "find-note",
"description": "Search your notes by keyword",
"tool": "search_notes",
"arg_key": "query"
}
]
}
Things to notice:
- The
envuses${secrets.notes.api_key}— Studio reads the saved api_key out of the OS keychain at spawn time and exposes it to your process asprocess.env.NOTES_API_KEY. Your code never sees the keychain. permissions.networkdeclares the one domain you'll talk to. Future versions will enforce this as a firewall; today it's a trust signal.- The first quick command (
/note) has noarg_keybut does havecontext_requirements. Studio renders a 3-field inline form (title, multiline content, tags enum) when the user types/note. - The third (
/find-note) usesarg_keyso/find-note dragonscallssearch_notes({query:"dragons"})without a form.
3. Implement the MCP server
Create src/server.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const API = "https://notes.example.com/api/v1";
const KEY = process.env.NOTES_API_KEY;
if (!KEY) {
console.error("NOTES_API_KEY missing — open Settings → MCP Servers → Notes");
process.exit(1);
}
async function call(method: string, path: string, body?: unknown) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
return res.json();
}
const server = new Server(
{ name: "notes", version: "0.0.1" },
{ capabilities: { tools: {} } },
);
const TOOLS = [
{
name: "list_notes",
description: "Return the 20 most recent notes.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "get_note",
description: "Fetch a single note by id.",
inputSchema: {
type: "object",
properties: { id: { type: "string", minLength: 1 } },
required: ["id"],
additionalProperties: false,
},
},
{
name: "create_note",
description: "Create a note with title, markdown content, and tag.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", minLength: 1 },
content: { type: "string" },
tags: { type: "string" }
},
required: ["title", "content"],
additionalProperties: false,
},
},
{
name: "update_note",
description: "Update an existing note's content.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", minLength: 1 },
content: { type: "string", minLength: 1 }
},
required: ["id", "content"],
additionalProperties: false,
},
},
{
name: "search_notes",
description: "Substring search across all notes.",
inputSchema: {
type: "object",
properties: { query: { type: "string", minLength: 1 } },
required: ["query"],
additionalProperties: false,
},
},
];
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const a = req.params.arguments as Record<string, string>;
let text = "";
try {
switch (req.params.name) {
case "list_notes": {
const notes = await call("GET", "/notes?limit=20");
text = notes.map((n: any) => `[${n.id}] ${n.title}`).join("\n");
break;
}
case "get_note": {
const n = await call("GET", `/notes/${a.id}`);
text = `# ${n.title}\n\n${n.content}`;
break;
}
case "create_note": {
const n = await call("POST", "/notes", {
title: a.title, content: a.content, tags: a.tags?.split(",").map(s => s.trim()),
});
text = `created note ${n.id}: ${n.title}`;
break;
}
case "update_note": {
await call("PATCH", `/notes/${a.id}`, { content: a.content });
text = `updated ${a.id}`;
break;
}
case "search_notes": {
const hits = await call("GET", `/notes/search?q=${encodeURIComponent(a.query)}`);
text = hits.map((h: any) => `[${h.id}] ${h.title} — ${h.snippet}`).join("\n");
break;
}
default:
throw new Error(`unknown tool: ${req.params.name}`);
}
return { content: [{ type: "text", text: text || "(no results)" }] };
} catch (err) {
return { content: [{ type: "text", text: (err as Error).message }], isError: true };
}
});
await server.connect(new StdioServerTransport());
4. Local install + test
# Build
npx tsc
# Install from your local path
node /path/to/mcp-widget/apps/cli/dist/index.js install --path "$(pwd)"
# Or from inside the panel
/install /path/to/notes-mcp
# Studio prompts for the API key — paste it, save
Now in the panel:
/mcp-servers -name notes— see the 5 tools + 3 quick commands/note— the inline 3-field form appears (title, content, tags dropdown). Fill, click Run./find-note dragons— instant tool call, no form/note -help— full usage breakdown including the context form fields- Natural-language prompt: "Create a note titled 'Q3 retro' with these bullets..." — the agent picks
create_noteon its own
5. Cross-MCP chains
Combine with the bundled MCPs the agent already has:
- "Take a screenshot, save it as a note titled 'design idea'" →
studio-core.screenshot→notes.create_note - "Find my notes about 'rust' and append my git status to each" →
notes.search_notes→notes.get_note× N →git.status→notes.update_note× N - "Open my last note in Chrome" →
notes.list_notes→chrome.open(notesUrl/{id})
You write 5 tools; the agent wires them into multi-step flows that touch any combination of installed MCPs.
6. Publish
git init && git add . && git commit -m "Initial Notes MCP"
git remote add origin https://github.com/you/notes-mcp.git
git push -u origin main
Share the URL with users: /install https://github.com/you/notes-mcp
Local development
Iterate without pushing to git:
# From anywhere
/install /path/to/your-mcp
# Or from the CLI
node apps/cli/dist/index.js install --path /path/to/your-mcp
Studio records a .source marker pointing at your repo. Every read of the manifest goes through the source dir — so you can edit widget.manifest.json and see changes on the next /mcp-servers without re-installing. Rebuild your dist/ when you change source code; the next tool call picks up the new binary.
To verify a build works against the live host:
node apps/cli/dist/index.js call <your-id>.<tool-name> '{"key":"value"}'
Publishing
- Push your repo to GitHub (any visibility).
- Tag a release matching your
version. - Share the URL:
/install https://github.com/your-org/your-mcp
Studio clones with --depth=1 at the default branch. Bump version on every release. Major bumps re-prompt for permissions; minor / patch don't.
Roadmap
What's shipped today, what's planned. Listed here before scope shifts during a sprint — adjust expectations accordingly. Mobile-specific roadmap lives on the mobile docs.
Shipped
- FAB + panel modes · floating compact panel anchored under a draggable button, plus a window-mode toggle
- Slash commands + autocomplete · built-in + MCP-declared
quick_commands - AI agent providers · Anthropic / OpenAI / Gemini APIs, plus local CLIs (Claude Code, Gemini CLI, Codex CLI)
- Voice input · one-shot + continuous, via the WebView's speech recognition
- Projects · persistent conversation contexts with per-project transcripts + agent memory
- Schedules · timed prompts with per-fire history threaded as agent memory
- Tabs · multi-session panel with independent transcripts + memory, persisted across launches
- MCP Builder · scaffold from a prompt, edit in-panel, install hooks, test commands, publish to GitHub
- Mobile remote-control · thin client over your tunnel, dark mode, OTA updates
- Per-MCP storage primitive · cross-MCP reads via
${storage.<id>.<key>} - Host bridge · MCPs can drive Studio's own panel (show/hide/toggle, FAB move)
- Umbrella keychain · single keychain prompt at launch instead of one per credential
- OAuth token persistence · access + refresh tokens survive Studio restarts
- Auto-updates · in-app updater with signed releases
- Gallery ·
/galleryfetches a curated registry and surfaces ready-to-paste/installlines for vetted third-party MCPs; Settings → Gallery adds search and one-click install store_resultmanifest field · quick commands write tool outputs back to storage (with optional JSON path) for chained${storage}reads- Auto-reauth on expired OAuth · detects auth-expired errors, refreshes tokens in place, respawns the MCP, retries — once. Falls through with a re-authorize hint if the refresh itself fails
- Streaming tool-call cards · each tool the agent invokes appears as its own card in the transcript, transitioning from running → done/error as the loop progresses. Cards mirror to the mobile companion app in real time.
- Per-project agent storage · the agent loop now spawns MCPs with the active project's storage overlay merged over its global view. Matches what quick commands have always done. Switching projects between agent runs respawns affected MCPs so the new context lands in
env. /tab pop· promote any tab into its own OS window with standard decorations. Closing the window leaves the tab intact in your main panel.- MCP startup-failure stderr · errors surfaced from a failed MCP spawn (binary missing, install hook bailed, crashes shortly after spawn) now include the tail of the MCP's stderr log inline in the transcript — debug without leaving the panel.
Planned
- Builder publish UX polish · README templating, repo description, license picker
Open an issue on the releases repo if you want to nudge priorities.