feat: the console MCP section; Connected Apps follows OAuth (TASK-3303, PLAN-2310 U5) (#1666)

* feat: the console MCP section, and Connected Apps follows OAuth (TASK-3303, PLAN-2310 U5)

The admin Settings page gains "MCP for agents": the toggle (saved on
click; disabled with "set by environment (PAD_MCP_ENABLED)" or "managed
by the operator" when locked) and the GET /api/v1/admin/mcp readout:
state (on, off, or blocked with the reason), public origin and where it
came from, MCP URL, https, how agents sign in, and while off, what would
resume (nothing was revoked).

The session, setup and admin stats payloads carry mcp_available and
oauth_available (per request). Connected Apps keys on oauthAvailable
instead of cloud mode, and the admin MCP Audit tab is always shown to
admins, with "No MCP activity recorded" as its empty state.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* fix(web): the MCP Off state no longer overstates what the routes answer (TASK-3303, codex r1)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* feat(web): the MCP toggle saves with an explicit Save, like the page's other sections (TASK-3303, lead ruling)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz

* fix(web): 'off by default' is a self-host fact (TASK-3303, codex r3 nit)

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
This commit is contained in:
xarmian
2026-09-29 16:41:24 -04:00
committed by GitHub
parent 6ed11181d8
commit e43bd7b858
13 changed files with 517 additions and 13 deletions
+6 -4
View File
@@ -855,10 +855,12 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"users": userCount,
"users_by_plan": planCounts,
"workspaces": len(workspaces),
"cloud_mode": s.cloudMode,
"users": userCount,
"users_by_plan": planCounts,
"workspaces": len(workspaces),
"cloud_mode": s.cloudMode,
"mcp_available": s.mcpAvailable(),
"oauth_available": s.oauthAvailable(),
})
}
+10 -1
View File
@@ -134,7 +134,11 @@ func (s *Server) setupStatePayload(setupMethod string) map[string]interface{} {
"email_configured": s.email != nil,
"mcp_public_url": s.sessionMCPPublicURL(),
"billing_available": s.cloudMode && s.billingAvailable,
"version": s.version,
// PLAN-2310 DR-7: the web UI keys Connected Apps on oauth_available
// rather than cloud_mode. Both are evaluated per request.
"mcp_available": s.mcpAvailable(),
"oauth_available": s.oauthAvailable(),
"version": s.version,
}
}
@@ -172,6 +176,11 @@ func (s *Server) sessionStatePayload(authenticated bool, user *models.User) map[
"email_configured": s.email != nil,
"mcp_public_url": s.sessionMCPPublicURL(),
"billing_available": s.cloudMode && s.billingAvailable,
// PLAN-2310 DR-7: whether MCP and its OAuth path are available now
// (the setting, the configured origin and its scheme; always on
// cloud). The console keys Connected Apps on oauth_available.
"mcp_available": s.mcpAvailable(),
"oauth_available": s.oauthAvailable(),
// webmcp_enabled gates the browser-side WebMCP surface. Read from the
// platform_settings kv table; default false when unset/absent or on
// any read error (fail closed). The web client uses it to decide
@@ -0,0 +1,63 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/config"
)
// PLAN-2310 DR-7 (TASK-3303): the session payload and the admin stats carry
// mcp_available and oauth_available, evaluated per request, so the console can
// key Connected Apps on the OAuth path rather than on cloud mode.
func TestMCPAvailabilityInPayloads(t *testing.T) {
cases := []struct {
name string
url string
on bool
withOAuth bool
mcp, oauth bool
}{
{"off", "https://pad.example.com", false, true, false, false},
{"on, https, OAuth built", "https://pad.example.com", true, true, true, true},
{"on, http", "http://10.0.0.5:7777", true, false, true, false},
{"on, no origin", "", true, false, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv, token := mcpServer(t, config.Config{URL: c.url}, nil)
if c.withOAuth {
o, err := newTestOAuthServer(t, srv)
if err != nil {
t.Fatalf("oauth: %v", err)
}
srv.oauthServer = o
}
if c.on {
if err := srv.store.SetPlatformSetting(settingMCPEnabled, "true"); err != nil {
t.Fatal(err)
}
}
for _, path := range []string{"/api/v1/auth/session", "/api/v1/admin/stats"} {
rr := doRequestWithCookie(srv, "GET", path, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("%s: %d %s", path, rr.Code, rr.Body.String())
}
var got struct {
MCP *bool `json:"mcp_available"`
OAuth *bool `json:"oauth_available"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("%s: %v", path, err)
}
if got.MCP == nil || got.OAuth == nil {
t.Fatalf("%s: fields missing: %s", path, rr.Body.String())
}
if *got.MCP != c.mcp || *got.OAuth != c.oauth {
t.Errorf("%s: mcp=%v oauth=%v, want %v %v", path, *got.MCP, *got.OAuth, c.mcp, c.oauth)
}
}
})
}
}
+12
View File
@@ -1,5 +1,6 @@
import type {
DecisionSettings,
MCPSettings,
DecisionSettingsInput,
Workspace,
DeletedWorkspace,
@@ -1114,6 +1115,12 @@ export interface AuthSession {
// AND the deployment is in cloud mode. Use authStore.billingAvailable rather
// than reading this field directly. TASK-800.
billing_available?: boolean;
// mcp_available / oauth_available (PLAN-2310 DR-7): whether MCP, and its
// OAuth path, are available right now. Always true on Pad Cloud; on a
// self-hosted install they follow the admin toggle, the configured origin
// and its scheme. Absent on older servers: treat as cloud_mode.
mcp_available?: boolean;
oauth_available?: boolean;
// email_configured is false when the self-host server has no transactional
// email provider (no Maileroo key). The /forgot-password page reads it to
// replace "we emailed you a link" with host-recovery guidance, since no
@@ -3146,6 +3153,11 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(settings)
}),
// MCP over HTTP (PLAN-2310 DR-2). PUT answers 409 set_by_environment
// when PAD_MCP_ENABLED forces the value, 403 on Pad Cloud.
getMCPSettings: () => request<MCPSettings>('/admin/mcp'),
updateMCPSettings: (enabled: boolean) =>
request<MCPSettings>('/admin/mcp', { method: 'PUT', body: JSON.stringify({ enabled }) }),
// Decision provider (TASK-3121). The response never carries the API
// key — only whether one is set. api_key is write-only.
getDecisionSettings: () => request<DecisionSettings>('/admin/decision-provider'),
+2
View File
@@ -59,6 +59,8 @@ export interface Stats {
users_by_plan: Record<string, number>;
workspaces: number;
cloud_mode: boolean;
mcp_available?: boolean;
oauth_available?: boolean;
}
export interface LimitTiers {
+5
View File
@@ -187,6 +187,11 @@ export const authStore = {
// this value — false means the CTA is hidden entirely, not just disabled.
// TASK-800.
get billingAvailable() { return session?.billing_available ?? false; },
// oauthAvailable: MCP's OAuth path is available (PLAN-2310 DR-7), which is
// what Connected Apps exists for. Always on Pad Cloud; on a self-hosted
// install, the admin toggle on with an https origin. An older server that
// does not send it offered OAuth only in cloud mode.
get oauthAvailable() { return session?.oauth_available ?? session?.cloud_mode ?? false; },
// nativeShell is true inside a Pad mobile app's web view: both shells append
// the `PadShell/1` marker to every request's user agent (PLAN-3291 DR-2;
// pad-mobile AppUserAgent.MARKER / UserAgent.marker). The prefix is matched
+23
View File
@@ -2748,6 +2748,29 @@ export interface ResolvedItemIdentity {
// Decision provider settings (TASK-3121) — GET/PUT /admin/decision-provider.
// The API key is never returned, masked or otherwise: only api_key_set.
// MCP over HTTP (PLAN-2310 DR-2 / DR-7): GET/PUT /api/v1/admin/mcp.
export interface MCPSettings {
enabled: boolean;
/** Where `enabled` comes from. `environment` and `cloud` are locked. */
source: 'setting' | 'environment' | 'cloud';
locked: boolean;
readiness: {
origin: string;
origin_var?: string;
mcp_url: string;
auth_server_url: string;
https: boolean;
/** `["oauth","pat"]` on https, `["pat"]` on http, `[]` with no usable origin. */
auth_methods: string[];
/** "VARIABLE value is not usable: reason" lines. */
problems: string[];
state: 'on' | 'off' | 'blocked';
blocked?: string;
/** What would work again if MCP were turned on; turning it off revokes nothing. */
resume: { oauth_connections: number; pats: number };
};
}
export interface DecisionSettings {
/** What the running server uses. */
effective: { enabled: boolean; provider: string; model: string; api_key_set: boolean };
+3 -1
View File
@@ -156,7 +156,9 @@
>
Deleted workspaces
</a>
{#if authStore.cloudMode}
<!-- Connected Apps lists OAuth grants, so it follows the OAuth path, not
cloud mode (PLAN-2310 DR-7). -->
{#if authStore.oauthAvailable}
<a
href="/console/connected-apps"
class="nav-link"
+5 -5
View File
@@ -23,11 +23,11 @@
{ label: 'Users', href: '/console/admin' },
{ label: 'Invitations', href: '/console/admin/invitations' },
{ label: 'Audit Log', href: '/console/admin/audit-log' },
// MCP audit log (TASK-960) — full-table view of every MCP call.
// Cloud-only: the /api/v1/admin/mcp-audit endpoint backs the
// MCP transport, which only mounts in cloud mode. Hiding the
// tab in self-host avoids a "the page always errors" UX.
...(cloudMode ? [{ label: 'MCP Audit', href: '/console/admin/mcp-audit' }] : []),
// MCP audit log (TASK-960): the full-table view of every MCP call.
// Always shown to admins (PLAN-2310 DR-7): its API answers on every
// install, MCP on or off, because reading past use while MCP is off
// is how an admin audits it. No history renders the empty state.
{ label: 'MCP Audit', href: '/console/admin/mcp-audit' },
...(cloudMode ? [{ label: 'Billing', href: '/console/admin/billing' }] : []),
{ label: 'Settings', href: '/console/admin/settings' }
]);
@@ -147,7 +147,7 @@
</div>
{:else if entries.length === 0}
<EmptyState
title="No MCP audit entries"
title="No MCP activity recorded"
message="Audit rows appear here as soon as the first /mcp request lands."
/>
{:else}
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createRawSnippet, flushSync, mount, unmount } from 'svelte';
/**
* PLAN-2310 DR-7 (TASK-3303): the admin MCP Audit tab is always visible to an
* admin, MCP on or off, because its API answers on every install and reading
* past use while MCP is off is how an admin audits it. It used to hide off
* cloud. A non-admin gets no admin tabs at all.
*/
const auth = vi.hoisted(() => ({ role: 'admin' }));
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/console/admin') } }));
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
get user() {
return { role: auth.role };
}
}
}));
vi.mock('$lib/stores/admin.svelte', () => ({
adminStore: {
loadStats: vi.fn(),
loading: false,
error: '',
// A self-hosted install with MCP off.
stats: { users: 1, users_by_plan: {}, workspaces: 1, cloud_mode: false, mcp_available: false, oauth_available: false }
}
}));
const { default: AdminLayout } = await import('./+layout.svelte');
let app: Record<string, unknown> | null = null;
let host: HTMLElement;
function render() {
host = document.createElement('div');
document.body.appendChild(host);
const children = createRawSnippet(() => ({ render: () => '<div></div>' }));
app = mount(AdminLayout, { target: host, props: { children } }) as Record<string, unknown>;
flushSync();
}
const tabLabels = () => [...host.querySelectorAll('a')].map((a) => a.textContent?.trim());
afterEach(() => {
if (app) unmount(app as never);
app = null;
host.remove();
});
describe('admin MCP Audit tab', () => {
it('is shown to an admin on a self-hosted install with MCP off', () => {
auth.role = 'admin';
render();
expect(tabLabels()).toContain('MCP Audit');
expect(host.querySelector('a[href="/console/admin/mcp-audit"]')).not.toBeNull();
});
it('is not shown to a non-admin', () => {
auth.role = 'member';
render();
expect(tabLabels()).not.toContain('MCP Audit');
});
});
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushSync, mount, unmount, tick } from 'svelte';
/**
* PLAN-2310 DR-7 (TASK-3303): the admin Settings page's MCP section renders
* each state GET /api/v1/admin/mcp can report. These assert the rendered page
* (CONVE-19), with the admin store and API client stubbed.
*/
const getMCP = vi.hoisted(() => vi.fn());
const putMCP = vi.hoisted(() => vi.fn());
vi.mock('$lib/stores/admin.svelte', () => ({
adminFetch: vi.fn(async () => null),
adminPatch: vi.fn(),
adminPost: vi.fn(),
getCSRFToken: () => '',
adminStore: { stats: null }
}));
vi.mock('$lib/api/client', () => ({
api: {
admin: {
getDecisionSettings: vi.fn(() => new Promise(() => {})),
getMCPSettings: () => getMCP(),
updateMCPSettings: (enabled: boolean) => putMCP(enabled)
}
}
}));
const { default: SettingsPage } = await import('./settings/+page.svelte');
const https = {
origin: 'https://pad.example.com',
origin_var: 'PAD_URL',
mcp_url: 'https://pad.example.com/mcp',
auth_server_url: 'https://pad.example.com',
https: true,
auth_methods: ['oauth', 'pat'],
problems: [] as string[],
state: 'on',
resume: { oauth_connections: 0, pats: 0 }
};
const settings = (over: Record<string, unknown>, rd: Record<string, unknown> = {}) => ({
enabled: true,
source: 'setting',
locked: false,
...over,
readiness: { ...https, ...rd }
});
let host: HTMLElement;
let app: Record<string, unknown> | null = null;
async function mountWith(body: unknown) {
getMCP.mockResolvedValue(body);
app = mount(SettingsPage, { target: host, props: {} }) as Record<string, unknown>;
flushSync();
for (let i = 0; i < 4; i++) await Promise.resolve();
await tick();
flushSync();
}
const q = (id: string) => host.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
const text = (id: string) => q(id)?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
beforeEach(() => {
host = document.createElement('div');
document.body.appendChild(host);
getMCP.mockReset();
putMCP.mockReset();
});
afterEach(() => {
if (app) unmount(app as never);
app = null;
host.remove();
});
describe('admin MCP section', () => {
it('on, https: OAuth and API tokens, toggle live', async () => {
await mountWith(settings({}));
expect((q('mcp-enabled') as HTMLInputElement).checked).toBe(true);
expect((q('mcp-enabled') as HTMLInputElement).disabled).toBe(false);
expect(q('mcp-state')?.dataset.state).toBe('on');
expect(text('mcp-auth')).toBe('OAuth and API tokens');
expect(text('mcp-readout')).toContain('https://pad.example.com/mcp');
expect(q('mcp-lock-note')).toBeNull();
});
it('on, http: API tokens only', async () => {
await mountWith(settings({}, { origin: 'http://10.0.0.5:7777', https: false, auth_methods: ['pat'] }));
expect(text('mcp-auth')).toBe('API tokens only');
});
it('off with resume counts says nothing was revoked', async () => {
await mountWith(settings({ enabled: false }, { state: 'off', resume: { oauth_connections: 3, pats: 1 } }));
expect(q('mcp-state')?.dataset.state).toBe('off');
expect(text('mcp-resume')).toContain('3 OAuth connections and 1 API token');
expect(text('mcp-resume')).toContain('revoked nothing');
});
it('off with nothing to resume shows no resume line', async () => {
await mountWith(settings({ enabled: false }, { state: 'off' }));
expect(q('mcp-resume')).toBeNull();
});
it('blocked, no origin: says why, origin not set', async () => {
const why = 'No public origin is configured. Set PAD_URL (or PUBLIC_URL) to the URL this server is reached at.';
await mountWith(settings({}, { origin: '', origin_var: undefined, mcp_url: '', auth_server_url: '', https: false, auth_methods: [], state: 'blocked', blocked: why }));
expect(q('mcp-state')?.dataset.state).toBe('blocked');
expect(text('mcp-state')).toContain(why);
expect(text('mcp-readout')).toContain('not set');
expect(text('mcp-auth')).toBe('None');
});
it('blocked, unusable origin: the reason once, the origin "not usable"', async () => {
const why = 'PAD_URL "pad.example.com/app" is not usable as the public origin: the scheme must be http or https';
await mountWith(settings({}, { origin: '', mcp_url: '', auth_server_url: '', https: false, auth_methods: [], problems: [why], state: 'blocked', blocked: why }));
expect(text('mcp-state')).toContain(why);
expect(text('mcp-readout')).toContain('not usable');
expect(host.querySelectorAll('[data-testid="mcp-problem"]').length).toBe(0);
});
it('locked by the environment: toggle disabled with its note, no Save', async () => {
await mountWith(settings({ source: 'environment', locked: true }));
expect((q('mcp-enabled') as HTMLInputElement).disabled).toBe(true);
expect(q('mcp-save')).toBeNull();
expect(text('mcp-lock-note')).toContain('set by environment');
});
it('Pad Cloud: toggle disabled, managed by the operator', async () => {
await mountWith(settings({ source: 'cloud', locked: true }));
expect((q('mcp-enabled') as HTMLInputElement).disabled).toBe(true);
expect(text('mcp-lock-note')).toContain('managed by the operator');
});
it('the checkbox edits a draft; only Save writes, then renders the answer', async () => {
await mountWith(settings({ enabled: false }, { state: 'off' }));
putMCP.mockResolvedValue(settings({}));
const save = q('mcp-save') as HTMLButtonElement;
expect(save.disabled, 'nothing to save before a change').toBe(true);
const box = q('mcp-enabled') as HTMLInputElement;
box.checked = true;
box.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
expect(putMCP, 'a click on the checkbox alone does not write').not.toHaveBeenCalled();
expect(save.disabled).toBe(false);
save.click();
for (let i = 0; i < 4; i++) await Promise.resolve();
await tick();
flushSync();
expect(putMCP).toHaveBeenCalledWith(true);
expect(q('mcp-state')?.dataset.state).toBe('on');
});
});
@@ -9,7 +9,7 @@
type LimitTiers
} from '$lib/stores/admin.svelte';
import { api } from '$lib/api/client';
import type { DecisionSettings, DecisionSettingsInput } from '$lib/types';
import type { DecisionSettings, DecisionSettingsInput, MCPSettings } from '$lib/types';
// Plan limits
let limits = $state<LimitTiers | null>(null);
@@ -67,6 +67,44 @@
decisionClearKey = false;
}
// MCP over HTTP (PLAN-2310 DR-7). The checkbox edits a draft and Save
// writes it, as the page's other sections do: a switch that opens an
// endpoint should not flip on a stray click. The readout is what GET
// /admin/mcp reports, which is configuration, not an
// observation of the deployment (the server cannot see TLS a proxy
// terminates).
let mcp = $state<MCPSettings | null>(null);
let mcpLoadError = $state('');
let savingMcp = $state(false);
let mcpDraft = $state(false);
let mcpStatus = $state<{ message: string; type: 'saved' | 'error' } | null>(null);
async function loadMcp() {
try {
mcp = await api.admin.getMCPSettings();
mcpDraft = mcp.enabled;
} catch (e) {
mcpLoadError = e instanceof Error ? e.message : 'Failed to load';
}
}
async function setMcpEnabled(enabled: boolean) {
savingMcp = true;
mcpStatus = null;
try {
mcp = await api.admin.updateMCPSettings(enabled);
mcpDraft = mcp.enabled;
mcpStatus = { message: 'Saved', type: 'saved' };
} catch (e) {
mcpStatus = { message: e instanceof Error ? e.message : 'Failed to save', type: 'error' };
} finally {
savingMcp = false;
}
}
const mcpAuthLabel = (methods: string[]) =>
methods.includes('oauth') ? 'OAuth and API tokens' : methods.includes('pat') ? 'API tokens only' : 'None';
async function loadDecision() {
try {
applyDecision(await api.admin.getDecisionSettings());
@@ -201,6 +239,7 @@
onMount(() => {
loadSettings();
loadDecision();
loadMcp();
});
</script>
@@ -493,6 +532,103 @@
</div>
</section>
<section class="section" data-testid="mcp-section">
<h2 class="section-title">MCP for agents</h2>
<p class="section-desc">
Lets remote agents (Claude.ai, ChatGPT, Cursor and other MCP clients) use this server at
its MCP URL, without installing the Pad CLI. Off by default on a self-hosted install.
</p>
<div class="email-card">
{#if mcpLoadError}
<p class="save-msg error-msg">Could not load: {mcpLoadError}</p>
{:else if !mcp}
<p class="save-msg">Loading…</p>
{:else}
{@const rd = mcp.readiness}
<label class="toggle-row">
<input
type="checkbox"
data-testid="mcp-enabled"
checked={mcpDraft}
disabled={mcp.locked || savingMcp}
onchange={(e) => (mcpDraft = e.currentTarget.checked)}
/>
<span>Enable MCP</span>
{#if mcp.source === 'environment'}
<span class="env-note" data-testid="mcp-lock-note">set by environment (PAD_MCP_ENABLED)</span>
{:else if mcp.source === 'cloud'}
<span class="env-note" data-testid="mcp-lock-note">managed by the operator</span>
{/if}
</label>
<p class="mcp-state" data-testid="mcp-state" data-state={rd.state}>
{#if rd.state === 'on'}
<span class="mcp-dot on" aria-hidden="true"></span>On: agents can connect.
{:else if rd.state === 'blocked'}
<span class="mcp-dot blocked" aria-hidden="true"></span>On, but blocked: {rd.blocked}
{:else}
<span class="mcp-dot" aria-hidden="true"></span>Off: agents cannot connect.
{/if}
</p>
<dl class="mcp-readout" data-testid="mcp-readout">
<dt>Public origin</dt>
<dd>
{#if rd.origin}
<code>{rd.origin}</code>{#if rd.origin_var}<span class="env-note">from {rd.origin_var}</span>{/if}
{:else if rd.problems.length > 0}
not usable <span class="env-note">see above</span>
{:else}
not set <span class="env-note">set PAD_URL to the URL this server is reached at</span>
{/if}
</dd>
<dt>MCP URL</dt>
<dd>{#if rd.mcp_url}<code>{rd.mcp_url}</code>{:else}—{/if}</dd>
<dt>https</dt>
<dd>{rd.origin ? (rd.https ? 'Yes' : 'No') : '—'}</dd>
<dt>Agents sign in with</dt>
<dd data-testid="mcp-auth">{mcpAuthLabel(rd.auth_methods)}</dd>
</dl>
{#each rd.problems.filter((p) => p !== rd.blocked) as problem (problem)}
<p class="save-msg error-msg" data-testid="mcp-problem">{problem}</p>
{/each}
{#if rd.state === 'off' && (rd.resume.oauth_connections > 0 || rd.resume.pats > 0)}
<p class="privacy-line" data-testid="mcp-resume">
Turning it on resumes {rd.resume.oauth_connections}
{rd.resume.oauth_connections === 1 ? 'OAuth connection' : 'OAuth connections'} and {rd.resume.pats}
{rd.resume.pats === 1 ? 'API token' : 'API tokens'}. Turning it off revoked nothing.
</p>
{/if}
{#if rd.origin}
<p class="privacy-line">
This reports configuration, not an observation: the server cannot see TLS a proxy
terminates. {rd.https ? 'An https URL on a server actually reached over http advertises OAuth that cannot complete.' : 'Over http, agents use API tokens only.'}
</p>
{/if}
{#if !mcp.locked}
<div class="edit-actions">
<button
class="btn primary"
data-testid="mcp-save"
disabled={savingMcp || mcpDraft === mcp.enabled}
onclick={() => setMcpEnabled(mcpDraft)}
>
{savingMcp ? 'Saving...' : 'Save MCP Settings'}
</button>
{#if mcpStatus}
<span class="save-msg" class:error-msg={mcpStatus.type === 'error'}>{mcpStatus.message}</span>
{/if}
</div>
{/if}
{/if}
</div>
</section>
<section class="section">
<h2 class="section-title">Integrations</h2>
<p class="section-desc">
@@ -712,4 +848,41 @@
.error-msg {
color: var(--accent-red, #d33);
}
.mcp-state {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.85rem;
color: var(--text-primary);
margin: 0;
}
.mcp-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.mcp-dot.on {
background: var(--accent-green);
}
.mcp-dot.blocked {
background: var(--accent-orange, var(--accent-red));
}
.mcp-readout {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-1) var(--space-4);
margin: 0;
font-size: 0.82rem;
}
.mcp-readout dt {
color: var(--text-secondary);
}
.mcp-readout dd {
margin: 0;
color: var(--text-primary);
min-width: 0;
overflow-wrap: anywhere;
}
</style>