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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,6 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 194/195 (99%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 239/240 (100%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 245/246 (100%) |
| [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) |
| [112-client-header-forwarding](./specs/112-client-header-forwarding/) | `shipped` | 38/40 (95%) |
3 changes: 2 additions & 1 deletion cmd/mcpproxy/parity_109_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func p109HelpRoot() *cobra.Command {
GetAttentionCommand(), GetStatusCommand(), GetDoctorCommand(), GetUpstreamCommand(),
GetReviewCommand(), GetSecurityCommand(), GetToolsCommand(), GetClientCommand(),
GetConnectCommand(), GetTokenCommand(), GetCatalogCommand(), GetRegistryCommand(),
GetActivityCommand(),
GetActivityCommand(), GetTelemetryCommand(),
)
clioutput.SetupHelpJSON(root)
return root
Expand Down Expand Up @@ -188,6 +188,7 @@ func TestParity109CLIGroupsAreRegisteredInMain(t *testing.T) {
"upstream": "GetUpstreamCommand", "review": "GetReviewCommand", "tools": "GetToolsCommand",
"client": "GetClientCommand", "connect": "GetConnectCommand", "token": "GetTokenCommand",
"catalog": "GetCatalogCommand", "registry": "GetRegistryCommand", "activity": "GetActivityCommand",
"telemetry": "GetTelemetryCommand",
}
raw, err := os.ReadFile(filepath.Join("..", "..", "specs", "109-ux-navigation-consistency", "parity-matrix.json"))
require.NoError(t, err)
Expand Down
34 changes: 22 additions & 12 deletions docs/api/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,25 +102,35 @@ curl "http://127.0.0.1:8080/api/v1/activity?request_id=a1b2c3d4-e5f6-7890-abcd-e

#### GET /api/v1/status

Get server status and statistics.
Get server status and statistics. The `data` object carries `running`, `edition`, `listen_addr`, `routing_mode`, `upstream_stats`, `started_at`, `timestamp` and the blocks below. (An earlier version of this page showed a different shape; it was stale.)

**Response:**
**Response (abridged):**
```json
{
"status": "running",
"version": "0.11.0",
"uptime": 3600,
"servers": {
"total": 5,
"connected": 4,
"quarantined": 1
},
"tools": {
"total": 42
"success": true,
"data": {
"running": true,
"edition": "personal",
"listen_addr": "127.0.0.1:8080",
"routing_mode": "retrieve_tools",
"upstream_stats": { "total_servers": 5, "connected_servers": 4, "quarantined_servers": 1, "total_tools": 42 },
"telemetry": { "enabled": false, "source": "env", "disabled_by": "MCPPROXY_TELEMETRY=false" }
}
}
```

**`telemetry`** is the effective telemetry state of the running core, so a UI can say whether telemetry is on and why. It is withheld from scoped callers (agent tokens), like `activation`.

| Field | Description |
|-------|-------------|
| `enabled` | Whether the core sends telemetry. Always equal to the resolved state: an environment opt-out wins over the config file. |
| `source` | `env` (an environment variable disabled it), `config` (`telemetry.enabled` is set in the config file, true or false) or `default` (unset, which means on). |
| `disabled_by` | Present only when `source` is `env`: `DO_NOT_TRACK`, `CI` or `MCPPROXY_TELEMETRY=false`. |

`GET /api/v1/config` keeps returning the stored `telemetry.enabled`, which can differ from `enabled` here when an environment variable overrides it. A dev (non-release) build never transmits whatever `enabled` says.

While an environment variable forces telemetry off, `POST /api/v1/config/apply` and `PATCH /api/v1/config` answer `422` and write nothing if the document would change `telemetry.enabled` (the value is judged after decoding, so a miscased key is caught too). A document that leaves `telemetry.enabled` as stored is accepted.

### Servers

#### GET /api/v1/servers
Expand Down
2 changes: 1 addition & 1 deletion docs/development/macos-tray.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ After a change under `Views/Profiles*`, `Views/Client*`, `Views/AccessExplainer*
10. **Scoped views.** Tools "View as Client… / Profile…" greys rows the subject cannot use, with the reason and a "Why?" button that opens the explainer. Activity shows a Caller column and Profile, Client and Token pickers. Home filters usage and sessions by the same three and shows Profile and Source columns. Servers filters by profile.
11. **Width and accessibility.** At a 900 pt window nothing is clipped on Profiles, the editor and Clients; run `check_accessibility`. The accessibility ids are listed in the plan for 108-k (K22): `profile-card-<name>`, `profile-editor`, `profile-editor-save`, `client-profile-picker-<id>`, `client-lock-toggle-<id>`, `clients-warnings-banner`, `access-explainer`, `guard-refusal`, `settings-anonymous-profile`, `connect-profile-picker`, `connect-lock-toggle`, `connect-mgmt-notice`.

**Driving the window without screenshots.** `screenshot_window` needs Screen Recording; when it fails (`Failed to capture window`), drive and read the window through the accessibility tree instead (`AXPress`, `AXValue`, `AXFocused` on the `profile-*`, `client-*`, `token-*`, `connect-*` ids above), keystroke into focused fields with System Events, and check a 900 pt window by listing elements whose frame leaves the window. Navigation shortcuts: ⌘1 Home, ⌘2 Clients, ⌘3 Profiles, ⌘4 Servers, ⌘5 Tools. A tray attached to an already-running core (`MCPPROXY_TRAY_SKIP_CORE=1`) needs the core's `MCPPROXY_SOCKET_PATH`; the SSE stream authenticates with the key it reads from `/api/v1/info`.
**Driving the window without screenshots.** `screenshot_window` needs Screen Recording; when it fails (`Failed to capture window`), drive and read the window through the accessibility tree instead (`AXPress`, `AXValue`, `AXFocused` on the `profile-*`, `client-*`, `token-*`, `connect-*` ids above), keystroke into focused fields with System Events, and check a 900 pt window by listing elements whose frame leaves the window. Navigation shortcuts: ⌘1 Home, ⌘2 Clients, ⌘3 Profiles, ⌘4 Servers, ⌘5 Tools. A tray attached to an already-running core (`MCPPROXY_TRAY_SKIP_CORE=1`) needs the core's `MCPPROXY_SOCKET_PATH`; the SSE stream authenticates with the key it reads from `/api/v1/info`. The tray ignores `HOME` (the Go core honours it), and an app started with plain `open` drops shell variables, so a dev bundle meant for a scratch core must be launched as the binary directly (or with `open --env …`) with `MCPPROXY_TRAY_SKIP_CORE=1 MCPPROXY_SOCKET_PATH=<scratch>/mcpproxy.sock`; otherwise it attaches to the real `~/.mcpproxy` core, which is how a first-run test once reported the wrong version and telemetry state. Before trusting any observation, confirm that Settings → Security shows "Connected core vX is listening on …" naming the scratch build.

**MCP config** (in Claude Code settings or `~/.claude/settings.json`):
```json
Expand Down
6 changes: 6 additions & 0 deletions docs/features/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ export MCPPROXY_TELEMETRY=false

This overrides the config file setting and is useful for CI/CD environments or system-wide policies.

### How the UI shows an environment opt-out

When an environment variable (`MCPPROXY_TELEMETRY=false`, `DO_NOT_TRACK` or `CI`) disables telemetry, the Web UI and the macOS app say so instead of showing the usual notice: the setup wizard's last step, the Home banner and the macOS welcome read "Anonymous usage telemetry is off — disabled by MCPPROXY_TELEMETRY=false in the environment. Nothing is sent." The telemetry toggle in Settings is shown off and disabled with the same reason; unset the variable and restart MCPProxy to change it. If you turned telemetry off in the config file yourself, no notice is shown. The core reports this state at `telemetry` in `GET /api/v1/status`.

Development builds (a version that is not a release number) never send telemetry, whatever the setting. That is a property of the build and is not reflected in the UI.

## Data handling

- Telemetry data is sent to a Cloudflare Worker over HTTPS
Expand Down
29 changes: 2 additions & 27 deletions frontend/src/components/OnboardingWizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -581,21 +581,7 @@
final step. TelemetryBanner.vue hides itself while the wizard
is open, so this is the only place it appears until the user
closes the wizard. -->
<p
v-if="!telemetryNoticeDismissed"
class="mt-4 border-t border-base-300 pt-3 text-[11px] opacity-60 flex items-center gap-2"
data-test="wizard-telemetry-notice"
>
<span class="flex-1">
MCPProxy sends anonymous usage statistics to help improve the product. No personal data is collected.
<a href="https://mcpproxy.app/telemetry" target="_blank" rel="noopener noreferrer" class="link link-hover underline">Learn more</a>
</span>
<button
class="btn btn-ghost btn-xs"
data-test="wizard-telemetry-notice-dismiss"
@click="dismissTelemetryNotice"
>Dismiss</button>
</p>
<TelemetryBanner variant="inline" />
</section>
</div>

Expand Down Expand Up @@ -705,6 +691,7 @@ import ManualServerForm from '@/components/ManualServerForm.vue'
import ImportServers from '@/components/ImportServers.vue'
import ClientConnectList from '@/components/ClientConnectList.vue'
import ReviewQueueList from '@/components/ReviewQueueList.vue'
import TelemetryBanner from '@/components/TelemetryBanner.vue'
import { useDialogOpen } from '@/composables/useDialogOpen'
import { skipReasonLabel } from '@/utils/importSkipReason'
import { serversStepView, awaitingReviewSentence } from '@/utils/onboardingServersStep'
Expand Down Expand Up @@ -799,18 +786,6 @@ const loadingImportSources = ref(false)
const recentActivity = ref<ActivityRecord[]>([])
const loadingActivity = ref(false)

// Spec 109-b FR-044: the telemetry notice's one-line form for the wizard's
// final step, sharing TelemetryBanner.vue's dismissal state (the store's
// `telemetryNoticeDismissed` ref, backed by one localStorage key) so acting
// on either surface silences both immediately — the banner and this wizard
// are mounted together on Dashboard.vue for the whole session, so a
// component-local copy read only at mount would miss the other surface's
// dismissal until a full reload.
const telemetryNoticeDismissed = computed(() => onboarding.telemetryNoticeDismissed)
function dismissTelemetryNotice() {
onboarding.dismissTelemetryNotice()
}

// Verify tab — second milestone (UX audit F13). Lifetime flag from the
// Spec 044 activation bucket, read off `GET /api/v1/status`, which already
// serves the whole block to an admin caller. The activity log cannot answer
Expand Down
81 changes: 69 additions & 12 deletions frontend/src/components/TelemetryBanner.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,53 @@
<template>
<!-- Inline variant: the wizard's Verify-step one-liner (Spec 109-b FR-044). -->
<p
v-if="variant === 'inline' && visible"
class="mt-4 border-t border-base-300 pt-3 text-[11px] opacity-60 flex items-center gap-2"
data-test="wizard-telemetry-notice"
:data-mode="mode"
>
<span class="flex-1">
<template v-if="mode === 'off_env' && state">
<span data-test="telemetry-off-env">{{ telemetryOffLine(state) }}</span>
</template>
<template v-else>
MCPProxy sends anonymous usage statistics to help improve the product. No personal data is collected.
</template>
<a href="https://mcpproxy.app/telemetry" target="_blank" rel="noopener noreferrer" class="link link-hover underline">Learn more</a>
</span>
<button
class="btn btn-ghost btn-xs"
data-test="wizard-telemetry-notice-dismiss"
@click="dismiss"
>Dismiss</button>
</p>

<!-- UX audit F14: a daisyUI alert is a GRID that flows in columns, so at 390px
the message shared the row with the action buttons and rendered as a
~10-character column ~440px tall — over half the viewport, before any
content. Below `sm` the alert stacks (`alert-vertical`) and the text child
gets `min-w-0` so it may shrink inside its track. -->
<div v-if="visible" class="alert alert-vertical sm:alert-horizontal alert-info" data-test="telemetry-banner">
<div
v-else-if="variant === 'banner' && visible"
class="alert alert-vertical sm:alert-horizontal alert-info"
data-test="telemetry-banner"
:data-mode="mode"
>
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1 min-w-0">
<!-- Environment opt-out (Spec 109 FR-044a): say it is off and why. The
setting is locked, so no disclosure line and no Manage link. -->
<div v-if="mode === 'off_env' && state" class="flex-1 min-w-0">
<span data-test="telemetry-off-env">{{ telemetryOffLine(state) }}</span>
<a
href="https://mcpproxy.app/telemetry"
target="_blank"
rel="noopener noreferrer"
class="link link-hover underline"
> Learn more</a>
</div>
<div v-else class="flex-1 min-w-0">
<span>MCPProxy sends anonymous usage statistics to help improve the product. No personal data is collected. </span>
<a
href="https://mcpproxy.app/telemetry"
Expand All @@ -23,6 +62,7 @@
</div>
<div class="flex items-center gap-2 shrink-0">
<RouterLink
v-if="mode !== 'off_env'"
to="/settings?focus=telemetry.enabled"
class="btn btn-sm btn-ghost"
data-test="telemetry-banner-settings-link"
Expand All @@ -40,23 +80,40 @@
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useOnboardingStore } from '@/stores/onboarding'
import { telemetryNoticeMode, telemetryOffLine } from '@/utils/telemetryState'

const props = withDefaults(defineProps<{ variant?: 'banner' | 'inline' }>(), { variant: 'banner' })

const onboarding = useOnboardingStore()

// Spec 109-b FR-044: the notice must not render while the wizard is open —
// it already offers its own one-line version in the Verify step (see
// OnboardingWizard.vue), and showing both would say the same thing twice
// while the wizard sits on top of everything else. Dismissal is the store's
// shared `telemetryNoticeDismissed` ref (backed by one localStorage key), not
// an independent per-component copy, so acting on either surface hides both
// immediately even though this banner and the wizard stay mounted together
// on Dashboard.vue and neither ever remounts.
const visible = computed(() => !onboarding.telemetryNoticeDismissed && !onboarding.wizardOpen)
const state = computed(() => onboarding.telemetryState)
const mode = computed(() => telemetryNoticeMode(state.value))

// Spec 109-b FR-044: the page banner must not render while the wizard is open —
// the wizard offers this same notice inline in its Verify step (variant
// "inline"), and showing both would say the same thing twice while the wizard
// sits on top of everything else. Dismissal is the store's shared
// `telemetryNoticeDismissed` ref (backed by one localStorage key), not an
// independent per-component copy, so acting on either surface hides both
// immediately even though the banner and the wizard stay mounted together on
// Dashboard.vue and neither ever remounts.
//
// Spec 109 FR-044a: a user who turned telemetry off in their own config is not
// nagged (`hidden`); an environment opt-out is stated, not disclosed.
const visible = computed(() => {
if (onboarding.telemetryNoticeDismissed) return false
if (mode.value === 'hidden') return false
return props.variant === 'inline' ? true : !onboarding.wizardOpen
})

function dismiss() {
onboarding.dismissTelemetryNotice()
}

onMounted(() => {
void onboarding.loadTelemetryState()
})
</script>
19 changes: 17 additions & 2 deletions frontend/src/components/settings/SettingField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
</span>
</div>
<p v-if="field.help" class="text-xs text-base-content/60 mt-0.5">{{ field.help }}</p>
<!-- Spec 109 FR-044a: the effective value is forced from outside the
config file (e.g. an environment opt-out); say why and how to unlock. -->
<p v-if="lock" class="text-xs text-warning mt-0.5" :data-test="`setting-locked-${field.key}`">{{ lock.reason }}</p>
</div>

<!-- Control -->
Expand All @@ -36,7 +39,8 @@
v-if="field.control === 'toggle'"
type="checkbox"
class="toggle toggle-primary"
:checked="!!modelValue"
:checked="lock ? !!lock.value : !!modelValue"
:disabled="!!lock"
:data-test="`setting-toggle-${field.key}`"
@change="emitVal(($event.target as HTMLInputElement).checked)"
/>
Expand All @@ -46,6 +50,7 @@
v-else-if="field.control === 'select'"
class="select select-bordered select-sm min-w-[12rem]"
:value="modelValue ?? ''"
:disabled="!!lock"
:data-test="`setting-select-${field.key}`"
@change="emitVal(($event.target as HTMLSelectElement).value)"
>
Expand All @@ -62,6 +67,7 @@
:min="field.min"
:max="field.max"
:step="field.step"
:disabled="!!lock"
:data-test="`setting-number-${field.key}`"
@input="onNumber(($event.target as HTMLInputElement).value)"
/>
Expand Down Expand Up @@ -129,6 +135,7 @@
:class="{ 'input-error': validationError }"
:value="modelValue ?? ''"
:placeholder="field.placeholder"
:disabled="!!lock"
:data-test="`setting-text-${field.key}`"
@input="emitText(($event.target as HTMLInputElement).value)"
/>
Expand Down Expand Up @@ -178,7 +185,14 @@
import { ref, computed } from 'vue'
import { docsUrl, validateField, type SettingField } from '@/views/settings/fields'

const props = defineProps<{ field: SettingField; modelValue: any; dirty?: boolean }>()
const props = defineProps<{
field: SettingField
modelValue: any
dirty?: boolean
// Spec 109 FR-044a: when set the control is disabled, shows `value`, and the
// reason is rendered under the help text. emitVal becomes a no-op.
lock?: { reason: string; value?: unknown } | null
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: any): void }>()

const showSecret = ref(false)
Expand Down Expand Up @@ -221,6 +235,7 @@ function confirmRegenerate() {
const validationError = computed(() => (props.dirty ? validateField(props.field, props.modelValue) : null))

function emitVal(v: any) {
if (props.lock) return
emit('update:modelValue', v)
}

Expand Down
Loading
Loading