@lowdefy/api 5.5.1 → 6.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/dist/context/createAuthorize.js +4 -1
- package/dist/context/resolveStrategyCaller.js +56 -0
- package/dist/index.js +16 -3
- package/dist/response/buildEndpointResult.js +33 -0
- package/dist/response/normalizeErrorSources.js +64 -0
- package/dist/response/omitErrorProps.js +51 -0
- package/dist/response/redactErrorResponse.js +30 -0
- package/dist/response/redactResponse.js +31 -0
- package/dist/routes/agent/authorizeAgent.js +39 -0
- package/dist/routes/agent/callAgent.js +7 -180
- package/dist/routes/agent/prepareAgent.js +173 -0
- package/dist/routes/auth/createLogger.js +2 -5
- package/dist/routes/auth/createPrefixedCookies.js +52 -0
- package/dist/routes/auth/{getNextAuthConfig.js → getAuthConfig.js} +27 -16
- package/dist/routes/auth/resolveCookies.js +37 -0
- package/dist/routes/auth/strategies/createAuthStrategies.js +77 -0
- package/dist/routes/auth/strategies/getAuthStrategies.js +31 -0
- package/dist/routes/endpoints/addStepResult.js +12 -2
- package/dist/routes/endpoints/authorizeApiEndpoint.js +8 -2
- package/dist/routes/endpoints/callEndpoint.js +32 -13
- package/dist/routes/endpoints/control/controlReject.js +3 -1
- package/dist/routes/endpoints/control/controlSetState.js +13 -2
- package/dist/routes/endpoints/control/controlThrow.js +4 -1
- package/dist/routes/endpoints/findSchedule.js +35 -0
- package/dist/routes/endpoints/forwardScheduledEndpoint.js +106 -0
- package/dist/routes/endpoints/getEndpointConfig.js +9 -3
- package/dist/routes/endpoints/getEnvironmentSchedules.js +29 -0
- package/dist/routes/endpoints/handleAgentCall.js +83 -0
- package/dist/routes/endpoints/handleEndpointCall.js +49 -1
- package/dist/routes/endpoints/handleRenderNotification.js +189 -0
- package/dist/routes/endpoints/handleValidateSchema.js +3 -1
- package/dist/routes/endpoints/isUnauthenticatedHuman.js +31 -0
- package/dist/routes/endpoints/resolveCronEnvironment.js +37 -0
- package/dist/routes/endpoints/runDetachedEndpoint.js +66 -0
- package/dist/routes/endpoints/runRoutine.js +15 -1
- package/dist/routes/endpoints/runScheduledEndpoint.js +104 -0
- package/dist/routes/endpoints/runWebhookEndpoint.js +83 -0
- package/dist/routes/endpoints/scheduleBackground.js +48 -0
- package/dist/routes/mcp/createMcpServer.js +160 -0
- package/dist/routes/notifications/derivePreview.js +30 -0
- package/dist/routes/notifications/getNotificationConfig.js +32 -0
- package/dist/routes/notifications/resolveNotificationLinks.js +72 -0
- package/dist/routes/notifications/resolveThemeLogo.js +35 -0
- package/dist/routes/page/dynamic/resolveDynamicContent.js +172 -0
- package/dist/routes/page/dynamic/unescapeOperators.js +36 -0
- package/dist/routes/page/dynamic/validateFragment.js +104 -0
- package/dist/routes/page/getPageConfig.js +16 -6
- package/dist/routes/request/callRequest.js +2 -1
- package/dist/routes/websocket/authorizeWebsocket.js +27 -0
- package/dist/routes/websocket/createChannelRegistry.js +269 -0
- package/dist/routes/websocket/createWebSocketConnection.js +131 -0
- package/dist/routes/websocket/getWebsocketConfig.js +30 -0
- package/dist/routes/websocket/getWebsocketResolver.js +33 -0
- package/dist/routes/websocket/prepareChannel.js +72 -0
- package/dist/test/testContext.js +10 -3
- package/package.json +12 -10
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
*/ import { serializer } from '@lowdefy/helpers';
|
|
16
|
+
import buildEndpointResult from '../../response/buildEndpointResult.js';
|
|
17
|
+
import createAuthorize from '../../context/createAuthorize.js';
|
|
18
|
+
import createEvaluateOperators from '../../context/createEvaluateOperators.js';
|
|
19
|
+
import getEndpointConfig from './getEndpointConfig.js';
|
|
20
|
+
import runRoutine from './runRoutine.js';
|
|
21
|
+
// Runs an endpoint invoked through the detached route (a CallApi step with
|
|
22
|
+
// `detached: true` fire-and-forgets an HTTP call back to the deployment, so the
|
|
23
|
+
// target runs in its OWN function invocation with a fresh duration budget).
|
|
24
|
+
// Like a cron run this is authorized by the transport layer (CRON_SECRET) and
|
|
25
|
+
// executes as a system context: no user session, `_user` resolves to undefined,
|
|
26
|
+
// and InternalApi endpoints are callable. Payload arrives serialized (dates
|
|
27
|
+
// etc. survive the HTTP hop via @lowdefy/helpers serializer).
|
|
28
|
+
async function runDetachedEndpoint(context, { endpointId, payload }) {
|
|
29
|
+
const { logger } = context;
|
|
30
|
+
context.endpointId = endpointId;
|
|
31
|
+
context.evaluateOperators = createEvaluateOperators(context);
|
|
32
|
+
logger.debug({
|
|
33
|
+
event: 'debug_detached_endpoint',
|
|
34
|
+
endpointId
|
|
35
|
+
});
|
|
36
|
+
const endpointConfig = await getEndpointConfig(context, {
|
|
37
|
+
endpointId
|
|
38
|
+
});
|
|
39
|
+
// Force a system context regardless of any session cookie sent with the request.
|
|
40
|
+
// system: true — nested CallApi steps are authorized like function calls (the run
|
|
41
|
+
// was already authorized at the transport layer), not re-gated on a user session.
|
|
42
|
+
context.session = undefined;
|
|
43
|
+
context.user = undefined;
|
|
44
|
+
context.system = true;
|
|
45
|
+
context.authorize = createAuthorize({
|
|
46
|
+
session: undefined,
|
|
47
|
+
system: true
|
|
48
|
+
});
|
|
49
|
+
const routineContext = {
|
|
50
|
+
steps: {},
|
|
51
|
+
payload: serializer.deserialize(payload ?? {}),
|
|
52
|
+
arrayIndices: [],
|
|
53
|
+
items: {},
|
|
54
|
+
state: {},
|
|
55
|
+
endpointDepth: 0
|
|
56
|
+
};
|
|
57
|
+
const { error, response, status } = await runRoutine(context, routineContext, {
|
|
58
|
+
routine: endpointConfig.routine
|
|
59
|
+
});
|
|
60
|
+
return buildEndpointResult(context, {
|
|
61
|
+
error,
|
|
62
|
+
response,
|
|
63
|
+
status
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export default runDetachedEndpoint;
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
+
import handleAgentCall from './handleAgentCall.js';
|
|
16
17
|
import handleControl from './control/handleControl.js';
|
|
17
18
|
import handleEndpointCall from './handleEndpointCall.js';
|
|
19
|
+
import handleRenderNotification from './handleRenderNotification.js';
|
|
18
20
|
import handleRequest from './handleRequest.js';
|
|
19
21
|
import handleValidateSchema from './handleValidateSchema.js';
|
|
20
22
|
async function runRoutine(context, routineContext, { routine }) {
|
|
@@ -35,6 +37,16 @@ async function runRoutine(context, routineContext, { routine }) {
|
|
|
35
37
|
step: routine
|
|
36
38
|
});
|
|
37
39
|
}
|
|
40
|
+
if (routine.id?.startsWith?.('agent:')) {
|
|
41
|
+
return await handleAgentCall(context, routineContext, {
|
|
42
|
+
step: routine
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (routine.id?.startsWith?.('notification:')) {
|
|
46
|
+
return await handleRenderNotification(context, routineContext, {
|
|
47
|
+
step: routine
|
|
48
|
+
});
|
|
49
|
+
}
|
|
38
50
|
return await handleControl(context, routineContext, {
|
|
39
51
|
control: routine
|
|
40
52
|
});
|
|
@@ -68,9 +80,11 @@ async function runRoutine(context, routineContext, { routine }) {
|
|
|
68
80
|
error
|
|
69
81
|
};
|
|
70
82
|
}
|
|
83
|
+
// handleError sets error.handled once it has logged - it is the single sink
|
|
84
|
+
// that owns the flag, so a nested runRoutine re-throwing this error does not
|
|
85
|
+
// log it again.
|
|
71
86
|
if (!error.handled) {
|
|
72
87
|
await context.handleError(error);
|
|
73
|
-
error.handled = true;
|
|
74
88
|
}
|
|
75
89
|
return {
|
|
76
90
|
status: 'error',
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
*/ import { serializer } from '@lowdefy/helpers';
|
|
16
|
+
import createAuthorize from '../../context/createAuthorize.js';
|
|
17
|
+
import buildEndpointResult from '../../response/buildEndpointResult.js';
|
|
18
|
+
import createEvaluateOperators from '../../context/createEvaluateOperators.js';
|
|
19
|
+
import findSchedule from './findSchedule.js';
|
|
20
|
+
import getEndpointConfig from './getEndpointConfig.js';
|
|
21
|
+
import getEnvironmentSchedules from './getEnvironmentSchedules.js';
|
|
22
|
+
import resolveCronEnvironment from './resolveCronEnvironment.js';
|
|
23
|
+
import runRoutine from './runRoutine.js';
|
|
24
|
+
import scheduleBackground from './scheduleBackground.js';
|
|
25
|
+
// Runs an endpoint routine on a schedule (cron). Unlike callEndpoint this does NOT check the
|
|
26
|
+
// endpoint's `auth` config and does NOT block InternalApi: a cron run is authorized by the transport
|
|
27
|
+
// layer (CRON_SECRET) plus the endpoint declaring the firing schedule, and runs as a system context
|
|
28
|
+
// (no user session, so `_user` resolves to undefined). An InternalApi with schedules is therefore a
|
|
29
|
+
// cron-only endpoint that is never client-callable.
|
|
30
|
+
// `environment` (x-lowdefy-cron-environment, set on requests forwarded from the production
|
|
31
|
+
// deployment) selects which environment's schedules apply; see resolveCronEnvironment.
|
|
32
|
+
async function runScheduledEndpoint(context, { endpointId, cron, environment }) {
|
|
33
|
+
const { logger } = context;
|
|
34
|
+
context.endpointId = endpointId;
|
|
35
|
+
context.evaluateOperators = createEvaluateOperators(context);
|
|
36
|
+
const cronEnvironment = resolveCronEnvironment({
|
|
37
|
+
config: context.config,
|
|
38
|
+
environment
|
|
39
|
+
});
|
|
40
|
+
logger.debug({
|
|
41
|
+
event: 'debug_scheduled_endpoint',
|
|
42
|
+
endpointId,
|
|
43
|
+
cron,
|
|
44
|
+
environment: cronEnvironment
|
|
45
|
+
});
|
|
46
|
+
const endpointConfig = await getEndpointConfig(context, {
|
|
47
|
+
endpointId
|
|
48
|
+
});
|
|
49
|
+
const schedules = getEnvironmentSchedules({
|
|
50
|
+
endpointConfig,
|
|
51
|
+
environment: cronEnvironment
|
|
52
|
+
});
|
|
53
|
+
const schedule = findSchedule({
|
|
54
|
+
schedules,
|
|
55
|
+
cron,
|
|
56
|
+
endpointId,
|
|
57
|
+
environment: cronEnvironment
|
|
58
|
+
});
|
|
59
|
+
// Force a system context regardless of any session cookie sent with the request.
|
|
60
|
+
// system: true — nested CallApi steps are authorized like function calls (the run
|
|
61
|
+
// was already authorized at the transport layer), not re-gated on a user session.
|
|
62
|
+
context.session = undefined;
|
|
63
|
+
context.user = undefined;
|
|
64
|
+
context.system = true;
|
|
65
|
+
context.authorize = createAuthorize({
|
|
66
|
+
session: undefined,
|
|
67
|
+
system: true
|
|
68
|
+
});
|
|
69
|
+
const routineContext = {
|
|
70
|
+
steps: {},
|
|
71
|
+
payload: schedule.payload ?? {},
|
|
72
|
+
arrayIndices: [],
|
|
73
|
+
items: {},
|
|
74
|
+
state: {},
|
|
75
|
+
endpointDepth: 0
|
|
76
|
+
};
|
|
77
|
+
// async: true — acknowledge the cron trigger immediately and run in the
|
|
78
|
+
// background; transport auth (CRON_SECRET) already passed at the route.
|
|
79
|
+
if (endpointConfig.async === true) {
|
|
80
|
+
scheduleBackground(context, {
|
|
81
|
+
event: 'background_scheduled_endpoint',
|
|
82
|
+
endpointId
|
|
83
|
+
}, ()=>runRoutine(context, routineContext, {
|
|
84
|
+
routine: endpointConfig.routine
|
|
85
|
+
}));
|
|
86
|
+
return {
|
|
87
|
+
error: null,
|
|
88
|
+
response: serializer.serialize({
|
|
89
|
+
accepted: true
|
|
90
|
+
}),
|
|
91
|
+
status: 'accepted',
|
|
92
|
+
success: true
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const { error, response, status } = await runRoutine(context, routineContext, {
|
|
96
|
+
routine: endpointConfig.routine
|
|
97
|
+
});
|
|
98
|
+
return buildEndpointResult(context, {
|
|
99
|
+
error,
|
|
100
|
+
response,
|
|
101
|
+
status
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export default runScheduledEndpoint;
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
*/ import { ConfigError } from '@lowdefy/errors';
|
|
16
|
+
import buildEndpointResult from '../../response/buildEndpointResult.js';
|
|
17
|
+
import createAuthorize from '../../context/createAuthorize.js';
|
|
18
|
+
import createEvaluateOperators from '../../context/createEvaluateOperators.js';
|
|
19
|
+
import getEndpointConfig from './getEndpointConfig.js';
|
|
20
|
+
import runRoutine from './runRoutine.js';
|
|
21
|
+
// Runs an endpoint declared `webhook: true` — a third-party webhook receiver
|
|
22
|
+
// (SNS, Event Grid, Stripe, ...) served on the standard /api/endpoints route,
|
|
23
|
+
// but taking the request RAW: bodies are the caller's own format, not
|
|
24
|
+
// Lowdefy's { payload } envelope, so the routine receives
|
|
25
|
+
// { body, query, headers } as its payload and its return value is sent back
|
|
26
|
+
// verbatim (handshakes require exact response shapes). Only endpoints that
|
|
27
|
+
// opt in are runnable here (a missing flag reads as a missing endpoint — no
|
|
28
|
+
// probing). The transport is public by design: authenticating the caller
|
|
29
|
+
// (shared-secret query param, signature header) is the webhook routine's own
|
|
30
|
+
// first step. Executes as a system context.
|
|
31
|
+
async function runWebhookEndpoint(context, { endpointId, body, query, headers }) {
|
|
32
|
+
const { logger } = context;
|
|
33
|
+
context.endpointId = endpointId;
|
|
34
|
+
context.evaluateOperators = createEvaluateOperators(context);
|
|
35
|
+
logger.debug({
|
|
36
|
+
event: 'debug_webhook_endpoint',
|
|
37
|
+
endpointId
|
|
38
|
+
});
|
|
39
|
+
const endpointConfig = await getEndpointConfig(context, {
|
|
40
|
+
endpointId
|
|
41
|
+
});
|
|
42
|
+
if (endpointConfig.webhook !== true) {
|
|
43
|
+
const err = new ConfigError(`API Endpoint "${endpointId}" does not exist.`);
|
|
44
|
+
logger.debug({
|
|
45
|
+
params: {
|
|
46
|
+
endpointId
|
|
47
|
+
},
|
|
48
|
+
err
|
|
49
|
+
}, err.message);
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
// Force a system context regardless of any session cookie sent with the request.
|
|
53
|
+
// system: true — nested CallApi steps are authorized like function calls (the run
|
|
54
|
+
// was already authorized at the transport layer), not re-gated on a user session.
|
|
55
|
+
context.session = undefined;
|
|
56
|
+
context.user = undefined;
|
|
57
|
+
context.system = true;
|
|
58
|
+
context.authorize = createAuthorize({
|
|
59
|
+
session: undefined,
|
|
60
|
+
system: true
|
|
61
|
+
});
|
|
62
|
+
const routineContext = {
|
|
63
|
+
steps: {},
|
|
64
|
+
payload: {
|
|
65
|
+
body: body ?? null,
|
|
66
|
+
query: query ?? {},
|
|
67
|
+
headers: headers ?? {}
|
|
68
|
+
},
|
|
69
|
+
arrayIndices: [],
|
|
70
|
+
items: {},
|
|
71
|
+
state: {},
|
|
72
|
+
endpointDepth: 0
|
|
73
|
+
};
|
|
74
|
+
const { error, response, status } = await runRoutine(context, routineContext, {
|
|
75
|
+
routine: endpointConfig.routine
|
|
76
|
+
});
|
|
77
|
+
return buildEndpointResult(context, {
|
|
78
|
+
error,
|
|
79
|
+
response,
|
|
80
|
+
status
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export default runWebhookEndpoint;
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
*/ // Run work after the response is sent. Platforms that reap the invocation once
|
|
16
|
+
// the response is flushed (Vercel fluid compute) inject context.waitUntil in
|
|
17
|
+
// their server's apiContext middleware to keep the invocation alive until the
|
|
18
|
+
// promise settles (bounded by the function's maxDuration); locally /
|
|
19
|
+
// self-hosted there is no waitUntil and the promise simply runs detached on
|
|
20
|
+
// the still-alive Node process.
|
|
21
|
+
//
|
|
22
|
+
// The outcome only exists in logs — completion and failure are logged through
|
|
23
|
+
// the request logger so they reach the platform log stream (and any log
|
|
24
|
+
// drains); a background failure must never surface as an unhandled rejection.
|
|
25
|
+
function scheduleBackground(context, { event, endpointId }, fn) {
|
|
26
|
+
const { logger } = context;
|
|
27
|
+
const promise = (async ()=>{
|
|
28
|
+
try {
|
|
29
|
+
const result = await fn();
|
|
30
|
+
logger.info({
|
|
31
|
+
event: `${event}_done`,
|
|
32
|
+
endpointId,
|
|
33
|
+
status: result?.status
|
|
34
|
+
});
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger.error({
|
|
37
|
+
event: `${event}_failed`,
|
|
38
|
+
endpointId,
|
|
39
|
+
err
|
|
40
|
+
}, err.message);
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
if (context.waitUntil) {
|
|
44
|
+
context.waitUntil(promise);
|
|
45
|
+
}
|
|
46
|
+
return promise;
|
|
47
|
+
}
|
|
48
|
+
export default scheduleBackground;
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
*/ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
16
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
17
|
+
import { AuthenticationError } from '@lowdefy/errors';
|
|
18
|
+
import { serializer, type } from '@lowdefy/helpers';
|
|
19
|
+
import callEndpoint from '../endpoints/callEndpoint.js';
|
|
20
|
+
import isUnauthenticatedHuman from '../endpoints/isUnauthenticatedHuman.js';
|
|
21
|
+
// LLM-safe tool names use the same rule as buildAgents tool naming.
|
|
22
|
+
function toToolName(id) {
|
|
23
|
+
return id.replaceAll('/', '__');
|
|
24
|
+
}
|
|
25
|
+
// Twin of cleanBuildArtifact in packages/utils/ai-utils/src/buildAgentTools.js -
|
|
26
|
+
// duplicated here rather than shared, since pulling in @lowdefy/ai-utils would
|
|
27
|
+
// add the ai SDK and MCP client deps to api just for this. Strips build-artifact
|
|
28
|
+
// serializer markers (~k, ~r, ~l) and unwraps { '~arr': [...] } back to a plain
|
|
29
|
+
// array, so payloadSchema reaches MCP clients as plain JSON Schema.
|
|
30
|
+
function cleanBuildArtifact(obj) {
|
|
31
|
+
return JSON.parse(JSON.stringify(serializer.deserialize(obj)));
|
|
32
|
+
}
|
|
33
|
+
// A stateless per-request MCP server exposing the configured api endpoints
|
|
34
|
+
// as tools. Built with the SDK's low-level Server: tool input schemas are
|
|
35
|
+
// config-provided JSON Schema (payloadSchema), which the low-level handlers
|
|
36
|
+
// accept directly - no zod conversion. The server is constructed with the
|
|
37
|
+
// request's context, so the caller is known at construction time: tools/list
|
|
38
|
+
// filters by context.authorize, and tools/call re-authorizes inside
|
|
39
|
+
// callEndpoint (defense in depth). Returns null when no mcp block is
|
|
40
|
+
// configured.
|
|
41
|
+
async function createMcpServer({ context }) {
|
|
42
|
+
const mcpConfig = await context.readConfigFile('mcp.json');
|
|
43
|
+
if (type.isNone(mcpConfig) || mcpConfig.configured !== true) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// serverInfo doubles as the connector card in clients such as claude.ai:
|
|
47
|
+
// title, websiteUrl and icons are optional branding the app may configure,
|
|
48
|
+
// and are omitted (not sent as undefined) when it does not. icons is config
|
|
49
|
+
// structure, so like payloadSchema it carries build-artifact markers that
|
|
50
|
+
// must not reach the client.
|
|
51
|
+
const serverInfo = {
|
|
52
|
+
name: mcpConfig.name,
|
|
53
|
+
version: mcpConfig.version
|
|
54
|
+
};
|
|
55
|
+
for (const key of [
|
|
56
|
+
'title',
|
|
57
|
+
'websiteUrl'
|
|
58
|
+
]){
|
|
59
|
+
if (!type.isNone(mcpConfig[key])) {
|
|
60
|
+
serverInfo[key] = mcpConfig[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!type.isNone(mcpConfig.icons)) {
|
|
64
|
+
serverInfo.icons = cleanBuildArtifact(mcpConfig.icons);
|
|
65
|
+
}
|
|
66
|
+
const server = new Server(serverInfo, {
|
|
67
|
+
capabilities: {
|
|
68
|
+
tools: {}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
server.setRequestHandler(ListToolsRequestSchema, async ()=>{
|
|
72
|
+
const tools = [];
|
|
73
|
+
for (const endpointId of mcpConfig.endpoints){
|
|
74
|
+
const endpointConfig = await context.readConfigFile(`api/${endpointId}.json`);
|
|
75
|
+
if (type.isNone(endpointConfig) || !context.authorize(endpointConfig)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
tools.push({
|
|
79
|
+
name: toToolName(endpointId),
|
|
80
|
+
description: endpointConfig.description,
|
|
81
|
+
inputSchema: cleanBuildArtifact(endpointConfig.payloadSchema)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
tools
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
server.setRequestHandler(CallToolRequestSchema, async (request)=>{
|
|
89
|
+
const { name, arguments: args } = request.params;
|
|
90
|
+
context.logger.info({
|
|
91
|
+
event: 'mcp_tool_call',
|
|
92
|
+
tool: name
|
|
93
|
+
});
|
|
94
|
+
const endpointId = mcpConfig.endpoints.find((id)=>toToolName(id) === name);
|
|
95
|
+
try {
|
|
96
|
+
if (!type.isNone(endpointId)) {
|
|
97
|
+
const { error, response, success } = await callEndpoint(context, {
|
|
98
|
+
blockId: '_mcp',
|
|
99
|
+
endpointId,
|
|
100
|
+
pageId: '_mcp',
|
|
101
|
+
payload: args ?? {}
|
|
102
|
+
});
|
|
103
|
+
if (!success) {
|
|
104
|
+
const deserialized = serializer.deserialize(error);
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: deserialized?.message ?? 'Endpoint failed.'
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
isError: true
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
type: 'text',
|
|
119
|
+
text: JSON.stringify(serializer.deserialize(response))
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// An unknown tool answers like a gated one for an anonymous caller on an
|
|
125
|
+
// auth'd app, so tools/call cannot be used to enumerate tool names that
|
|
126
|
+
// tools/list already hides from that caller.
|
|
127
|
+
if (await isUnauthenticatedHuman(context)) {
|
|
128
|
+
throw new AuthenticationError(`Authentication required for API endpoint "${name}".`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: 'text',
|
|
134
|
+
text: `Unknown tool "${name}".`
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
isError: true
|
|
138
|
+
};
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// Unauthenticated calls to gated tools are expected probing traffic -
|
|
141
|
+
// a warn line and the 401-shaped message, not a structured error log.
|
|
142
|
+
if (error.name === 'AuthenticationError') {
|
|
143
|
+
context.logger.warn(`Unauthenticated MCP tool call: ${name}`);
|
|
144
|
+
} else {
|
|
145
|
+
context.logger.error(error);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
content: [
|
|
149
|
+
{
|
|
150
|
+
type: 'text',
|
|
151
|
+
text: error.message
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
isError: true
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return server;
|
|
159
|
+
}
|
|
160
|
+
export default createMcpServer;
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
+
// Rough inversion of the interpolation pipeline for the preview text: strip
|
|
17
|
+
// author markdown syntax and unescape the backslash-escaped interpolated values.
|
|
18
|
+
function stripMarkdown(text) {
|
|
19
|
+
return text.replace(/```[\s\S]*?```/g, ' ').replace(/`([^`]*)`/g, '$1').replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^#{1,6}\s+/gm, '').replace(/^\s*>\s?/gm, '').replace(/^\s*[-*+]\s+/gm, '').replace(/(\*\*|__|\*|_|~~)/g, '').replace(/\\([!-/:-@[-`{-~])/g, '$1').replace(/\s+/g, ' ').trim();
|
|
20
|
+
}
|
|
21
|
+
function derivePreview({ properties }) {
|
|
22
|
+
if (type.isString(properties.preview) && properties.preview !== '') {
|
|
23
|
+
return properties.preview;
|
|
24
|
+
}
|
|
25
|
+
if (type.isString(properties.message) && properties.message !== '') {
|
|
26
|
+
return stripMarkdown(properties.message).slice(0, 140);
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export default derivePreview;
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
*/ import { ConfigError } from '@lowdefy/errors';
|
|
16
|
+
async function getNotificationConfig({ logger, readConfigFile }, { notificationId, configKey }) {
|
|
17
|
+
const notification = await readConfigFile(`notifications/${notificationId}.json`);
|
|
18
|
+
if (!notification) {
|
|
19
|
+
const err = new ConfigError(`Notification "${notificationId}" does not exist.`, {
|
|
20
|
+
configKey
|
|
21
|
+
});
|
|
22
|
+
logger.debug({
|
|
23
|
+
params: {
|
|
24
|
+
notificationId
|
|
25
|
+
},
|
|
26
|
+
err
|
|
27
|
+
}, err.message);
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
return notification;
|
|
31
|
+
}
|
|
32
|
+
export default getNotificationConfig;
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
*/ import { serializer, type, urlQuery } from '@lowdefy/helpers';
|
|
16
|
+
function resolveLinkValue({ link, option, serverUrl, basePath, landingPage, recordId }) {
|
|
17
|
+
// Absolute URLs pass through — links into other apps or external destinations.
|
|
18
|
+
// They skip any landing page, so they carry no mark-as-read.
|
|
19
|
+
if (type.isString(link)) {
|
|
20
|
+
return link;
|
|
21
|
+
}
|
|
22
|
+
if (type.isObject(link) && !type.isNone(link.pageId)) {
|
|
23
|
+
if (type.isNone(landingPage)) {
|
|
24
|
+
// No landing page configured — link straight to the target page.
|
|
25
|
+
const query = urlQuery.stringify(link.urlQuery ?? {});
|
|
26
|
+
return `${serverUrl}${basePath}/${link.pageId}${query ? `?${query}` : ''}`;
|
|
27
|
+
}
|
|
28
|
+
// The option query param is the dot-path of the link inside the record's
|
|
29
|
+
// data — the landing page reads the original target back with
|
|
30
|
+
// get(record.data, option) after marking the record read.
|
|
31
|
+
const query = urlQuery.stringify({
|
|
32
|
+
_id: recordId,
|
|
33
|
+
option
|
|
34
|
+
});
|
|
35
|
+
return `${serverUrl}${basePath}${landingPage}?${query}`;
|
|
36
|
+
}
|
|
37
|
+
return link;
|
|
38
|
+
}
|
|
39
|
+
// Resolves link values in a copy of the data item to URLs; the stored record
|
|
40
|
+
// keeps the original { pageId, urlQuery } objects for in-app navigation.
|
|
41
|
+
// data.links is the framework convention; link fields inside arrays are
|
|
42
|
+
// resolved for the data keys the template declares (Template.dataKeys), so
|
|
43
|
+
// custom templates get the same treatment as the built-in ones.
|
|
44
|
+
function resolveNotificationLinks({ item, dataKeys, serverUrl, basePath, landingPage, recordId }) {
|
|
45
|
+
const resolved = serializer.copy(item);
|
|
46
|
+
Object.keys(resolved.links ?? {}).forEach((key)=>{
|
|
47
|
+
resolved.links[key] = resolveLinkValue({
|
|
48
|
+
link: resolved.links[key],
|
|
49
|
+
option: `links.${key}`,
|
|
50
|
+
serverUrl,
|
|
51
|
+
basePath,
|
|
52
|
+
landingPage,
|
|
53
|
+
recordId
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
(dataKeys ?? []).forEach((arrayKey)=>{
|
|
57
|
+
if (!type.isArray(resolved[arrayKey])) return;
|
|
58
|
+
resolved[arrayKey].forEach((entry, index)=>{
|
|
59
|
+
if (!type.isObject(entry) || type.isNone(entry.link)) return;
|
|
60
|
+
entry.link = resolveLinkValue({
|
|
61
|
+
link: entry.link,
|
|
62
|
+
option: `${arrayKey}.${index}.link`,
|
|
63
|
+
serverUrl,
|
|
64
|
+
basePath,
|
|
65
|
+
landingPage,
|
|
66
|
+
recordId
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
return resolved;
|
|
71
|
+
}
|
|
72
|
+
export default resolveNotificationLinks;
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
+
// A theme logo written as an app-relative path ("/logo.png", a public/ asset)
|
|
17
|
+
// resolves against the deployment's serverUrl + basePath, so one config works
|
|
18
|
+
// across environments. Absolute ("https://...") and protocol-relative ("//...")
|
|
19
|
+
// URLs pass through. Without a serverUrl an email client can never fetch a
|
|
20
|
+
// relative path, so the logo is dropped — EmailLayout falls back to the
|
|
21
|
+
// companyName text header, which beats a broken image.
|
|
22
|
+
function resolveThemeLogo({ theme, serverUrl, basePath }) {
|
|
23
|
+
const { logo, ...rest } = theme;
|
|
24
|
+
if (!type.isString(logo) || !logo.startsWith('/') || logo.startsWith('//')) {
|
|
25
|
+
return theme;
|
|
26
|
+
}
|
|
27
|
+
if (type.isNone(serverUrl)) {
|
|
28
|
+
return rest;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
...rest,
|
|
32
|
+
logo: `${serverUrl}${basePath}${logo}`
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export default resolveThemeLogo;
|