mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
disable mcp on non https or loopback host
This commit is contained in:
parent
e1d38c254f
commit
c510d8976c
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: "" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}/"])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ export interface Capabilities {
|
|||
slack: boolean
|
||||
aiAssistant: AssistantCapability
|
||||
changeDetection: boolean
|
||||
mcp: boolean
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user