@unchainedshop/api 5.0.0-alpha.2 → 5.0.0-alpha.4
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/README.md +8 -6
- package/lib/adminUiPlugins.d.ts +97 -0
- package/lib/adminUiPlugins.js +164 -0
- package/lib/auth.d.ts +0 -8
- package/lib/auth.js +0 -20
- package/lib/chat/generateImageHandler.d.ts +273 -7
- package/lib/chat/utils.d.ts +8 -0
- package/lib/chat/utils.js +54 -2
- package/lib/createGraphQLServer.js +6 -0
- package/lib/express/chatHandler.js +63 -61
- package/lib/express/createMCPMiddleware.js +19 -78
- package/lib/express/index.d.ts +6 -2
- package/lib/express/index.js +67 -7
- package/lib/fastify/chatHandler.js +61 -61
- package/lib/fastify/index.d.ts +5 -1
- package/lib/fastify/index.js +70 -11
- package/lib/fastify/mcpHandler.js +24 -75
- package/lib/loaders/assortmentLinksLoader.js +24 -22
- package/lib/loaders/assortmentMediaTextLoader.js +1 -0
- package/lib/loaders/assortmentProductLoader.js +7 -4
- package/lib/loaders/assortmentTextLoader.js +1 -0
- package/lib/loaders/cartLoader.d.ts +13 -0
- package/lib/loaders/cartLoader.js +8 -0
- package/lib/loaders/filterTextLoader.js +1 -0
- package/lib/loaders/index.d.ts +9 -0
- package/lib/loaders/index.js +2 -0
- package/lib/loaders/productMediaTextLoader.js +1 -0
- package/lib/loaders/productTextLoader.js +1 -0
- package/lib/loaders/productVariationTextLoader.js +1 -0
- package/lib/locale-context.d.ts +2 -4
- package/lib/locale-context.js +2 -1
- package/lib/mcp/handleMcpHttpRequest.d.ts +2 -0
- package/lib/mcp/handleMcpHttpRequest.js +83 -0
- package/lib/mcp/index.d.ts +1 -1
- package/lib/mcp/nodeHttpBridge.d.ts +3 -0
- package/lib/mcp/nodeHttpBridge.js +64 -0
- package/lib/mcp/resources/localization.d.ts +5 -1
- package/lib/mcp/resources/localization.js +93 -69
- package/lib/mcp/tools/assortment/handlers/getAssortmentFilters.js +1 -1
- package/lib/mcp/tools/assortment/handlers/getAssortmentProducts.js +1 -1
- package/lib/mcp/tools/assortment/index.d.ts +1 -1
- package/lib/mcp/tools/assortment/index.js +4 -1
- package/lib/mcp/tools/assortment/schemas.d.ts +2 -2
- package/lib/mcp/tools/filter/handlers/createFilter.js +2 -4
- package/lib/mcp/tools/filter/handlers/createFilterOption.js +2 -4
- package/lib/mcp/tools/filter/handlers/removeFilterOption.js +2 -4
- package/lib/mcp/tools/filter/handlers/updateFilter.js +2 -2
- package/lib/mcp/tools/filter/index.d.ts +1 -1
- package/lib/mcp/tools/filter/index.js +4 -1
- package/lib/mcp/tools/filter/schemas.d.ts +2 -2
- package/lib/mcp/tools/localization/index.d.ts +1 -1
- package/lib/mcp/tools/localization/index.js +4 -1
- package/lib/mcp/tools/localization/schemas.d.ts +2 -2
- package/lib/mcp/tools/order/index.d.ts +1 -1
- package/lib/mcp/tools/order/index.js +4 -1
- package/lib/mcp/tools/order/schemas.d.ts +2 -2
- package/lib/mcp/tools/product/index.d.ts +1 -1
- package/lib/mcp/tools/product/index.js +4 -1
- package/lib/mcp/tools/product/schemas.d.ts +7 -7
- package/lib/mcp/tools/product/schemas.js +3 -2
- package/lib/mcp/tools/provider/index.d.ts +1 -1
- package/lib/mcp/tools/provider/index.js +4 -1
- package/lib/mcp/tools/provider/schemas.d.ts +2 -2
- package/lib/mcp/tools/quotation/index.d.ts +1 -1
- package/lib/mcp/tools/quotation/index.js +4 -1
- package/lib/mcp/tools/quotation/schemas.d.ts +2 -2
- package/lib/mcp/tools/system/index.d.ts +1 -1
- package/lib/mcp/tools/system/index.js +4 -1
- package/lib/mcp/tools/users/handlers/getUserPaymentCredentials.js +1 -0
- package/lib/mcp/tools/users/index.d.ts +1 -1
- package/lib/mcp/tools/users/index.js +4 -1
- package/lib/mcp/tools/users/schemas.d.ts +2 -2
- package/lib/mcp/utils/getNormalizedAssortmentDetails.js +2 -2
- package/lib/mcp/utils/getNormalizedUserDetails.js +1 -0
- package/lib/mcp/utils/sharedSchemas.d.ts +1 -0
- package/lib/mcp/utils/sharedSchemas.js +1 -0
- package/lib/middleware/createAuthMiddleware.js +2 -39
- package/lib/resolvers/index.js +2 -1
- package/lib/resolvers/mutations/bookmarks/createBookmark.d.ts +1 -1
- package/lib/resolvers/mutations/bookmarks/createBookmark.js +4 -3
- package/lib/resolvers/mutations/filters/createFilter.js +3 -4
- package/lib/resolvers/mutations/filters/createFilterOption.d.ts +1 -1
- package/lib/resolvers/mutations/filters/createFilterOption.js +3 -4
- package/lib/resolvers/mutations/filters/removeFilterOption.d.ts +1 -1
- package/lib/resolvers/mutations/filters/removeFilterOption.js +2 -8
- package/lib/resolvers/mutations/filters/updateFilter.d.ts +1 -1
- package/lib/resolvers/mutations/filters/updateFilter.js +2 -5
- package/lib/resolvers/mutations/index.js +3 -3
- package/lib/resolvers/mutations/orders/checkoutCart.js +1 -1
- package/lib/resolvers/mutations/products/updateProductCommerce.js +10 -1
- package/lib/resolvers/scalars/LowerCaseString.d.ts +1 -1
- package/lib/resolvers/scalars/index.d.ts +1 -1
- package/lib/resolvers/type/assortment/assortment-types.js +2 -2
- package/lib/resolvers/type/filter/loaded-filter-types.d.ts +1 -1
- package/lib/resolvers/type/filter/loaded-filter-types.js +1 -1
- package/lib/resolvers/type/index.d.ts +9 -5
- package/lib/resolvers/type/product/product-bundle-types.d.ts +1 -1
- package/lib/resolvers/type/product/product-plan-types.d.ts +1 -1
- package/lib/resolvers/type/product/product-simple-types.d.ts +1 -1
- package/lib/resolvers/type/product/product-tokenized-types.d.ts +1 -1
- package/lib/resolvers/type/quotation-types.d.ts +4 -0
- package/lib/resolvers/type/quotation-types.js +1 -0
- package/lib/resolvers/type/user-types.js +3 -2
- package/lib/roles/index.js +2 -0
- package/lib/roles/loggedIn.js +11 -0
- package/lib/schema/inputTypes.js +3 -3
- package/lib/schema/scalars.js +1 -0
- package/lib/schema/types/filter.js +2 -2
- package/lib/schema/types/product/product.js +1 -1
- package/lib/schema/types/quotation.js +5 -0
- package/lib/utils/optionalPeerError.d.ts +1 -0
- package/lib/utils/optionalPeerError.js +6 -0
- package/package.json +44 -15
package/lib/chat/utils.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { createLogger } from '@unchainedshop/logger';
|
|
2
|
+
import { isPeerNotInstalledError } from "../utils/optionalPeerError.js";
|
|
3
|
+
const logger = createLogger('unchained:api:chat');
|
|
4
|
+
export const logOptionalPeerLoadError = (packageName, error) => {
|
|
5
|
+
if (isPeerNotInstalledError(packageName, error)) {
|
|
6
|
+
logger.warn(`optional peer npm package '${packageName}' not installed, chat will not work`);
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
logger.error(`failed to load '${packageName}'`, error);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
1
12
|
let NoSuchToolError;
|
|
2
13
|
let InvalidArgumentError;
|
|
3
14
|
try {
|
|
@@ -8,9 +19,9 @@ try {
|
|
|
8
19
|
catch {
|
|
9
20
|
}
|
|
10
21
|
export const errorHandler = (error) => {
|
|
11
|
-
if (NoSuchToolError
|
|
22
|
+
if (NoSuchToolError?.isInstance(error))
|
|
12
23
|
return 'NoSuchToolError';
|
|
13
|
-
if (InvalidArgumentError
|
|
24
|
+
if (InvalidArgumentError?.isInstance(error))
|
|
14
25
|
return 'InvalidToolArgumentsError';
|
|
15
26
|
if (error?.message?.toLowerCase()?.includes('forbidden'))
|
|
16
27
|
return 'NetworkError';
|
|
@@ -18,6 +29,47 @@ export const errorHandler = (error) => {
|
|
|
18
29
|
return 'LimitExceeded';
|
|
19
30
|
return `Failed to stream response: ${error?.message || 'Unknown error'}`;
|
|
20
31
|
};
|
|
32
|
+
export const chatErrorStatus = (error) => error?.statusCode === 503 ? 503 : 500;
|
|
33
|
+
export const createChatRequestLifecycle = (response, configuredSignal) => {
|
|
34
|
+
const disconnectController = new AbortController();
|
|
35
|
+
const signal = configuredSignal
|
|
36
|
+
? AbortSignal.any([configuredSignal, disconnectController.signal])
|
|
37
|
+
: disconnectController.signal;
|
|
38
|
+
let clientClose;
|
|
39
|
+
let closing;
|
|
40
|
+
const detach = () => {
|
|
41
|
+
response.off('close', abortOnDisconnect);
|
|
42
|
+
signal.removeEventListener('abort', closeAfterAbort);
|
|
43
|
+
};
|
|
44
|
+
const close = async () => {
|
|
45
|
+
detach();
|
|
46
|
+
if (!clientClose)
|
|
47
|
+
return;
|
|
48
|
+
closing ??= clientClose();
|
|
49
|
+
await closing;
|
|
50
|
+
};
|
|
51
|
+
const closeAfterAbort = () => {
|
|
52
|
+
void close().catch((error) => logger.error('Failed to close MCP chat client', error));
|
|
53
|
+
};
|
|
54
|
+
const abortOnDisconnect = () => {
|
|
55
|
+
if (!response.writableFinished && !disconnectController.signal.aborted) {
|
|
56
|
+
disconnectController.abort(new Error('Chat HTTP client disconnected'));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
response.once('close', abortOnDisconnect);
|
|
60
|
+
signal.addEventListener('abort', closeAfterAbort, { once: true });
|
|
61
|
+
if (signal.aborted)
|
|
62
|
+
queueMicrotask(closeAfterAbort);
|
|
63
|
+
return {
|
|
64
|
+
signal,
|
|
65
|
+
setClientClose(closeClient) {
|
|
66
|
+
clientClose = closeClient;
|
|
67
|
+
if (signal.aborted)
|
|
68
|
+
closeAfterAbort();
|
|
69
|
+
},
|
|
70
|
+
close,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
21
73
|
export const categorizeTools = (toolName) => {
|
|
22
74
|
const name = toolName.toLowerCase();
|
|
23
75
|
if (name.includes('product'))
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createLogger } from '@unchainedshop/logger';
|
|
2
2
|
import { createYoga, createSchema, } from 'graphql-yoga';
|
|
3
|
+
import { useEngine } from '@envelop/core';
|
|
4
|
+
import { parse, validate, specifiedRules, execute, subscribe } from 'graphql';
|
|
3
5
|
const logger = createLogger('unchained:api');
|
|
4
6
|
export default async (options) => {
|
|
5
7
|
const schema = 'schema' in options
|
|
@@ -15,6 +17,10 @@ export default async (options) => {
|
|
|
15
17
|
return ctx.req?.unchainedContext;
|
|
16
18
|
},
|
|
17
19
|
...options,
|
|
20
|
+
plugins: [
|
|
21
|
+
useEngine({ parse, validate, specifiedRules, execute, subscribe }),
|
|
22
|
+
...(options.plugins ?? []),
|
|
23
|
+
],
|
|
18
24
|
});
|
|
19
25
|
return server;
|
|
20
26
|
};
|
|
@@ -1,36 +1,36 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import { errorHandler } from "../chat/utils.js";
|
|
2
|
+
import { chatErrorStatus, createChatRequestLifecycle, errorHandler, logOptionalPeerLoadError, } from "../chat/utils.js";
|
|
3
3
|
import generateImageHandler from "../chat/generateImageHandler.js";
|
|
4
4
|
import defaultSystemPrompt from "../chat/defaultSystemPrompt.js";
|
|
5
5
|
import normalizeToolsIndex from "../chat/normalizeToolsIndex.js";
|
|
6
|
+
import { buildChatResourceContext } from "../mcp/resources/localization.js";
|
|
6
7
|
import { createLogger } from '@unchainedshop/logger';
|
|
7
8
|
const logger = createLogger('unchained:api:chat');
|
|
8
9
|
let convertToModelMessages;
|
|
9
10
|
let stepCountIs;
|
|
10
11
|
let streamText;
|
|
11
12
|
let createMCPClient;
|
|
12
|
-
let StreamableHTTPClientTransport;
|
|
13
|
-
let Client;
|
|
14
13
|
try {
|
|
15
14
|
const aiTools = await import('ai');
|
|
16
|
-
const mcpTools = await import('@ai-sdk/mcp');
|
|
17
|
-
const mcpSDKClientLibrary = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
|
18
|
-
const mcpSDKClient = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
19
|
-
StreamableHTTPClientTransport = mcpSDKClientLibrary.StreamableHTTPClientTransport;
|
|
20
|
-
Client = mcpSDKClient.Client;
|
|
21
15
|
convertToModelMessages = aiTools.convertToModelMessages;
|
|
22
16
|
stepCountIs = aiTools.stepCountIs;
|
|
23
17
|
streamText = aiTools.streamText;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
logOptionalPeerLoadError('ai', error);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const mcpTools = await import('@ai-sdk/mcp');
|
|
24
24
|
createMCPClient = mcpTools.createMCPClient;
|
|
25
25
|
}
|
|
26
|
-
catch {
|
|
27
|
-
|
|
26
|
+
catch (error) {
|
|
27
|
+
logOptionalPeerLoadError('@ai-sdk/mcp', error);
|
|
28
28
|
}
|
|
29
29
|
const setupMCPChatHandler = (chatConfiguration) => {
|
|
30
30
|
if (!chatConfiguration || !chatConfiguration.model) {
|
|
31
31
|
throw new Error('Model is required');
|
|
32
32
|
}
|
|
33
|
-
const { tools: additionalTools = {}, unchainedMCPUrl = `${process.env.ROOT_URL}/mcp`, model, imageGenerationTool, ...restChatConfig } = chatConfiguration;
|
|
33
|
+
const { tools: additionalTools = {}, unchainedMCPUrl = `${process.env.ROOT_URL}/mcp`, model, imageGenerationTool, abortSignal: configuredAbortSignal, onAbort: configuredOnAbort, onEnd: configuredOnEnd, onFinish: configuredOnFinish, ...restChatConfig } = chatConfiguration;
|
|
34
34
|
const system = chatConfiguration.system ?? defaultSystemPrompt;
|
|
35
35
|
const mcpChatHandler = async (req, res) => {
|
|
36
36
|
if (req.method === 'OPTIONS') {
|
|
@@ -43,50 +43,33 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
43
43
|
res.status(405).json({ error: 'Method Not Allowed. Use POST.' });
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Cookie: req.headers.cookie || '',
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
const client = await createMCPClient({
|
|
63
|
-
transport,
|
|
64
|
-
});
|
|
46
|
+
const unchainedContext = req.unchainedContext;
|
|
47
|
+
const user = unchainedContext?.user;
|
|
48
|
+
if (!user) {
|
|
49
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!(user.roles || []).includes('admin')) {
|
|
53
|
+
res.status(403).json({ error: 'forbidden', message: 'Chat requires admin privileges' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let client;
|
|
57
|
+
const lifecycle = createChatRequestLifecycle(res, configuredAbortSignal);
|
|
65
58
|
try {
|
|
59
|
+
client = await createMCPClient({
|
|
60
|
+
transport: {
|
|
61
|
+
type: 'http',
|
|
62
|
+
url: unchainedMCPUrl,
|
|
63
|
+
headers: {
|
|
64
|
+
Cookie: req.headers.cookie || '',
|
|
65
|
+
...(req.headers.authorization ? { Authorization: req.headers.authorization } : {}),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
initializationOptions: { signal: lifecycle.signal },
|
|
69
|
+
});
|
|
70
|
+
lifecycle.setClientClose(() => client.close());
|
|
66
71
|
const defaultUnchainedTools = await client.tools();
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
const resources = await sdkClient.listResources();
|
|
70
|
-
if (resources?.resources) {
|
|
71
|
-
const resourceTexts = await Promise.all(resources.resources.map(async (resource) => {
|
|
72
|
-
try {
|
|
73
|
-
const content = await sdkClient.readResource({ uri: resource.uri });
|
|
74
|
-
if (content?.contents?.[0]?.text) {
|
|
75
|
-
return `${resource.name}:\n${content.contents[0].text}`;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
catch (e) {
|
|
79
|
-
logger.error(`Failed to read resource ${resource.uri}: ${e.message}`);
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}));
|
|
83
|
-
resourceContext =
|
|
84
|
-
'\n\nAVAILABLE SHOP CONFIGURATION:\n' + resourceTexts.filter(Boolean).join('\n\n');
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
catch (e) {
|
|
88
|
-
logger.error(`Failed to fetch MCP resources: ${e.message}`);
|
|
89
|
-
}
|
|
72
|
+
const resourceContext = await buildChatResourceContext(unchainedContext);
|
|
90
73
|
const tools = {
|
|
91
74
|
...defaultUnchainedTools,
|
|
92
75
|
...additionalTools,
|
|
@@ -95,6 +78,7 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
95
78
|
tools.generateImage = generateImageHandler(req)(imageGenerationTool);
|
|
96
79
|
}
|
|
97
80
|
if (req.method === 'GET') {
|
|
81
|
+
await lifecycle.close();
|
|
98
82
|
res.status(200).json({
|
|
99
83
|
tools: normalizeToolsIndex(tools),
|
|
100
84
|
cached: false,
|
|
@@ -130,15 +114,28 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
130
114
|
}
|
|
131
115
|
const messagesToInclude = normalizedMessages.slice(startIndex);
|
|
132
116
|
const result = streamText({
|
|
133
|
-
stopWhen: stepCountIs(
|
|
134
|
-
temperature: 0.2,
|
|
117
|
+
stopWhen: stepCountIs(500),
|
|
135
118
|
maxRetries: 3,
|
|
136
119
|
...restChatConfig,
|
|
120
|
+
abortSignal: lifecycle.signal,
|
|
137
121
|
system: system + resourceContext,
|
|
138
122
|
model,
|
|
139
123
|
tools: cacheControlledTools,
|
|
140
|
-
|
|
141
|
-
|
|
124
|
+
onEnd: async (event) => {
|
|
125
|
+
try {
|
|
126
|
+
await (configuredOnEnd ?? configuredOnFinish)?.(event);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await lifecycle.close();
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
onAbort: async (event) => {
|
|
133
|
+
try {
|
|
134
|
+
await configuredOnAbort?.(event);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await lifecycle.close();
|
|
138
|
+
}
|
|
142
139
|
},
|
|
143
140
|
messages: messagesToInclude,
|
|
144
141
|
providerOptions: {
|
|
@@ -149,20 +146,25 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
149
146
|
},
|
|
150
147
|
},
|
|
151
148
|
});
|
|
149
|
+
void result.finishReason.then(undefined, async () => {
|
|
150
|
+
await lifecycle.close();
|
|
151
|
+
});
|
|
152
152
|
result.pipeUIMessageStreamToResponse(res, {
|
|
153
153
|
onError: errorHandler,
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
156
|
catch (err) {
|
|
157
|
-
|
|
158
|
-
await
|
|
159
|
-
|
|
157
|
+
logger.error(err);
|
|
158
|
+
await lifecycle.close();
|
|
159
|
+
if (lifecycle.signal.aborted || res.destroyed || res.writableEnded)
|
|
160
|
+
return;
|
|
161
|
+
res.status(chatErrorStatus(err)).json({ error: errorHandler(err) });
|
|
160
162
|
}
|
|
161
163
|
};
|
|
162
164
|
return mcpChatHandler;
|
|
163
165
|
};
|
|
164
166
|
export const connectChat = (app, chatConfiguration) => {
|
|
165
|
-
if (!createMCPClient) {
|
|
167
|
+
if (!createMCPClient || !streamText) {
|
|
166
168
|
logger.warn('Optional dependencies for AI SDK Chat Handler are not installed. Please install @ai-sdk/mcp and ai packages to use this feature.');
|
|
167
169
|
return;
|
|
168
170
|
}
|
|
@@ -1,84 +1,25 @@
|
|
|
1
|
-
import initMCPServer from "../mcp/index.js";
|
|
2
1
|
import { createLogger } from '@unchainedshop/logger';
|
|
2
|
+
import handleMcpHttpRequest from "../mcp/handleMcpHttpRequest.js";
|
|
3
|
+
import { toWebRequest, sendWebResponse } from "../mcp/nodeHttpBridge.js";
|
|
3
4
|
const logger = createLogger('unchained:api:mcp');
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
isInitializeRequest = mcpSDKClient.isInitializeRequest;
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
logger.warn(`optional peer npm package '@modelcontextprotocol/sdk' not installed, mcp will not work`);
|
|
17
|
-
}
|
|
18
|
-
const transports = {};
|
|
19
|
-
const handlePostRequest = async (req, res) => {
|
|
20
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
21
|
-
let transport;
|
|
22
|
-
if (sessionId && transports[sessionId]) {
|
|
23
|
-
transport = transports[sessionId];
|
|
5
|
+
const createMCPMiddleware = async (req, res) => {
|
|
6
|
+
try {
|
|
7
|
+
if (req.method !== 'POST' && req.method !== 'GET' && req.method !== 'DELETE') {
|
|
8
|
+
res.status(405).send('Method Not Allowed');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const bodyText = req.method === 'POST' && req.body !== undefined ? JSON.stringify(req.body) : undefined;
|
|
12
|
+
const response = await handleMcpHttpRequest(req.unchainedContext, toWebRequest(req, res, bodyText), req.method === 'POST' ? req.body : undefined);
|
|
13
|
+
await sendWebResponse(res, response);
|
|
24
14
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (transport.sessionId) {
|
|
34
|
-
delete transports[transport.sessionId];
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
const roles = req.unchainedContext.user?.roles || [];
|
|
38
|
-
const server = initMCPServer(new McpServer({
|
|
39
|
-
name: 'Unchained MCP Server',
|
|
40
|
-
version: '1.0.0',
|
|
41
|
-
}), req.unchainedContext, roles);
|
|
42
|
-
await server.connect(transport);
|
|
15
|
+
catch (error) {
|
|
16
|
+
logger.error(error);
|
|
17
|
+
if (!res.headersSent) {
|
|
18
|
+
res.status(500).json({ error: 'Internal Server Error' });
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
res.destroy();
|
|
22
|
+
}
|
|
43
23
|
}
|
|
44
|
-
else {
|
|
45
|
-
res.status(400).json({
|
|
46
|
-
jsonrpc: '2.0',
|
|
47
|
-
error: {
|
|
48
|
-
code: -32000,
|
|
49
|
-
message: 'Bad Request: No valid session ID provided',
|
|
50
|
-
},
|
|
51
|
-
id: null,
|
|
52
|
-
});
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
await transport.handleRequest(req, res, req.body);
|
|
56
|
-
};
|
|
57
|
-
const handleSessionRequest = async (req, res) => {
|
|
58
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
59
|
-
if (!sessionId || !transports[sessionId]) {
|
|
60
|
-
res.status(400).send('Invalid or missing session ID');
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
const transport = transports[sessionId];
|
|
64
|
-
await transport.handleRequest(req, res);
|
|
65
|
-
};
|
|
66
|
-
const createMCPMiddleware = (req, res, next) => {
|
|
67
|
-
if (!req.unchainedContext.user) {
|
|
68
|
-
res.status(401);
|
|
69
|
-
res.header('WWW-Authenticate', `Bearer realm="Unchained MCP", error="invalid_token", resource="${process.env.ROOT_URL || 'http://localhost:4010'}",`);
|
|
70
|
-
res.json({
|
|
71
|
-
error: 'invalid_token',
|
|
72
|
-
resource_metadata: `${process.env.ROOT_URL || 'http://localhost:4010'}/.well-known/oauth-protected-resource`,
|
|
73
|
-
});
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
if (req.method === 'POST') {
|
|
77
|
-
return handlePostRequest(req, res, next);
|
|
78
|
-
}
|
|
79
|
-
else if (req.method === 'GET' || req.method === 'DELETE') {
|
|
80
|
-
return handleSessionRequest(req, res, next);
|
|
81
|
-
}
|
|
82
|
-
res.status(405).send('Method Not Allowed');
|
|
83
24
|
};
|
|
84
25
|
export default createMCPMiddleware;
|
package/lib/express/index.d.ts
CHANGED
|
@@ -4,11 +4,15 @@ import type { UnchainedCore } from '@unchainedshop/core';
|
|
|
4
4
|
import type { AuthConfig } from '../auth.ts';
|
|
5
5
|
import type { ChatConfiguration } from '../chat/utils.ts';
|
|
6
6
|
import { connectChat } from './chatHandler.ts';
|
|
7
|
+
import { type AdminUIPluginConfig, type AdminUIThemeConfig } from '../adminUiPlugins.ts';
|
|
8
|
+
export type { AdminUIPluginConfig, AdminUIPluginEntityConfig, AdminUIPluginPageConfig, AdminUIPluginTabConfig, AdminUIPluginWidgetConfig, AdminUIPluginSlotConfig, AdminUIThemeTokens, AdminUIThemeConfig, } from '../adminUiPlugins.ts';
|
|
7
9
|
export interface AdminUIRouterOptions {
|
|
8
|
-
prefix
|
|
10
|
+
prefix?: string;
|
|
9
11
|
enabled?: boolean;
|
|
12
|
+
theme?: AdminUIThemeConfig;
|
|
13
|
+
plugins?: AdminUIPluginConfig[];
|
|
10
14
|
}
|
|
11
|
-
export declare const adminUIRouter: (enabled?: boolean) => import("express-serve-static-core").Router;
|
|
15
|
+
export declare const adminUIRouter: (enabled?: boolean, theme?: AdminUIThemeConfig, plugins?: AdminUIPluginConfig[]) => import("express-serve-static-core").Router;
|
|
12
16
|
export declare const connect: (expressApp: e.Express, { graphqlHandler, unchainedAPI, }: {
|
|
13
17
|
graphqlHandler: YogaServerInstance<any, any>;
|
|
14
18
|
unchainedAPI: UnchainedCore;
|
package/lib/express/index.js
CHANGED
|
@@ -1,20 +1,77 @@
|
|
|
1
1
|
import e from 'express';
|
|
2
2
|
import cookieParser from 'cookie-parser';
|
|
3
3
|
import { pluginRegistry } from '@unchainedshop/core';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
4
7
|
import { getCurrentContextResolver } from "../context.js";
|
|
5
8
|
import { createAuthContext } from "../middleware/createAuthMiddleware.js";
|
|
6
9
|
import createMCPMiddleware from "./createMCPMiddleware.js";
|
|
7
10
|
import { connectChat } from "./chatHandler.js";
|
|
8
11
|
import { mountRoutes } from "./mountRoutes.js";
|
|
9
12
|
import { createBackchannelLogoutRoute } from "../handlers/createBackchannelLogoutHandler.js";
|
|
10
|
-
|
|
13
|
+
import { preparePluginAssets, resolveAdminUIPath, } from "../adminUiPlugins.js";
|
|
14
|
+
export const adminUIRouter = (enabled = true, theme, plugins = []) => {
|
|
11
15
|
const router = e.Router();
|
|
12
|
-
const
|
|
13
|
-
|
|
16
|
+
const adminUIPath = resolveAdminUIPath();
|
|
17
|
+
if (!adminUIPath)
|
|
18
|
+
return router;
|
|
14
19
|
if (enabled) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
let themePromise;
|
|
21
|
+
const resolveThemeCSS = async () => {
|
|
22
|
+
let css = '/* default theme */';
|
|
23
|
+
if (theme) {
|
|
24
|
+
try {
|
|
25
|
+
const { generateThemeCSS } = await import('@unchainedshop/admin-ui/theme');
|
|
26
|
+
css = generateThemeCSS(theme);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
console.warn("npm dependency @unchainedshop/admin-ui is not installed, can't apply admin-ui theme");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const etag = `"${createHash('sha256').update(css).digest('hex').slice(0, 8)}"`;
|
|
33
|
+
return { css, etag };
|
|
34
|
+
};
|
|
35
|
+
router.get('/admin-ui-theme.css', async (req, res) => {
|
|
36
|
+
const { css, etag } = await (themePromise ||= resolveThemeCSS());
|
|
37
|
+
if (req.headers['if-none-match'] === etag) {
|
|
38
|
+
return res.status(304).end();
|
|
39
|
+
}
|
|
40
|
+
res
|
|
41
|
+
.set('Cache-Control', 'public, max-age=0, must-revalidate')
|
|
42
|
+
.set('ETag', etag)
|
|
43
|
+
.type('text/css')
|
|
44
|
+
.send(css);
|
|
45
|
+
});
|
|
46
|
+
const log = { info: console.info, warn: console.warn };
|
|
47
|
+
const devMode = process.env.NODE_ENV !== 'production';
|
|
48
|
+
const { routes: pluginRoutes } = preparePluginAssets(plugins, log, { devMode });
|
|
49
|
+
for (const [path, asset] of pluginRoutes) {
|
|
50
|
+
router.get(path, (req, res) => {
|
|
51
|
+
if (asset.etag) {
|
|
52
|
+
if (req.headers['if-none-match'] === asset.etag) {
|
|
53
|
+
return res.status(304).end();
|
|
54
|
+
}
|
|
55
|
+
res.set('ETag', asset.etag);
|
|
56
|
+
}
|
|
57
|
+
const body = typeof asset.content === 'function' ? asset.content() : asset.content;
|
|
58
|
+
res
|
|
59
|
+
.set('Cache-Control', asset.cacheControl)
|
|
60
|
+
.set('X-Content-Type-Options', 'nosniff')
|
|
61
|
+
.set('Access-Control-Allow-Origin', '*')
|
|
62
|
+
.type(asset.contentType)
|
|
63
|
+
.send(body);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
router.use(e.static(adminUIPath));
|
|
67
|
+
const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html');
|
|
68
|
+
const hasExtHtml = existsSync(extHtmlPath);
|
|
69
|
+
router.get(/(.*)/, (req, res) => {
|
|
70
|
+
const urlPath = req.path.replace(/\/+$/, '');
|
|
71
|
+
if (hasExtHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) {
|
|
72
|
+
return res.sendFile(extHtmlPath);
|
|
73
|
+
}
|
|
74
|
+
return res.sendFile(join(adminUIPath, 'index.html'));
|
|
18
75
|
});
|
|
19
76
|
}
|
|
20
77
|
return router;
|
|
@@ -74,6 +131,9 @@ const createAddContextMiddleware = (authConfig, trustProxy = false) => async fun
|
|
|
74
131
|
}
|
|
75
132
|
};
|
|
76
133
|
export const connect = async (expressApp, { graphqlHandler, unchainedAPI, }, { allowRemoteToLocalhostSecureCookies = false, adminUI = false, chat, authConfig, trustProxy = false, } = {}) => {
|
|
134
|
+
const adminUIOptions = typeof adminUI === 'object' ? adminUI : undefined;
|
|
135
|
+
const adminUITheme = adminUIOptions?.theme;
|
|
136
|
+
const adminUIPlugins = adminUIOptions?.plugins || [];
|
|
77
137
|
if (allowRemoteToLocalhostSecureCookies) {
|
|
78
138
|
if (process.env.NODE_ENV === 'production') {
|
|
79
139
|
throw new Error('allowRemoteToLocalhostSecureCookies is not allowed in production. ' +
|
|
@@ -104,7 +164,7 @@ export const connect = async (expressApp, { graphqlHandler, unchainedAPI, }, { a
|
|
|
104
164
|
}
|
|
105
165
|
mountRoutes(expressApp, unchainedAPI, routes);
|
|
106
166
|
if (adminUI) {
|
|
107
|
-
expressApp.use(
|
|
167
|
+
expressApp.use(adminUIOptions?.prefix || '/', adminUIRouter(true, adminUITheme, adminUIPlugins));
|
|
108
168
|
}
|
|
109
169
|
};
|
|
110
170
|
export { connectChat };
|