diff --git a/internal/server/handlers_admin_users.go b/internal/server/handlers_admin_users.go index bc6cf0677..cce197ed2 100644 --- a/internal/server/handlers_admin_users.go +++ b/internal/server/handlers_admin_users.go @@ -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(), }) } diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index b2fb5a025..0ff09b396 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -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 diff --git a/internal/server/mcp_availability_payload_test.go b/internal/server/mcp_availability_payload_test.go new file mode 100644 index 000000000..c9a202cb1 --- /dev/null +++ b/internal/server/mcp_availability_payload_test.go @@ -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) + } + } + }) + } +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 58f23ae80..b9cd8858e 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -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('/admin/mcp'), + updateMCPSettings: (enabled: boolean) => + request('/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('/admin/decision-provider'), diff --git a/web/src/lib/stores/admin.svelte.ts b/web/src/lib/stores/admin.svelte.ts index c01ec6afe..b7f8651e3 100644 --- a/web/src/lib/stores/admin.svelte.ts +++ b/web/src/lib/stores/admin.svelte.ts @@ -59,6 +59,8 @@ export interface Stats { users_by_plan: Record; workspaces: number; cloud_mode: boolean; + mcp_available?: boolean; + oauth_available?: boolean; } export interface LimitTiers { diff --git a/web/src/lib/stores/auth.svelte.ts b/web/src/lib/stores/auth.svelte.ts index 58836ad10..67702ee5e 100644 --- a/web/src/lib/stores/auth.svelte.ts +++ b/web/src/lib/stores/auth.svelte.ts @@ -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 diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index b66f8371f..ed3afca8f 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -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 }; diff --git a/web/src/routes/console/+layout.svelte b/web/src/routes/console/+layout.svelte index 83a273caa..cb7c87883 100644 --- a/web/src/routes/console/+layout.svelte +++ b/web/src/routes/console/+layout.svelte @@ -156,7 +156,9 @@ > Deleted workspaces - {#if authStore.cloudMode} + + {#if authStore.oauthAvailable} {:else if entries.length === 0} {:else} diff --git a/web/src/routes/console/admin/mcpAuditTab.svelte.test.ts b/web/src/routes/console/admin/mcpAuditTab.svelte.test.ts new file mode 100644 index 000000000..854471a83 --- /dev/null +++ b/web/src/routes/console/admin/mcpAuditTab.svelte.test.ts @@ -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 | null = null; +let host: HTMLElement; +function render() { + host = document.createElement('div'); + document.body.appendChild(host); + const children = createRawSnippet(() => ({ render: () => '
' })); + app = mount(AdminLayout, { target: host, props: { children } }) as Record; + 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'); + }); +}); diff --git a/web/src/routes/console/admin/mcpSettingsSection.svelte.test.ts b/web/src/routes/console/admin/mcpSettingsSection.svelte.test.ts new file mode 100644 index 000000000..9dee2d264 --- /dev/null +++ b/web/src/routes/console/admin/mcpSettingsSection.svelte.test.ts @@ -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, rd: Record = {}) => ({ + enabled: true, + source: 'setting', + locked: false, + ...over, + readiness: { ...https, ...rd } +}); + +let host: HTMLElement; +let app: Record | null = null; + +async function mountWith(body: unknown) { + getMCP.mockResolvedValue(body); + app = mount(SettingsPage, { target: host, props: {} }) as Record; + 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'); + }); +}); diff --git a/web/src/routes/console/admin/settings/+page.svelte b/web/src/routes/console/admin/settings/+page.svelte index 88c3aeb8b..56a4a7e92 100644 --- a/web/src/routes/console/admin/settings/+page.svelte +++ b/web/src/routes/console/admin/settings/+page.svelte @@ -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(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(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(); }); @@ -493,6 +532,103 @@ +
+

MCP for agents

+

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

+ + +
+

Integrations

@@ -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; + }