@lowdefy/server 6.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/App.jsx +41 -21
- package/client/Page.jsx +101 -24
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +60 -0
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/client/loadAllIcons.js +45 -0
- package/client/loadPageTypes.js +44 -0
- package/client/main.jsx +22 -1
- package/client/reloadedForTypesKey.js +21 -0
- package/client/shouldReloadForTypes.js +38 -0
- package/client/shouldReloadForTypes.test.mjs +63 -0
- package/client/types.js +28 -0
- package/lib/build/security.js +26 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +224 -39
- package/lib/client/auth/AuthNotConfigured.js +44 -4
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
- package/lib/client/bundleBuildId.js +23 -0
- package/lib/client/createLogUsage.js +46 -25
- package/lib/client/createLogUsage.test.mjs +90 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -7
- package/lib/server/auth/getStrategies.js +40 -0
- package/lib/server/getClientAddress.js +56 -0
- package/lib/server/getClientAddress.test.mjs +108 -0
- package/lib/server/log/createHandleError.js +16 -4
- package/lib/server/log/createHandleError.test.mjs +102 -0
- package/lib/server/log/createLogger.js +10 -0
- package/lib/server/log/createLogger.test.mjs +117 -0
- package/lib/server/log/logRequest.js +12 -4
- package/lib/server/log/maskCredentialKeys.js +37 -0
- package/lib/server/log/projectErrorForLog.js +61 -0
- package/lib/server/log/redactUrlQuery.js +75 -0
- package/lib/server/log/redactUrlQuery.test.mjs +73 -0
- package/lib/server/{auth/session.js → log/serializeErrorForLog.js} +5 -11
- package/lib/server/log/serializeErrorForLog.test.mjs +337 -0
- package/lib/server/scrubSecrets.js +21 -0
- package/lib/server/sentry/captureSentryError.js +29 -3
- package/lib/server/sentry/captureSentryError.test.mjs +128 -0
- package/lib/server/sentry/initSentry.js +46 -14
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +365 -0
- package/lib/server/sentry/scrubEvent.js +40 -0
- package/lib/server/sentry/scrubEvent.test.mjs +88 -0
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +1 -0
- package/lowdefy/build.test.mjs +68 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +47 -42
- package/package.original.json +47 -42
- package/src/app.js +74 -17
- package/src/app.test.mjs +118 -0
- package/src/html/collectChunkClosure.js +31 -0
- package/src/html/collectLazyImports.js +41 -0
- package/src/html/collectPageTypesAssets.js +57 -0
- package/src/html/collectPageTypesAssets.test.mjs +130 -0
- package/src/html/getAssets.js +20 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/getPageAssets.js +32 -0
- package/src/html/getPageAssets.test.mjs +47 -0
- package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
- package/src/html/renderPage.js +80 -19
- package/src/html/renderPage.test.mjs +189 -0
- package/src/html/template.js +50 -15
- package/src/html/template.test.mjs +126 -0
- package/src/index.js +28 -19
- package/src/initServer.js +61 -0
- package/src/initServer.test.mjs +116 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +68 -13
- package/src/middleware/apiContext.test.mjs +178 -0
- package/src/middleware/createSameOriginGuard.js +36 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +56 -4
- package/src/middleware/errorHandler.test.mjs +440 -0
- package/src/middleware/noStore.js +27 -0
- package/src/middleware/refuseOtherBuild.js +49 -0
- package/src/middleware/refuseOtherBuild.test.mjs +63 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/sentry.js +13 -5
- package/src/middleware/stampBuildId.js +30 -0
- package/src/middleware/stampBuildId.test.mjs +41 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/apiPage.js +54 -9
- package/src/routes/apiPage.test.mjs +143 -0
- package/src/routes/auth.js +15 -9
- package/src/routes/auth.test.mjs +85 -0
- package/src/routes/clientError.js +11 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +4 -2
- package/src/routes/detached.js +22 -9
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +146 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +26 -3
- package/src/routes/websocket.test.mjs +106 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/vite.config.js +55 -2
- package/lib/server/apiWrapper.js +0 -87
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
import { jest } from '@jest/globals';
|
|
19
|
+
|
|
20
|
+
// The real URI helpers drive the challenge strings, so the header assertions
|
|
21
|
+
// below exercise the actual BETTER_AUTH_URL derivation - only createMcpServer
|
|
22
|
+
// is stubbed out.
|
|
23
|
+
const uriHelpers = await import('@lowdefy/api/routes/mcp/getMcpUri.js');
|
|
24
|
+
|
|
25
|
+
const mockCreateMcpServer = jest.fn();
|
|
26
|
+
jest.unstable_mockModule('@lowdefy/api', () => ({
|
|
27
|
+
...uriHelpers,
|
|
28
|
+
createMcpServer: mockCreateMcpServer,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
jest.unstable_mockModule('@hono/mcp', () => ({
|
|
32
|
+
StreamableHTTPTransport: class {
|
|
33
|
+
handleRequest() {
|
|
34
|
+
return new Response(JSON.stringify({ served: true }), {
|
|
35
|
+
status: 200,
|
|
36
|
+
headers: { 'content-type': 'application/json' },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const mockAuthJson = { configured: true, oauthProvider: { consentPage: '/oauth/consent' } };
|
|
43
|
+
jest.unstable_mockModule('../../lib/build/auth.js', () => ({
|
|
44
|
+
default: mockAuthJson,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const { default: mcpHandler } = await import('./mcp.js');
|
|
48
|
+
|
|
49
|
+
const originalBetterAuthUrl = process.env.BETTER_AUTH_URL;
|
|
50
|
+
|
|
51
|
+
const mockReadConfigFile = jest.fn();
|
|
52
|
+
|
|
53
|
+
let contextOverrides;
|
|
54
|
+
|
|
55
|
+
function createApp() {
|
|
56
|
+
const app = new Hono();
|
|
57
|
+
app.use('*', async (c, next) => {
|
|
58
|
+
c.set('lowdefyContext', {
|
|
59
|
+
config: { basePath: '' },
|
|
60
|
+
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
|
61
|
+
readConfigFile: mockReadConfigFile,
|
|
62
|
+
...contextOverrides,
|
|
63
|
+
});
|
|
64
|
+
await next();
|
|
65
|
+
});
|
|
66
|
+
app.all('/api/mcp', mcpHandler);
|
|
67
|
+
return app;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
process.env.BETTER_AUTH_URL = 'https://app.test.com';
|
|
72
|
+
mockAuthJson.oauthProvider = { consentPage: '/oauth/consent' };
|
|
73
|
+
contextOverrides = {};
|
|
74
|
+
mockReadConfigFile.mockReset();
|
|
75
|
+
mockCreateMcpServer.mockReset();
|
|
76
|
+
mockCreateMcpServer.mockResolvedValue({ connect: jest.fn() });
|
|
77
|
+
mockReadConfigFile.mockResolvedValue({ configured: true, hasPublicTool: true, endpoints: [] });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterAll(() => {
|
|
81
|
+
if (originalBetterAuthUrl === undefined) {
|
|
82
|
+
delete process.env.BETTER_AUTH_URL;
|
|
83
|
+
} else {
|
|
84
|
+
process.env.BETTER_AUTH_URL = originalBetterAuthUrl;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('mcpHandler answers a refused member token with its reason and no resource metadata', async () => {
|
|
89
|
+
contextOverrides.mcpAuth = {
|
|
90
|
+
tokenStatus: 'invalid',
|
|
91
|
+
memberToken: true,
|
|
92
|
+
description: 'This token has expired.',
|
|
93
|
+
};
|
|
94
|
+
const res = await createApp().request('/api/mcp', { method: 'POST' });
|
|
95
|
+
expect(res.status).toEqual(401);
|
|
96
|
+
expect(res.headers.get('WWW-Authenticate')).toEqual(
|
|
97
|
+
'Bearer error="invalid_token", error_description="This token has expired."'
|
|
98
|
+
);
|
|
99
|
+
expect(mockCreateMcpServer).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('mcpHandler answers a member token whose member is gone with that reason', async () => {
|
|
103
|
+
contextOverrides.mcpAuth = {
|
|
104
|
+
tokenStatus: 'invalid',
|
|
105
|
+
memberToken: true,
|
|
106
|
+
description: "This token's member can no longer use it.",
|
|
107
|
+
};
|
|
108
|
+
const res = await createApp().request('/api/mcp', { method: 'POST' });
|
|
109
|
+
expect(res.status).toEqual(401);
|
|
110
|
+
expect(res.headers.get('WWW-Authenticate')).toEqual(
|
|
111
|
+
'Bearer error="invalid_token", error_description="This token\'s member can no longer use it."'
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('mcpHandler serves a valid member token caller', async () => {
|
|
116
|
+
contextOverrides.mcpAuth = {
|
|
117
|
+
tokenStatus: 'valid',
|
|
118
|
+
tokenId: 'token_1',
|
|
119
|
+
organizationId: 'org_1',
|
|
120
|
+
grantedScopes: ['mcp:read', 'mcp:write'],
|
|
121
|
+
};
|
|
122
|
+
const res = await createApp().request('/api/mcp', { method: 'POST' });
|
|
123
|
+
expect(res.status).toEqual(200);
|
|
124
|
+
expect(mockCreateMcpServer).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getAsIssuer, getMcpResourceUri, MCP_OAUTH_SCOPES } from '@lowdefy/api';
|
|
18
|
+
|
|
19
|
+
import lowdefyConfig from '../../lib/build/config.js';
|
|
20
|
+
|
|
21
|
+
// RFC 9728 protected-resource metadata for the MCP endpoint. Constant per
|
|
22
|
+
// deployment: the URIs derive from the pinned BETTER_AUTH_URL, never a Host
|
|
23
|
+
// header. The organization a token acts in is not part of the resource - it is
|
|
24
|
+
// chosen at authorization and carried as a token claim.
|
|
25
|
+
function mcpProtectedResourceMetadataHandler(c) {
|
|
26
|
+
return c.json({
|
|
27
|
+
resource: getMcpResourceUri({ config: lowdefyConfig }),
|
|
28
|
+
authorization_servers: [getAsIssuer({ config: lowdefyConfig })],
|
|
29
|
+
scopes_supported: MCP_OAUTH_SCOPES,
|
|
30
|
+
bearer_methods_supported: ['header'],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default mcpProtectedResourceMetadataHandler;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
import { jest } from '@jest/globals';
|
|
19
|
+
|
|
20
|
+
jest.unstable_mockModule('../../lib/build/config.js', () => ({
|
|
21
|
+
default: { basePath: '' },
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { default: mcpProtectedResourceMetadataHandler } = await import(
|
|
25
|
+
'./mcpProtectedResourceMetadata.js'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const originalBetterAuthUrl = process.env.BETTER_AUTH_URL;
|
|
29
|
+
|
|
30
|
+
function createApp() {
|
|
31
|
+
const app = new Hono();
|
|
32
|
+
app.get('/.well-known/oauth-protected-resource/api/mcp', mcpProtectedResourceMetadataHandler);
|
|
33
|
+
return app;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
process.env.BETTER_AUTH_URL = 'https://app.test.com';
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
if (originalBetterAuthUrl === undefined) {
|
|
42
|
+
delete process.env.BETTER_AUTH_URL;
|
|
43
|
+
} else {
|
|
44
|
+
process.env.BETTER_AUTH_URL = originalBetterAuthUrl;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('metadata handler serves the single MCP resource document', async () => {
|
|
49
|
+
const res = await createApp().request('/.well-known/oauth-protected-resource/api/mcp');
|
|
50
|
+
expect(res.status).toEqual(200);
|
|
51
|
+
expect(await res.json()).toEqual({
|
|
52
|
+
resource: 'https://app.test.com/api/mcp',
|
|
53
|
+
authorization_servers: ['https://app.test.com/api/auth'],
|
|
54
|
+
scopes_supported: ['mcp:read', 'mcp:write', 'offline_access'],
|
|
55
|
+
bearer_methods_supported: ['header'],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('metadata handler derives the resource from BETTER_AUTH_URL, never the request host', async () => {
|
|
60
|
+
const res = await createApp().request(
|
|
61
|
+
'http://evil.example.com/.well-known/oauth-protected-resource/api/mcp'
|
|
62
|
+
);
|
|
63
|
+
expect(res.status).toEqual(200);
|
|
64
|
+
expect((await res.json()).resource).toEqual('https://app.test.com/api/mcp');
|
|
65
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createAsMetadataHandler } from '@lowdefy/api';
|
|
18
|
+
import { type } from '@lowdefy/helpers';
|
|
19
|
+
|
|
20
|
+
import authJson from '../../lib/build/auth.js';
|
|
21
|
+
import mcpProtectedResourceMetadataHandler from './mcpProtectedResourceMetadata.js';
|
|
22
|
+
|
|
23
|
+
// OAuth discovery for the MCP resource. Both documents are public,
|
|
24
|
+
// unauthenticated and constant per deployment, so they mount outside the api
|
|
25
|
+
// context. Nothing mounts when the app is not an authorization server - the
|
|
26
|
+
// build then guaranteed an all-public tool surface with nothing to discover.
|
|
27
|
+
function mountOauthDiscovery({ app, auth }) {
|
|
28
|
+
if (type.isNone(authJson.oauthProvider)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
app.get('/.well-known/oauth-protected-resource/api/mcp', mcpProtectedResourceMetadataHandler);
|
|
32
|
+
// The auth instance is absent only in dev mock mode, which runs no auth
|
|
33
|
+
// engine - the static resource document above still serves there.
|
|
34
|
+
if (type.isNone(auth)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// The oauth-provider serves its AS metadata relative to the auth mount, so
|
|
38
|
+
// the RFC 8414 path-inserted location a client derives from the issuer must
|
|
39
|
+
// be mounted here, re-exposing the same live document.
|
|
40
|
+
const asMetadataHandler = createAsMetadataHandler({ auth });
|
|
41
|
+
app.get('/.well-known/oauth-authorization-server/api/auth', (c) => asMetadataHandler(c.req.raw));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default mountOauthDiscovery;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
import { jest } from '@jest/globals';
|
|
19
|
+
|
|
20
|
+
const mockCreateAsMetadataHandler = jest.fn(
|
|
21
|
+
() => async () =>
|
|
22
|
+
new Response(JSON.stringify({ issuer: 'https://app.test.com/api/auth' }), {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
jest.unstable_mockModule('@lowdefy/api', () => ({
|
|
28
|
+
createAsMetadataHandler: mockCreateAsMetadataHandler,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
jest.unstable_mockModule('./mcpProtectedResourceMetadata.js', () => ({
|
|
32
|
+
default: (c) => c.json({ resource: 'mcp' }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const mockAuthJson = { configured: true };
|
|
36
|
+
jest.unstable_mockModule('../../lib/build/auth.js', () => ({
|
|
37
|
+
default: mockAuthJson,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const { default: mountOauthDiscovery } = await import('./mountOauthDiscovery.js');
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
delete mockAuthJson.oauthProvider;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('mountOauthDiscovery mounts nothing when oauthProvider is not configured', async () => {
|
|
47
|
+
const app = new Hono();
|
|
48
|
+
mountOauthDiscovery({ app, auth: { api: {} } });
|
|
49
|
+
const resourceRes = await app.request('/.well-known/oauth-protected-resource/api/mcp');
|
|
50
|
+
expect(resourceRes.status).toEqual(404);
|
|
51
|
+
const asRes = await app.request('/.well-known/oauth-authorization-server/api/auth');
|
|
52
|
+
expect(asRes.status).toEqual(404);
|
|
53
|
+
expect(mockCreateAsMetadataHandler).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('mountOauthDiscovery mounts both discovery documents when oauthProvider is configured', async () => {
|
|
57
|
+
mockAuthJson.oauthProvider = { consentPage: '/oauth/consent' };
|
|
58
|
+
const app = new Hono();
|
|
59
|
+
const auth = { api: {} };
|
|
60
|
+
mountOauthDiscovery({ app, auth });
|
|
61
|
+
const resourceRes = await app.request('/.well-known/oauth-protected-resource/api/mcp');
|
|
62
|
+
expect(resourceRes.status).toEqual(200);
|
|
63
|
+
expect(await resourceRes.json()).toEqual({ resource: 'mcp' });
|
|
64
|
+
const asRes = await app.request('/.well-known/oauth-authorization-server/api/auth');
|
|
65
|
+
expect(asRes.status).toEqual(200);
|
|
66
|
+
expect(await asRes.json()).toEqual({ issuer: 'https://app.test.com/api/auth' });
|
|
67
|
+
expect(mockCreateAsMetadataHandler).toHaveBeenCalledWith({ auth });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('mountOauthDiscovery serves only the static resource document without an auth instance', async () => {
|
|
71
|
+
mockAuthJson.oauthProvider = { consentPage: '/oauth/consent' };
|
|
72
|
+
const app = new Hono();
|
|
73
|
+
mountOauthDiscovery({ app, auth: null });
|
|
74
|
+
const resourceRes = await app.request('/.well-known/oauth-protected-resource/api/mcp');
|
|
75
|
+
expect(resourceRes.status).toEqual(200);
|
|
76
|
+
const asRes = await app.request('/.well-known/oauth-authorization-server/api/auth');
|
|
77
|
+
expect(asRes.status).toEqual(404);
|
|
78
|
+
expect(mockCreateAsMetadataHandler).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import getRequestPath from '../lib/getRequestPath.js';
|
|
18
|
+
|
|
19
|
+
// `/` (home) and `/404` are fixed routes ahead of the page matcher; every other
|
|
20
|
+
// path renders the page it matches. `.*`, not `.+`: under a basePath,
|
|
21
|
+
// `<basePath>/` arrives with an empty rest, and is the app root like
|
|
22
|
+
// `<basePath>` itself.
|
|
23
|
+
function mountPageRoutes({ app, basePath, renderPage }) {
|
|
24
|
+
app.get('/', (c) => renderPage(c, { path: '', matchedPath: '' }));
|
|
25
|
+
app.get('/404', (c) => renderPage(c, { path: '404', matchedPath: '404', status: 404 }));
|
|
26
|
+
app.get('/:rest{.*}', (c) => renderPage(c, getRequestPath({ c, basePath, prefix: '/' })));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default mountPageRoutes;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
|
|
19
|
+
import mountPageRoutes from './mountPageRoutes.js';
|
|
20
|
+
|
|
21
|
+
function request({ basePath, path }) {
|
|
22
|
+
const app = basePath === '' ? new Hono() : new Hono().basePath(basePath);
|
|
23
|
+
mountPageRoutes({
|
|
24
|
+
app,
|
|
25
|
+
basePath,
|
|
26
|
+
renderPage: (c, { path: pagePath, matchedPath, status = 200 }) =>
|
|
27
|
+
c.json({ path: pagePath, matchedPath }, status),
|
|
28
|
+
});
|
|
29
|
+
return app.request(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test.each([
|
|
33
|
+
['', '/', '', ''],
|
|
34
|
+
['/app', '/app', '', ''],
|
|
35
|
+
['/app', '/app/', '', ''],
|
|
36
|
+
['/app', '/app/invoices', 'invoices', 'invoices'],
|
|
37
|
+
['', '/support/tickets/1234/', 'support/tickets/1234/', 'support/tickets/1234'],
|
|
38
|
+
['', '/support/tickets/a%2Fb%20c', 'support/tickets/a%2Fb%20c', 'support/tickets/a%2Fb%20c'],
|
|
39
|
+
['/app', '/app/tickets//1', 'tickets//1', 'tickets//1'],
|
|
40
|
+
])(
|
|
41
|
+
'with basePath "%s", %s renders path "%s" matched as "%s"',
|
|
42
|
+
async (basePath, url, path, matchedPath) => {
|
|
43
|
+
const res = await request({ basePath, path: url });
|
|
44
|
+
expect(res.status).toEqual(200);
|
|
45
|
+
expect(await res.json()).toEqual({ path, matchedPath });
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
test.each([[''], ['/app']])(
|
|
50
|
+
'with basePath "%s", /404 is a fixed route with status 404',
|
|
51
|
+
async (basePath) => {
|
|
52
|
+
const res = await request({ basePath, path: `${basePath}/404` });
|
|
53
|
+
expect(res.status).toEqual(404);
|
|
54
|
+
expect(await res.json()).toEqual({ path: '404', matchedPath: '404' });
|
|
55
|
+
}
|
|
56
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Claude Code merges the protocol version it negotiated (mcp-protocol-version)
|
|
18
|
+
// with a static MCP-Protocol-Version it sets on every request, and the Fetch
|
|
19
|
+
// Headers constructor folds the two spellings of one header into a single
|
|
20
|
+
// comma-joined value: "2025-11-25, 2025-11-25". @hono/mcp compares that raw
|
|
21
|
+
// string against its supported list, so a client that agrees with the server
|
|
22
|
+
// twice over is answered 404 "Unsupported protocol version" - which Claude Code
|
|
23
|
+
// shows as "MCP endpoint not found". Collapsing a repeated value to the one
|
|
24
|
+
// version it names is lossless; a header naming two DIFFERENT versions is left
|
|
25
|
+
// for the transport to refuse. Runs before the transport reads the header: the
|
|
26
|
+
// transport reads c.req.header(), which reads the raw request headers live.
|
|
27
|
+
function normalizeMcpProtocolVersionHeader({ request }) {
|
|
28
|
+
const value = request.headers.get('mcp-protocol-version');
|
|
29
|
+
if (value === null || !value.includes(',')) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const versions = [
|
|
33
|
+
...new Set(
|
|
34
|
+
value
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((version) => version.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
),
|
|
39
|
+
];
|
|
40
|
+
if (versions.length !== 1) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
request.headers.set('mcp-protocol-version', versions[0]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default normalizeMcpProtocolVersionHeader;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
|
|
19
|
+
import normalizeMcpProtocolVersionHeader from './normalizeMcpProtocolVersionHeader.js';
|
|
20
|
+
|
|
21
|
+
function createApp() {
|
|
22
|
+
const app = new Hono();
|
|
23
|
+
app.post('/api/mcp', (c) => {
|
|
24
|
+
normalizeMcpProtocolVersionHeader({ request: c.req.raw });
|
|
25
|
+
return c.json({ protocolVersion: c.req.header('mcp-protocol-version') ?? null });
|
|
26
|
+
});
|
|
27
|
+
return app;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function send(headerValue) {
|
|
31
|
+
const headers = new Headers();
|
|
32
|
+
if (headerValue !== undefined) {
|
|
33
|
+
headers.set('mcp-protocol-version', headerValue);
|
|
34
|
+
}
|
|
35
|
+
const res = await createApp().request('/api/mcp', { method: 'POST', headers });
|
|
36
|
+
return (await res.json()).protocolVersion;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('collapses a repeated protocol version to the single version it names', async () => {
|
|
40
|
+
expect(await send('2025-11-25, 2025-11-25')).toEqual('2025-11-25');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('collapses the value the Fetch Headers constructor builds from both header spellings', async () => {
|
|
44
|
+
const headers = new Headers({
|
|
45
|
+
'mcp-protocol-version': '2025-11-25',
|
|
46
|
+
'MCP-Protocol-Version': '2025-11-25',
|
|
47
|
+
});
|
|
48
|
+
const res = await createApp().request('/api/mcp', { method: 'POST', headers });
|
|
49
|
+
expect((await res.json()).protocolVersion).toEqual('2025-11-25');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('leaves a single protocol version untouched', async () => {
|
|
53
|
+
expect(await send('2025-06-18')).toEqual('2025-06-18');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('leaves a header naming two different versions for the transport to refuse', async () => {
|
|
57
|
+
expect(await send('2025-11-25, 2025-06-18')).toEqual('2025-11-25, 2025-06-18');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('leaves an absent header absent', async () => {
|
|
61
|
+
expect(await send(undefined)).toEqual(null);
|
|
62
|
+
});
|
package/src/routes/request.js
CHANGED
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { callRequest } from '@lowdefy/api';
|
|
17
|
+
import { callRequest, parseRequestBody } from '@lowdefy/api';
|
|
18
18
|
|
|
19
19
|
import getPathSegments from '../lib/getPathSegments.js';
|
|
20
20
|
|
|
21
21
|
async function requestHandler(c) {
|
|
22
22
|
if (c.req.method !== 'POST') {
|
|
23
|
-
|
|
23
|
+
// A wrong-method request is client-caused: answer 405 rather than raising a
|
|
24
|
+
// fault that would be logged at error level and answered with a 500.
|
|
25
|
+
return c.json({ error: 'Method not allowed.' }, 405);
|
|
24
26
|
}
|
|
25
27
|
const context = c.get('lowdefyContext');
|
|
26
28
|
const segments = getPathSegments(c, '/api/request/');
|
|
@@ -29,7 +31,7 @@ async function requestHandler(c) {
|
|
|
29
31
|
}
|
|
30
32
|
const requestId = segments[segments.length - 1];
|
|
31
33
|
const pageId = segments.slice(0, -1).join('/');
|
|
32
|
-
const { actionId, blockId, payload } = await c.req.
|
|
34
|
+
const { actionId, blockId, payload } = parseRequestBody({ text: await c.req.text() });
|
|
33
35
|
context.logger.info({ event: 'call_request', pageId, requestId, blockId, actionId });
|
|
34
36
|
const response = await callRequest(context, { blockId, pageId, payload, requestId });
|
|
35
37
|
return c.json(response);
|