Skip to content
Open
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ Env vars: `MCPPROXY_LISTEN`, `MCPPROXY_API_KEY`, `MCPPROXY_DEBUG`, `MCPPROXY_TEL

All server responses include a unified `health` field: `level` (healthy|degraded|unhealthy), `admin_state` (enabled|disabled|quarantined), plus `summary`/`detail`/`action`.

**Connect payload (Spec 075)**: `GET /api/v1/connect` is content-read-free (stat-only; no macOS App-Data prompt) — each `ClientStatus` carries `access_state="unknown"`. `GET /api/v1/connect/{client}` resolves it on-demand to `accessible|absent|malformed|denied` (+ `remediation` when denied); a denied connect/disconnect returns `403` with remediation. See [docs/api/rest-api.md](docs/api/rest-api.md#connect-client-wizard).

## Security Model

- **Localhost-only by default** (`127.0.0.1:8080`); **API key always required** (auto-generated and persisted if not provided).
Expand Down
46 changes: 41 additions & 5 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,14 +651,50 @@ additive-compatible and gains two fields:
|-------|------|---------|
| `exists` | bool | Config file present (metadata only). |
| `connected` | bool | mcpproxy registered in the config. Authoritative **only** when `access_state == "accessible"`; `false`/unresolved in the overall listing. |
| `access_state` | string | `"unknown"` in the overall listing (not content-checked); resolved to `"accessible"`, `"absent"`, or `"malformed"` by an on-demand single-client read. |
| `remediation` | string | Present only when access is denied (populated by later stories). |
| `access_state` | string | `"unknown"` in the overall listing (not content-checked); resolved to `"accessible"`, `"absent"`, `"malformed"`, or `"denied"` by an on-demand single-client read. |
| `remediation` | string | Present only when `access_state == "denied"`; carries the actionable fix text (App Data toggle + `tccutil reset` command). |

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 (connect/disconnect, or the CLI
`mcpproxy connect` command), which is the only place a privacy prompt may
legitimately appear.
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.

#### 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**
(`200` with `access_state="denied"` + `remediation`), not as an HTTP error.

```bash
curl "http://127.0.0.1:8080/api/v1/connect/claude-desktop?apikey=your-api-key"
```

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

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).

##### macOS App Data privacy & Connect

On macOS, client configs (Claude Desktop, Cursor, VS Code, …) live under another
app's container, gated by the **Privacy & Security ▸ App Data** TCC permission.
If mcpproxy is denied, an on-demand read returns `access_state="denied"` with
remediation. Fix it by enabling mcpproxy under **System Settings ▸ Privacy &
Security ▸ App Data**, or reset the decision and retry:

```bash
tccutil reset SystemPolicyAppData com.smartmcpproxy.mcpproxy
# dev builds: com.smartmcpproxy.mcpproxy.dev
```

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

### Real-time Updates

Expand Down
61 changes: 61 additions & 0 deletions internal/httpapi/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package httpapi

import (
"encoding/json"
"errors"
"fmt"
"net/http"

Expand Down Expand Up @@ -37,6 +38,45 @@ func (s *Server) handleGetConnectStatus(w http.ResponseWriter, r *http.Request)
s.writeSuccess(w, statuses)
}

// handleGetConnectClientStatus godoc
// @Summary Get a single client's connection status (on-demand)
// @Description Resolves one client's status by reading its config file on demand.
// @Description This is the only Connect endpoint that opens a client config file, so
// @Description on macOS it is the sole place an App-Data privacy prompt may legitimately
// @Description appear (scoped to this user action). Resolves access_state to
// @Description accessible|absent|denied|malformed and populates remediation when denied.
// @Tags connect
// @Produce json
// @Security ApiKeyAuth
// @Security ApiKeyQuery
// @Param client path string true "Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)"
// @Success 200 {object} contracts.APIResponse "ClientStatus"
// @Failure 404 {object} contracts.ErrorResponse "Unknown client"
// @Failure 503 {object} contracts.ErrorResponse "Service unavailable"
// @Router /api/v1/connect/{client} [get]
func (s *Server) handleGetConnectClientStatus(w http.ResponseWriter, r *http.Request) {
svc := s.getConnectService()
if svc == nil {
s.writeError(w, r, http.StatusServiceUnavailable, "connect service not available")
return
}

clientID := chi.URLParam(r, "client")
if clientID == "" {
s.writeError(w, r, http.StatusBadRequest, "client ID is required")
return
}

status, err := svc.GetStatus(clientID)
if err != nil {
// GetStatus only errors for an unknown client; a permission denial is
// reported in-band via access_state=denied + remediation, not as an error.
s.writeError(w, r, http.StatusNotFound, err.Error())
return
}
s.writeSuccess(w, status)
}

// handleConnectClient godoc
// @Summary Connect MCPProxy to a client
// @Description Register MCPProxy as an MCP server in the specified client's configuration file.
Expand Down Expand Up @@ -77,6 +117,11 @@ func (s *Server) handleConnectClient(w http.ResponseWriter, r *http.Request) {

result, err := svc.Connect(clientID, req.ServerName, req.Force)
if err != nil {
// A macOS App-Data (TCC) denial surfaces as 403 carrying remediation,
// distinct from a generic 400 or a 404 not-found (Spec 075 contract).
if s.writeIfAccessDenied(w, r, err) {
return
}
// Distinguish between "unknown client" and other errors
client := connect.FindClient(clientID)
if client == nil {
Expand Down Expand Up @@ -135,6 +180,9 @@ func (s *Server) handleDisconnectClient(w http.ResponseWriter, r *http.Request)

result, err := svc.Disconnect(clientID, req.ServerName)
if err != nil {
if s.writeIfAccessDenied(w, r, err) {
return
}
client := connect.FindClient(clientID)
if client == nil {
s.writeError(w, r, http.StatusNotFound, err.Error())
Expand All @@ -152,6 +200,19 @@ func (s *Server) handleDisconnectClient(w http.ResponseWriter, r *http.Request)
s.writeSuccess(w, result)
}

// writeIfAccessDenied maps a permission-denied client-config access to a 403
// response whose body carries the remediation text. It returns true when it
// handled the error (a typed *connect.AccessError), so callers can stop. This
// keeps a macOS App-Data block distinct from a generic failure (Spec 075).
func (s *Server) writeIfAccessDenied(w http.ResponseWriter, r *http.Request, err error) bool {
var accessErr *connect.AccessError
if errors.As(err, &accessErr) {
s.writeError(w, r, http.StatusForbidden, accessErr.Error())
return true
}
return false
}

// getConnectService returns the connect service, creating it lazily from config if needed.
func (s *Server) getConnectService() *connect.Service {
if s.connectService != nil {
Expand Down
172 changes: 172 additions & 0 deletions internal/httpapi/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package httpapi
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"syscall"
"testing"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/connect"
Expand All @@ -15,6 +17,12 @@ import (
"go.uber.org/zap"
)

// denyingReader returns a permission-denied PathError for every read, simulating
// a macOS App-Data (TCC) block without a real OS denial (Spec 075).
func denyingReader(path string) ([]byte, error) {
return nil, &fs.PathError{Op: "open", Path: path, Err: syscall.EPERM}
}

func TestHandleConnectClient_OpenCodeAdoptedAlreadyExistsReturns200(t *testing.T) {
logger := zap.NewNop().Sugar()
mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
Expand Down Expand Up @@ -79,6 +87,170 @@ func TestHandleConnectClient_OpenCodeMissingConfigReturns400(t *testing.T) {
assert.Contains(t, resp.Error, "does not exist")
}

// TestHandleGetConnectStatus_IncludesAccessStateUnknown asserts the overall
// GET /connect payload is additive: each entry carries access_state, set to
// "unknown" by the content-read-free overall status (Spec 075 T025, contract).
func TestHandleGetConnectStatus_IncludesAccessStateUnknown(t *testing.T) {
logger := zap.NewNop().Sugar()
mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
srv := NewServer(mockCtrl, logger, nil)
home := t.TempDir()
srv.SetConnectService(connect.NewServiceWithHome("127.0.0.1:8080", "", home))

req := httptest.NewRequest(http.MethodGet, "/api/v1/connect", http.NoBody)
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data []connect.ClientStatus `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
assert.True(t, resp.Success)
require.NotEmpty(t, resp.Data)
for _, st := range resp.Data {
assert.Equal(t, "unknown", st.AccessState, "overall status must not content-read client %s", st.ID)
assert.Empty(t, st.Remediation)
}
}

// TestHandleGetConnectClientStatus_Connected asserts the on-demand per-client
// route resolves access_state=accessible and connected=true after a real
// connect (Spec 075 T025, contract GET /connect/{client}).
func TestHandleGetConnectClientStatus_Connected(t *testing.T) {
logger := zap.NewNop().Sugar()
mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
srv := NewServer(mockCtrl, logger, nil)
home := t.TempDir()
svc := connect.NewServiceWithHome("127.0.0.1:8080", "", home)
srv.SetConnectService(svc)

_, err := svc.Connect("claude-code", "mcpproxy", false)
require.NoError(t, err)

req := httptest.NewRequest(http.MethodGet, "/api/v1/connect/claude-code", http.NoBody)
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data connect.ClientStatus `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
assert.True(t, resp.Success)
assert.Equal(t, "claude-code", resp.Data.ID)
assert.True(t, resp.Data.Connected)
assert.Equal(t, "accessible", resp.Data.AccessState)
}

// TestHandleGetConnectClientStatus_Absent asserts the on-demand route reports
// access_state=absent for a client with no config file present.
func TestHandleGetConnectClientStatus_Absent(t *testing.T) {
logger := zap.NewNop().Sugar()
mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
srv := NewServer(mockCtrl, logger, nil)
home := t.TempDir()
srv.SetConnectService(connect.NewServiceWithHome("127.0.0.1:8080", "", home))

req := httptest.NewRequest(http.MethodGet, "/api/v1/connect/claude-code", http.NoBody)
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data connect.ClientStatus `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
assert.True(t, resp.Success)
assert.False(t, resp.Data.Connected)
assert.Equal(t, "absent", resp.Data.AccessState)
}

// TestHandleGetConnectClientStatus_UnknownClient asserts an unknown client id
// yields 404 (not a 200 with an empty body).
func TestHandleGetConnectClientStatus_UnknownClient(t *testing.T) {
logger := zap.NewNop().Sugar()
mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
srv := NewServer(mockCtrl, logger, nil)
srv.SetConnectService(connect.NewServiceWithHome("127.0.0.1:8080", "", t.TempDir()))

req := httptest.NewRequest(http.MethodGet, "/api/v1/connect/not-a-real-client", http.NoBody)
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)

assert.Equal(t, http.StatusNotFound, w.Code)
}

// TestHandleGetConnectClientStatus_DeniedSurfacesRemediation asserts that a
// macOS App-Data block on the on-demand read resolves access_state=denied and
// carries remediation text in the 200 body (Spec 075 contract).
func TestHandleGetConnectClientStatus_DeniedSurfacesRemediation(t *testing.T) {
logger := zap.NewNop().Sugar()
mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
srv := NewServer(mockCtrl, logger, nil)
home := t.TempDir()

// Config must stat-exist so the on-demand content read is attempted; the
// injected reader then denies it.
cfgPath := connect.ConfigPath("claude-code", home)
require.NoError(t, os.MkdirAll(filepath.Dir(cfgPath), 0o755))
require.NoError(t, os.WriteFile(cfgPath, []byte(`{}`), 0o644))

svc := connect.NewServiceWithReader("127.0.0.1:8080", "", home, denyingReader)
srv.SetConnectService(svc)

req := httptest.NewRequest(http.MethodGet, "/api/v1/connect/claude-code", http.NoBody)
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data connect.ClientStatus `json:"data"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
assert.True(t, resp.Success)
assert.False(t, resp.Data.Connected, "denied must not read as plain not-connected")
assert.Equal(t, "denied", resp.Data.AccessState)
assert.Contains(t, resp.Data.Remediation, "tccutil reset SystemPolicyAppData")
}

// TestHandleConnectClient_DeniedReturnsRemediation asserts a permission-denied
// write surfaces remediation in the HTTP error body, distinct from a generic
// 400 or a 404 not-found (Spec 075 contract POST /connect/{client}).
func TestHandleConnectClient_DeniedReturnsRemediation(t *testing.T) {
logger := zap.NewNop().Sugar()
mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
srv := NewServer(mockCtrl, logger, nil)
home := t.TempDir()
svc := connect.NewServiceWithReader("127.0.0.1:8080", "", home, denyingReader)
srv.SetConnectService(svc)

body, _ := json.Marshal(ConnectRequest{ServerName: "mcpproxy", Force: false})
req := httptest.NewRequest(http.MethodPost, "/api/v1/connect/claude-code", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", "test-key")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)

assert.Equal(t, http.StatusForbidden, w.Code)
var resp struct {
Success bool `json:"success"`
Error string `json:"error"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
assert.False(t, resp.Success)
assert.Contains(t, resp.Error, "tccutil reset SystemPolicyAppData")
}

func TestHandleConnectClient_TrueConflictStillReturns409(t *testing.T) {
logger := zap.NewNop().Sugar()
mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
Expand Down
1 change: 1 addition & 0 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,7 @@ func (s *Server) setupRoutes() {

// Client connect/disconnect
r.Get("/connect", s.handleGetConnectStatus)
r.Get("/connect/{client}", s.handleGetConnectClientStatus)
r.Post("/connect/{client}", s.handleConnectClient)
r.Delete("/connect/{client}", s.handleDisconnectClient)

Expand Down
2 changes: 1 addition & 1 deletion oas/docs.go

Large diffs are not rendered by default.

Loading
Loading