@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.
Files changed (119) hide show
  1. package/client/App.jsx +41 -21
  2. package/client/Page.jsx +101 -24
  3. package/client/clearReloadedForTypes.js +29 -0
  4. package/client/clientBuildImports.test.mjs +60 -0
  5. package/client/getShownPage.js +28 -0
  6. package/client/getShownPage.test.mjs +52 -0
  7. package/client/loadAllIcons.js +45 -0
  8. package/client/loadPageTypes.js +44 -0
  9. package/client/main.jsx +22 -1
  10. package/client/reloadedForTypesKey.js +21 -0
  11. package/client/shouldReloadForTypes.js +38 -0
  12. package/client/shouldReloadForTypes.test.mjs +63 -0
  13. package/client/types.js +28 -0
  14. package/lib/build/security.js +26 -0
  15. package/lib/client/auth/Auth.jsx +11 -6
  16. package/lib/client/auth/AuthConfigured.jsx +224 -39
  17. package/lib/client/auth/AuthNotConfigured.js +44 -4
  18. package/lib/client/auth/createFetchCheckingBuild.js +45 -0
  19. package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
  20. package/lib/client/bundleBuildId.js +23 -0
  21. package/lib/client/createLogUsage.js +46 -25
  22. package/lib/client/createLogUsage.test.mjs +90 -0
  23. package/lib/client/getCspNonce.js +25 -0
  24. package/lib/server/auth/createSystemContext.js +70 -0
  25. package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -7
  26. package/lib/server/auth/getStrategies.js +40 -0
  27. package/lib/server/getClientAddress.js +56 -0
  28. package/lib/server/getClientAddress.test.mjs +108 -0
  29. package/lib/server/log/createHandleError.js +16 -4
  30. package/lib/server/log/createHandleError.test.mjs +102 -0
  31. package/lib/server/log/createLogger.js +10 -0
  32. package/lib/server/log/createLogger.test.mjs +117 -0
  33. package/lib/server/log/logRequest.js +12 -4
  34. package/lib/server/log/maskCredentialKeys.js +37 -0
  35. package/lib/server/log/projectErrorForLog.js +61 -0
  36. package/lib/server/log/redactUrlQuery.js +75 -0
  37. package/lib/server/log/redactUrlQuery.test.mjs +73 -0
  38. package/lib/server/{auth/session.js → log/serializeErrorForLog.js} +5 -11
  39. package/lib/server/log/serializeErrorForLog.test.mjs +337 -0
  40. package/lib/server/scrubSecrets.js +21 -0
  41. package/lib/server/sentry/captureSentryError.js +29 -3
  42. package/lib/server/sentry/captureSentryError.test.mjs +128 -0
  43. package/lib/server/sentry/initSentry.js +46 -14
  44. package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
  45. package/lib/server/sentry/initSentry.test.mjs +365 -0
  46. package/lib/server/sentry/scrubEvent.js +40 -0
  47. package/lib/server/sentry/scrubEvent.test.mjs +88 -0
  48. package/lib/server/sentry/sentrySdk.js +23 -0
  49. package/lib/server/sentry/setSentryUser.js +3 -3
  50. package/lowdefy/build.mjs +1 -0
  51. package/lowdefy/build.test.mjs +68 -0
  52. package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
  53. package/package.json +47 -42
  54. package/package.original.json +47 -42
  55. package/src/app.js +74 -17
  56. package/src/app.test.mjs +118 -0
  57. package/src/html/collectChunkClosure.js +31 -0
  58. package/src/html/collectLazyImports.js +41 -0
  59. package/src/html/collectPageTypesAssets.js +57 -0
  60. package/src/html/collectPageTypesAssets.test.mjs +130 -0
  61. package/src/html/getAssets.js +20 -1
  62. package/src/html/getHomePath.js +29 -0
  63. package/src/html/getPageAssets.js +32 -0
  64. package/src/html/getPageAssets.test.mjs +47 -0
  65. package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
  66. package/src/html/renderPage.js +80 -19
  67. package/src/html/renderPage.test.mjs +189 -0
  68. package/src/html/template.js +50 -15
  69. package/src/html/template.test.mjs +126 -0
  70. package/src/index.js +28 -19
  71. package/src/initServer.js +61 -0
  72. package/src/initServer.test.mjs +116 -0
  73. package/src/lib/getRequestPath.js +29 -0
  74. package/src/middleware/apiContext.js +68 -13
  75. package/src/middleware/apiContext.test.mjs +178 -0
  76. package/src/middleware/createSameOriginGuard.js +36 -0
  77. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  78. package/src/middleware/errorHandler.js +56 -4
  79. package/src/middleware/errorHandler.test.mjs +440 -0
  80. package/src/middleware/noStore.js +27 -0
  81. package/src/middleware/refuseOtherBuild.js +49 -0
  82. package/src/middleware/refuseOtherBuild.test.mjs +63 -0
  83. package/src/middleware/requestTimeout.js +38 -0
  84. package/src/middleware/sentry.js +13 -5
  85. package/src/middleware/stampBuildId.js +30 -0
  86. package/src/middleware/stampBuildId.test.mjs +41 -0
  87. package/src/middleware/stampCaller.js +38 -0
  88. package/src/middleware/stampCaller.test.mjs +64 -0
  89. package/src/routes/agent.js +15 -3
  90. package/src/routes/apiPage.js +54 -9
  91. package/src/routes/apiPage.test.mjs +143 -0
  92. package/src/routes/auth.js +15 -9
  93. package/src/routes/auth.test.mjs +85 -0
  94. package/src/routes/clientError.js +11 -13
  95. package/src/routes/cron.js +3 -1
  96. package/src/routes/cronForward.js +4 -2
  97. package/src/routes/detached.js +22 -9
  98. package/src/routes/endpoints.js +27 -26
  99. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  100. package/src/routes/mcp.js +82 -5
  101. package/src/routes/mcp.test.mjs +215 -0
  102. package/src/routes/mcpMemberToken.test.mjs +125 -0
  103. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  104. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  105. package/src/routes/mountOauthDiscovery.js +44 -0
  106. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  107. package/src/routes/mountPageRoutes.js +29 -0
  108. package/src/routes/mountPageRoutes.test.mjs +56 -0
  109. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  110. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  111. package/src/routes/request.js +5 -3
  112. package/src/routes/requestBody.test.mjs +146 -0
  113. package/src/routes/usage.js +6 -2
  114. package/src/routes/user.js +27 -0
  115. package/src/routes/websocket.js +26 -3
  116. package/src/routes/websocket.test.mjs +106 -0
  117. package/src/routes/wellKnownFallback.js +30 -0
  118. package/vite.config.js +55 -2
  119. 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
+ });
@@ -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
- throw new Error('Only POST requests are supported.');
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.json();
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);