Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 65 additions & 6 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,17 +661,18 @@ additive-compatible and gains two fields:
A client that is installed but not yet content-checked reads as
`exists=true, connected=false, access_state="unknown"`. Resolving `connected`
requires an explicit per-client read (the per-client status route below,
connect/disconnect, or the CLI `mcpproxy connect` command), which is the only
place a privacy prompt may legitimately appear.
connect/disconnect, or the CLI `mcpproxy connect` command), which is where a
privacy prompt may legitimately appear.

#### GET /api/v1/connect/{client}

On-demand single-client status. Reads the one client's config **at request
time** and returns a full `ClientStatus` with `access_state` resolved to
`accessible | absent | malformed | denied` and `connected` set accordingly.
This is the sole Connect endpoint that opens a client config file, so on macOS
it is the only place an App-Data privacy prompt may legitimately appear (scoped
to this user action). Unknown client → `404`. A denial is reported **in-band**
This — like the other per-client routes below (preview, connect/disconnect,
undo) — opens the client's config file at request time, so on macOS an App-Data
privacy prompt may legitimately appear here (scoped to this user action), never
from the overall listing. Unknown client → `404`. A denial is reported **in-band**
(`200` with `access_state="denied"` + `remediation`), not as an HTTP error.

```bash
Expand All @@ -684,6 +685,63 @@ Connect/disconnect are unchanged except that a permission-denied config access
now returns **`403 Forbidden`** whose error body carries the remediation text
(distinct from a generic `400` or a `404` not-found).

Every connect/disconnect that modifies an **existing** config file first writes
a timestamped backup next to it (`<config>.bak.<YYYYMMDD-HHMMSS>`, same
directory and file mode) and returns its path as `backup_path` in the result.
When two operations land in the same second, a numeric suffix keeps every
backup distinct (`<config>.bak.<YYYYMMDD-HHMMSS>-1`, `-2`, …) — a backup is
never overwritten. Backups accumulate one per operation and are **never
deleted automatically**; there is no retention bound, so an undo (below) can
always find its backup.

#### GET /api/v1/connect/{client}/preview

Returns the exact change a subsequent connect would make — target config path,
format (`json`/`toml`), server key, entry name, and the exact entry contents —
**without** modifying the file or creating a backup (Spec 078 US1). An embedded
API key is masked in the payload (`contains_api_key` flags that a credential is
written); `entry_exists` distinguishes a create from an overwrite of a
same-named entry. Reads the config on demand to classify create-vs-overwrite,
so on macOS this may raise an App-Data prompt; a denial returns `403` +
remediation. Optional `?server_name=` mirrors the name a subsequent connect
would use.

#### POST /api/v1/connect/{client}/undo

One-click undo of the immediately-preceding connect (Spec 078 US3). Body:

```json
{ "server_name": "mcpproxy", "backup_name": "<basename of backup_path from the connect result>" }
```

`backup_name` is the **bare filename** of the backup the connect returned in
`backup_path` — a name, never a path. The server resolves the full path itself
inside that client's own config directory (derived from the client registry, not
the request), so a caller-supplied value can never contribute a directory
component and cannot escape the config dir (defense against path injection).

- **`backup_name` set** — restores the config **byte-for-byte** from that
backup. This is the only revert that can bring back a pre-existing
same-named entry that a `force=true` connect overwrote (surgical
`DELETE /connect/{client}` cannot).
- **`backup_name` empty** — the connect created the file (its result carried no
`backup_path`); undo deletes the created file, restoring the "no file" state.

Safety semantics:

- Undo **refuses with `409 Conflict`** when the config changed since the
connect (it verifies the current file is byte-identical to what that connect
wrote) — it never clobbers later edits. Fall back to
`DELETE /connect/{client}` for a surgical entry removal.
- A vanished backup returns `404`; a `backup_name` that is a path (contains a
directory separator) or does not match `<config>.bak.*` for that client
returns `400`.
- Undo takes its **own safety backup** of the current file before restoring or
deleting, returned as `backup_path` in the result
(`action` = `restored` or `deleted`).
- A macOS App-Data denial returns `403` + remediation, like the other
per-client routes.

##### macOS App Data privacy & Connect

On macOS, client configs (Claude Desktop, Cursor, VS Code, …) live under another
Expand All @@ -698,7 +756,8 @@ tccutil reset SystemPolicyAppData com.smartmcpproxy.mcpproxy
```

The overall `GET /api/v1/connect` listing never triggers this prompt (it is
content-read-free); only the per-client read above can.
content-read-free); only the per-client routes above (status, preview,
connect/disconnect, undo) can.

### Real-time Updates

Expand Down
2 changes: 1 addition & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ Add mcpproxy as a remote MCP server via Settings → Connectors → Add Custom C

#### Option A: Free Plan — JSON Configuration

> **💡 Built-in wizard:** mcpproxy's **Connect** wizard (Web UI / tray) can write this bridge configuration for you — pick **Claude Desktop**, click **Review & connect** to see the exact entry that will be written (a timestamped backup is created first), then confirm with **Connect**. It registers the `npx -y mcp-remote` bridge shown below (Node.js required). The manual steps remain available if you prefer to edit the file yourself.
> **💡 Built-in wizard:** mcpproxy's **Connect** wizard (Web UI / tray) can write this bridge configuration for you — pick **Claude Desktop**, click **Review & connect** to see the exact entry that will be written (a timestamped backup is created first), then confirm with **Connect**. It registers the `npx -y mcp-remote` bridge shown below (Node.js required). The manual steps remain available if you prefer to edit the file yourself. Changed your mind? The wizard offers a one-click **Undo** right next to the backup path it just showed: it reverts the connect by restoring the config byte-for-byte from that backup (or removing the file if the connect created it), and it refuses — rather than clobbering your edits — if the file changed in the meantime. Backups are never overwritten: two operations in the same second get distinct names (`.bak.<timestamp>-1`, `-2`, …), and none are deleted automatically.

1. Create the config file if it doesn't exist:

Expand Down
143 changes: 138 additions & 5 deletions frontend/src/components/ConnectModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,69 @@
>
No prior config file existed, so no backup was needed.
</div>
<!-- Spec 078 US3: one-click undo of the connect just performed (session-
scoped). Clicking Undo first shows the change to be reverted
(FR-009); the restore only runs on the panel's confirm. -->
<div
v-if="resultSuccess && lastConnect && !undoPanelOpen"
data-test="connect-undo-offer"
class="mt-2 flex items-center justify-between gap-2 rounded-lg bg-base-200 px-3 py-2"
>
<span class="text-xs opacity-70">Changed your mind? You can revert this connect.</span>
<button
data-test="connect-undo"
@click="undoPanelOpen = true"
class="btn btn-ghost btn-xs shrink-0 text-error"
:disabled="undoBusy"
title="Revert this connect: restore the config to its pre-connect state"
>
Undo
</button>
</div>
<div
v-if="resultSuccess && lastConnect && undoPanelOpen"
data-test="connect-undo-panel"
class="mt-2 rounded-lg bg-base-200/60 border border-error/40 px-3 py-2 space-y-2"
>
<p class="text-xs opacity-70 leading-relaxed">
<template v-if="lastConnect.backupPath">
Undo restores
<code class="font-mono text-[11px] break-all">{{ lastConnect.configPath }}</code>
to its exact pre-connect state from the backup (a safety copy of the current file is saved first).
</template>
<template v-else>
Undo removes
<code class="font-mono text-[11px] break-all">{{ lastConnect.configPath }}</code>
— it did not exist before mcpproxy connected (a safety copy is saved first).
</template>
</p>
<div v-if="lastConnect.preview">
<div class="text-[11px] font-semibold uppercase tracking-wider text-error/80 mb-1">− will be reverted</div>
<pre
data-test="connect-undo-entry"
class="text-[11px] font-mono whitespace-pre-wrap break-all rounded bg-base-300/60 border-l-2 border-error px-2 py-1.5 leading-relaxed"
>{{ lastConnect.preview.entry_text }}</pre>
</div>
<div class="flex items-center gap-2 pt-0.5">
<button
data-test="connect-undo-confirm"
@click="confirmUndo"
class="btn btn-error btn-xs"
:disabled="undoBusy"
>
<span v-if="undoBusy" class="loading loading-spinner loading-xs"></span>
<span v-else>Undo connect</span>
</button>
<button
data-test="connect-undo-cancel"
@click="undoPanelOpen = false"
class="btn btn-ghost btn-xs"
:disabled="undoBusy"
>
Keep
</button>
</div>
</div>
<!-- Spec 078 US2 / SC-005: Connect All renders EVERY successful
client's backup outcome — one line per client, each with its own
copy affordance — instead of only the last connect's path. -->
Expand Down Expand Up @@ -341,6 +404,19 @@ const copiedClient = ref<string | null>(null)
const previews = ref<Record<string, ConnectPreview>>({})
const previewLoading = reactive<Record<string, boolean>>({})
const previewError = ref<Record<string, string>>({})
// Spec 078 US3: the connect performed last in THIS modal session, so a one-
// click Undo can revert it. preview is the confirmed preview snapshot (what
// was written), shown again in the undo panel before reverting (FR-009).
// null = no undoable connect (none yet, undone, disconnected, or bulk run).
const lastConnect = ref<{
id: string
serverName: string
configPath: string
backupPath: string | null
preview?: ConnectPreview
} | null>(null)
const undoPanelOpen = ref(false)
const undoBusy = ref(false)

// MCP-2952: `GET /api/v1/connect` is stat-only (#706/MCP-2829) and reports
// connected=false for every client. Merge the content-resolved
Expand Down Expand Up @@ -457,20 +533,34 @@ function clearPreview(clientId: string) {
// Confirm proceeds with the connect. If an entry already exists, confirming
// implies force=true (the user saw the overwrite warning in the preview).
async function confirmConnect(clientId: string) {
const force = previews.value[clientId]?.entry_exists === true
const preview = previews.value[clientId]
const force = preview?.entry_exists === true
bulkBackups.value = []
copiedBulkClient.value = null
await connect(clientId, force)
const outcome = await connect(clientId, force)
// Spec 078 US3: a successful single connect becomes undoable in this session.
if (outcome.ok) {
lastConnect.value = {
id: clientId,
serverName: 'mcpproxy',
configPath: outcome.configPath,
backupPath: outcome.backupPath,
preview,
}
undoPanelOpen.value = false
}
clearPreview(clientId)
}

// Returns the outcome so connectAll can accumulate per-client backup results
// (ok=true with backupPath string = backup created; null = no prior file).
async function connect(clientId: string, force = false): Promise<{ ok: boolean; backupPath: string | null }> {
async function connect(clientId: string, force = false): Promise<{ ok: boolean; backupPath: string | null; configPath: string }> {
loading.clients[clientId] = true
resultMessage.value = ''
resultBackupPath.value = undefined
copiedBackup.value = false
lastConnect.value = null
undoPanelOpen.value = false

try {
const response = await api.connectClient(clientId, 'mcpproxy', force)
Expand All @@ -486,7 +576,7 @@ async function connect(clientId: string, force = false): Promise<{ ok: boolean;
title: 'Client Connected',
message: `MCPProxy registered in ${clientId}`,
})
return { ok: true, backupPath }
return { ok: true, backupPath, configPath: response.data.config_path }
}
resultMessage.value = response.error || 'Failed to connect'
resultSuccess.value = false
Expand All @@ -500,7 +590,43 @@ async function connect(clientId: string, force = false): Promise<{ ok: boolean;
} finally {
loading.clients[clientId] = false
}
return { ok: false, backupPath: null }
return { ok: false, backupPath: null, configPath: '' }
}

// Spec 078 US3: revert the last connect performed in this modal session. The
// backend refuses (409) when the config changed since the connect — surfaced
// honestly as the error message; it never clobbers later edits.
async function confirmUndo() {
const target = lastConnect.value
if (!target) return
undoBusy.value = true
try {
const response = await api.undoConnectClient(target.id, target.serverName, target.backupPath)
if (response.success && response.data) {
resultMessage.value = response.data.message || `Reverted the ${target.id} connect`
resultSuccess.value = true
resultBackupPath.value = undefined
lastConnect.value = null
undoPanelOpen.value = false
await fetchClients()
void onboarding.fetchState()
systemStore.addToast({
type: 'info',
title: 'Connect undone',
message: `${target.id} restored to its pre-connect state`,
})
} else {
resultMessage.value = response.error || 'Failed to undo the connect'
resultSuccess.value = false
undoPanelOpen.value = false
}
} catch (err) {
resultMessage.value = err instanceof Error ? err.message : 'Unknown error'
resultSuccess.value = false
undoPanelOpen.value = false
} finally {
undoBusy.value = false
}
}

async function disconnect(clientId: string) {
Expand All @@ -510,6 +636,9 @@ async function disconnect(clientId: string) {
copiedBackup.value = false
bulkBackups.value = []
copiedBulkClient.value = null
// A disconnect supersedes any pending session undo (the entry is gone).
lastConnect.value = null
undoPanelOpen.value = false

try {
const client = clients.value.find(c => c.id === clientId)
Expand Down Expand Up @@ -657,6 +786,8 @@ function close() {
copiedBulkClient.value = null
previews.value = {}
previewError.value = {}
lastConnect.value = null
undoPanelOpen.value = false
emit('close')
}

Expand All @@ -674,6 +805,8 @@ watch(() => props.show, (newVal) => {
copiedBulkClient.value = null
previews.value = {}
previewError.value = {}
lastConnect.value = null
undoPanelOpen.value = false
}
})
</script>
Loading
Loading