diff --git a/docker/prod/README.md b/docker/prod/README.md index e6f93865..1029a9dd 100644 --- a/docker/prod/README.md +++ b/docker/prod/README.md @@ -46,7 +46,7 @@ fails the boot with an error naming it. | Variable | Meaning | | --- | --- | -| `OXYNOTE_PUBLIC_URL` | the origin users open in the browser, e.g. `https://notes.example.com`. Scheme + host only — every public URL, the cookie domain, and the CORS rules derive from it. Defaults to `http://localhost:8080`, which is right only while the container's port 8080 is published as host port 8080; set it for a domain or any other host port. | +| `OXYNOTE_PUBLIC_URL` | the origin users open in the browser, e.g. `https://notes.example.com`. Scheme + host only — every public URL, the cookie domain, and the CORS rules derive from it. Defaults to `http://localhost:8080`, which is right only while the container's port 8080 is published as host port 8080; set it for a domain or any other host port. The MCP server needs an `https://` origin or a loopback host; on any other `http://` origin it is off and the boot logs a warning. | ### Optional features diff --git a/docker/prod/launcher/src/index.ts b/docker/prod/launcher/src/index.ts index f88a3323..2079a083 100644 --- a/docker/prod/launcher/src/index.ts +++ b/docker/prod/launcher/src/index.ts @@ -22,6 +22,7 @@ import { corePort, coreUrl, dataDir, + mcpAvailable, postgresPort, webPort, webUrl, @@ -217,6 +218,12 @@ async function main(): Promise { bakedSentryDsns, inheritedEnv, ) + if (!mcpAvailable(config.publicOrigin)) { + log.warn( + `MCP is off: ${config.publicOrigin} is plain http on a non-loopback host. Serve Oxynote over https to use MCP.`, + ) + } + const embeddedDatabase = config.databaseDsn === undefined if (embeddedDatabase) { diff --git a/docker/prod/launcher/src/mapping.test.ts b/docker/prod/launcher/src/mapping.test.ts index ff4ab849..be008907 100644 --- a/docker/prod/launcher/src/mapping.test.ts +++ b/docker/prod/launcher/src/mapping.test.ts @@ -4,6 +4,7 @@ import { buildChildEnvs, caddyPort, corePort, + mcpAvailable, webPort, } from "./mapping.js" import { testConfig, testSecrets } from "./test-helpers.js" @@ -101,6 +102,21 @@ describe("buildChildEnvs", () => { ) }) + it("turns MCP off in both services when it is unavailable", ({ + expect, + }) => { + const envs = build( + testConfig({ + publicOrigin: "http://192.168.1.6:18555", + }), + ) + + expect(envs.core.OXYNOTE_CORE_SERVER_MCP_RESOURCE_URL).toBe("") + expect( + envs.authRealtime.OXYNOTE_AUTH_REALTIME_MCP_RESOURCE, + ).toBe("") + }) + it("wires the services to each other over loopback", ({ expect }) => { const envs = build() @@ -413,3 +429,36 @@ describe("buildChildEnvs", () => { ) }) }) + +describe("mcpAvailable", () => { + it.for([ + { name: "https", input: "https://192.168.1.6", expected: true }, + { + name: "http on localhost", + input: "http://localhost:8080", + expected: true, + }, + { + name: "http on a loopback IPv4", + input: "http://127.0.0.1:8080", + expected: true, + }, + { + name: "http on the loopback IPv6", + input: "http://[::1]:8080", + expected: true, + }, + { + name: "http on a LAN address", + input: "http://192.168.1.6:18555", + expected: false, + }, + { + name: "http on a domain", + input: "http://notes.example.com", + expected: false, + }, + ])("reports $expected for $name", ({ input, expected }, { expect }) => { + expect(mcpAvailable(input)).toBe(expected) + }) +}) diff --git a/docker/prod/launcher/src/mapping.ts b/docker/prod/launcher/src/mapping.ts index a1f84e76..02be83d6 100644 --- a/docker/prod/launcher/src/mapping.ts +++ b/docker/prod/launcher/src/mapping.ts @@ -97,6 +97,18 @@ function databaseDsn(config: Config, secrets: Secrets): string { ) } +// Better Auth's MCP provider refuses a resource URL that is plain http on a +// non-loopback host, such as a NAS on a LAN address. MCP is left off there +// so the rest of the app still boots. +export function mcpAvailable(publicOrigin: string): boolean { + const url = new URL(publicOrigin) + + return ( + url.protocol === "https:" || + /^(localhost|\[::1\]|127\.\d+\.\d+\.\d+)$/.test(url.hostname) + ) +} + // buildChildEnvs assembles each process's environment from scratch: a child // receives exactly its own component's variables and nothing else, so no // flat OXYNOTE_* value and no other component's secret ever leaks through. @@ -108,7 +120,10 @@ export function buildChildEnvs( ): ChildEnvs { const publicCoreUrl = `${config.publicOrigin}/core` const publicAuthRealtimeUrl = `${config.publicOrigin}/auth-realtime` - const mcpResourceUrl = `${config.publicOrigin}/core/api/mcp` + // an empty resource URL turns MCP off in both services. + const mcpResourceUrl = mcpAvailable(config.publicOrigin) + ? `${config.publicOrigin}/core/api/mcp` + : "" const dbDsn = databaseDsn(config, secrets) const sentryDsns = config.crashReportingDisabled ? { webDsn: "", coreDsn: "", authRealtimeDsn: "" } diff --git a/docs/ai.md b/docs/ai.md index cd2a5fd7..3dd9e599 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -3,8 +3,9 @@ Both features run inside the [production image](../docker/prod/README.md) and are configured through its `OXYNOTE_*` variables. The assistant is the in-app chat; the MCP server lets external clients such as Claude Code use -the same tools against your documents. The MCP server is always on and does -not need the assistant. +the same tools against your documents. The MCP server does not need the +assistant, but it needs an `https://` public URL or a loopback host such +as `localhost`. On a plain `http://` LAN address it is off. ## AI assistant diff --git a/server/AGENTS.md b/server/AGENTS.md index 1744c948..7a0bf149 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -39,8 +39,7 @@ GitHub and Slack callback URLs point at `:8080/core/api/apps/...`. - `/api/...`: session-authed via auth-realtime's `/api/auth/get-session` (`SERVER_AUTH_BETTER_AUTH_URL`). `GET /api/capabilities` reports one - boolean per optional service (`github`, `slack`, `changeDetection`), - snapshotted at boot from each client's `Configured()`, plus `aiAssistant` + boolean per optional service, snapshotted at boot, plus `aiAssistant` (`status` + `model`). - `/api/x/...`: no auth; auth-realtime fetches/stores branch content here (`/x/documents/{id}/branches`, `/x/documents/{id}/branch/{branchId}`), diff --git a/server/auth-realtime/src/auth.test.ts b/server/auth-realtime/src/auth.test.ts index 470339e3..99106a4f 100644 --- a/server/auth-realtime/src/auth.test.ts +++ b/server/auth-realtime/src/auth.test.ts @@ -1140,6 +1140,28 @@ describe("createAuth", () => { ]) }) + it.for([ + { + name: "registers", + input: "http://localhost:8080/core/api/mcp", + expected: true, + }, + { name: "skips", input: undefined, expected: false }, + ])( + "$name the MCP provider by whether a resource is set", + ({ input, expected }, { expect }) => { + const { auth } = buildAuth({ + env: testEnv({ mcpResource: input }), + }) + + expect( + auth.options.plugins.some( + (p) => p.id === "oauth-provider", + ), + ).toBe(expected) + }, + ) + describe("email callbacks", () => { it("sends a password reset on the public link", async ({ expect, diff --git a/server/auth-realtime/src/auth.ts b/server/auth-realtime/src/auth.ts index 94b9f667..dbecdb0f 100644 --- a/server/auth-realtime/src/auth.ts +++ b/server/auth-realtime/src/auth.ts @@ -814,164 +814,178 @@ export function createAuth({ }, }, }), - mcp({ - loginPage: `${env.frontendUrl}/login`, - consentPage: `${env.frontendUrl}/oauth-consent`, - resource: env.mcpResource, - scopes: [ - "documents:read", - "documents:write", - "data-sources:read", - ], - // MCP clients self-register per RFC 7591 before - // the user has any session, so registration must - // be open. What a client can actually do is - // still gated by the user's consent and the - // token scopes. - allowDynamicClientRegistration: true, - allowUnauthenticatedClientRegistration: true, - customAccessTokenClaims: ({ user }) => - organizationClaims(store, user), - schema: { - oauthClient: { - modelName: "oauth_clients", - fields: { - clientId: "client_id", - clientSecret: - "client_secret", - clientDiscoveryId: - "client_discovery_id", - skipConsent: - "skip_consent", - enableEndSession: - "enable_end_session", - subjectType: - "subject_type", - clientCredentialsScopes: - "client_credentials_scopes", - userId: "fk_user_id", - createdAt: "created_at", - updatedAt: "updated_at", - softwareId: "software_id", - softwareVersion: - "software_version", - softwareStatement: - "software_statement", - redirectUris: - "redirect_uris", - postLogoutRedirectUris: - "post_logout_redirect_uris", - backchannelLogoutUri: - "backchannel_logout_uri", - backchannelLogoutSessionRequired: - "backchannel_logout_session_required", - tokenEndpointAuthMethod: - "token_endpoint_auth_method", - applicationType: - "application_type", - jwksUri: "jwks_uri", - grantTypes: "grant_types", - responseTypes: - "response_types", - requirePKCE: - "require_pkce", - dpopBoundAccessTokens: - "dpop_bound_access_tokens", - referenceId: - "reference_id", - }, - }, - oauthResource: { - modelName: "oauth_resources", - fields: { - accessTokenTtl: - "access_token_ttl", - refreshTokenTtl: - "refresh_token_ttl", - signingAlgorithm: - "signing_algorithm", - signingKeyId: - "signing_key_id", - allowedScopes: - "allowed_scopes", - customClaims: - "custom_claims", - dpopBoundAccessTokensRequired: - "dpop_bound_access_tokens_required", - createdAt: "created_at", - updatedAt: "updated_at", - policyVersion: - "policy_version", - }, - }, - oauthClientResource: { - modelName: "oauth_client_resources", - fields: { - clientId: "client_id", - resourceId: "resource_id", - createdAt: "created_at", - }, - }, - oauthRefreshToken: { - modelName: "oauth_refresh_tokens", - fields: { - clientId: "client_id", - sessionId: "session_id", - userId: "fk_user_id", - referenceId: - "reference_id", - authorizationCodeId: - "authorization_code_id", - requestedUserInfoClaims: - "requested_user_info_claims", - expiresAt: "expires_at", - createdAt: "created_at", - rotatedAt: "rotated_at", - rotationReplayResponse: - "rotation_replay_response", - rotationReplayExpiresAt: - "rotation_replay_expires_at", - authTime: "auth_time", - }, - }, - oauthAccessToken: { - modelName: "oauth_access_tokens", - fields: { - clientId: "client_id", - sessionId: "session_id", - userId: "fk_user_id", - referenceId: - "reference_id", - authorizationCodeId: - "authorization_code_id", - requestedUserInfoClaims: - "requested_user_info_claims", - refreshId: "fk_refresh_token_id", - expiresAt: "expires_at", - createdAt: "created_at", - }, - }, - oauthConsent: { - modelName: "oauth_consents", - fields: { - clientId: "client_id", - userId: "fk_user_id", - referenceId: - "reference_id", - requestedUserInfoClaims: - "requested_user_info_claims", - createdAt: "created_at", - updatedAt: "updated_at", - }, - }, - oauthClientAssertion: { - modelName: "oauth_client_assertions", - fields: { - expiresAt: "expires_at", - }, - }, - }, - }), + // MCP is off when no resource URL is set. Better Auth rejects + // a plain-http resource on a non-loopback host. + ...(env.mcpResource === undefined + ? [] + : [ + mcp({ + loginPage: `${env.frontendUrl}/login`, + consentPage: `${env.frontendUrl}/oauth-consent`, + resource: env.mcpResource, + scopes: [ + "documents:read", + "documents:write", + "data-sources:read", + ], + // MCP clients self-register per RFC 7591 before + // the user has any session, so registration must + // be open. What a client can actually do is + // still gated by the user's consent and the + // token scopes. + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + customAccessTokenClaims: + ({ user }) => + organizationClaims( + store, + user, + ), + schema: { + oauthClient: { + modelName: "oauth_clients", + fields: { + clientId: "client_id", + clientSecret: + "client_secret", + clientDiscoveryId: + "client_discovery_id", + skipConsent: + "skip_consent", + enableEndSession: + "enable_end_session", + subjectType: + "subject_type", + clientCredentialsScopes: + "client_credentials_scopes", + userId: "fk_user_id", + createdAt: "created_at", + updatedAt: "updated_at", + softwareId: "software_id", + softwareVersion: + "software_version", + softwareStatement: + "software_statement", + redirectUris: + "redirect_uris", + postLogoutRedirectUris: + "post_logout_redirect_uris", + backchannelLogoutUri: + "backchannel_logout_uri", + backchannelLogoutSessionRequired: + "backchannel_logout_session_required", + tokenEndpointAuthMethod: + "token_endpoint_auth_method", + applicationType: + "application_type", + jwksUri: "jwks_uri", + grantTypes: "grant_types", + responseTypes: + "response_types", + requirePKCE: + "require_pkce", + dpopBoundAccessTokens: + "dpop_bound_access_tokens", + referenceId: + "reference_id", + }, + }, + oauthResource: { + modelName: "oauth_resources", + fields: { + accessTokenTtl: + "access_token_ttl", + refreshTokenTtl: + "refresh_token_ttl", + signingAlgorithm: + "signing_algorithm", + signingKeyId: + "signing_key_id", + allowedScopes: + "allowed_scopes", + customClaims: + "custom_claims", + dpopBoundAccessTokensRequired: + "dpop_bound_access_tokens_required", + createdAt: "created_at", + updatedAt: "updated_at", + policyVersion: + "policy_version", + }, + }, + oauthClientResource: + { + modelName: "oauth_client_resources", + fields: { + clientId: "client_id", + resourceId: "resource_id", + createdAt: "created_at", + }, + }, + oauthRefreshToken: + { + modelName: "oauth_refresh_tokens", + fields: { + clientId: "client_id", + sessionId: "session_id", + userId: "fk_user_id", + referenceId: + "reference_id", + authorizationCodeId: + "authorization_code_id", + requestedUserInfoClaims: + "requested_user_info_claims", + expiresAt: "expires_at", + createdAt: "created_at", + rotatedAt: "rotated_at", + rotationReplayResponse: + "rotation_replay_response", + rotationReplayExpiresAt: + "rotation_replay_expires_at", + authTime: "auth_time", + }, + }, + oauthAccessToken: + { + modelName: "oauth_access_tokens", + fields: { + clientId: "client_id", + sessionId: "session_id", + userId: "fk_user_id", + referenceId: + "reference_id", + authorizationCodeId: + "authorization_code_id", + requestedUserInfoClaims: + "requested_user_info_claims", + refreshId: "fk_refresh_token_id", + expiresAt: "expires_at", + createdAt: "created_at", + }, + }, + oauthConsent: { + modelName: "oauth_consents", + fields: { + clientId: "client_id", + userId: "fk_user_id", + referenceId: + "reference_id", + requestedUserInfoClaims: + "requested_user_info_claims", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + oauthClientAssertion: + { + modelName: "oauth_client_assertions", + fields: { + expiresAt: "expires_at", + }, + }, + }, + }), + ]), ], hooks: { before: createAuthMiddleware(async (ctx) => { diff --git a/server/auth-realtime/src/env.test.ts b/server/auth-realtime/src/env.test.ts index ca2bb39e..9fedeef0 100644 --- a/server/auth-realtime/src/env.test.ts +++ b/server/auth-realtime/src/env.test.ts @@ -57,6 +57,14 @@ describe("loadEnv", () => { expect(env.authOrigin).toBe("http://localhost:8080") }) + it("leaves MCP off without a resource", ({ expect }) => { + const env = loadEnv( + completeEnv({ OXYNOTE_AUTH_REALTIME_MCP_RESOURCE: "" }), + ) + + expect(env.mcpResource).toBeUndefined() + }) + it("derives the MCP token issuer from the auth origin", ({ expect, }) => { diff --git a/server/auth-realtime/src/env.ts b/server/auth-realtime/src/env.ts index 24b5e389..d67c7ab1 100644 --- a/server/auth-realtime/src/env.ts +++ b/server/auth-realtime/src/env.ts @@ -30,7 +30,8 @@ export interface Env { frontendUrl: string organizationInvitationUrl: string trustedOrigins: string[] - mcpResource: string + // undefined turns MCP off: the OAuth provider is not registered. + mcpResource: string | undefined // MCP access tokens carry better-auth's resolved base URL (origin + // basePath) as their issuer. The internal MCP session endpoint // verifies against it. @@ -136,9 +137,11 @@ const SOCIAL_PROVIDERS = [ }[] // every variable the service reads, spelled out. Only the social -// credentials and the valkey URL are allowed to be absent — a provider -// with neither half is left unregistered, and one with a single half is a -// boot error rather than a broken OAuth redirect discovered by a user. +// credentials, the valkey URL, the trusted origins and the MCP resource +// are allowed to be absent. A missing MCP resource leaves MCP off. A +// provider with neither half is left unregistered, and one with a single +// half is a boot error rather than a broken OAuth redirect discovered by +// a user. const schema = z .object({ OXYNOTE_AUTH_REALTIME_BACKEND_URL: httpUrl(), @@ -154,7 +157,7 @@ const schema = z OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL: httpUrl(), OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS: z.string().optional(), - OXYNOTE_AUTH_REALTIME_MCP_RESOURCE: httpUrl(), + OXYNOTE_AUTH_REALTIME_MCP_RESOURCE: httpUrl().optional(), OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS: limit(1), OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS: limit(Infinity), // better-auth's limiter buckets by client IP, and sign-in and diff --git a/server/auth-realtime/src/routes.test.ts b/server/auth-realtime/src/routes.test.ts index e2e9657a..15ee9246 100644 --- a/server/auth-realtime/src/routes.test.ts +++ b/server/auth-realtime/src/routes.test.ts @@ -182,7 +182,7 @@ async function mcpToken( .setProtectedHeader({ alg: "ES256", kid: "test-key" }) .setSubject(claims.sub ?? "user-1") .setIssuer(overrides.issuer ?? ENV.mcpTokenIssuer) - .setAudience(overrides.audience ?? ENV.mcpResource) + .setAudience(overrides.audience ?? ENV.mcpResource ?? "") .setExpirationTime("1h") .sign(privateKey) } @@ -1193,6 +1193,25 @@ describe("createRoutes", () => { }) describe("GET /internal/mcp/session", () => { + it("answers 404 while MCP is off", async ({ expect }) => { + const auth = stubAuth() + const { app } = build({ + env: testEnv({ mcpResource: undefined }), + auth, + }) + + const res = await app.request( + "/internal/mcp/session", + bearer("any"), + ) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ + error: "mcp disabled", + }) + expect.soft(auth.api.getJwks).not.toHaveBeenCalled() + }) + it.for([ { name: "no Authorization header", input: undefined }, { name: "a non-bearer scheme", input: "Basic abc" }, diff --git a/server/auth-realtime/src/routes.ts b/server/auth-realtime/src/routes.ts index baff1e06..4382ba9e 100644 --- a/server/auth-realtime/src/routes.ts +++ b/server/auth-realtime/src/routes.ts @@ -600,6 +600,12 @@ export function createRoutes({ // /internal routes, this is protected by the container network plus // Caddy's 403 on /auth-realtime/api/internal/*. app.get("/internal/mcp/session", async (c) => { + // without a resource there is no audience to check, so no + // token could be verified safely. + if (env.mcpResource === undefined) { + return c.json({ error: "mcp disabled" }, 404) + } + const header = c.req.header("Authorization") ?? "" if (!header.toLowerCase().startsWith("bearer ")) { return c.json({ error: "missing bearer token" }, 401) diff --git a/server/core/cmd/core/main.go b/server/core/cmd/core/main.go index 591fafa8..b78c6468 100644 --- a/server/core/cmd/core/main.go +++ b/server/core/cmd/core/main.go @@ -329,6 +329,12 @@ func main() { //nolint:maintidx // main performs linear wiring of all components warnDisabled(log, assistantMan.Configured(), "assistant is disabled") warnDisabled(log, webchangeClient.Configured(), "changedetection integration is disabled") + mcpOpts := server.MCPOptions{ + SessionURL: buildinfo.Getenv("SERVER_MCP_SESSION_URL"), + ResourceURL: buildinfo.Getenv("SERVER_MCP_RESOURCE_URL"), + } + warnDisabled(log, mcpOpts.Enabled(), "mcp is disabled") + serverHost, serverPort, err := parseServerAddress(buildinfo.Getenv("SERVER_ADDRESS")) if err != nil { fail(log, closers, "cannot read the configuration", err) @@ -349,10 +355,7 @@ func main() { //nolint:maintidx // main performs linear wiring of all components Auth: server.AuthOptions{ BetterAuthURL: buildinfo.Getenv("SERVER_AUTH_BETTER_AUTH_URL"), }, - MCP: server.MCPOptions{ - SessionURL: buildinfo.Getenv("SERVER_MCP_SESSION_URL"), - ResourceURL: buildinfo.Getenv("SERVER_MCP_RESOURCE_URL"), - }, + MCP: mcpOpts, }, dbc, metrics, diff --git a/server/core/internal/server/capabilities.go b/server/core/internal/server/capabilities.go index 74ee106d..56a6bda6 100644 --- a/server/core/internal/server/capabilities.go +++ b/server/core/internal/server/capabilities.go @@ -25,6 +25,9 @@ type Capabilities struct { // ChangeDetection reports whether the changedetection.io integration // is configured. ChangeDetection bool `json:"changeDetection"` + + // MCP reports whether the MCP surface is served. + MCP bool `json:"mcp"` } // AssistantCapability describes the AI assistant's availability, diff --git a/server/core/internal/server/capabilities_test.go b/server/core/internal/server/capabilities_test.go index d1da02ee..a9e51407 100644 --- a/server/core/internal/server/capabilities_test.go +++ b/server/core/internal/server/capabilities_test.go @@ -35,7 +35,8 @@ func Test_Server_fetchCapabilities(t *testing.T) { "github": false, "slack": true, "aiAssistant": {"status": "active-but-weak", "model": "claude-sonnet-5"}, - "changeDetection": false + "changeDetection": false, + "mcp": false }`, rec.Body.String(), ) diff --git a/server/core/internal/server/internal/mcp/http.go b/server/core/internal/server/internal/mcp/http.go index 333377dc..1551d7bf 100644 --- a/server/core/internal/server/internal/mcp/http.go +++ b/server/core/internal/server/internal/mcp/http.go @@ -43,10 +43,16 @@ type Options struct { // ResourceURL is the canonical public URL of this MCP server // (RFC 8707). The RFC 9728 metadata URL advertised in - // WWW-Authenticate challenges is derived from it. + // WWW-Authenticate challenges is derived from it. Empty turns the + // MCP surface off. ResourceURL string } +// Enabled reports whether the MCP surface is served. +func (o Options) Enabled() bool { + return o.ResourceURL != "" +} + // Validate checks whether the options are valid. func (o Options) Validate() error { if o.SessionURL == "" { diff --git a/server/core/internal/server/internal/mcp/http_test.go b/server/core/internal/server/internal/mcp/http_test.go index 11172df7..32018bab 100644 --- a/server/core/internal/server/internal/mcp/http_test.go +++ b/server/core/internal/server/internal/mcp/http_test.go @@ -168,6 +168,13 @@ func Test_Options_Validate(t *testing.T) { assert.NoError(t, Options{SessionURL: _testSessionURL, ResourceURL: _testResourceURL}.Validate()) } +func Test_Options_Enabled(t *testing.T) { + t.Parallel() + + assert.False(t, Options{SessionURL: _testSessionURL}.Enabled()) + assert.True(t, Options{ResourceURL: _testResourceURL}.Enabled()) +} + func Test_NewHandler(t *testing.T) { t.Parallel() diff --git a/server/core/internal/server/router.go b/server/core/internal/server/router.go index ad0efcbe..e593bf2b 100644 --- a/server/core/internal/server/router.go +++ b/server/core/internal/server/router.go @@ -115,7 +115,9 @@ func (s *Server) httpRouter() chi.Router { // The MCP surface authenticates with OAuth bearer tokens // through its own middleware, so it mounts outside the // cookie-session subtree. - sr.Mount("/mcp", s.handlers.mcp) + if s.handlers.mcp != nil { + sr.Mount("/mcp", s.handlers.mcp) + } // Third parties deliver here, so this subtree has to stay // reachable through the front door — unlike /x below. diff --git a/server/core/internal/server/router_test.go b/server/core/internal/server/router_test.go index 39ec2e24..f305a9f3 100644 --- a/server/core/internal/server/router_test.go +++ b/server/core/internal/server/router_test.go @@ -78,6 +78,11 @@ func Test_Server_httpRouter(t *testing.T) { // mount walks as "*"). assert.True(t, routes["GET /api/*/capabilities"]) + // MCP is off while its handler is nil, so nothing is mounted for it. + for route := range routes { + assert.NotContains(t, route, "/api/mcp") + } + // the service-to-service surface stays where it is. assert.True(t, routes["POST /api/x/organizations/{organizationId}/teardown"]) assert.True(t, routes["PUT /api/x/documents/{documentId}/branch/{branchId}/"]) diff --git a/server/core/internal/server/server.go b/server/core/internal/server/server.go index a8f9fc26..b65c1ef9 100644 --- a/server/core/internal/server/server.go +++ b/server/core/internal/server/server.go @@ -91,6 +91,10 @@ func (o Options) validate() error { return errors.New("invalid port") } + if !o.MCP.Enabled() { + return nil + } + return o.MCP.Validate() } @@ -164,6 +168,7 @@ func NewServer( Slack: slackMan.Configured(), AIAssistant: opts.Assistant, ChangeDetection: webchangeClient.Configured(), + MCP: opts.MCP.Enabled(), }, } @@ -197,12 +202,15 @@ func NewServer( // the MCP handler serves the assistant's tool registry over the // Model Context Protocol; it must be built after the tree notifier // is wired so MCP-driven mutations notify sidebars too. - mcpHandler, err := mcp.NewHandler(log, assistantMan, db, client, opts.MCP) - if err != nil { - return nil, fmt.Errorf("building mcp handler: %w", err) + if opts.MCP.Enabled() { + mcpHandler, err := mcp.NewHandler(log, assistantMan, db, client, opts.MCP) + if err != nil { + return nil, fmt.Errorf("building mcp handler: %w", err) + } + + srv.handlers.mcp = mcpHandler } - srv.handlers.mcp = mcpHandler srv.handlers.github = github.NewHandler(log, db, githubMan) srv.handlers.slack = slack.NewHandler(log, db, client, slackMan) srv.handlers.notification = notification.NewHandler(log, db, notifier) diff --git a/server/core/internal/server/server_test.go b/server/core/internal/server/server_test.go index f5875e8d..cee7679c 100644 --- a/server/core/internal/server/server_test.go +++ b/server/core/internal/server/server_test.go @@ -57,7 +57,8 @@ func Test_Options_validate(t *testing.T) { assert.NoError(t, Options{Port: 0, MCP: mcpOpts}.validate()) assert.NoError(t, Options{Port: 8080, MCP: mcpOpts}.validate()) assert.EqualError(t, Options{Port: 999}.validate(), "invalid port") - assert.EqualError(t, Options{}.validate(), "missing mcp session url") + assert.NoError(t, Options{}.validate()) + assert.EqualError(t, Options{MCP: MCPOptions{ResourceURL: "http://test.com/core/api/mcp"}}.validate(), "missing mcp session url") } // stubSearcher satisfies the document handler's Searcher. @@ -222,9 +223,46 @@ func Test_NewServer(t *testing.T) { assert.NotNil(t, srv.handlers.mcp) assert.Len(t, hookMan.BindHookChangeCalls(), 1) - // nothing above is configured, so every capability reports false; - // the search gateway mock is the one switched on. - assert.Equal(t, Capabilities{}, srv.capabilities) + // nothing above is configured but MCP, so every other capability + // reports false. + assert.Equal(t, Capabilities{MCP: true}, srv.capabilities) + }) + + t.Run("MCP off without a resource URL", func(t *testing.T) { + t.Parallel() + + srv, err := NewServer( + log, + Options{Port: 8080}, + db, + fc, + storer, + // a manager of this subtest's own: NewServer calls + // SetTreeNotifier, which must not race the parallel + // success case's identical call on a shared manager. + assistantCore.NewManager(log, nil, &redis.Pool{}, nil, nil, fc, nil, nil, stubSearchTrigger{}, nil, githubMan, nil, nil, "claude"), + datasourceCore.NewManager(log, nil), + nil, + githubMan, + slackMan, + webchange.NewClient("", ""), + &HookManagerMock{}, + stubSearcher{}, + stubSearchTrigger{}, + notifier, + nil, + http.DefaultClient, + ) + require.NoError(t, err) + require.NotNil(t, srv) + + t.Cleanup(func() { + srv.ws.Close() + assert.NoError(t, srv.Close()) + }) + + assert.Nil(t, srv.handlers.mcp) + assert.False(t, srv.capabilities.MCP) }) t.Run("Capabilities reflect the configured services", func(t *testing.T) { @@ -273,6 +311,7 @@ func Test_NewServer(t *testing.T) { Model: "claude-opus-5", }, ChangeDetection: true, + MCP: true, }, srv.capabilities) }) } diff --git a/web/app/components/settings/BaseModal.nuxt.test.ts b/web/app/components/settings/BaseModal.nuxt.test.ts index 2c10a052..2f08d42e 100644 --- a/web/app/components/settings/BaseModal.nuxt.test.ts +++ b/web/app/components/settings/BaseModal.nuxt.test.ts @@ -136,6 +136,16 @@ describe("", { concurrent: false }, () => { expect(dialogText()).not.toContain(t("settings.apps.title")) }) + it("drops the MCP section on a deployment without MCP", async ({ + expect, + }) => { + seedCapabilities({ mcp: false }) + + await mountModal() + + expect(dialogText()).not.toContain(t("settings.mcp.title")) + }) + it("gives the deep-linkable sections their anchors", async ({ expect }) => { await mountModal() diff --git a/web/app/components/settings/BaseModal.vue b/web/app/components/settings/BaseModal.vue index 3c81c799..d32fe353 100644 --- a/web/app/components/settings/BaseModal.vue +++ b/web/app/components/settings/BaseModal.vue @@ -20,7 +20,7 @@ const emit = defineEmits<{ }>() const { t } = useI18n({ useScope: "global" }) -const { isAnyAppEnabled } = useCapabilitiesAPI() +const { isAnyAppEnabled, isMcpEnabled } = useCapabilitiesAPI() const openAction = ref< | null | "email-change" @@ -58,11 +58,15 @@ const sections = computed(() => [ }, ] : []), - { - title: t("settings.mcp.title"), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- eslint's ts program resolves .vue imports as error typed, vue-tsc accepts this - component: McpSection, - }, + ...(isMcpEnabled.value + ? [ + { + title: t("settings.mcp.title"), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- eslint's ts program resolves .vue imports as error typed, vue-tsc accepts this + component: McpSection, + }, + ] + : []), { title: t("settings.data-sources.title"), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- eslint's ts program resolves .vue imports as error typed, vue-tsc accepts this diff --git a/web/app/components/test-helpers.ts b/web/app/components/test-helpers.ts index f859c694..1a7f6f0f 100644 --- a/web/app/components/test-helpers.ts +++ b/web/app/components/test-helpers.ts @@ -79,6 +79,7 @@ export function seedCapabilities(capabilities: Partial = {}) { github: true, slack: true, changeDetection: true, + mcp: true, aiAssistant: { status: AssistantStatus.Active, model: "test-model" }, ...capabilities, }) diff --git a/web/app/composables/api/useCapabilitiesAPI.nuxt.test.ts b/web/app/composables/api/useCapabilitiesAPI.nuxt.test.ts index 52cb417c..07763ad7 100644 --- a/web/app/composables/api/useCapabilitiesAPI.nuxt.test.ts +++ b/web/app/composables/api/useCapabilitiesAPI.nuxt.test.ts @@ -17,6 +17,7 @@ function stubCapabilities(overrides: Partial = {}): Capabilities { github: true, slack: true, changeDetection: true, + mcp: true, aiAssistant: { status: AssistantStatus.Active, model: "claude-opus-5" }, ...overrides, } @@ -51,6 +52,7 @@ describe("useCapabilitiesAPI", { concurrent: false }, () => { github: true, slack: false, changeDetection: true, + mcp: false, }), ) const api = makeCapabilitiesAPI() @@ -60,6 +62,7 @@ describe("useCapabilitiesAPI", { concurrent: false }, () => { expect(api.isGithubEnabled.value).toBe(true) expect(api.isSlackEnabled.value).toBe(false) expect(api.isChangeDetectionEnabled.value).toBe(true) + expect(api.isMcpEnabled.value).toBe(false) }) it("reports every service enabled before the request resolves", ({ @@ -70,6 +73,7 @@ describe("useCapabilitiesAPI", { concurrent: false }, () => { expect(api.isGithubEnabled.value).toBe(true) expect(api.isSlackEnabled.value).toBe(true) expect(api.isChangeDetectionEnabled.value).toBe(true) + expect(api.isMcpEnabled.value).toBe(true) expect(api.isAssistantEnabled.value).toBe(true) }) @@ -89,6 +93,7 @@ describe("useCapabilitiesAPI", { concurrent: false }, () => { expect(api.isGithubEnabled.value).toBe(true) expect(api.isSlackEnabled.value).toBe(true) expect(api.isChangeDetectionEnabled.value).toBe(true) + expect(api.isMcpEnabled.value).toBe(true) expect(api.isAssistantEnabled.value).toBe(true) }) }) diff --git a/web/app/composables/api/useCapabilitiesAPI.ts b/web/app/composables/api/useCapabilitiesAPI.ts index 208f932a..def9c46e 100644 --- a/web/app/composables/api/useCapabilitiesAPI.ts +++ b/web/app/composables/api/useCapabilitiesAPI.ts @@ -32,6 +32,7 @@ export default function () { // leaves nothing for the apps settings section to show const isAnyAppEnabled = enabled((c) => c.github || c.slack) const isChangeDetectionEnabled = enabled((c) => c.changeDetection) + const isMcpEnabled = enabled((c) => c.mcp) const assistantStatus = computed( () => fetchCapabilities.data.value?.aiAssistant.status ?? null, @@ -51,6 +52,7 @@ export default function () { isSlackEnabled, isAnyAppEnabled, isChangeDetectionEnabled, + isMcpEnabled, isAssistantEnabled, assistantStatus, assistantModel, diff --git a/web/app/utils/api/capabilities.ts b/web/app/utils/api/capabilities.ts index 932789c9..64bc1387 100644 --- a/web/app/utils/api/capabilities.ts +++ b/web/app/utils/api/capabilities.ts @@ -17,4 +17,5 @@ export interface Capabilities { slack: boolean aiAssistant: AssistantCapability changeDetection: boolean + mcp: boolean }