MCP Studio

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:

Slash commands

CommandWhat it does
/helpShow all built-in commands
/mcp-serversList installed MCPs (use -name <id> to drill into one)
/install <git-url-or-path>Install a new MCP from git or a local directory
/galleryBrowse 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)
/clearClear the transcript
/hideClose 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:

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:

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:

ShapeWhat it renders
approveAllow / Deny buttons. Defaults to "Allow" / "Deny"; the MCP can override labels.
select_oneRadio list. Single-click submits when there are ≤ 5 options; longer lists require an explicit Submit.
select_manyCheckbox list with optional min / max validation.
free_textinput (or textarea when multiline: true). Enter submits; Shift+Enter newline.

Four sources can trigger one:

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:

ToolWhat it does
panel_showOpen the chat panel (anchored under the FAB)
panel_hideHide the panel without closing the app
panel_toggleFlip panel visibility
fab_moveMove 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>/.

CommandWhat it does
/projectList 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:

CommandWhat it does
/schedulelist all schedules
/schedule addopen 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:

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:

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.

CommandWhat it does
/build-mcpOpen 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.

Two ways to browse the curated list of third-party MCPs:

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

  1. Open Settings → Mobile in the desktop app, paste your tunnel URL (e.g. https://studio.tail-xxx.ts.net) and click Enable.
  2. Click Generate pair code. You get a mcpstudio://pair?t=… URI valid for 5 minutes.
  3. Install the MCP Studio mobile app — APK + iOS TestFlight links on the mobile download page.
  4. 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

Deferred to v2

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:

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

Deferred

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:

MCPWhat it doesInstall 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).

ToolArgs
openurl
searchquery
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.

ToolArgs
list_dirpath
read_filepath, max_bytes?
write_filepath, content, create_parents?
create_dirpath
deletepath, recursive?
movefrom, to
statpath
searchpath, 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.

ToolArgs
panel_show
panel_hide
panel_toggle
fab_movex, 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:

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:

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:

"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:

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:

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:

5. Cross-MCP chains

Combine with the bundled MCPs the agent already has:

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

  1. Push your repo to GitHub (any visibility).
  2. Tag a release matching your version.
  3. 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

Planned

Open an issue on the releases repo if you want to nudge priorities.