disable mcp on non https or loopback host

This commit is contained in:
david 2026-10-10 12:51:37 +03:00
parent e1d38c254f
commit c510d8976c
27 changed files with 431 additions and 191 deletions

View File

@ -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

View File

@ -22,6 +22,7 @@ import {
corePort,
coreUrl,
dataDir,
mcpAvailable,
postgresPort,
webPort,
webUrl,
@ -217,6 +218,12 @@ async function main(): Promise<void> {
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) {

View File

@ -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)
})
})

View File

@ -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: "" }

View File

@ -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

View File

@ -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}`),

View File

@ -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,

View File

@ -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) => {

View File

@ -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,
}) => {

View File

@ -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

View File

@ -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" },

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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(),
)

View File

@ -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 == "" {

View File

@ -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()

View File

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

View File

@ -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}/"])

View File

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

View File

@ -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)
})
}

View File

@ -136,6 +136,16 @@ describe("<BaseModal>", { 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()

View File

@ -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

View File

@ -79,6 +79,7 @@ export function seedCapabilities(capabilities: Partial<Capabilities> = {}) {
github: true,
slack: true,
changeDetection: true,
mcp: true,
aiAssistant: { status: AssistantStatus.Active, model: "test-model" },
...capabilities,
})

View File

@ -17,6 +17,7 @@ function stubCapabilities(overrides: Partial<Capabilities> = {}): 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)
})
})

View File

@ -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,

View File

@ -17,4 +17,5 @@ export interface Capabilities {
slack: boolean
aiAssistant: AssistantCapability
changeDetection: boolean
mcp: boolean
}